From a718a2bde636a32e6f78752d6792105b63a0eac5 Mon Sep 17 00:00:00 2001 From: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com> Date: Wed, 27 May 2026 06:27:07 -0700 Subject: [PATCH 01/93] skill_creator: Make window resizable and form scrollable (#57754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Skill Creator window previously couldn't be resized — its minimum size was set to the same dimensions as its initial size, so users couldn't make it smaller or shorter. Its contents also didn't scroll, so on smaller windows the Skill Content editor would be squeezed below its usable size. This change: - Lowers the window's minimum size to 500×420 so it can be resized in both directions. - Wraps the form body in a scrollable container with a vertical scrollbar. - Replaces `flex_1 + min_h_0` on the form fields and Skill Content wrapper with `flex_grow + flex_shrink_0`. Combined with the existing `min_h(160)` on the body editor, this means: - When there's extra vertical space, the Skill Content editor grows to fill it. - When the window is short, the form keeps its content at natural size and the outer container scrolls instead of crushing the editor. Closes AI-315 Release Notes: - Made the Skill Creator window resizable and its contents scrollable when the window is smaller than the form. --- crates/skill_creator/src/skill_creator.rs | 62 +++++++++++++++-------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/crates/skill_creator/src/skill_creator.rs b/crates/skill_creator/src/skill_creator.rs index c6540e2fbd9..141787895df 100644 --- a/crates/skill_creator/src/skill_creator.rs +++ b/crates/skill_creator/src/skill_creator.rs @@ -8,8 +8,8 @@ use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorSty use fs::Fs; use futures::AsyncReadExt; use gpui::{ - App, Bounds, Entity, FocusHandle, Focusable, Subscription, Task, TextStyle, Tiling, - TitlebarOptions, WeakEntity, WindowBounds, WindowHandle, WindowOptions, actions, point, + App, Bounds, Entity, FocusHandle, Focusable, ScrollHandle, Subscription, Task, TextStyle, + Tiling, TitlebarOptions, WeakEntity, WindowBounds, WindowHandle, WindowOptions, actions, point, }; use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request, StatusCode, Url}; use language::{Buffer, LanguageRegistry, language_settings::SoftWrap}; @@ -23,7 +23,7 @@ use std::time::Duration; use theme_settings::ThemeSettings; use ui::{ ContextMenu, Divider, DropdownMenu, DropdownStyle, Headline, HeadlineSize, SwitchField, - prelude::*, + WithScrollbar, prelude::*, utils::platform_title_bar_height, }; use ui_input::{ErasedEditorEvent, InputField}; use util::ResultExt; @@ -172,6 +172,9 @@ pub fn open_skill_creator( } let window_size = gpui::size(px(900.), px(1050.)); + // Allow the window to be resized noticeably smaller than the + // default so that the form scrolls inside the available space. + let window_min_size = gpui::size(px(500.), px(420.)); cx.update(|cx| { let app_id = ReleaseChannel::global(cx).app_id(); @@ -196,7 +199,7 @@ pub fn open_skill_creator( window_bounds: Some(WindowBounds::Windowed(bounds)), window_background: cx.theme().window_background_appearance(), window_decorations: Some(window_decorations), - window_min_size: Some(window_size), + window_min_size: Some(window_min_size), kind: gpui::WindowKind::Floating, ..Default::default() }, @@ -252,6 +255,7 @@ pub struct SkillCreator { url_import_debounce_task: Option>, // Held so replacing it or switching back to the form cancels an in-flight import. url_import_task: Option>, + scroll_handle: ScrollHandle, _subscriptions: Vec, } @@ -427,6 +431,7 @@ impl SkillCreator { save_task: None, url_import_debounce_task: None, url_import_task: None, + scroll_handle: ScrollHandle::new(), _subscriptions: subscriptions, } } @@ -803,7 +808,7 @@ impl SkillCreator { fn render_url_import(&self) -> impl IntoElement { v_flex() - .min_h_0() + .flex_shrink_0() .gap_2() .child( h_flex() @@ -839,10 +844,15 @@ impl SkillCreator { } fn render_form_fields(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // `flex_grow` lets the form fields absorb extra vertical space when + // the window is tall; `flex_shrink_0` keeps them at their natural + // (content + body min-height) size when the window is short, which + // causes the surrounding scroll container to start scrolling rather + // than squeezing the body editor below its minimum height. v_flex() .id("skill-creator-form-fields") - .flex_1() - .min_h_0() + .flex_grow() + .flex_shrink_0() .gap_4() .child( v_flex() @@ -857,7 +867,8 @@ impl SkillCreator { .child(Divider::horizontal()) .child( v_flex() - .flex_1() + .flex_grow() + .flex_shrink_0() .gap_2() .child(Label::new("Skill Content")) .child(self.render_body_field(window, cx)), @@ -1048,13 +1059,14 @@ impl SkillCreator { ) } - fn render_header(&self, cx: &mut Context) -> impl IntoElement { + fn render_header(&self, window: &Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); let needs_traffic_light_clearance = cfg!(target_os = "macos"); + let header_height = platform_title_bar_height(window); h_flex() .w_full() - .h_10() + .h(header_height) .px_4() .when(needs_traffic_light_clearance, |this| this.pl(px(84.))) .border_b_1() @@ -1132,20 +1144,28 @@ impl Render for SkillCreator { .text_color(theme.colors().text) .bg(theme.colors().panel_background) .children(self.title_bar.clone()) - .child(self.render_header(cx)) + .child(self.render_header(window, cx)) .child( - v_flex() - .id("skill-creator-form") - .tab_index(0) - .tab_group() - .tab_stop(false) + div() .flex_1() .min_h_0() - .gap_4() - .p_4() - .child(self.render_url_import()) - .child(Divider::horizontal()) - .child(self.render_form_fields(window, cx)), + .w_full() + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + .child( + v_flex() + .id("skill-creator-form") + .tab_index(0) + .tab_group() + .tab_stop(false) + .size_full() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .gap_4() + .p_4() + .child(self.render_url_import()) + .child(Divider::horizontal()) + .child(self.render_form_fields(window, cx)), + ), ) .child( h_flex() From a9296b9a5ac2942fc5c4a7bfe0de0f53abdbe042 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 27 May 2026 10:30:08 -0300 Subject: [PATCH 02/93] docs: Update information regarding rules/skills (#57824) Update some content regarding the skills migration and its impact on the Git commit prompt. Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner --- docs/src/ai/rules.md | 5 +++-- docs/src/git.md | 14 ++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/src/ai/rules.md b/docs/src/ai/rules.md index 9258be157e6..87f6008ce67 100644 --- a/docs/src/ai/rules.md +++ b/docs/src/ai/rules.md @@ -72,9 +72,10 @@ You can set any rule as the default by clicking the paper clip icon button in th ## Migrating to Skills {#migrating-to-skills} -When you update to Zed v1.4.0, your existing Rules are migrated to Skills automatically: +As of Zed v1.4.0, your existing Rules are migrated to Skills automatically: - **Non-default Rules** become global skills in `~/.agents/skills/`, each with `disable-model-invocation: true`. They remain user-invocable via `/skill-name` or `@`-mention. - **Default Rules** are appended to your global `AGENTS.md` file (`~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows), preserving their behavior of being included in every conversation. +- **Git Commit** prompt customizations are also appended to the global `AGENTS.md` file. -A banner in the title bar announces the migration when it runs. Your original Rule data is not deleted, so downgrading to an earlier version of Zed leaves your Rules intact. +Lastly, note that all of the content you had available in the Rules Library hasn't been deleted, so downgrading to an earlier version of Zed leaves your Rules intact. diff --git a/docs/src/git.md b/docs/src/git.md index c2e19e3d88d..0d0fcc1a4e8 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -272,12 +272,12 @@ To view a stash's contents, select it in the stash picker and press {#kb stash_p ## AI Support in Git Zed currently supports LLM-powered commit message generation. -You can ask AI to generate a commit message by focusing on the message editor within the Git Panel and either clicking on the pencil icon in the bottom left, or reaching for the {#action git::GenerateCommitMessage} ({#kb git::GenerateCommitMessage}) keybinding. +You can ask AI to generate a commit message by focusing on the message editor within the Git Panel and either clicking on the pencil icon in the bottom left, or reaching for the {#action git::GenerateCommitMessage}, or through the {#kb git::GenerateCommitMessage} keybinding. > Note that you need to have an LLM provider configured either via your own API keys or through Zed's hosted AI models. > Visit [the AI configuration page](./ai/configuration.md) to learn how to do so. -You can specify your preferred model to use by providing a `commit_message_model` agent setting. +You can specify your preferred model for this task by adding a `commit_message_model` field to your agent settings. See [Feature-specific models](./ai/agent-settings.md#feature-specific-models) for more information. ```json [settings] @@ -285,18 +285,16 @@ See [Feature-specific models](./ai/agent-settings.md#feature-specific-models) fo "agent": { "commit_message_model": { "provider": "anthropic", - "model": "claude-3-5-haiku" + "model": "claude-4-5-haiku" } } } ``` -To customize the format of generated commit messages, run {#action agent::OpenRulesLibrary} and select the "Commit message" rule on the left side. -From there, you can modify the prompt to match your desired format. +To add custom commit instructions for the model, use the global `AGENTS.md` file located `~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows. - - -Any specific instructions for commit messages added to [Rules files](./ai/rules.md) are also picked up by the model tasked with writing your commit message. +> Before Zed v1.4.0, this was done through the Rules Library, which has been removed. +> See [the "Migrating to Skills" docs](./ai/rules.md#migrating-to-skills) in the Rules page for more information. ## Git Integrations From a55e3e99f521f4fdc8b4c9052591740d76553bf2 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 27 May 2026 09:52:40 -0400 Subject: [PATCH 03/93] agent_skills: Read SKILL.md in one shot via fs.load (#57510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #57466 simplifying `load_skill_frontmatter` rather than fixing a separate bug. #57466 fixed the chunked-read loop so it truncates accumulated bytes at the frontmatter boundary, preventing `str::from_utf8` from failing on a multi-byte grapheme split across chunks. That fix is correct, but the chunked read it's working around isn't pulling its weight: `MAX_SKILL_FILE_SIZE` is 100KB and there's already a metadata pre-check, so "stop reading early once we've seen the closing `---`" saves at most ~25 pages per file while forcing the use of `open_sync`, a hand-rolled loop, and a long comment about `smol::unblock` vs the GPUI test scheduler. This PR: - replaces the chunked `open_sync` + read loop with a single `fs.load(...)` call (the same primitive `read_skill_body` already uses); - deletes `closing_delimiter_end`, `SKILL_READ_CHUNK_SIZE`, the `std::io::{self, Read}` import, and the `Parking forbidden` paragraph; - tightens the metadata pre-check so a metadata error (permissions, I/O, etc.) bails out instead of silently falling through to a blind read; - removes the now-obsolete `test_load_skill_frontmatter_with_emoji_at_chunk_boundary` test — by construction, a single full read can't split a UTF-8 sequence at any boundary. Closes AI-303 Release Notes: - N/A --- crates/agent_skills/agent_skills.rs | 156 ++++------------------------ 1 file changed, 21 insertions(+), 135 deletions(-) diff --git a/crates/agent_skills/agent_skills.rs b/crates/agent_skills/agent_skills.rs index 6a78346c380..7f0d863fc91 100644 --- a/crates/agent_skills/agent_skills.rs +++ b/crates/agent_skills/agent_skills.rs @@ -4,7 +4,6 @@ use fs::Fs; use futures::StreamExt; use gpui::{Global, SharedString}; use serde::{Deserialize, Serialize}; -use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::sync::Arc; use util::paths::component_matches_ignore_ascii_case; @@ -41,7 +40,6 @@ pub struct SkillScopeId(pub usize); /// entries would fan out an equally large number of concurrent OS-level I/O /// operations, potentially exhausting file descriptors or stalling the app. const SKILL_IO_CONCURRENCY: usize = 16; -const SKILL_READ_CHUNK_SIZE: usize = 4096; /// Maximum size for a single SKILL.md file (100KB) pub const MAX_SKILL_FILE_SIZE: usize = 100 * 1024; @@ -558,53 +556,15 @@ async fn find_skill_files(fs: &Arc, directory: &Path) -> Vec { .await } -/// Returns the byte index ONE PAST the end of the closing frontmatter -/// delimiter line in `bytes`, or `None` if no closing delimiter has been -/// seen yet. Used by the chunked reader to know when it has enough -/// bytes to stop pulling from disk. +/// Read `skill_file_path` from disk and parse its frontmatter. The +/// SKILL.md body is parsed away by `parse_skill_frontmatter` and not +/// surfaced here; it's re-read on demand via `read_skill_body` when a +/// skill is actually being loaded for the model. /// -/// Scans for the first `\n---` line followed by `\n`, `\r\n`, or EOF -/// (excluding the opening line itself, which sits at byte 0 and is -/// naturally skipped because we only consider lines following a `\n`). -/// This may overshoot in pathological cases (e.g. `---` inside a quoted -/// YAML string), but `parse_skill_frontmatter`'s candidate-and-validate -/// logic still produces a correct result or a YAML parse error. -fn closing_delimiter_end(bytes: &[u8]) -> Option { - for (i, &b) in bytes.iter().enumerate() { - if b != b'\n' { - continue; - } - let line_start = i + 1; - if line_start + 3 > bytes.len() { - continue; - } - if &bytes[line_start..line_start + 3] != b"---" { - continue; - } - let after_dashes = line_start + 3; - if after_dashes == bytes.len() { - return Some(after_dashes); - } - if bytes[after_dashes] == b'\n' { - return Some(after_dashes + 1); - } - if after_dashes + 1 < bytes.len() - && bytes[after_dashes] == b'\r' - && bytes[after_dashes + 1] == b'\n' - { - return Some(after_dashes + 2); - } - // Line is `---trailing` or `----`; keep scanning. - } - None -} - -/// Read just enough of `skill_file_path` from disk to parse its -/// frontmatter. The SKILL.md body is NOT loaded — that's deferred to -/// `read_skill_body`, called only when a skill is actually being -/// materialized for the model. Reading in 4KB chunks keeps the peak -/// memory cost of loading N skills proportional to total frontmatter -/// size, not total file size. +/// We load the whole file in one go rather than streaming up to the +/// closing `---`. `MAX_SKILL_FILE_SIZE` is 100KB and the metadata check +/// below caps the worst case at that, so the peak transient cost is +/// trivially small (≤ `MAX_SKILL_FILE_SIZE` × `SKILL_IO_CONCURRENCY`). pub async fn load_skill_frontmatter( fs: Arc, skill_file_path: PathBuf, @@ -612,10 +572,15 @@ pub async fn load_skill_frontmatter( ) -> Result { // Short-circuit on oversized files before reading any of their // contents, so a stray multi-GB file named `SKILL.md` can't OOM the - // app. We only act on a positive signal that the file is too large; - // if metadata fails or is unavailable, we fall through to the read - // loop, which is itself capped at `MAX_SKILL_FILE_SIZE`. - if let Ok(Some(metadata)) = fs.metadata(&skill_file_path).await + // app. If metadata is unavailable, refuse to read. + let metadata = fs + .metadata(&skill_file_path) + .await + .map_err(|e| SkillLoadError { + path: skill_file_path.clone(), + message: format!("Failed to read SKILL.md metadata: {}", e), + })?; + if let Some(metadata) = metadata && metadata.len > MAX_SKILL_FILE_SIZE as u64 { return Err(SkillLoadError { @@ -627,54 +592,15 @@ pub async fn load_skill_frontmatter( }); } - let mut reader = fs - .open_sync(&skill_file_path) + let content = fs + .load(&skill_file_path) .await .map_err(|e| SkillLoadError { path: skill_file_path.clone(), - message: format!("Failed to open file: {}", e), + message: format!("Failed to read file: {}", e), })?; - // The chunked read is intentionally synchronous: `Fs::open_sync` - // returns a synchronous `Read` (RealFs uses `std::fs::File`), and - // production callers already wrap `load_skills_from_directory` in - // `cx.background_spawn`, so any blocking happens on the background - // executor — not on the foreground thread. Routing through - // `smol::unblock` instead would schedule the work on smol's blocking - // pool, whose wakeups don't drive GPUI's test scheduler and therefore - // panic with "Parking forbidden" under `TestAppContext`. - let read_result: Result, io::Error> = (|| { - let mut accumulated: Vec = Vec::new(); - let mut chunk = [0u8; SKILL_READ_CHUNK_SIZE]; - loop { - let n = reader.read(&mut chunk)?; - if n == 0 { - break; - } - accumulated.extend_from_slice(&chunk[..n]); - if let Some(end) = closing_delimiter_end(&accumulated) { - // Discard body bytes swept up in the last chunk so that e.g. multi-byte - // graphemes split at the boundary won't cause `str::from_utf8` to fail. - accumulated.truncate(end); - break; - } - if accumulated.len() > MAX_SKILL_FILE_SIZE { - break; - } - } - Ok(accumulated) - })(); - let accumulated = read_result.map_err(|e| SkillLoadError { - path: skill_file_path.clone(), - message: format!("Failed to read file: {}", e), - })?; - - let content = std::str::from_utf8(&accumulated).map_err(|e| SkillLoadError { - path: skill_file_path.clone(), - message: format!("SKILL.md is not valid UTF-8: {}", e), - })?; - - parse_skill_frontmatter(&skill_file_path, content, source).map_err(|e| SkillLoadError { + parse_skill_frontmatter(&skill_file_path, &content, source).map_err(|e| SkillLoadError { path: skill_file_path.clone(), message: e.to_string(), }) @@ -1836,46 +1762,6 @@ description: A skill with no body content assert_eq!(skill.directory_path, PathBuf::from("/skills/my-skill")); } - #[gpui::test] - async fn test_load_skill_frontmatter_with_emoji_at_chunk_boundary(cx: &mut TestAppContext) { - // We must be able to load skill frontmatter even when a - // multipoint grapheme crosses the chunk read boundary. - let fs = FakeFs::new(cx.executor()); - let frontmatter = "---\nname: my-skill\ndescription: Example skill testing multipoint graphemes at chunk boundary\n---\n"; - - // Pad contents so that the emoji's first byte lands - // at the last byte of the first read chunk. - let padding = "a".repeat(SKILL_READ_CHUNK_SIZE - frontmatter.len() - 1); - let content = format!("{frontmatter}{padding}✅"); - - assert!( - (frontmatter.len() + padding.len()) < SKILL_READ_CHUNK_SIZE, - "emoji must start before the second chunk" - ); - assert!( - content.len() > SKILL_READ_CHUNK_SIZE, - "skill is longer than a chunk, so we know that the emoji crosses chunk boundaries" - ); - - fs.insert_tree( - "/skills", - serde_json::json!({ - "my-skill": { - "SKILL.md": content, - } - }), - ) - .await; - - load_skill_frontmatter( - fs as Arc, - PathBuf::from("/skills/my-skill/SKILL.md"), - SkillSource::Global, - ) - .await - .expect("frontmatter should parse even when a multipoint grapheme such as an emoji crosses the byte chunk boundary"); - } - #[gpui::test] async fn test_read_skill_body_returns_trimmed_body(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.executor()); From d2cbf930f72d0268b0e486ead1056141959926bf Mon Sep 17 00:00:00 2001 From: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com> Date: Wed, 27 May 2026 07:02:18 -0700 Subject: [PATCH 04/93] Allow path tools to operate on global agent skills (#57760) Extends the same `~/.agents/skills` special case that `create_directory`, `edit_file`, and `write_file` already use to the path tools, so the agent can copy or move skills into and out of the global skills folder and delete individual skills or skill resources beneath it. - `copy_path` now allows source and/or destination to be a descendant of `~/.agents/skills`, going through `fs::copy_recursive` directly when one side is outside the project. - `move_path` now allows source and/or destination to be a descendant of `~/.agents/skills`, going through `fs.rename` directly when one side is outside the project. Moving the `~/.agents/skills` root itself is rejected. - `delete_path` now allows deleting any file or directory beneath `~/.agents/skills`, going through `fs.remove_dir` / `fs.remove_file` directly. Deleting the `~/.agents/skills` root itself is rejected. - All three tools still always prompt for approval on agent-skill paths, even when default tool permissions are set to allow. - Added shared helpers in `tool_permissions.rs` for resolving global skill descendants and rejecting operations on the skills root where needed. - Added tests covering copying and moving skills in both directions, deleting a global skill directory/file, and rejecting deletion of the skills root. Release Notes: - Agent can now copy or move skills into and out of `~/.agents/skills` and delete individual skills, with an explicit confirmation prompt for each operation --- crates/agent/src/tools/copy_path_tool.rs | 186 +++++++++++++++++++ crates/agent/src/tools/delete_path_tool.rs | 185 ++++++++++++++++++- crates/agent/src/tools/move_path_tool.rs | 204 ++++++++++++++++++++- crates/agent/src/tools/tool_permissions.rs | 50 +++++ 4 files changed, 623 insertions(+), 2 deletions(-) diff --git a/crates/agent/src/tools/copy_path_tool.rs b/crates/agent/src/tools/copy_path_tool.rs index ae3246d9994..6d300551a59 100644 --- a/crates/agent/src/tools/copy_path_tool.rs +++ b/crates/agent/src/tools/copy_path_tool.rs @@ -1,5 +1,6 @@ use super::tool_permissions::{ authorize_symlink_escapes, canonicalize_worktree_roots, collect_symlink_escapes, + resolve_creatable_global_skill_descendant_path, resolve_global_skill_descendant_path, sensitive_settings_kind, }; use crate::{ @@ -23,6 +24,7 @@ use util::markdown::MarkdownInlineCode; /// /// This tool should be used when it's desirable to create a copy of a file or directory without modifying the original. /// It's much more efficient than doing this by separately reading and then writing the file or directory's contents, so this tool should be preferred over that approach whenever copying is the goal. +/// The only supported paths outside the project are descendants of `~/.agents/skills`, for global agent skills. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct CopyPathToolInput { /// The source path of the file or directory to copy. @@ -100,6 +102,15 @@ impl AgentTool for CopyPathTool { let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; + let global_source_path = + resolve_global_skill_descendant_path(Path::new(&input.source_path), fs.as_ref()) + .await; + let global_destination_path = resolve_creatable_global_skill_descendant_path( + Path::new(&input.destination_path), + fs.as_ref(), + ) + .await; + let symlink_escapes: Vec<(&str, std::path::PathBuf)> = project.read_with(cx, |project, cx| { collect_symlink_escapes( @@ -160,6 +171,63 @@ impl AgentTool for CopyPathTool { authorize.await.map_err(|e| e.to_string())?; } + if global_source_path.is_some() || global_destination_path.is_some() { + let source_path = if let Some(global_source_path) = global_source_path { + global_source_path + } else { + project.read_with(cx, |project, cx| { + let project_path = project.find_project_path(&input.source_path, cx).ok_or_else(|| { + format!("Source path {} was not found in the project.", input.source_path) + })?; + project.entry_for_path(&project_path, cx).ok_or_else(|| { + format!("Source path {} was not found in the project.", input.source_path) + })?; + project.absolute_path(&project_path, cx).ok_or_else(|| { + format!("Source path {} could not be resolved.", input.source_path) + }) + })? + }; + + let destination_path = if let Some(global_destination_path) = global_destination_path + { + global_destination_path + } else { + project.read_with(cx, |project, cx| { + let project_path = project.find_project_path(&input.destination_path, cx).ok_or_else(|| { + format!( + "Destination path {} was outside the project.", + input.destination_path + ) + })?; + project.absolute_path(&project_path, cx).ok_or_else(|| { + format!( + "Destination path {} could not be resolved.", + input.destination_path + ) + }) + })? + }; + + futures::select! { + result = fs::copy_recursive( + fs.as_ref(), + &source_path, + &destination_path, + fs::CopyOptions::default(), + ).fuse() => { + result.map_err(|e| format!("Copying {} to {}: {e}", input.source_path, input.destination_path))?; + } + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Copy cancelled by user".to_string()); + } + } + + return Ok(format!( + "Copied {} to {}", + input.source_path, input.destination_path + )); + } + let copy_task = project.update(cx, |project, cx| { match project .find_project_path(&input.source_path, cx) @@ -222,6 +290,124 @@ mod tests { }); } + #[gpui::test] + async fn test_copy_path_global_skill_directory_to_project(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root/project"), json!({})).await; + let skill_dir = agent_skills::global_skills_dir().join("my-skill"); + fs.insert_tree(&skill_dir, json!({ "SKILL.md": "content" })) + .await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let tool = Arc::new(CopyPathTool::new(project)); + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("my-skill") + .to_string_lossy() + .into_owned(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(CopyPathToolInput { + source_path: input_path, + destination_path: path!("/root/project/my-skill").to_string(), + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("agent skills"), + "Authorization title should mention agent skills, got: {title}", + ); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let result = task.await; + assert!(result.is_ok(), "should copy after approval: {result:?}"); + assert!(fs.is_dir(&skill_dir).await); + assert_eq!( + fs.load(path!("/root/project/my-skill/SKILL.md").as_ref()) + .await + .unwrap(), + "content" + ); + } + + #[gpui::test] + async fn test_copy_path_project_directory_to_global_skill_directory(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root/project"), + json!({ "exported-skill": { "SKILL.md": "content" } }), + ) + .await; + let skills_dir = agent_skills::global_skills_dir(); + fs.create_dir(&skills_dir).await.unwrap(); + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let tool = Arc::new(CopyPathTool::new(project)); + let destination_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("exported-skill") + .to_string_lossy() + .into_owned(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(CopyPathToolInput { + source_path: path!("/root/project/exported-skill").to_string(), + destination_path, + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("agent skills"), + "Authorization title should mention agent skills, got: {title}", + ); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let result = task.await; + assert!(result.is_ok(), "should copy after approval: {result:?}"); + assert!( + fs.is_dir(path!("/root/project/exported-skill").as_ref()) + .await + ); + assert_eq!( + fs.load(skills_dir.join("exported-skill").join("SKILL.md").as_ref()) + .await + .unwrap(), + "content" + ); + } + #[gpui::test] async fn test_copy_path_symlink_escape_source_requests_authorization(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs index ef6a6f6ce81..e791e6feb51 100644 --- a/crates/agent/src/tools/delete_path_tool.rs +++ b/crates/agent/src/tools/delete_path_tool.rs @@ -1,6 +1,6 @@ use super::tool_permissions::{ authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape, - sensitive_settings_kind, + resolve_global_skill_descendant_path, resolves_to_global_skills_dir, sensitive_settings_kind, }; use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, @@ -20,6 +20,8 @@ use std::sync::Arc; use util::markdown::MarkdownInlineCode; /// Deletes the file or directory (and the directory's contents, recursively) at the specified path in the project, and returns confirmation of the deletion. +/// +/// The only supported paths outside the project are descendants of `~/.agents/skills`, for global agent skills. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct DeletePathToolInput { /// The path of the file or directory to delete. @@ -95,6 +97,16 @@ impl AgentTool for DeletePathTool { let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; + if resolves_to_global_skills_dir(Path::new(&path), fs.as_ref()).await { + return Err( + "Cannot delete the global agent skills directory itself. Delete a skill directory or file beneath it instead." + .to_string(), + ); + } + + let global_skill_path = + resolve_global_skill_descendant_path(Path::new(&path), fs.as_ref()).await; + let symlink_escape_target = project.read_with(cx, |project, cx| { detect_symlink_escape(project, &path, &canonical_roots, cx) .map(|(_, target)| target) @@ -147,6 +159,38 @@ impl AgentTool for DeletePathTool { authorize.await.map_err(|e| e.to_string())?; } + if let Some(global_skill_path) = global_skill_path { + let metadata = fs + .metadata(&global_skill_path) + .await + .map_err(|e| format!("Deleting {path}: {e}"))? + .ok_or_else(|| format!("Deleting {path}: path not found"))?; + + futures::select! { + result = async { + if metadata.is_dir { + fs.remove_dir( + &global_skill_path, + fs::RemoveOptions { + recursive: true, + ..fs::RemoveOptions::default() + }, + ) + .await + } else { + fs.remove_file(&global_skill_path, fs::RemoveOptions::default()).await + } + }.fuse() => { + result.map_err(|e| format!("Deleting {path}: {e}"))?; + } + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Delete cancelled by user".to_string()); + } + } + + return Ok(format!("Deleted {path}")); + } + let (project_path, worktree_snapshot) = project.read_with(cx, |project, cx| { let project_path = project.find_project_path(&path, cx).ok_or_else(|| { format!("Couldn't delete {path} because that path isn't in this project.") @@ -248,6 +292,145 @@ mod tests { }); } + #[gpui::test] + async fn test_delete_path_global_skill_directory(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root/project"), json!({})).await; + let skills_dir = agent_skills::global_skills_dir(); + let skill_dir = skills_dir.join("my-skill"); + fs.insert_tree(&skill_dir, json!({ "SKILL.md": "content" })) + .await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(DeletePathTool::new(project, action_log)); + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("my-skill") + .to_string_lossy() + .into_owned(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(DeletePathToolInput { path: input_path }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("agent skills"), + "Authorization title should mention agent skills, got: {title}", + ); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let result = task.await; + assert!(result.is_ok(), "should delete after approval: {result:?}"); + assert!(fs.is_dir(&skills_dir).await); + assert!(!fs.is_dir(&skill_dir).await); + } + + #[gpui::test] + async fn test_delete_path_global_skill_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root/project"), json!({})).await; + let skill_file = agent_skills::global_skills_dir() + .join("my-skill") + .join("references") + .join("notes.md"); + fs.create_dir(skill_file.parent().unwrap()).await.unwrap(); + fs.insert_file(&skill_file, b"notes".to_vec()).await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(DeletePathTool::new(project, action_log)); + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("my-skill") + .join("references") + .join("notes.md") + .to_string_lossy() + .into_owned(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(DeletePathToolInput { path: input_path }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let result = task.await; + assert!(result.is_ok(), "should delete after approval: {result:?}"); + assert!(!fs.is_file(&skill_file).await); + } + + #[gpui::test] + async fn test_delete_path_rejects_global_skills_root(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root/project"), json!({})).await; + let skills_dir = agent_skills::global_skills_dir(); + fs.create_dir(&skills_dir).await.unwrap(); + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(DeletePathTool::new(project, action_log)); + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .to_string_lossy() + .into_owned(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let result = cx + .update(|cx| { + tool.run( + ToolInput::resolved(DeletePathToolInput { path: input_path }), + event_stream, + cx, + ) + }) + .await; + + assert!(result.is_err(), "should reject deleting skills root"); + assert!(fs.is_dir(&skills_dir).await); + assert!( + !matches!( + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) + ), + "Deleting the skills root should fail before requesting authorization", + ); + } + #[gpui::test] async fn test_delete_path_symlink_escape_requests_authorization(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tools/move_path_tool.rs b/crates/agent/src/tools/move_path_tool.rs index 3963565caf2..000f17a38c9 100644 --- a/crates/agent/src/tools/move_path_tool.rs +++ b/crates/agent/src/tools/move_path_tool.rs @@ -1,6 +1,7 @@ use super::tool_permissions::{ authorize_symlink_escapes, canonicalize_worktree_roots, collect_symlink_escapes, - sensitive_settings_kind, + resolve_creatable_global_skill_descendant_path, resolve_global_skill_descendant_path, + resolves_to_global_skills_dir, sensitive_settings_kind, }; use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, @@ -22,6 +23,7 @@ use util::markdown::MarkdownInlineCode; /// If the source and destination directories are the same, but the filename is different, this performs a rename. Otherwise, it performs a move. /// /// This tool should be used when it's desirable to move or rename a file or directory without changing its contents at all. +/// The only supported paths outside the project are descendants of `~/.agents/skills`, for global agent skills. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct MovePathToolInput { /// The source path of the file or directory to move/rename. @@ -116,6 +118,28 @@ impl AgentTool for MovePathTool { let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; + if resolves_to_global_skills_dir(Path::new(&input.source_path), fs.as_ref()).await + || resolves_to_global_skills_dir( + Path::new(&input.destination_path), + fs.as_ref(), + ) + .await + { + return Err( + "Cannot move the global agent skills directory itself. Move a skill directory or file beneath it instead." + .to_string(), + ); + } + + let global_source_path = + resolve_global_skill_descendant_path(Path::new(&input.source_path), fs.as_ref()) + .await; + let global_destination_path = resolve_creatable_global_skill_descendant_path( + Path::new(&input.destination_path), + fs.as_ref(), + ) + .await; + let symlink_escapes: Vec<(&str, std::path::PathBuf)> = project.read_with(cx, |project, cx| { collect_symlink_escapes( @@ -176,6 +200,65 @@ impl AgentTool for MovePathTool { authorize.await.map_err(|e| e.to_string())?; } + if global_source_path.is_some() || global_destination_path.is_some() { + let source_path = if let Some(global_source_path) = global_source_path { + global_source_path + } else { + project.read_with(cx, |project, cx| { + let project_path = project.find_project_path(&input.source_path, cx).ok_or_else(|| { + format!("Source path {} was not found in the project.", input.source_path) + })?; + project.entry_for_path(&project_path, cx).ok_or_else(|| { + format!("Source path {} was not found in the project.", input.source_path) + })?; + project.absolute_path(&project_path, cx).ok_or_else(|| { + format!("Source path {} could not be resolved.", input.source_path) + }) + })? + }; + + let destination_path = if let Some(global_destination_path) = global_destination_path + { + global_destination_path + } else { + project.read_with(cx, |project, cx| { + let project_path = project.find_project_path(&input.destination_path, cx).ok_or_else(|| { + format!( + "Destination path {} was outside the project.", + input.destination_path + ) + })?; + project.absolute_path(&project_path, cx).ok_or_else(|| { + format!( + "Destination path {} could not be resolved.", + input.destination_path + ) + }) + })? + }; + + futures::select! { + result = fs.rename( + &source_path, + &destination_path, + fs::RenameOptions { + create_parents: true, + ..fs::RenameOptions::default() + }, + ).fuse() => { + result.map_err(|e| format!("Moving {} to {}: {e}", input.source_path, input.destination_path))?; + } + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Move cancelled by user".to_string()); + } + } + + return Ok(format!( + "Moved {} to {}", + input.source_path, input.destination_path + )); + } + let rename_task = project.update(cx, |project, cx| { match project .find_project_path(&input.source_path, cx) @@ -232,6 +315,125 @@ mod tests { }); } + #[gpui::test] + async fn test_move_path_global_skill_directory_to_project(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root/project"), json!({})).await; + let skill_dir = agent_skills::global_skills_dir().join("my-skill"); + fs.insert_tree(&skill_dir, json!({ "SKILL.md": "content" })) + .await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let tool = Arc::new(MovePathTool::new(project)); + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("my-skill") + .to_string_lossy() + .into_owned(); + let destination_path = path!("/root/project/my-skill").to_string(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(MovePathToolInput { + source_path: input_path, + destination_path, + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("agent skills"), + "Authorization title should mention agent skills, got: {title}", + ); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let result = task.await; + assert!(result.is_ok(), "should move after approval: {result:?}"); + assert!(!fs.is_dir(&skill_dir).await); + assert_eq!( + fs.load(path!("/root/project/my-skill/SKILL.md").as_ref()) + .await + .unwrap(), + "content" + ); + } + + #[gpui::test] + async fn test_move_path_project_directory_to_global_skill_directory(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root/project"), + json!({ "exported-skill": { "SKILL.md": "content" } }), + ) + .await; + let skills_dir = agent_skills::global_skills_dir(); + fs.create_dir(&skills_dir).await.unwrap(); + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let tool = Arc::new(MovePathTool::new(project)); + let destination_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("exported-skill") + .to_string_lossy() + .into_owned(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(MovePathToolInput { + source_path: path!("/root/project/exported-skill").to_string(), + destination_path, + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("agent skills"), + "Authorization title should mention agent skills, got: {title}", + ); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let result = task.await; + assert!(result.is_ok(), "should move after approval: {result:?}"); + assert!( + !fs.is_dir(path!("/root/project/exported-skill").as_ref()) + .await + ); + assert_eq!( + fs.load(skills_dir.join("exported-skill").join("SKILL.md").as_ref()) + .await + .unwrap(), + "content" + ); + } + #[gpui::test] async fn test_move_path_symlink_escape_source_requests_authorization(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tools/tool_permissions.rs b/crates/agent/src/tools/tool_permissions.rs index ebcc888db16..4d52dad7001 100644 --- a/crates/agent/src/tools/tool_permissions.rs +++ b/crates/agent/src/tools/tool_permissions.rs @@ -199,6 +199,56 @@ pub async fn resolve_creatable_global_skill_path(path: &Path, fs: &dyn Fs) -> Op } } +fn is_strict_descendant(path: &Path, ancestor: &Path) -> bool { + path != ancestor && path.starts_with(ancestor) +} + +/// Returns whether `path` resolves to the global agent skills directory itself. +/// +/// This is used by destructive tools to reject operations targeting the root +/// `~/.agents/skills` directory while still allowing operations on individual +/// skills or resources beneath it. +pub async fn resolves_to_global_skills_dir(path: &Path, fs: &dyn Fs) -> bool { + let Some(normalized_path) = resolve_lexical_global_skill_path(path) else { + return false; + }; + let Some(canonical_path) = canonicalize_with_ancestors(&normalized_path, fs).await else { + return false; + }; + let Some(canonical_skills_dir) = canonical_global_skills_dir(fs).await else { + return false; + }; + + canonical_path == canonical_skills_dir +} + +/// Filters a previously-resolved global skills path so that callers which +/// must never act on `~/.agents/skills` itself (move, delete) only see paths +/// that point strictly below the skills root. +async fn restrict_to_skill_descendant( + canonical_path: Option, + fs: &dyn Fs, +) -> Option { + let canonical_path = canonical_path?; + let canonical_skills_dir = canonical_global_skills_dir(fs).await?; + is_strict_descendant(&canonical_path, &canonical_skills_dir).then_some(canonical_path) +} + +/// Like [`resolve_global_skill_path`], but only succeeds for paths strictly +/// below `~/.agents/skills`, not the skills directory itself. +pub async fn resolve_global_skill_descendant_path(path: &Path, fs: &dyn Fs) -> Option { + restrict_to_skill_descendant(resolve_global_skill_path(path, fs).await, fs).await +} + +/// Like [`resolve_creatable_global_skill_path`], but only succeeds for paths +/// strictly below `~/.agents/skills`, not the skills directory itself. +pub async fn resolve_creatable_global_skill_descendant_path( + path: &Path, + fs: &dyn Fs, +) -> Option { + restrict_to_skill_descendant(resolve_creatable_global_skill_path(path, fs).await, fs).await +} + /// Returns the kind of sensitive settings or agent skills location this path targets, if any: /// either inside a `.zed/` local-settings directory, inside `.agents/skills/`, or inside /// the global config dir. From ceff1b227be0782fbbeb030369a0d90d3aa6537b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 27 May 2026 11:03:28 -0300 Subject: [PATCH 05/93] agent_ui: Improve menu items related to rules (#57828) Also adds a menu item called "Rules Library" that takes to the documentation, which will explain the removal of the feature as a whole. Release Notes: - N/A Co-authored-by: Bennet Bo Fenner --- crates/agent_ui/src/agent_panel.rs | 39 ++++++++++++++++++++++++------ crates/client/src/zed_urls.rs | 4 +++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 567cab36c1b..c3bc3088360 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -57,7 +57,7 @@ use anyhow::Result; #[cfg(feature = "audio")] use audio::{Audio, Sound}; use chrono::{DateTime, Utc}; -use client::UserStore; +use client::{UserStore, zed_urls}; use cloud_api_types::Plan; use collections::HashMap; use editor::{Editor, MultiBuffer}; @@ -4958,9 +4958,20 @@ impl AgentPanel { if global_agents_md_loaded { let workspace = workspace.clone(); - menu = menu.entry( - "Open Global AGENTS.md", - None, + + menu = menu.custom_entry( + |_window, _cx| { + h_flex() + .w_full() + .gap_1() + .child(Label::new("Open Global Rules")) + .child( + Label::new("(AGENTS.md)") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() + }, move |window, cx| { workspace .update(cx, |workspace, cx| { @@ -4983,9 +4994,19 @@ impl AgentPanel { if let Some(path) = project_agents_md_path.clone() { let workspace = workspace.clone(); - menu = menu.entry( - "Open Project AGENTS.md", - None, + menu = menu.custom_entry( + |_window, _cx| { + h_flex() + .w_full() + .gap_1() + .child(Label::new("Open Project Rules")) + .child( + Label::new("(AGENTS.md)") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() + }, move |window, cx| { let path = path.clone(); workspace @@ -5007,6 +5028,10 @@ impl AgentPanel { ); } + menu = menu.entry("Rules Library", None, |_window, cx| { + cx.open_url(&zed_urls::rules_docs(cx)); + }); + menu = menu.separator(); } diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 70ff9537d7a..8cd284da035 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -56,6 +56,10 @@ pub fn skills_docs(cx: &App) -> String { format!("{server_url}/docs/ai/skills", server_url = server_url(cx)) } +pub fn rules_docs(cx: &App) -> String { + format!("{server_url}/docs/ai/rules", server_url = server_url(cx)) +} + /// Returns the URL to Zed's ACP registry blog post. pub fn acp_registry_blog(cx: &App) -> String { format!( From f838eb1248e4eac041a200a95dfa070cabf98fa1 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 27 May 2026 16:08:15 +0200 Subject: [PATCH 06/93] git_ui: Respect global `AGENTS.md` when generating commit message (#57827) Release Notes: - agent: Fixed an issue where commit message generation would not respect instructions from global `AGENTS.md` --------- Co-authored-by: Richard Feldman --- crates/agent/src/agent.rs | 2 - crates/agent/src/thread.rs | 4 +- crates/agent_settings/Cargo.toml | 2 +- crates/agent_settings/src/agent_settings.rs | 2 + .../src/user_agents_md.rs | 0 crates/agent_ui/src/agent_panel.rs | 3 +- crates/agent_ui/src/agent_ui.rs | 1 - .../src/conversation_view/thread_view.rs | 3 +- crates/git_ui/src/git_panel.rs | 113 ++++++++++++++---- crates/zed/src/zed.rs | 6 +- 10 files changed, 101 insertions(+), 35 deletions(-) rename crates/{agent => agent_settings}/src/user_agents_md.rs (100%) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index df3ae91682d..b2561f17072 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -10,7 +10,6 @@ mod thread; mod thread_store; mod tool_permissions; mod tools; -mod user_agents_md; use context_server::ContextServerId; pub use db::*; @@ -23,7 +22,6 @@ pub use thread::*; pub use thread_store::*; pub use tool_permissions::*; pub use tools::*; -pub use user_agents_md::{UserAgentsMd, UserAgentsMdState, init as init_user_agents_md}; use acp_thread::{ AcpThread, AgentModelSelector, AgentSessionInfo, AgentSessionList, AgentSessionListRequest, diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 458f3c1d2a7..593a9f6de4c 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -4,11 +4,11 @@ use crate::{ FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, RenameTool, SpawnAgentTool, SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, - UpdatePlanTool, UpdateTitleTool, UserAgentsMd, WebSearchTool, WriteFileTool, - decide_permission_from_settings, + UpdatePlanTool, UpdateTitleTool, WebSearchTool, WriteFileTool, decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; +use agent_settings::UserAgentsMd; use feature_flags::{ FeatureFlagAppExt as _, LspToolFeatureFlag, RenameToolFeatureFlag, UpdatePlanToolFeatureFlag, UpdateTitleToolFeatureFlag, diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index 985c0309afb..fd6f18f12af 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -21,6 +21,7 @@ futures.workspace = true gpui.workspace = true language_model.workspace = true log.workspace = true +paths.workspace = true project.workspace = true regex.workspace = true schemars.workspace = true @@ -31,7 +32,6 @@ util.workspace = true [dev-dependencies] fs.workspace = true gpui = { workspace = true, features = ["test-support"] } -paths.workspace = true serde_json_lenient.workspace = true serde_json.workspace = true diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 7453d60e48d..d7b9d0ed018 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -1,4 +1,5 @@ mod agent_profile; +mod user_agents_md; use std::path::{Component, Path}; use std::sync::{Arc, LazyLock}; @@ -20,6 +21,7 @@ use settings::{ }; pub use crate::agent_profile::*; +pub use crate::user_agents_md::{UserAgentsMd, UserAgentsMdState, init as init_user_agents_md}; pub const SUMMARIZE_THREAD_PROMPT: &str = include_str!("prompts/summarize_thread_prompt.txt"); pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str = diff --git a/crates/agent/src/user_agents_md.rs b/crates/agent_settings/src/user_agents_md.rs similarity index 100% rename from crates/agent/src/user_agents_md.rs rename to crates/agent_settings/src/user_agents_md.rs diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index c3bc3088360..d19c8ef0120 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -10,9 +10,10 @@ use std::{ }; use acp_thread::{AcpThread, AcpThreadEvent, MentionUri, ThreadStatus}; -use agent::{ContextServerRegistry, SharedThread, ThreadStore, UserAgentsMd}; +use agent::{ContextServerRegistry, SharedThread, ThreadStore}; use agent_client_protocol::schema as acp; use agent_servers::AgentServer; +use agent_settings::UserAgentsMd; use collections::HashSet; use db::kvp::{Dismissable, KeyValueStore}; use itertools::Itertools; diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index acc45fa0bb5..88f0bcb34b9 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -688,7 +688,6 @@ fn update_command_palette_filter(cx: &mut App) { TypeId::of::(), TypeId::of::(), TypeId::of::(), - TypeId::of::(), TypeId::of::(), TypeId::of::(), TypeId::of::(), diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 01bef136c0d..5fabb8f4569 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -8,7 +8,8 @@ use agent_client_protocol::schema as acp; use std::cell::RefCell; use acp_thread::{ContentBlock, PlanEntry}; -use agent::{SkillLoadingError, SkillLoadingErrorsUpdated, UserAgentsMd}; +use agent::{SkillLoadingError, SkillLoadingErrorsUpdated}; +use agent_settings::UserAgentsMd; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; use feature_flags::AcpBetaFeatureFlag; diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index f60737af586..8a7f3c5a134 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -9,7 +9,7 @@ use crate::{branch_picker, picker_prompt, render_remote_button}; use crate::{ git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, }; -use agent_settings::AgentSettings; +use agent_settings::{AgentSettings, UserAgentsMd}; use alacritty_terminal::vte::ansi; use anyhow::Context as _; use askpass::AskPassDelegate; @@ -2699,6 +2699,40 @@ impl GitPanel { .unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().to_string()) } + fn build_commit_message_prompt( + prompt: &str, + user_agents_md: Option<&str>, + rules_content: Option<&str>, + subject: &str, + diff_text: &str, + ) -> String { + let user_agents_md_section = match user_agents_md { + Some(user_agents_md) => format!( + "\n\nThe user has provided the following rules that you should follow when writing the commit message. Project-specific rules may override these instructions when they conflict:\n\ + \n{user_agents_md}\n\n" + ), + None => String::new(), + }; + + let rules_section = match rules_content { + Some(rules) => format!( + "\n\nThe user has provided the following rules specific to this project that you should follow when writing the commit message:\n\ + \n{rules}\n\n" + ), + None => String::new(), + }; + + let subject_section = if subject.trim().is_empty() { + String::new() + } else { + format!("\nHere is the user's subject line:\n{subject}") + }; + + format!( + "{prompt}{user_agents_md_section}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}" + ) + } + /// Generates a commit message using an LLM. pub fn generate_commit_message(&mut self, cx: &mut Context) { if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) { @@ -2730,7 +2764,7 @@ impl GitPanel { let repo_work_dir = repo.read(cx).work_directory_abs_path.clone(); self.generate_commit_message_task = Some(cx.spawn(async move |this, mut cx| { - async move { + async move { let _defer = cx.on_drop(&this, |this, _cx| { this.generate_commit_message_task.take(); }); @@ -2762,32 +2796,33 @@ impl GitPanel { const MAX_DIFF_BYTES: usize = 20_000; diff_text = Self::compress_commit_diff(&diff_text, MAX_DIFF_BYTES); - let rules_content = Self::load_project_rules(&project, &repo_work_dir, &mut cx).await; + let rules_content = + Self::load_project_rules(&project, &repo_work_dir, &mut cx).await; + let user_agents_md = cx.update(|cx| { + UserAgentsMd::global(cx) + .and_then(|user_agents_md| user_agents_md.content().cloned()) + }); let prompt = Self::load_commit_message_prompt(&mut cx).await; let subject = this.update(cx, |this, cx| { - this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default() + this.commit_editor + .read(cx) + .text(cx) + .lines() + .next() + .map(ToOwned::to_owned) + .unwrap_or_default() })?; let text_empty = subject.trim().is_empty(); - let rules_section = match &rules_content { - Some(rules) => format!( - "\n\nThe user has provided the following project rules that you should follow when writing the commit message:\n\ - \n{rules}\n\n" - ), - None => String::new(), - }; - - let subject_section = if text_empty { - String::new() - } else { - format!("\nHere is the user's subject line:\n{subject}") - }; - - let content = format!( - "{prompt}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}" + let content = Self::build_commit_message_prompt( + &prompt, + user_agents_md.as_deref(), + rules_content.as_deref(), + &subject, + &diff_text, ); let request = LanguageModelRequest { @@ -2816,7 +2851,11 @@ impl GitPanel { this.update(cx, |this, cx| { this.commit_message_buffer(cx).update(cx, |buffer, cx| { let insert_position = buffer.anchor_before(buffer.len()); - buffer.edit([(insert_position..insert_position, "\n")], None, cx) + buffer.edit( + [(insert_position..insert_position, "\n")], + None, + cx, + ) }); })?; } @@ -2826,8 +2865,13 @@ impl GitPanel { Ok(text) => { this.update(cx, |this, cx| { this.commit_message_buffer(cx).update(cx, |buffer, cx| { - let insert_position = buffer.anchor_before(buffer.len()); - buffer.edit([(insert_position..insert_position, text)], None, cx); + let insert_position = + buffer.anchor_before(buffer.len()); + buffer.edit( + [(insert_position..insert_position, text)], + None, + cx, + ); }); })?; } @@ -2845,7 +2889,8 @@ impl GitPanel { anyhow::Ok(()) } - .log_err().await + .log_err() + .await })); } @@ -8726,6 +8771,26 @@ mod tests { assert_eq!(result, expected); } + #[test] + fn test_commit_message_prompt_includes_user_agents_md_before_project_rules() { + let prompt = GitPanel::build_commit_message_prompt( + "Write a commit message.", + Some("Use terse commit messages."), + Some("Use the git_ui prefix."), + "Update generated message", + "diff --git a/file b/file", + ); + + assert!(prompt.contains("Use terse commit messages.")); + assert!(prompt.contains("Use the git_ui prefix.")); + assert!(prompt.contains("Update generated message")); + assert!(prompt.contains("diff --git a/file b/file")); + + let user_agents_md_index = prompt.find("").unwrap(); + let project_rules_index = prompt.find("").unwrap(); + assert!(user_agents_md_index < project_rules_index); + } + #[gpui::test] async fn test_suggest_commit_message(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 3be90dbc039..a4771d0e08c 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -15,7 +15,7 @@ pub mod visual_tests; #[cfg(target_os = "windows")] pub(crate) mod windows_only_instance; -use agent::{UserAgentsMdState, init_user_agents_md}; +use agent_settings::{UserAgentsMdState, init_user_agents_md}; use agent_ui::AgentDiffToolbar; use anyhow::Context as _; pub use app_menus::*; @@ -1882,8 +1882,8 @@ fn init_cursor_hide_mode(cx: &mut App) { /// Starts watching `~/.config/zed/AGENTS.md` (or the platform equivalent) and /// surfaces any read errors using the same notification UI as settings errors. /// -/// The file itself is loaded into [`agent::UserAgentsMd`] for inclusion in the -/// native agent's system prompt. +/// The file itself is loaded into [`agent_settings::UserAgentsMd`] for inclusion +/// in prompts. pub fn watch_user_agents_md(fs: Arc, cx: &mut App) { struct UserAgentsMdParseError; let notification_id = NotificationId::unique::(); From a65e677376f2d918ffdb71e42223defc73d7f33d Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Wed, 27 May 2026 10:11:23 -0400 Subject: [PATCH 07/93] ep: Make `tree-sitter` dependency optional in `edit_prediction_metrics` (#57829) Needed for use in cloud Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A or Added/Fixed/Improved ... --- crates/edit_prediction/Cargo.toml | 2 +- crates/edit_prediction_cli/Cargo.toml | 2 +- crates/edit_prediction_metrics/Cargo.toml | 5 ++++- .../edit_prediction_metrics/src/edit_prediction_metrics.rs | 2 ++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index 9e4805938f2..ea11fac3cd9 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -31,7 +31,7 @@ credentials_provider.workspace = true db.workspace = true edit_prediction_types.workspace = true edit_prediction_context.workspace = true -edit_prediction_metrics.workspace = true +edit_prediction_metrics = { workspace = true, features = ["tree-sitter"] } feature_flags.workspace = true fs.workspace = true futures.workspace = true diff --git a/crates/edit_prediction_cli/Cargo.toml b/crates/edit_prediction_cli/Cargo.toml index 97db020a552..81e91fa262d 100644 --- a/crates/edit_prediction_cli/Cargo.toml +++ b/crates/edit_prediction_cli/Cargo.toml @@ -61,7 +61,7 @@ terminal_view.workspace = true util.workspace = true watch.workspace = true edit_prediction = { workspace = true, features = ["cli-support"] } -edit_prediction_metrics.workspace = true +edit_prediction_metrics = { workspace = true, features = ["tree-sitter"] } telemetry_events.workspace = true wasmtime.workspace = true zeta_prompt.workspace = true diff --git a/crates/edit_prediction_metrics/Cargo.toml b/crates/edit_prediction_metrics/Cargo.toml index ba4990d9381..080c3a60b2c 100644 --- a/crates/edit_prediction_metrics/Cargo.toml +++ b/crates/edit_prediction_metrics/Cargo.toml @@ -11,12 +11,15 @@ workspace = true [lib] path = "src/edit_prediction_metrics.rs" +[features] +tree-sitter = ["dep:tree-sitter"] + [dependencies] imara-diff.workspace = true serde.workspace = true serde_json = "1.0" similar = "2.7.0" -tree-sitter.workspace = true +tree-sitter = { workspace = true, optional = true } zeta_prompt.workspace = true [dev-dependencies] diff --git a/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs b/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs index 74ad639b7e9..81a37148c79 100644 --- a/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs +++ b/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs @@ -4,6 +4,7 @@ mod prediction_score; mod reversal; mod summary; mod tokenize; +#[cfg(feature = "tree-sitter")] mod tree_sitter; pub use kept_rate::AnnotatedToken; @@ -30,4 +31,5 @@ pub use prediction_score::{ }; pub use reversal::compute_prediction_reversal_ratio_from_history; pub use summary::{PredictionSummaryInput, QaSummaryData, SummaryJson, compute_summary}; +#[cfg(feature = "tree-sitter")] pub use tree_sitter::count_tree_sitter_errors; From 0464de8fddb51f3e1718379b1bc39950ca570a8e Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 27 May 2026 11:34:28 -0300 Subject: [PATCH 08/93] skills_creator: Improve header alignment (#57834) Tiny UI tweak here to improve the label/traffic lights alignment in the skills creator header. Release Notes: - N/A Co-authored-by: Martin Ye Co-authored-by: Bennet Bo Fenner --- crates/skill_creator/src/skill_creator.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/skill_creator/src/skill_creator.rs b/crates/skill_creator/src/skill_creator.rs index 141787895df..599764e4c7a 100644 --- a/crates/skill_creator/src/skill_creator.rs +++ b/crates/skill_creator/src/skill_creator.rs @@ -23,7 +23,7 @@ use std::time::Duration; use theme_settings::ThemeSettings; use ui::{ ContextMenu, Divider, DropdownMenu, DropdownStyle, Headline, HeadlineSize, SwitchField, - WithScrollbar, prelude::*, utils::platform_title_bar_height, + WithScrollbar, prelude::*, }; use ui_input::{ErasedEditorEvent, InputField}; use util::ResultExt; @@ -1059,18 +1059,16 @@ impl SkillCreator { ) } - fn render_header(&self, window: &Window, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme().clone(); + fn render_header(&self, _window: &Window, cx: &mut Context) -> impl IntoElement { let needs_traffic_light_clearance = cfg!(target_os = "macos"); - let header_height = platform_title_bar_height(window); h_flex() .w_full() - .h(header_height) + .h_11() .px_4() .when(needs_traffic_light_clearance, |this| this.pl(px(84.))) .border_b_1() - .border_color(theme.colors().border) + .border_color(cx.theme().colors().border) .child(Headline::new("Skill Creator").size(HeadlineSize::XSmall)) } From 80a4042ff584ea68b5d5a072926bdb9af4792b45 Mon Sep 17 00:00:00 2001 From: Fanteria Date: Wed, 27 May 2026 16:36:41 +0200 Subject: [PATCH 09/93] vim: Fix dot repeat after macro replay not capturing insertion text (#57684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #47251 Fix dot (`.`) repeat not correctly repeating the last change after replaying a macro (`@register`) ([#47251](https://github.com/zed-industries/zed/issues/47251)) When replaying a macro that contains text insertions, `replay_insert_event` calls `handle_input` directly and never emits `InputHandled`, so the `observe_insertion` subscription never fires. This left the dot register stale — `.` after `@register` would repeat an earlier change instead of the last one made by the macro. Fix by calling `observe_insertion` explicitly in the `ReplayableAction::Insertion` branch of `Replayer::next`. Release Notes: - Fixed dot (`.`) repeat not repeating the last change made by a macro (`@register`). --------- Co-authored-by: Smit Barmase --- crates/editor/src/input.rs | 6 +++++ crates/vim/src/normal/repeat.rs | 28 +++++++++++++++++++++++ crates/vim/test_data/test_dot_repeat.json | 22 ++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/crates/editor/src/input.rs b/crates/editor/src/input.rs index 473c2ad53b6..afd0850c6ad 100644 --- a/crates/editor/src/input.rs +++ b/crates/editor/src/input.rs @@ -34,6 +34,12 @@ impl Editor { cx.emit(EditorEvent::InputIgnored { text: text.into() }); return; } + + cx.emit(EditorEvent::InputHandled { + utf16_range_to_replace: relative_utf16_range.clone(), + text: text.into(), + }); + if let Some(relative_utf16_range) = relative_utf16_range { let selections = self .selections diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index 387bca0912b..d1b79de4f57 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -457,6 +457,34 @@ mod test { cx.run_until_parked(); cx.simulate_shared_keystrokes(".").await; cx.shared_state().await.assert_eq("THE QUICK ˇbrown fox"); + + // "q l" (note after macro should be used last change made by macro) + cx.set_shared_state("ˇ").await; + cx.simulate_shared_keystrokes("q l shift-o h e l l o space w o r l d escape q") + .await; + cx.simulate_shared_keystrokes("@ l").await; + cx.shared_state() + .await + .assert_eq("hello worlˇd\nhello world\n"); + cx.simulate_shared_keystrokes(".").await; + cx.shared_state() + .await + .assert_eq("hello worlˇd\nhello world\nhello world\n"); + } + + #[gpui::test] + async fn test_dot_repeat_after_macro_change_motion(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("ˇfoo foo", Mode::Normal); + cx.simulate_keystrokes("q l c f o x escape q"); + cx.assert_state("ˇxo foo", Mode::Normal); + + cx.simulate_keystrokes("w @ l"); + cx.assert_state("xo ˇxo", Mode::Normal); + + cx.simulate_keystrokes("."); + cx.assert_state("xo ˇx", Mode::Normal); } #[gpui::test] diff --git a/crates/vim/test_data/test_dot_repeat.json b/crates/vim/test_data/test_dot_repeat.json index 331ef52ecb9..b22cd96981c 100644 --- a/crates/vim/test_data/test_dot_repeat.json +++ b/crates/vim/test_data/test_dot_repeat.json @@ -36,3 +36,25 @@ {"Put":{"state":"THE QUIˇck brown fox"}} {"Key":"."} {"Get":{"state":"THE QUICK ˇbrown fox","mode":"Normal"}} +{"Put":{"state":"ˇ"}} +{"Key":"q"} +{"Key":"l"} +{"Key":"shift-o"} +{"Key":"h"} +{"Key":"e"} +{"Key":"l"} +{"Key":"l"} +{"Key":"o"} +{"Key":"space"} +{"Key":"w"} +{"Key":"o"} +{"Key":"r"} +{"Key":"l"} +{"Key":"d"} +{"Key":"escape"} +{"Key":"q"} +{"Key":"@"} +{"Key":"l"} +{"Get":{"state":"hello worlˇd\nhello world\n","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"hello worlˇd\nhello world\nhello world\n","mode":"Normal"}} From 32d0737318d3000c23212ff36ee57fde03fa090c Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Wed, 27 May 2026 18:35:46 +0300 Subject: [PATCH 10/93] Add cooldown when file watch limit is reached (#57720) Stop trying to add new watches for 5 seconds after receiving the "OS file watch limit reached" error. This was flooding the logs and many pointless syscalls. Related to #57422, #57042, FR-18 Release Notes: - Improved file watcher behavior when the OS file watch limit is reached. --- crates/fs/src/fs_watcher.rs | 166 +++++++++++++++++++++++++++++------- 1 file changed, 135 insertions(+), 31 deletions(-) diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index 4615156694b..062e543daf2 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -6,7 +6,7 @@ use std::{ ops::DerefMut, path::Path, sync::{Arc, LazyLock, OnceLock}, - time::Duration, + time::{Duration, Instant}, }; use util::{ResultExt, paths::SanitizedPath}; @@ -50,11 +50,13 @@ impl FsWatcher { fn add_existing_path(&self, path: Arc) -> anyhow::Result<()> { let registration_path = path.clone(); - let registration = - register_existing_path(path, self.tx.clone(), self.pending_path_events.clone())?; - self.registrations - .lock() - .insert(registration_path, registration); + if let Some(registration) = + register_existing_path(path, self.tx.clone(), self.pending_path_events.clone())? + { + self.registrations + .lock() + .insert(registration_path, registration); + } Ok(()) } @@ -182,7 +184,7 @@ fn register_existing_path( path: Arc, tx: async_channel::Sender<()>, pending_path_events: Arc>>, -) -> anyhow::Result { +) -> anyhow::Result> { let mode = if requires_poll_watcher(path.as_ref()) { log::info!( "Using poll watcher ({}ms interval) for {}", @@ -196,20 +198,24 @@ fn register_existing_path( }; let root_path = SanitizedPath::new_arc(path.as_ref()); let path_for_callback = path.clone(); - let registration_id = global_watcher().add(path, mode, move |event: ¬ify::Event| { - log::trace!("watcher received event: {event:?}"); - push_notify_event( - &tx, - &pending_path_events, - &root_path, - path_for_callback.as_ref(), - event, - ); - })?; - Ok(FsWatcherRegistration { + let Some(registration_id) = + global_watcher().add(path, mode, move |event: ¬ify::Event| { + log::trace!("watcher received event: {event:?}"); + push_notify_event( + &tx, + &pending_path_events, + &root_path, + path_for_callback.as_ref(), + event, + ); + })? + else { + return Ok(None); + }; + Ok(Some(FsWatcherRegistration { id: registration_id, mode, - }) + })) } #[cfg(target_os = "linux")] @@ -345,7 +351,7 @@ async fn poll_path_until_created( } match register_existing_path(path.clone(), tx.clone(), pending_path_events.clone()) { - Ok(registration) => { + Ok(Some(registration)) => { { let mut pending_registrations = pending_registrations.lock(); if pending_registrations.remove(path.as_ref()).is_none() { @@ -370,6 +376,7 @@ async fn poll_path_until_created( ); return; } + Ok(None) => {} Err(error) => { log::warn!("failed to watch newly-created path {path:?}: {error}; retrying"); } @@ -501,10 +508,16 @@ struct WatcherState { watchers: HashMap, native_path_registrations: HashMap, PathRegistrationState>, poll_path_registrations: HashMap, PathRegistrationState>, + cooldown_until: Option, last_registration: WatcherRegistrationId, } impl WatcherState { + fn is_native_watch_limit_cooldown_active(&self) -> bool { + self.cooldown_until + .is_some_and(|cooldown_until| cooldown_until > Instant::now()) + } + fn path_registrations( &mut self, mode: WatcherMode, @@ -565,15 +578,30 @@ impl GlobalWatcher { path: Arc, mode: WatcherMode, cb: impl Fn(¬ify::Event) + Send + Sync + 'static, - ) -> anyhow::Result { + ) -> anyhow::Result> { let mut state = self.state.lock(); - let registrations_for_mode = state.path_registrations(mode); - let path_already_covered = - path_already_covered(path.as_ref(), registrations_for_mode, mode); + let (path_already_covered, path_already_registered) = { + let registrations_for_mode = state.path_registrations(mode); + ( + path_already_covered(path.as_ref(), registrations_for_mode, mode), + registrations_for_mode.contains_key(&path), + ) + }; + + if !path_already_covered && !path_already_registered { + if mode == WatcherMode::Native && state.is_native_watch_limit_cooldown_active() { + return Ok(None); + } - if !path_already_covered && !registrations_for_mode.contains_key(&path) { drop(state); - self.watch(&path, mode)?; + match self.watch(&path, mode) { + Ok(()) => {} + Err(error) if mode == WatcherMode::Native && is_max_files_watch_error(&error) => { + self.start_native_watch_limit_cooldown(&path); + return Ok(None); + } + Err(error) => return Err(error), + } state = self.state.lock(); } @@ -595,7 +623,20 @@ impl GlobalWatcher { has_os_watcher: !path_already_covered, }); - Ok(id) + Ok(Some(id)) + } + + fn start_native_watch_limit_cooldown(&self, path: &Path) { + let mut state = self.state.lock(); + let now = Instant::now(); + let should_log = !state.is_native_watch_limit_cooldown_active(); + state.cooldown_until = Some(now + *NATIVE_WATCH_LIMIT_COOLDOWN); + if should_log { + log::warn!( + "OS file watch limit reached while watching {path:?}; skipping new native file watcher registrations for {} seconds", + NATIVE_WATCH_LIMIT_COOLDOWN.as_secs() + ); + } } pub fn remove(&self, id: WatcherRegistrationId) { @@ -688,6 +729,12 @@ fn path_already_covered( .any(|ancestor| path_registrations.contains_key(ancestor)) } +fn is_max_files_watch_error(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(|error| matches!(&error.kind, notify::ErrorKind::MaxFilesWatch)) +} + static POLL_INTERVAL: LazyLock = LazyLock::new(|| { let poll_ms: u64 = std::env::var("ZED_FILE_WATCHER_POLL_MS") .ok() @@ -697,6 +744,15 @@ static POLL_INTERVAL: LazyLock = LazyLock::new(|| { Duration::from_millis(poll_ms) }); +static NATIVE_WATCH_LIMIT_COOLDOWN: LazyLock = LazyLock::new(|| { + let cooldown_seconds: u64 = std::env::var("ZED_NATIVE_WATCH_LIMIT_COOLDOWN_SECONDS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(5) + .clamp(0, 300); + Duration::from_secs(cooldown_seconds) +}); + pub fn poll_interval() -> Duration { *POLL_INTERVAL } @@ -709,6 +765,7 @@ fn global_watcher() -> &'static GlobalWatcher { watchers: Default::default(), native_path_registrations: Default::default(), poll_path_registrations: Default::default(), + cooldown_until: None, last_registration: Default::default(), }), native_watcher: Mutex::new(None), @@ -789,6 +846,7 @@ mod tests { watched_paths: HashSet, watch_calls: Vec, unwatch_calls: Vec, + fail_with_watch_limit: bool, } struct SharedFakeWatchBackend(Arc>); @@ -798,6 +856,9 @@ mod tests { let path = path.to_path_buf(); let mut backend = self.0.lock(); backend.watch_calls.push(path.clone()); + if backend.fail_with_watch_limit { + return Err(notify::Error::new(notify::ErrorKind::MaxFilesWatch)); + } backend.watched_paths.insert(path); Ok(()) } @@ -815,15 +876,31 @@ mod tests { } fn test_watcher(poll_watcher: Arc>) -> GlobalWatcher { + test_watcher_with_backends(None, Some(poll_watcher)) + } + + fn test_watcher_with_backends( + native_watcher: Option>>, + poll_watcher: Option>>, + ) -> GlobalWatcher { GlobalWatcher { state: Mutex::new(WatcherState { watchers: Default::default(), native_path_registrations: Default::default(), poll_path_registrations: Default::default(), + cooldown_until: None, last_registration: Default::default(), }), - native_watcher: Mutex::new(None), - poll_watcher: Mutex::new(Some(Box::new(SharedFakeWatchBackend(poll_watcher)))), + native_watcher: Mutex::new( + native_watcher.map(|watcher| { + Box::new(SharedFakeWatchBackend(watcher)) as Box + }), + ), + poll_watcher: Mutex::new( + poll_watcher.map(|watcher| { + Box::new(SharedFakeWatchBackend(watcher)) as Box + }), + ), } } @@ -844,10 +921,12 @@ mod tests { let parent_registration = watcher .add(parent.as_ref().into(), WatcherMode::Poll, |_| {}) - .expect("add parent watch"); + .expect("add parent watch") + .expect("parent watch registered"); let child_registration = watcher .add(child.as_ref().into(), WatcherMode::Poll, |_| {}) - .expect("add covered child watch"); + .expect("add covered child watch") + .expect("child watch registered"); watcher.remove(parent_registration); watcher.remove(child_registration); @@ -857,6 +936,31 @@ mod tests { assert_eq!(backend.unwatch_calls, &[parent.to_path_buf()]); } + #[test] + fn native_watch_limit_cools_down_subsequent_native_registrations() { + let native_backend = Arc::new(Mutex::new(FakeWatchBackend { + fail_with_watch_limit: true, + ..Default::default() + })); + let poll_backend = Arc::new(Mutex::new(FakeWatchBackend::default())); + let watcher = test_watcher_with_backends(Some(native_backend.clone()), Some(poll_backend)); + let first_path = Arc::::from(Path::new("/repo/first")); + let second_path = Arc::::from(Path::new("/repo/second")); + + let first_registration = watcher + .add(first_path.clone(), WatcherMode::Native, |_| {}) + .expect("native watch limit is handled"); + let second_registration = watcher + .add(second_path, WatcherMode::Native, |_| {}) + .expect("native watch limit backoff is handled"); + + assert!(first_registration.is_none()); + assert!(second_registration.is_none()); + + let native_backend = native_backend.lock(); + assert_eq!(native_backend.watch_calls, &[first_path.to_path_buf()]); + } + #[test] fn test_coalesce_pending_rescans() { let test_cases = [ From bc6a483e5c8b9e99913057544913762769292af3 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 27 May 2026 12:08:52 -0400 Subject: [PATCH 11/93] Add Actions to open AGENTS.md (#57847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2026-05-27 at 12 08 26 PM Add command palette actions for opening global and project-specific AGENTS.md files Closes AI-324 Release Notes: - Added commands to open global and project-specific AGENTS.md rules --- crates/agent_ui/src/agent_panel.rs | 106 +++++++++++------- crates/agent_ui/src/agent_ui.rs | 16 +++ crates/command_palette/src/command_palette.rs | 66 ++++++++++- crates/zed_actions/src/lib.rs | 6 + 4 files changed, 149 insertions(+), 45 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index d19c8ef0120..0894c68685c 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -29,7 +29,8 @@ use zed_actions::{ ResolveConflictsWithAgent, ReviewBranchDiff, }, assistant::{ - CreateSkillFromUrl, FocusAgent, OpenRulesLibrary, OpenSkillCreator, Toggle, ToggleFocus, + CreateSkillFromUrl, FocusAgent, OpenGlobalAgentsMdRules, OpenProjectAgentsMdRules, + OpenRulesLibrary, OpenSkillCreator, Toggle, ToggleFocus, }, }; @@ -179,6 +180,60 @@ fn read_global_last_created_entry_kind(kvp: &KeyValueStore) -> Option, + require_existing_file: bool, + cx: &App, +) -> Option { + let rel_path = util::rel_path::RelPath::unix("AGENTS.md").ok()?; + project + .read(cx) + .visible_worktrees(cx) + .next() + .and_then(|worktree| { + let worktree = worktree.read(cx); + + if require_existing_file { + let entry = worktree.entry_for_path(rel_path)?; + if !entry.is_file() { + return None; + } + } + + Some(worktree.absolutize(rel_path)) + }) +} + +fn open_global_rules(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + workspace + .open_abs_path( + paths::agents_file().clone(), + workspace::OpenOptions { + focus: Some(true), + ..Default::default() + }, + window, + cx, + ) + .detach_and_log_err(cx); +} + +fn open_project_rules(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + if let Some(path) = project_agents_md_path(workspace.project(), false, cx) { + workspace + .open_abs_path( + path, + workspace::OpenOptions { + focus: Some(true), + ..Default::default() + }, + window, + cx, + ) + .detach_and_log_err(cx); + } +} + async fn write_global_last_created_entry_kind(kvp: KeyValueStore, entry_kind: AgentPanelEntryKind) { if let Some(json) = serde_json::to_string(&LastCreatedEntryKind { entry_kind }).log_err() { kvp.write_kvp(LAST_CREATED_ENTRY_KIND_KEY.to_string(), json) @@ -315,6 +370,12 @@ pub fn init(cx: &mut App) { }); } }) + .register_action(|workspace, _: &OpenGlobalAgentsMdRules, window, cx| { + open_global_rules(workspace, window, cx); + }) + .register_action(|workspace, _: &OpenProjectAgentsMdRules, window, cx| { + open_project_rules(workspace, window, cx); + }) .register_action(|workspace, action: &OpenSkillCreator, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); @@ -4862,21 +4923,7 @@ impl AgentPanel { .active_conversation_view() .is_some_and(|conversation_view| conversation_view.read(cx).supports_logout()); - let project_agents_md_path: Option = self - .project - .read(cx) - .visible_worktrees(cx) - .next() - .and_then(|worktree| { - let worktree = worktree.read(cx); - let rel_path = util::rel_path::RelPath::unix("AGENTS.md").ok()?; - let entry = worktree.entry_for_path(rel_path)?; - if entry.is_file() { - Some(worktree.absolutize(rel_path)) - } else { - None - } - }); + let project_agents_md_path = project_agents_md_path(&self.project, true, cx); let global_agents_md_loaded = UserAgentsMd::global(cx) .and_then(|md| md.content()) @@ -4976,24 +5023,14 @@ impl AgentPanel { move |window, cx| { workspace .update(cx, |workspace, cx| { - workspace - .open_abs_path( - paths::agents_file().clone(), - workspace::OpenOptions { - focus: Some(true), - ..Default::default() - }, - window, - cx, - ) - .detach_and_log_err(cx); + open_global_rules(workspace, window, cx); }) .log_err(); }, ); } - if let Some(path) = project_agents_md_path.clone() { + if project_agents_md_path.is_some() { let workspace = workspace.clone(); menu = menu.custom_entry( |_window, _cx| { @@ -5009,20 +5046,9 @@ impl AgentPanel { .into_any_element() }, move |window, cx| { - let path = path.clone(); workspace .update(cx, |workspace, cx| { - workspace - .open_abs_path( - path, - workspace::OpenOptions { - focus: Some(true), - ..Default::default() - }, - window, - cx, - ) - .detach_and_log_err(cx); + open_project_rules(workspace, window, cx); }) .log_err(); }, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 88f0bcb34b9..fc67e12904a 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -909,6 +909,14 @@ mod tests { !filter.is_hidden(&zed_actions::assistant::CreateSkillFromUrl), "CreateSkillFromUrl should be visible by default" ); + assert!( + !filter.is_hidden(&zed_actions::assistant::OpenGlobalAgentsMdRules), + "OpenGlobalAgentsMdRules should be visible by default" + ); + assert!( + !filter.is_hidden(&zed_actions::assistant::OpenProjectAgentsMdRules), + "OpenProjectAgentsMdRules should be visible by default" + ); }); // Disable agent @@ -932,6 +940,14 @@ mod tests { filter.is_hidden(&NewTerminalThread), "NewTerminalThread should be hidden when agent is disabled" ); + assert!( + filter.is_hidden(&zed_actions::assistant::OpenGlobalAgentsMdRules), + "OpenGlobalAgentsMdRules should be hidden when agent is disabled" + ); + assert!( + filter.is_hidden(&zed_actions::assistant::OpenProjectAgentsMdRules), + "OpenProjectAgentsMdRules should be hidden when agent is disabled" + ); }); // Test EditPredictionProvider diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 35af6f071be..3104fecf204 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -695,26 +695,69 @@ impl PickerDelegate for CommandPaletteDelegate { } pub fn humanize_action_name(name: &str) -> String { - let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count(); + let chars = name.chars().collect::>(); + let capacity = name.len() + chars.iter().filter(|c| c.is_uppercase()).count(); let mut result = String::with_capacity(capacity); - for char in name.chars() { + let mut index = 0; + + while index < chars.len() { + let char = chars[index]; if char == ':' { if result.ends_with(':') { result.push(' '); } else { result.push(':'); } + index += 1; } else if char == '_' { result.push(' '); + index += 1; } else if char.is_uppercase() { - if !result.ends_with(' ') { - result.push(' '); + let start = index; + index += 1; + while chars + .get(index) + .is_some_and(|next_char| next_char.is_uppercase()) + { + index += 1; + } + + let uppercase_run = &chars[start..index]; + if uppercase_run.len() > 1 { + let split_before_last = chars + .get(index) + .is_some_and(|next_char| next_char.is_lowercase()); + let acronym_end = if split_before_last { + uppercase_run.len() - 1 + } else { + uppercase_run.len() + }; + + if acronym_end > 0 { + if !result.ends_with(' ') { + result.push(' '); + } + result.extend(&uppercase_run[..acronym_end]); + } + + if split_before_last { + if !result.ends_with(' ') { + result.push(' '); + } + result.extend(uppercase_run[acronym_end].to_lowercase()); + } + } else { + if !result.ends_with(' ') { + result.push(' '); + } + result.extend(char.to_lowercase()); } - result.extend(char.to_lowercase()); } else { result.push(char); + index += 1; } } + result } @@ -753,6 +796,19 @@ mod tests { humanize_action_name("go_to_line::Deploy"), "go to line: deploy" ); + assert_eq!( + humanize_action_name("agent::OpenGlobalAGENTS.mdRules"), + "agent: open global AGENTS.md rules" + ); + assert_eq!( + humanize_action_name("agent::OpenProjectAGENTS.mdRules"), + "agent: open project AGENTS.md rules" + ); + assert_eq!(humanize_action_name("editor::OpenURL"), "editor: open URL"); + assert_eq!( + humanize_action_name("editor::OpenURLParser"), + "editor: open URL parser" + ); } #[test] diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 384c6fac54b..b84c4c7b97a 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -580,6 +580,12 @@ pub mod assistant { OpenSkillCreator, /// Opens the skill creator window to import a skill from a GitHub URL. CreateSkillFromUrl, + /// Opens the user-global AGENTS.md rules file. + #[action(name = "OpenGlobalAGENTS.mdRules")] + OpenGlobalAgentsMdRules, + /// Opens the project AGENTS.md rules file. + #[action(name = "OpenProjectAGENTS.mdRules")] + OpenProjectAgentsMdRules, ] ); From aa6062edaffc2104e37f9752e8741353f8b07c4a Mon Sep 17 00:00:00 2001 From: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com> Date: Wed, 27 May 2026 09:21:30 -0700 Subject: [PATCH 12/93] Stop loading deprecated agent rules (#57844) Summary: - Stop loading persisted default Zed Rules into native agent project context. - Remove legacy user-rules rendering from agent system prompt templates. - Keep Skills and project rules file support unchanged. Tests: - cargo test -p agent test_system_prompt_does_not_render_legacy_zed_rules_section --lib - cargo test -p prompt_store test_empty_skills_sets_has_skills_false - cargo test -p prompt_store test_project_context_does_not_filter_by_budget Closes AI-325 Release Notes: - Fixed deprecated Rules being automatically included in new agent requests. --- crates/agent/src/agent.rs | 130 +++++------------- crates/agent/src/native_agent_server.rs | 7 +- crates/agent/src/templates.rs | 19 ++- .../templates/experimental_system_prompt.hbs | 17 +-- crates/agent/src/templates/system_prompt.hbs | 14 +- crates/agent/src/tests/mod.rs | 39 +++--- crates/agent/src/tools/evals/edit_file.rs | 2 +- crates/agent/src/tools/evals/terminal_tool.rs | 2 +- crates/agent/src/tools/evals/write_file.rs | 2 +- crates/eval_cli/src/main.rs | 8 +- crates/prompt_store/src/prompts.rs | 24 +--- 11 files changed, 78 insertions(+), 186 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index b2561f17072..c08517d7df3 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -49,10 +49,7 @@ use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageMo use project::{ AgentId, Project, ProjectItem, ProjectPath, Worktree, trusted_worktrees::TrustedWorktrees, }; -use prompt_store::{ - ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext, - WorktreeContext, -}; +use prompt_store::{ProjectContext, RULES_FILE_NAMES, RulesFileContext, WorktreeContext}; use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, Settings as _, update_settings_file}; use std::any::Any; @@ -306,7 +303,6 @@ pub struct NativeAgent { templates: Arc, /// Cached model information models: LanguageModels, - prompt_store: Option>, fs: Arc, _subscriptions: Vec, /// Tracks the lifecycle of global skills directory observation. We @@ -353,20 +349,16 @@ impl NativeAgent { pub fn new( thread_store: Entity, templates: Arc, - prompt_store: Option>, fs: Arc, cx: &mut App, ) -> Entity { log::debug!("Creating new NativeAgent"); cx.new(|cx| { - let mut subscriptions = vec![cx.subscribe( + let subscriptions = vec![cx.subscribe( &LanguageModelRegistry::global(cx), Self::handle_models_updated_event, )]; - if let Some(prompt_store) = prompt_store.as_ref() { - subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) - } if !cx.has_global::() { cx.set_global(SkillIndex::default()); @@ -379,7 +371,6 @@ impl NativeAgent { projects: HashMap::default(), templates, models: LanguageModels::new(cx), - prompt_store, fs, _subscriptions: subscriptions, skills_state: SkillsState::default(), @@ -638,7 +629,7 @@ impl NativeAgent { return project_id; } - let project_context = cx.new(|_| ProjectContext::new(vec![], vec![])); + let project_context = cx.new(|_| ProjectContext::new(vec![])); self.register_project_with_initial_context(project.clone(), project_context, cx); if let Some(state) = self.projects.get_mut(&project_id) { state.project_context_needs_refresh.send(()).ok(); @@ -731,7 +722,6 @@ impl NativeAgent { .context("project state not found")?; anyhow::Ok(Self::build_project_context( &state.project, - this.prompt_store.as_ref(), this.fs.clone(), cx, )) @@ -803,7 +793,6 @@ impl NativeAgent { fn build_project_context( project: &Entity, - prompt_store: Option<&Entity>, fs: Arc, cx: &mut App, ) -> Task<(ProjectContext, Vec, Vec)> { @@ -885,22 +874,8 @@ impl NativeAgent { .collect(); cx.background_spawn(async move { future::join_all(project_skills_futures).await }) }; - let default_user_rules_task = if let Some(prompt_store) = prompt_store.as_ref() { - prompt_store.read_with(cx, |prompt_store, cx| { - let prompts = prompt_store.default_prompt_metadata(); - let load_tasks = prompts.into_iter().map(|prompt_metadata| { - let contents = prompt_store.load(prompt_metadata.id, cx); - async move { (contents.await, prompt_metadata) } - }); - cx.background_spawn(future::join_all(load_tasks)) - }) - } else { - Task::ready(vec![]) - }; - cx.spawn(async move |_cx| { - let (worktrees, default_user_rules) = - future::join(future::join_all(worktree_tasks), default_user_rules_task).await; + let worktrees = future::join_all(worktree_tasks).await; let worktrees = worktrees .into_iter() @@ -913,27 +888,6 @@ impl NativeAgent { }) .collect::>(); - let default_user_rules = default_user_rules - .into_iter() - .flat_map(|(contents, prompt_metadata)| match contents { - Ok(contents) => Some(UserRulesContext { - uuid: prompt_metadata.id.as_user()?, - title: prompt_metadata.title.map(|title| title.to_string()), - contents, - }), - Err(_err) => { - // TODO: show error message - // this.update(cx, |_, cx| { - // cx.emit(RulesLoadingError { - // message: format!("{err:?}").into(), - // }); - // }) - // .ok(); - None - } - }) - .collect::>(); - // Load and combine skills. `combine_skills` deliberately // does NOT deduplicate — the autocomplete popup needs to // see every entry so users can disambiguate same-named @@ -957,8 +911,7 @@ impl NativeAgent { let (catalog_skills, budget_errors) = select_catalog_skills(&overridden); skill_errors.extend(budget_errors); - let project_context = - ProjectContext::new(worktrees, default_user_rules).with_skills(catalog_skills); + let project_context = ProjectContext::new(worktrees).with_skills(catalog_skills); (project_context, skills, skill_errors) }) } @@ -1119,17 +1072,6 @@ impl NativeAgent { } } - fn handle_prompts_updated_event( - &mut self, - _prompt_store: Entity, - _event: &prompt_store::PromptsUpdatedEvent, - _cx: &mut Context, - ) { - for state in self.projects.values_mut() { - state.project_context_needs_refresh.send(()).ok(); - } - } - fn handle_models_updated_event( &mut self, _registry: Entity, @@ -3499,7 +3441,7 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); // Creating a session registers the project and triggers context building. let connection = NativeAgentConnection(agent.clone()); @@ -3590,7 +3532,7 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); // Simulate the user-interaction trigger that the agent panel // fires (input focus, slash autocomplete, or submit). In tests @@ -3653,7 +3595,7 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); // First scan trigger: nothing on disk yet, state stays idle. cx.update(|cx| { @@ -3730,7 +3672,7 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); // First scan trigger: nothing on disk yet. cx.update(|cx| { @@ -3876,7 +3818,7 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); // Open a parent session through the connection, the same way // production does. This triggers project-context refresh which @@ -3989,7 +3931,7 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); let acp_thread = cx @@ -4094,7 +4036,7 @@ mod internal_tests { Project::test_with_worktree_trust(fs.clone(), [Path::new("/project")], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); let acp_thread = cx @@ -4175,10 +4117,9 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let connection = - NativeAgentConnection(cx.update(|cx| { - NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx) - })); + let connection = NativeAgentConnection( + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)), + ); // Create a thread/session let acp_thread = cx @@ -4252,7 +4193,7 @@ mod internal_tests { // Create the agent and connection let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); // Create a thread/session @@ -4349,7 +4290,7 @@ mod internal_tests { let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); let acp_thread = cx @@ -4440,7 +4381,7 @@ mod internal_tests { let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -4491,9 +4432,8 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); // Register a thinking model. @@ -4594,9 +4534,8 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); // Register a model where id() != name(), like real Anthropic models @@ -4710,9 +4649,8 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -4892,9 +4830,8 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -4973,9 +4910,8 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5057,9 +4993,8 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5202,9 +5137,8 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index bc0f75bcff5..b6752239fe7 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -9,9 +9,7 @@ use fs::Fs; use gpui::{App, Entity, Task}; use language_model::{LanguageModelId, LanguageModelProviderId, LanguageModelRegistry}; use project::{AgentId, Project}; -use prompt_store::PromptStore; use settings::{LanguageModelSelection, Settings as _, update_settings_file}; -use util::ResultExt as _; use crate::{NativeAgent, NativeAgentConnection, ThreadStore, templates::Templates}; @@ -45,15 +43,12 @@ impl AgentServer for NativeAgentServer { log::debug!("NativeAgentServer::connect"); let fs = self.fs.clone(); let thread_store = self.thread_store.clone(); - let prompt_store = PromptStore::global(cx); cx.spawn(async move |cx| { log::debug!("Creating templates for native agent"); let templates = Templates::new(); - let prompt_store = prompt_store.await.log_err(); log::debug!("Creating native agent entity"); - let agent = - cx.update(|cx| NativeAgent::new(thread_store, templates, prompt_store, fs, cx)); + let agent = cx.update(|cx| NativeAgent::new(thread_store, templates, fs, cx)); // Create the connection wrapper let connection = NativeAgentConnection(agent); diff --git a/crates/agent/src/templates.rs b/crates/agent/src/templates.rs index 51f27eaeddf..2f8dda39c75 100644 --- a/crates/agent/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -112,7 +112,7 @@ mod tests { project_entry_id: 1, }), }]; - let project = ProjectContext::new(worktrees, Vec::new()); + let project = ProjectContext::new(worktrees); let template = SystemPromptTemplate { project: &project, available_tools: vec!["echo".into()], @@ -150,4 +150,21 @@ mod tests { let rendered = template.render(&templates).unwrap(); assert!(!rendered.contains("### Personal `AGENTS.md`")); } + + #[test] + fn test_system_prompt_does_not_render_legacy_zed_rules_section() { + let project = prompt_store::ProjectContext::default(); + let template = SystemPromptTemplate { + project: &project, + available_tools: vec!["echo".into()], + model_name: Some("test-model".to_string()), + date: "2026-01-01".to_string(), + user_agents_md: None, + }; + let templates = Templates::new(); + let rendered = template.render(&templates).unwrap(); + + assert!(!rendered.contains("The user has specified the following rules")); + assert!(!rendered.contains("Rules title:")); + } } diff --git a/crates/agent/src/templates/experimental_system_prompt.hbs b/crates/agent/src/templates/experimental_system_prompt.hbs index 824ee679a2a..63a34ccdcad 100644 --- a/crates/agent/src/templates/experimental_system_prompt.hbs +++ b/crates/agent/src/templates/experimental_system_prompt.hbs @@ -173,12 +173,11 @@ The current project contains the following root directories: You are powered by the model named {{model_name}}. {{/if}} -{{#if (or has_rules has_user_rules)}} +{{#if has_rules}} ## User's Custom Instructions The following additional instructions are provided by the user and should be followed to the best of your ability{{#if (gt (len available_tools) 0)}} without interfering with the tool use guidelines{{/if}}. -{{#if has_rules}} There are project rules that apply to these root directories: {{#each worktrees}} {{#if rules_file}} @@ -189,17 +188,3 @@ There are project rules that apply to these root directories: {{/if}} {{/each}} {{/if}} - -{{#if has_user_rules}} -The user has specified the following rules that should be applied: -{{#each user_rules}} - -{{#if title}} -Rules title: {{title}} -{{/if}} -`````` -{{contents}} -`````` -{{/each}} -{{/if}} -{{/if}} diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 54962540757..c7dffcdfe7e 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -223,7 +223,7 @@ To use a Skill: 4. If the Skill references additional files, use `read_file` to access them. Paths inside a Skill resolve relative to that Skill's directory (the parent of its `SKILL.md`). {{/if}} -{{#if (or user_agents_md has_rules has_user_rules)}} +{{#if (or user_agents_md has_rules)}} ## User's Custom Instructions The following additional instructions are provided by the user and should be followed to the best of your ability{{#if (gt (len available_tools) 0)}} without interfering with the tool use guidelines{{/if}}. @@ -254,16 +254,4 @@ There are project rules that apply to these root directories: {{/each}} {{/if}} -{{#if has_user_rules}} -The user has specified the following rules that should be applied: -{{#each user_rules}} - -{{#if title}} -Rules title: {{title}} -{{/if}} -`````` -{{contents}} -`````` -{{/each}} -{{/if}} {{/if}} diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 8eb236b9598..e75cfc4fac4 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -3520,8 +3520,8 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let thread_store = cx.new(|cx| ThreadStore::new(cx)); // Create agent and connection - let agent = cx - .update(|cx| NativeAgent::new(thread_store, templates.clone(), None, fake_fs.clone(), cx)); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, templates.clone(), fake_fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); // Create a thread using new_thread @@ -5091,9 +5091,8 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5226,9 +5225,8 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5374,9 +5372,8 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5504,9 +5501,8 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -6037,9 +6033,8 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -6163,9 +6158,8 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -6337,9 +6331,8 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx diff --git a/crates/agent/src/tools/evals/edit_file.rs b/crates/agent/src/tools/evals/edit_file.rs index 08bf869d14b..8a5802c8182 100644 --- a/crates/agent/src/tools/evals/edit_file.rs +++ b/crates/agent/src/tools/evals/edit_file.rs @@ -361,7 +361,7 @@ impl EditToolTest { abs_path: Path::new("/path/to/root").into(), rules_file: None, }]; - let project_context = ProjectContext::new(worktrees, Vec::default()); + let project_context = ProjectContext::new(worktrees); let tool_names = tools .iter() .map(|tool| tool.name.clone().into()) diff --git a/crates/agent/src/tools/evals/terminal_tool.rs b/crates/agent/src/tools/evals/terminal_tool.rs index c06ce08db44..80856b5a8c8 100644 --- a/crates/agent/src/tools/evals/terminal_tool.rs +++ b/crates/agent/src/tools/evals/terminal_tool.rs @@ -220,7 +220,7 @@ impl TerminalToolTest { abs_path: Path::new("/path/to/root").into(), rules_file: None, }]; - let project_context = ProjectContext::new(worktrees, Vec::default()); + let project_context = ProjectContext::new(worktrees); let tool_names = tools .iter() .map(|tool| tool.name.clone().into()) diff --git a/crates/agent/src/tools/evals/write_file.rs b/crates/agent/src/tools/evals/write_file.rs index b8f3de78587..cc998c7da9e 100644 --- a/crates/agent/src/tools/evals/write_file.rs +++ b/crates/agent/src/tools/evals/write_file.rs @@ -191,7 +191,7 @@ impl WriteToolTest { abs_path: Path::new("/path/to/root").into(), rules_file: None, }]; - let project_context = ProjectContext::new(worktrees, Vec::default()); + let project_context = ProjectContext::new(worktrees); let tool_names = tools .iter() .map(|tool| tool.name.clone().into()) diff --git a/crates/eval_cli/src/main.rs b/crates/eval_cli/src/main.rs index 3a323776ef4..5d45c948868 100644 --- a/crates/eval_cli/src/main.rs +++ b/crates/eval_cli/src/main.rs @@ -560,13 +560,7 @@ async fn run_agent( let agent = cx.update(|cx| { let thread_store = cx.new(|cx| ThreadStore::new(cx)); - NativeAgent::new( - thread_store, - Templates::new(), - None, - app_state.fs.clone(), - cx, - ) + NativeAgent::new(thread_store, Templates::new(), app_state.fs.clone(), cx) }); let connection = Rc::new(NativeAgentConnection(agent.clone())); diff --git a/crates/prompt_store/src/prompts.rs b/crates/prompt_store/src/prompts.rs index 6417f49f85b..328ee93af02 100644 --- a/crates/prompt_store/src/prompts.rs +++ b/crates/prompt_store/src/prompts.rs @@ -19,8 +19,6 @@ use util::{ ResultExt, get_default_system_shell_preferring_bash, rel_path::RelPath, shell::ShellKind, }; -use crate::UserPromptId; - pub const RULES_FILE_NAMES: &[&str] = &[ ".rules", ".cursorrules", @@ -38,14 +36,11 @@ pub struct ProjectContext { pub worktrees: Vec, /// Whether any worktree has a rules_file. Provided as a field because handlebars can't do this. pub has_rules: bool, - pub user_rules: Vec, - /// `!user_rules.is_empty()` - provided as a field because handlebars can't do this. - pub has_user_rules: bool, pub os: String, pub arch: String, pub shell: String, - // Similarly to `has_rules` / `has_user_rules`, `has_skills` is a - // derived flag exposed to the handlebars template (which can't do + // Similarly to `has_rules`, `has_skills` is a derived flag exposed + // to the handlebars template (which can't do // `!skills.is_empty()`). These are `pub(crate)` so the only way to // set them from outside is via `with_skills`, which keeps the two // fields in sync. @@ -54,15 +49,13 @@ pub struct ProjectContext { } impl ProjectContext { - pub fn new(worktrees: Vec, default_user_rules: Vec) -> Self { + pub fn new(worktrees: Vec) -> Self { let has_rules = worktrees .iter() .any(|worktree| worktree.rules_file.is_some()); Self { worktrees, has_rules, - has_user_rules: !default_user_rules.is_empty(), - user_rules: default_user_rules, os: std::env::consts::OS.to_string(), arch: std::env::consts::ARCH.to_string(), shell: ShellKind::new(&get_default_system_shell_preferring_bash(), cfg!(windows)) @@ -91,13 +84,6 @@ impl ProjectContext { } } -#[derive(Debug, Clone, Eq, PartialEq, Serialize)] -pub struct UserRulesContext { - pub uuid: UserPromptId, - pub title: Option, - pub contents: String, -} - #[derive(Debug, Clone, Eq, PartialEq, Serialize)] pub struct WorktreeContext { pub root_name: String, @@ -167,14 +153,14 @@ mod tests { }; let summary = SkillSummary::from(&skill); - let context = ProjectContext::new(vec![], vec![]).with_skills(vec![summary]); + let context = ProjectContext::new(vec![]).with_skills(vec![summary]); assert_eq!(context.skills.len(), 1); assert_eq!(context.skills[0].description, huge_description); } #[test] fn test_empty_skills_sets_has_skills_false() { - let context = ProjectContext::new(vec![], vec![]); + let context = ProjectContext::new(vec![]); assert!(!context.has_skills); assert!(context.skills.is_empty()); } From 63f725e8d6b2bb5ad1651523b689d54b45349574 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 27 May 2026 17:27:18 +0100 Subject: [PATCH 13/93] markdown: Merman (#57644) Big PR that replaces `mermaid-rs` with `merman`. Adds a new crate `mermaid_render` that exposes a simple API for rendering a mermaid diagram to an SVG string. ## Why is it so big? Some of this is explained in the crate-level docs for `mermaid_render`, but the short version is: - `mermaid-rs` emits "good enough" SVGs for most use cases. It also ships with a reasonable default theme - `merman` emits *very accurate* SVGs, but with borderline unusable CSS - Most of the new code in this PR are a series of passes to clean up the output SVG by: - Injecting good CSS - Fixing issues in the `merman`-generated SVGs - Tweaking the final result to avoid issues with `usvg` and `resvg`, which are what will eventually be used to rasterize the SVG - This code *could* be much smaller, but the following design decisions made it take a lot more code: - Using a real XML parser instead of basic string manipulation - Avoiding allocating strings in as many places as possible Because of this, the design is as follows: - First, construct a `merman` theme from the user's theme, and render the mermaid to an SVG string - Post-process - each step is roughly a `fn(Iterator>) -> Iterator>`, where `Event` is the type for events produced by `quick-xml` (a pull-based XML parser) ## Note for reviewers It's a big diff, sorry :sweat_smile: happy to pair review. The new crate is essentially a leaf crate - it does technically depend on `gpui`, but only for the `Hsla` and `Rgba` types. Extracting a new `gpui_color` crate felt like overkill for this already-very-big PR. Each post-process pass is in its own submodule, and has a doc comment explaining the before/after. Note that bugs in this code are perhaps less serious than bugs in other parts of the code: - The code has been thoroughly audited for potentially-panicking code paths - as far as I know, there are none (excluding some `.expect()`s on calls to `write!` with `String`, which is [cannot return `Err`][string write]) - A bug in this code (given that it will not cause a panic) will, at worst, result in an invalid diagram being rendered, or simply falling back to showing the code. - The current `mermaid-rs` renderer *already* does this in quite a lot of cases, sometimes showing outright misleading information. --- Some eye candy: | Before (`mermaid-rs`) | After (`merman`) | | - | - | | image | image | | image | image | | Failed to render | image | | image | image | | image | image | | image | image | | image | image | | image | image | | image | image | | image | image | | image | image | | image | image | | image | image | | image | image | Release Notes: - Improved: Mermaid diagrams now render faster and more accurately [string write]: https://doc.rust-lang.org/src/alloc/string.rs.html#3342-3354 --------- Co-authored-by: Danilo Leal Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- Cargo.lock | 788 +++++++++++++++++- Cargo.toml | 3 +- crates/agent/src/templates/system_prompt.hbs | 8 +- crates/markdown/Cargo.toml | 2 +- crates/markdown/src/markdown.rs | 28 +- crates/markdown/src/mermaid.rs | 281 +++---- crates/mermaid_render/Cargo.toml | 27 + crates/mermaid_render/LICENSE-GPL | 1 + crates/mermaid_render/src/mermaid_render.rs | 181 ++++ crates/mermaid_render/src/postprocess.rs | 136 +++ .../src/postprocess/accent_colors.rs | 375 +++++++++ .../accent_colors/class_diagram.rs | 112 +++ .../src/postprocess/accent_colors/mindmap.rs | 127 +++ .../accent_colors/sequence_diagram.rs | 98 +++ .../src/postprocess/element_fixup.rs | 305 +++++++ .../src/postprocess/fallback_fixup.rs | 223 +++++ .../src/postprocess/foreignobject_wrap.rs | 96 +++ .../src/postprocess/inject_css.rs | 517 ++++++++++++ .../src/postprocess/strip_foreignobject.rs | 114 +++ .../src/postprocess/strip_invalid_css.rs | 161 ++++ crates/mermaid_render/src/postprocess/util.rs | 148 ++++ crates/mermaid_render/src/render.rs | 122 +++ .../tests/check_invalid_attrs.rs | 394 +++++++++ typos.toml | 6 +- 24 files changed, 4068 insertions(+), 185 deletions(-) create mode 100644 crates/mermaid_render/Cargo.toml create mode 120000 crates/mermaid_render/LICENSE-GPL create mode 100644 crates/mermaid_render/src/mermaid_render.rs create mode 100644 crates/mermaid_render/src/postprocess.rs create mode 100644 crates/mermaid_render/src/postprocess/accent_colors.rs create mode 100644 crates/mermaid_render/src/postprocess/accent_colors/class_diagram.rs create mode 100644 crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs create mode 100644 crates/mermaid_render/src/postprocess/accent_colors/sequence_diagram.rs create mode 100644 crates/mermaid_render/src/postprocess/element_fixup.rs create mode 100644 crates/mermaid_render/src/postprocess/fallback_fixup.rs create mode 100644 crates/mermaid_render/src/postprocess/foreignobject_wrap.rs create mode 100644 crates/mermaid_render/src/postprocess/inject_css.rs create mode 100644 crates/mermaid_render/src/postprocess/strip_foreignobject.rs create mode 100644 crates/mermaid_render/src/postprocess/strip_invalid_css.rs create mode 100644 crates/mermaid_render/src/postprocess/util.rs create mode 100644 crates/mermaid_render/src/render.rs create mode 100644 crates/mermaid_render/tests/check_invalid_attrs.rs diff --git a/Cargo.lock b/Cargo.lock index 5514585ffc3..ccddd99f6b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -203,7 +203,7 @@ dependencies = [ "smallvec", "sqlez", "streaming_diff", - "strsim", + "strsim 0.11.1", "task", "telemetry", "tempfile", @@ -603,7 +603,7 @@ version = "4.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" dependencies = [ - "cssparser", + "cssparser 0.35.0", "html5ever 0.35.0", "maplit", "tendril", @@ -716,6 +716,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + [[package]] name = "approx" version = "0.5.1" @@ -802,6 +811,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -1987,6 +2005,12 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bigdecimal" version = "0.4.8" @@ -2048,6 +2072,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -2066,6 +2099,12 @@ dependencies = [ "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -2804,6 +2843,16 @@ dependencies = [ "libc", ] +[[package]] +name = "cgmath" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" +dependencies = [ + "approx 0.4.0", + "num-traits", +] + [[package]] name = "channel" version = "0.1.0" @@ -2932,7 +2981,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", "terminal_size", ] @@ -4354,6 +4403,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + [[package]] name = "cssparser-macros" version = "0.6.1" @@ -4538,6 +4600,16 @@ dependencies = [ "util", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + [[package]] name = "darling" version = "0.20.11" @@ -4568,6 +4640,20 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -4578,7 +4664,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] @@ -4592,7 +4678,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] @@ -4605,10 +4691,21 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -4876,6 +4973,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -5035,6 +5163,16 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -5043,10 +5181,21 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -5175,6 +5324,28 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dugong" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f5b0a9f36306eb29685e6e27b82df4d0bb5af64261324f7d5f7716d7c39ba1b" +dependencies = [ + "dugong-graphlib", + "rustc-hash 2.1.1", + "serde", + "serde_json", +] + +[[package]] +name = "dugong-graphlib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75aca4df30a85b3ba8cead498f4e38e9de4aff630155ce47515d11dfd729c6ea" +dependencies = [ + "hashbrown 0.16.1", + "rustc-hash 2.1.1", +] + [[package]] name = "dunce" version = "1.0.5" @@ -5660,6 +5831,15 @@ dependencies = [ "phf 0.11.3", ] +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -7176,6 +7356,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -7474,6 +7663,114 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glam" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da" + +[[package]] +name = "glam" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b" + +[[package]] +name = "glam" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16" + +[[package]] +name = "glam" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776" + +[[package]] +name = "glam" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34" + +[[package]] +name = "glam" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca" + +[[package]] +name = "glam" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f" + +[[package]] +name = "glam" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815" + +[[package]] +name = "glam" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774" + +[[package]] +name = "glam" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c" + +[[package]] +name = "glam" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945" + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glam" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" + +[[package]] +name = "glam" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94" + +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" + +[[package]] +name = "glam" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" + +[[package]] +name = "glam" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" + [[package]] name = "glib" version = "0.21.5" @@ -8378,6 +8675,19 @@ dependencies = [ "regex", ] +[[package]] +name = "htmlize" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e815d50d9e411ba2690d730e6ec139c08260dddb756df315dbd16d01a587226" +dependencies = [ + "memchr", + "pastey 0.1.1", + "phf 0.13.1", + "phf_codegen 0.13.1", + "serde_json", +] + [[package]] name = "http" version = "0.2.12" @@ -9262,12 +9572,13 @@ dependencies = [ [[package]] name = "json5" -version = "1.3.1" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" dependencies = [ + "pest", + "pest_derive", "serde", - "ucd-trie", ] [[package]] @@ -9471,6 +9782,15 @@ dependencies = [ "libc", ] +[[package]] +name = "kurbo" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd85a5776cd9500c2e2059c8c76c3b01528566b7fcbaf8098b55a33fc298849b" +dependencies = [ + "arrayvec", +] + [[package]] name = "kurbo" version = "0.11.3" @@ -9491,6 +9811,37 @@ dependencies = [ "log", ] +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set 0.5.3", + "ena", + "itertools 0.11.0", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] + [[package]] name = "language" version = "0.1.0" @@ -9530,7 +9881,7 @@ dependencies = [ "shellexpand", "smallvec", "streaming-iterator", - "strsim", + "strsim 0.11.1", "sum_tree", "task", "text", @@ -10275,6 +10626,58 @@ dependencies = [ "value-bag", ] +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + +[[package]] +name = "lol_html" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6888e8653f6e49cb2924c660fc367a8beeb6239b71e117fa082153c6ea44d427" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cssparser 0.36.0", + "encoding_rs", + "foldhash 0.2.0", + "hashbrown 0.16.1", + "memchr", + "mime", + "precomputed-hash", + "selectors", + "thiserror 2.0.17", +] + [[package]] name = "loom" version = "0.7.2" @@ -10444,6 +10847,18 @@ dependencies = [ "libc", ] +[[package]] +name = "manatee" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5ed3cc0bf5f911d242bed4b4cdf12c00186e471e9fdf57d9dd0a033bbbdc87" +dependencies = [ + "indexmap 2.11.4", + "nalgebra", + "rustc-hash 2.1.1", + "thiserror 2.0.17", +] + [[package]] name = "maplit" version = "1.0.2" @@ -10469,7 +10884,7 @@ dependencies = [ "linkify", "log", "markup5ever_rcdom", - "mermaid-rs-renderer", + "mermaid_render", "node_runtime", "pulldown-cmark 0.13.0", "settings", @@ -10513,7 +10928,7 @@ checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" dependencies = [ "log", "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", "string_cache", "string_cache_codegen", "tendril", @@ -10568,6 +10983,16 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-owned" version = "0.3.4" @@ -10708,19 +11133,78 @@ dependencies = [ ] [[package]] -name = "mermaid-rs-renderer" -version = "0.2.2" -source = "git+https://github.com/zed-industries/mermaid-rs-renderer?rev=782b89a7da3f0e91e51f98d00a93acba679be6fb#782b89a7da3f0e91e51f98d00a93acba679be6fb" +name = "mermaid_render" +version = "0.1.0" dependencies = [ "anyhow", - "fontdb", + "gpui", + "mermaid_render", + "merman", + "quick-xml 0.38.3", + "serde_json", +] + +[[package]] +name = "merman" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3209bcfe9c8e9787a7534f8d97f1d27f7d2fdd54d3c49d9f59bb693aedabbf95" +dependencies = [ + "merman-core", + "merman-render", + "thiserror 2.0.17", +] + +[[package]] +name = "merman-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72fc438439ca428b449486f8eaf9ce2f8ab76cea159181d0b1b454e1192e4649" +dependencies = [ + "chrono", + "euclid", + "htmlize", + "indexmap 2.11.4", "json5", - "once_cell", + "lalrpop", + "lalrpop-util", + "logos", + "lol_html", "regex", + "rustc-hash 2.1.1", + "ryu-js", "serde", "serde_json", + "serde_yaml", "thiserror 2.0.17", - "ttf-parser", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "merman-render" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5698e2681196051479ae8bc5153ed7eda6490f56240fd8d92da7c3932a00735" +dependencies = [ + "base64 0.22.1", + "chrono", + "dugong", + "indexmap 2.11.4", + "manatee", + "merman-core", + "pulldown-cmark 0.12.2", + "regex", + "roughr-merman", + "rustc-hash 2.1.1", + "ryu-js", + "serde", + "serde_json", + "svgtypes 0.11.0", + "thiserror 2.0.17", + "unicode-width", + "url", ] [[package]] @@ -11022,6 +11506,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "nalgebra" +version = "0.34.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df76ea0ff5c7e6b88689085804d6132ded0ddb9de5ca5b8aeb9eeadc0508a70a" +dependencies = [ + "approx 0.5.1", + "glam 0.14.0", + "glam 0.15.2", + "glam 0.16.0", + "glam 0.17.3", + "glam 0.18.0", + "glam 0.19.0", + "glam 0.20.5", + "glam 0.21.3", + "glam 0.22.0", + "glam 0.23.0", + "glam 0.24.2", + "glam 0.25.0", + "glam 0.27.0", + "glam 0.28.0", + "glam 0.29.3", + "glam 0.30.10", + "glam 0.31.1", + "glam 0.32.1", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + [[package]] name = "nanoid" version = "0.4.0" @@ -12173,9 +12690,10 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" dependencies = [ - "approx", + "approx 0.5.1", "fast-srgb8", "palette_derive", + "phf 0.11.3", ] [[package]] @@ -12925,6 +13443,17 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + [[package]] name = "phf_codegen" version = "0.11.3" @@ -12935,6 +13464,16 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -12955,6 +13494,16 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.3.0", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" @@ -12981,6 +13530,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -12999,6 +13561,15 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.1", +] + [[package]] name = "picker" version = "0.1.0" @@ -13193,6 +13764,16 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "points_on_curve" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca77ae128f56aad518f82cf0af3dcda13b874e59a608dbb287c7887fec97b505" +dependencies = [ + "euclid", + "num-traits", +] + [[package]] name = "polling" version = "3.11.0" @@ -13915,7 +14496,20 @@ checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ "bitflags 2.10.0", "memchr", - "pulldown-cmark-escape", + "pulldown-cmark-escape 0.10.1", + "unicase", +] + +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags 2.10.0", + "getopts", + "memchr", + "pulldown-cmark-escape 0.11.0", "unicase", ] @@ -13936,6 +14530,12 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pulley-interpreter" version = "36.0.9" @@ -14359,6 +14959,12 @@ dependencies = [ "objc2-quartz-core", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.11.0" @@ -14488,6 +15094,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -14908,7 +15525,7 @@ dependencies = [ "log", "pico-args", "rgb", - "svgtypes", + "svgtypes 0.15.3", "tiny-skia", "usvg", "zune-jpeg 0.4.21", @@ -15068,6 +15685,22 @@ dependencies = [ "ztracing", ] +[[package]] +name = "roughr-merman" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34fd013a888d8b2119b3ed57cd3df0f1f9dd2db3fa94fdb98f58d60f9855ef0" +dependencies = [ + "derive_builder", + "euclid", + "num-traits", + "palette", + "points_on_curve", + "rand 0.8.6", + "svg_path_ops", + "svgtypes 0.11.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -15508,12 +16141,27 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" + [[package]] name = "saa" version = "5.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0ba8adb63e0deebd0744d8fc5bea394c08029159deaf680513fec1a3949144" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "safetensors" version = "0.4.5" @@ -15885,6 +16533,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2" +dependencies = [ + "bitflags 2.10.0", + "cssparser 0.36.0", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash 2.1.1", + "servo_arc", + "smallvec", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -16115,6 +16782,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "session" version = "0.1.0" @@ -16456,6 +17132,19 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simba" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +dependencies = [ + "approx 0.5.1", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -17137,6 +17826,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -17296,6 +17991,16 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +[[package]] +name = "svg_path_ops" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2ed183bad71dff813db12a317785a8565c9b44732cca3c2effd40a06eb9cd28" +dependencies = [ + "cgmath", + "svgtypes 0.11.0", +] + [[package]] name = "svg_preview" version = "0.1.0" @@ -17309,13 +18014,23 @@ dependencies = [ "zed_actions", ] +[[package]] +name = "svgtypes" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4b0611e7f3277f68c0fa18e385d9e2d26923691379690039548f867cef02a7" +dependencies = [ + "kurbo 0.9.5", + "siphasher 0.3.11", +] + [[package]] name = "svgtypes" version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" dependencies = [ - "kurbo", + "kurbo 0.11.3", "siphasher 1.0.1", ] @@ -17857,6 +18572,17 @@ dependencies = [ "utf-8", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -19389,7 +20115,7 @@ dependencies = [ "flate2", "fontdb", "imagesize", - "kurbo", + "kurbo 0.11.3", "log", "pico-args", "roxmltree", @@ -19397,7 +20123,7 @@ dependencies = [ "simplecss", "siphasher 1.0.1", "strict-num", - "svgtypes", + "svgtypes 0.15.3", "tiny-skia-path", "unicode-bidi", "unicode-script", @@ -20495,7 +21221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" dependencies = [ "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", "string_cache", "string_cache_codegen", ] @@ -20789,6 +21515,16 @@ dependencies = [ "wasite", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "wiggle" version = "36.0.9" diff --git a/Cargo.toml b/Cargo.toml index 9880a3f517f..4441be03839 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,6 +130,7 @@ members = [ "crates/lsp", "crates/markdown", "crates/markdown_preview", + "crates/mermaid_render", "crates/media", "crates/menu", "crates/migrator", @@ -389,10 +390,10 @@ lmstudio = { path = "crates/lmstudio" } lsp = { path = "crates/lsp" } markdown = { path = "crates/markdown" } markdown_preview = { path = "crates/markdown_preview" } +mermaid_render = { path = "crates/mermaid_render" } svg_preview = { path = "crates/svg_preview" } media = { path = "crates/media" } menu = { path = "crates/menu" } -mermaid-rs-renderer = { git = "https://github.com/zed-industries/mermaid-rs-renderer", rev = "782b89a7da3f0e91e51f98d00a93acba679be6fb", default-features = false } migrator = { path = "crates/migrator" } mistral = { path = "crates/mistral" } multi_buffer = { path = "crates/multi_buffer" } diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index c7dffcdfe7e..8e90d0970eb 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -23,15 +23,13 @@ graph TD A[Start] --> B[End] ``` +The renderer supports the following diagram types: flowchart, sequence, class, state, ER, gantt, pie, gitgraph, mindmap, timeline, quadrant chart, xy chart, and journey. Other diagram types will only show as code. + Mermaid diagrams are automatically themed to match the user's editor theme. Do not include `%%{init}%%` directives or define your own `classDef` styles. Do *NOT* include inline HTML elements in mermaid diagrams, as they cannot be rendered. It is better to simply skip formatting (e.g. bold/italic/etc.). -When you need accent colors for emphasis (e.g. color-coding layers, categories, or states), use the pre-defined classes `accent0` through `accent7` with the `:::` syntax: - - A:::accent0 --> B:::accent1 --> C:::accent2 - -These classes automatically match the user's theme. Do not hardcode hex color values unless an exact color match is specifically required. Note that the rendered view may be narrow, so try to prioritize generating taller diagrams over wider ones. +Mermaid diagrams are automatically color-coded using the user's theme accent palette. Do not hardcode hex color values unless an exact color match is specifically required. Note that the rendered view may be narrow, so try to prioritize generating taller diagrams over wider ones. {{#if (gt (len available_tools) 0)}} ## Tool Use diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index be12bf2fe7f..3982cf9506a 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -29,7 +29,7 @@ language.workspace = true linkify.workspace = true log.workspace = true markup5ever_rcdom.workspace = true -mermaid-rs-renderer.workspace = true +mermaid_render = { path = "../mermaid_render" } pulldown-cmark.workspace = true settings.workspace = true stacksafe.workspace = true diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index f1b0154b454..4cbcbe85678 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -34,8 +34,8 @@ use gpui::{ FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image, ImageFormat, ImageSource, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, - StyleRefinement, StyledImage, StyledText, Task, TextAlign, TextLayout, TextRun, TextStyle, - TextStyleRefinement, actions, img, point, quad, + StyleRefinement, StyledImage, StyledText, Subscription, Task, TextAlign, TextLayout, TextRun, + TextStyle, TextStyleRefinement, actions, img, point, quad, }; use language::{CharClassifier, Language, LanguageRegistry, Rope}; use parser::CodeBlockMetadata; @@ -333,6 +333,7 @@ pub struct Markdown { fallback_code_block_language: Option, options: MarkdownOptions, mermaid_state: MermaidState, + _mermaid_theme_subscription: Option, mermaid_showing_code: HashSet, copied_code_blocks: HashSet, wrapped_code_blocks: HashSet, @@ -497,6 +498,16 @@ impl Markdown { cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); + + let theme_subscription = if options.render_mermaid_diagrams { + Some( + cx.observe_global::(|this: &mut Self, cx| { + this.invalidate_mermaid_cache(cx); + }), + ) + } else { + None + }; let mut this = Self { source, selection: Selection::default(), @@ -513,6 +524,7 @@ impl Markdown { fallback_code_block_language, options, mermaid_state: MermaidState::default(), + _mermaid_theme_subscription: theme_subscription, mermaid_showing_code: HashSet::default(), copied_code_blocks: HashSet::default(), wrapped_code_blocks: HashSet::default(), @@ -561,15 +573,15 @@ impl Markdown { .retain(|id, _| ids.contains(id)); } - /// Used in the agent panel to force a re-render when the theme changes pub fn invalidate_mermaid_cache(&mut self, cx: &mut Context) { - if self.options.render_mermaid_diagrams && !self.parsed_markdown.mermaid_diagrams.is_empty() + if !self.options.render_mermaid_diagrams || self.parsed_markdown.mermaid_diagrams.is_empty() { - self.mermaid_state.clear(); - let parsed_markdown = self.parsed_markdown.clone(); - self.mermaid_state.update(&parsed_markdown, cx); - cx.notify(); + return; } + + self.mermaid_state.clear(); + self.mermaid_state.update(&self.parsed_markdown, cx); + cx.notify(); } pub(crate) fn is_mermaid_showing_code(&self, source_offset: usize) -> bool { diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs index 019cb6d78ad..8730c318f0c 100644 --- a/crates/markdown/src/mermaid.rs +++ b/crates/markdown/src/mermaid.rs @@ -1,7 +1,7 @@ use collections::HashMap; use gpui::{ - Animation, AnimationExt, AnyElement, ClickEvent, ClipboardItem, Context, Entity, Hsla, - ImageSource, RenderImage, Rgba, StyledText, Task, img, pulsating_between, + Animation, AnimationExt, AnyElement, ClickEvent, ClipboardItem, Context, Entity, ImageSource, + RenderImage, StyledText, Task, img, pulsating_between, }; use std::collections::BTreeMap; use std::ops::Range; @@ -104,18 +104,12 @@ impl CachedMermaidDiagram { let render_image_clone = render_image.clone(); let svg_renderer = cx.svg_renderer(); let mermaid_theme = build_mermaid_theme(cx); - let accent_classdefs = build_accent_classdefs(cx); let task = cx.spawn(async move |this, cx| { let value = cx .background_spawn(async move { - let options = mermaid_rs_renderer::RenderOptions { - theme: mermaid_theme, - layout: mermaid_rs_renderer::LayoutConfig::default(), - }; - let full_source = format!("{}\n{}", contents.contents, accent_classdefs); let svg_string = - mermaid_rs_renderer::render_with_options(&full_source, options)?; + mermaid_render::render_to_svg(&contents.contents, &mermaid_theme)?; let scale = contents.scale as f32 / 100.0; svg_renderer .render_single_frame(svg_string.as_bytes(), scale) @@ -153,128 +147,71 @@ impl CachedMermaidDiagram { } } -/// Converts an HSLA color to a CSS hex string (e.g. `#1a2b3c`). -fn hsla_to_hex(color: Hsla) -> String { - let rgba: Rgba = color.to_rgb(); - let r = (rgba.r * 255.0).round() as u8; - let g = (rgba.g * 255.0).round() as u8; - let b = (rgba.b * 255.0).round() as u8; - format!("#{r:02x}{g:02x}{b:02x}") +/// Merman has somewhat limited text measurement capabilities. +/// +/// When it doesn't have metrics for any of the specified fonts, it chooses a +/// fairly narrow width, which causes visible overflow. Adding `sans-serif` +/// allows it to fall back to a more conservative (i.e. wider) measurement. +/// +/// This isn't perfect - very wide fonts will likely still cause overflow. A +/// proper fix would involve somehow piping `resvg`'s actual measurements into +/// `merman`, but that is a lot of work for a fairly uncommon edge case. +fn mermaid_font_family(font_family: &str) -> String { + let font_family = gpui::font_name_with_fallbacks(font_family, "system-ui"); + if font_family + .split(',') + .any(|family| family.trim().eq_ignore_ascii_case("sans-serif")) + { + font_family.to_string() + } else { + format!("{font_family}, sans-serif") + } } -fn mermaid_font_family(font_family: &str) -> &str { - gpui::font_name_with_fallbacks(font_family, "system-ui") -} - -fn build_mermaid_theme(cx: &Context) -> mermaid_rs_renderer::Theme { +fn build_mermaid_theme(cx: &Context) -> mermaid_render::MermaidTheme { let colors = cx.theme().colors(); let theme_settings = ThemeSettings::get_global(cx); - let mut theme = mermaid_rs_renderer::Theme::modern(); - - theme.font_family = mermaid_font_family(theme_settings.ui_font.family.as_ref()).to_string(); - theme.background = hsla_to_hex(colors.editor_background); - theme.primary_color = hsla_to_hex(colors.surface_background); - theme.primary_text_color = hsla_to_hex(colors.text); - theme.primary_border_color = hsla_to_hex(colors.border); - theme.line_color = hsla_to_hex(colors.border); - theme.secondary_color = hsla_to_hex(colors.element_background); - theme.tertiary_color = hsla_to_hex(colors.ghost_element_hover); - theme.edge_label_background = hsla_to_hex(colors.editor_background); - theme.cluster_background = hsla_to_hex(colors.panel_background); - theme.cluster_border = hsla_to_hex(colors.border_variant); - theme.text_color = hsla_to_hex(colors.text); - let accents = cx.theme().accents(); - let pie_colors: [String; 12] = - std::array::from_fn(|i| hsla_to_hex(accents.color_for_index(i as u32))); - theme.pie_colors = pie_colors; - theme.pie_title_text_color = hsla_to_hex(colors.text); - theme.pie_section_text_color = "#fff".to_string(); - theme.pie_legend_text_color = hsla_to_hex(colors.text); - theme.pie_stroke_color = hsla_to_hex(colors.border); - theme.pie_outer_stroke_color = hsla_to_hex(colors.border); - - theme.sequence_actor_fill = hsla_to_hex(colors.element_background); - theme.sequence_actor_border = hsla_to_hex(colors.border); - theme.sequence_actor_line = hsla_to_hex(colors.border); - theme.sequence_note_fill = hsla_to_hex(colors.surface_background); - theme.sequence_note_border = hsla_to_hex(colors.border_variant); - theme.sequence_activation_fill = hsla_to_hex(colors.ghost_element_hover); - theme.sequence_activation_border = hsla_to_hex(colors.border); + let is_dark = !cx.theme().appearance.is_light(); let players = cx.theme().players(); - theme.git_colors = std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].cursor)); - theme.git_inv_colors = - std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].background)); - theme.git_branch_label_colors = std::array::from_fn(|_| "#fff".to_string()); - theme.git_commit_label_color = hsla_to_hex(colors.text); - theme.git_commit_label_background = hsla_to_hex(colors.element_background); - theme.git_tag_label_color = hsla_to_hex(colors.text); - theme.git_tag_label_background = hsla_to_hex(colors.element_background); - theme.git_tag_label_border = hsla_to_hex(colors.border); + let git_branch_colors = std::array::from_fn(|i| players.0[i % players.0.len()].cursor); + let git_branch_label_colors = git_branch_colors.map(mermaid_render::text_color_for_background); - theme -} - -fn build_accent_classdefs(cx: &Context) -> String { - use std::fmt::Write; - let players = &cx.theme().players(); - let is_light = cx.theme().appearance.is_light(); - let mut defs = String::new(); - for (i, player) in players.0.iter().enumerate() { - let (fill, text_color) = accent_fill_and_text(player.background, is_light); - let fill = hsla_to_hex(fill); - let stroke = hsla_to_hex(player.cursor); - let text_color = hsla_to_hex(text_color); - writeln!( - defs, - "classDef accent{i} fill:{fill},stroke:{stroke},color:{text_color}" - ) - .ok(); + mermaid_render::MermaidTheme { + dark_mode: is_dark, + font_family: mermaid_font_family(theme_settings.ui_font.family.as_ref()), + background: colors.editor_background, + primary_color: colors.surface_background, + primary_text_color: colors.text, + primary_border_color: colors.border, + secondary_color: colors.element_background, + tertiary_color: colors.ghost_element_hover, + line_color: colors.border, + text_color: colors.text, + edge_label_background: colors.editor_background, + cluster_background: colors.panel_background, + cluster_border: colors.border_variant, + note_background: colors.surface_background, + note_border: colors.border_variant, + actor_background: colors.element_background, + actor_border: colors.border, + activation_background: colors.ghost_element_hover, + activation_border: colors.border, + git_branch_colors, + git_branch_label_colors, + er_attr_bg_odd: colors.surface_background, + er_attr_bg_even: colors.element_background, + error_color: cx.theme().status().error, + warning_color: cx.theme().status().warning, + accent_colors: players + .0 + .iter() + .map(|player| mermaid_render::AccentColor { + foreground: player.cursor, + background: player.background, + }) + .collect(), } - defs -} - -/// Adjusts an accent fill color to ensure readable text contrast. -/// -/// On dark themes, darkens the fill and uses white text. -/// On light themes, lightens the fill and uses black text. -/// The fill is adjusted until it meets a minimum WCAG contrast ratio -/// of ~4.5:1 against the chosen text color. -fn accent_fill_and_text(color: Hsla, is_light: bool) -> (Hsla, Hsla) { - let mut fill = color; - if is_light { - // Lighten fill until luminance is high enough for black text. - // Target: relative luminance >= 0.35 → contrast ratio ~8:1 with black. - for _ in 0..50 { - if relative_luminance(fill) >= 0.35 { - break; - } - fill.l = (fill.l + 0.02).min(1.0); - } - (fill, gpui::black()) - } else { - // Darken fill until luminance is low enough for white text. - // Target: relative luminance <= 0.18 → contrast ratio ~4.6:1 with white. - for _ in 0..50 { - if relative_luminance(fill) <= 0.18 { - break; - } - fill.l = (fill.l - 0.02).max(0.0); - } - (fill, gpui::white()) - } -} - -fn relative_luminance(color: Hsla) -> f32 { - let rgba: Rgba = color.to_rgb(); - fn linearize(c: f32) -> f32 { - if c <= 0.04045 { - c / 12.92 - } else { - ((c + 0.055) / 1.055).powf(2.4) - } - } - 0.2126 * linearize(rgba.r) + 0.7152 * linearize(rgba.g) + 0.0722 * linearize(rgba.b) } fn parse_mermaid_info(info: &str) -> Option { @@ -292,6 +229,38 @@ fn parse_mermaid_info(info: &str) -> Option { ) } +/// We deliberately block rendering of some diagram types, even though `merman` +/// supports them, because we have not yet written custom CSS to ensure text is +/// readable. +fn is_supported_diagram_type(source: &str) -> bool { + /// If updating this list, also update the system prompt! + const SUPPORTED_PREFIXES: &[&str] = &[ + "flowchart", + "graph", + "sequenceDiagram", + "classDiagram", + "stateDiagram", + "stateDiagram-v2", + "erDiagram", + "gantt", + "pie", + "gitGraph", + "mindmap", + "timeline", + "quadrantChart", + "xychart-beta", + "journey", + ]; + let first_token = source + .trim_start() + .split(|c: char| c.is_whitespace() || c == '\n') + .next() + .unwrap_or(""); + SUPPORTED_PREFIXES + .iter() + .any(|prefix| first_token.eq_ignore_ascii_case(prefix)) +} + pub(crate) fn extract_mermaid_diagrams( source: &str, events: &[(Range, MarkdownEvent)], @@ -324,6 +293,9 @@ pub(crate) fn extract_mermaid_diagrams( .strip_suffix('\n') .unwrap_or(&source[metadata.content_range.clone()]) .to_string(); + if !is_supported_diagram_type(&contents) { + continue; + } mermaid_diagrams.insert( source_range.start, ParsedMarkdownMermaidDiagram { @@ -588,24 +560,10 @@ mod tests { MarkdownStyle, WrapButtonVisibility, }; use collections::HashMap; - use gpui::{Context, Hsla, IntoElement, Render, RenderImage, TestAppContext, Window, size}; + use gpui::{Context, IntoElement, Render, RenderImage, TestAppContext, Window, size}; use std::sync::Arc; use ui::prelude::*; - #[gpui::property_test] - fn accent_fill_and_text_sufficient_contrast( - #[strategy = Hsla::opaque_strategy()] color: Hsla, - light_mode: bool, - ) { - let (fill, text) = super::accent_fill_and_text(color, light_mode); - let fill_luminance = super::relative_luminance(fill); - let text_luminance = super::relative_luminance(text); - let lighter = fill_luminance.max(text_luminance); - let darker = fill_luminance.min(text_luminance); - let contrast_ratio = (lighter + 0.05) / (darker + 0.05); - assert!(contrast_ratio >= 4.5,); - } - fn ensure_theme_initialized(cx: &mut TestAppContext) { cx.update(|cx| { if !cx.has_global::() { @@ -693,11 +651,27 @@ mod tests { #[test] fn test_mermaid_font_family_resolves_zed_virtual_fonts() { - assert_eq!(super::mermaid_font_family(".ZedSans"), "IBM Plex Sans"); - assert_eq!(super::mermaid_font_family("Zed Plex Sans"), "IBM Plex Sans"); - assert_eq!(super::mermaid_font_family(".ZedMono"), "Lilex"); - assert_eq!(super::mermaid_font_family(".SystemUIFont"), "system-ui"); - assert_eq!(super::mermaid_font_family("Custom Font"), "Custom Font"); + assert_eq!( + super::mermaid_font_family(".ZedSans"), + "IBM Plex Sans, sans-serif" + ); + assert_eq!( + super::mermaid_font_family("Zed Plex Sans"), + "IBM Plex Sans, sans-serif" + ); + assert_eq!(super::mermaid_font_family(".ZedMono"), "Lilex, sans-serif"); + assert_eq!( + super::mermaid_font_family(".SystemUIFont"), + "system-ui, sans-serif" + ); + assert_eq!( + super::mermaid_font_family("Custom Font"), + "Custom Font, sans-serif" + ); + assert_eq!( + super::mermaid_font_family("Custom Font, sans-serif"), + "Custom Font, sans-serif" + ); } #[test] @@ -721,6 +695,27 @@ mod tests { assert_eq!(diagram.contents.scale, 150); } + #[test] + fn test_unsupported_diagram_types_are_skipped() { + let markdown = concat!( + "```mermaid\nsankey-beta\n```\n\n", + "```mermaid\nblock-beta\n```\n\n", + "```mermaid\nflowchart TD\n A --> B\n```", + ); + let events = crate::parser::parse_markdown_with_options(markdown, false, false).events; + let diagrams = extract_mermaid_diagrams(markdown, &events); + assert_eq!( + diagrams.len(), + 1, + "Only the flowchart should be extracted; sankey and block should be skipped" + ); + let diagram = diagrams.values().next().unwrap(); + assert!( + diagram.contents.contents.contains("flowchart"), + "The extracted diagram should be the flowchart" + ); + } + #[gpui::test] fn test_mermaid_fallback_on_edit(cx: &mut TestAppContext) { let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]); diff --git a/crates/mermaid_render/Cargo.toml b/crates/mermaid_render/Cargo.toml new file mode 100644 index 00000000000..73a32ba81fa --- /dev/null +++ b/crates/mermaid_render/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "mermaid_render" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/mermaid_render.rs" +doctest = false + +[features] +test-support = [] + +[dependencies] +anyhow.workspace = true +gpui.workspace = true +merman = { version = "0.4", features = ["render"] } +quick-xml.workspace = true +serde_json.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +mermaid_render = { path = ".", features = ["test-support"] } diff --git a/crates/mermaid_render/LICENSE-GPL b/crates/mermaid_render/LICENSE-GPL new file mode 120000 index 00000000000..89e542f750c --- /dev/null +++ b/crates/mermaid_render/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/mermaid_render/src/mermaid_render.rs b/crates/mermaid_render/src/mermaid_render.rs new file mode 100644 index 00000000000..1e17d8d780b --- /dev/null +++ b/crates/mermaid_render/src/mermaid_render.rs @@ -0,0 +1,181 @@ +// for a very big json! macro +#![recursion_limit = "256"] + +//! Crate for rendering Mermaid diagram strings to SVG strings. +//! +//! The entrypoint to this crate is [`render_to_svg`]. +//! +//! It takes a `&str` and a [`MermaidTheme`]. The output is an SVG with the +//! following properties: +//! - The style matches the provided theme +//! - Nodes are given accent colors, even if none are provided in the mermaid +//! source. +//! - The SVG has been tweaked based on the assumption that it will be rasterized +//! using `usvg`/`resvg`. Some bugs/quirks of `usvg`/`resvg` are accounted for +//! in this crate. +//! +//! This module uses the [`merman`] crate for rendering, rather than +//! `mermaid-rs`, which was used in the previous implementation of mermaid +//! rendering in Zed. Merman provides significantly more accurate rendering, and +//! seems to be somewhat faster, but by default has poor CSS, making diagrams +//! look weird without significant cleanup. This is made worse by the fact that +//! `usvg`/`resvg` doesn't support some features that [`merman`] relies on. +//! +//! As such, this crate is quite large. But the code is very self-contained, and +//! has few dependencies. In fact, the [`gpui`] dependency is only needed for +//! the [`Hsla`] and [`Rgba`] color types. +//! +//! The [`render_to_svg`] function operates in two stages: +//! - [`render`] the mermaid text to SVG using [`merman`]. +//! - [`postprocess`] the SVG to clean incorrect output and add styling. +//! +//! The postprocessing is also split up into stages. We parse the generated SVG +//! using [`quick_xml`], which produces an iterator of +//! [`Event<'_>`](quick_xml::events::Event)s. This iterator is then repeatedly +//! transformed, and finally collected back into an SVG string. +//! +//! This approach: +//! - Avoids doing multiple expensive string insertions. +//! - Avoids parsing the SVG multiple times (without needing to put all the +//! logic in one huge function). +//! - But is quite a bit more complex. +//! +//! I think this complexity is justified because of the drastic performance +//! impact, as well as the low-risk nature; this code cannot panic, and errors +//! in the output just produce weird-looking diagrams. +//! +//! ## Color handling +//! +//! We try to match the users theme, and also apply accent colors to diagrams to +//! make them more visually interesting. Accent colors are derived from the +//! `player_colors` in the Zed theme. +//! +//! There are three parts to color handling: +//! +//! 1. A [`merman::MermaidConfig`] is passed when initially rendering the +//! diagram. This sets most "normal" colors (background, text, etc.). However, +//! it's not possible to color nodes individually, and not all parts of the +//! diagrams are correctly themed. +//! 2. `postprocess::accent_colors` injects custom CSS classes (e.g. +//! `zed-accent-0`) to specific elements, based on the diagram type and +//! node. +//! 3. `postprocess::inject_css` injects CSS rules for the classes applied by +//! `accent_colors` + +mod postprocess; +mod render; + +use anyhow::Result; +use gpui::{Hsla, Rgba}; + +#[derive(Debug, Clone, Copy)] +pub struct AccentColor { + pub foreground: Hsla, + pub background: Hsla, +} + +#[derive(Debug, Clone)] +pub struct MermaidTheme { + pub dark_mode: bool, + pub font_family: String, + pub background: Hsla, + pub primary_color: Hsla, + pub primary_text_color: Hsla, + pub primary_border_color: Hsla, + pub secondary_color: Hsla, + pub tertiary_color: Hsla, + pub line_color: Hsla, + pub text_color: Hsla, + pub edge_label_background: Hsla, + pub cluster_background: Hsla, + pub cluster_border: Hsla, + pub note_background: Hsla, + pub note_border: Hsla, + pub actor_background: Hsla, + pub actor_border: Hsla, + pub activation_background: Hsla, + pub activation_border: Hsla, + pub git_branch_colors: [Hsla; 8], + pub git_branch_label_colors: [Hsla; 8], + pub er_attr_bg_odd: Hsla, + pub er_attr_bg_even: Hsla, + pub error_color: Hsla, + pub warning_color: Hsla, + pub accent_colors: Vec, +} + +/// Default theme for testing. +#[cfg(any(test, feature = "test-support"))] +impl Default for MermaidTheme { + fn default() -> Self { + use gpui::{hsla, rgb}; + let git_branch_colors: [Hsla; 8] = [ + hsla(240.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(60.0 / 360.0, 1.0, 0.435_294_12, 1.0), + hsla(80.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(210.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(180.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(150.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(300.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(0.0, 1.0, 0.462_745_1, 1.0), + ]; + let git_branch_label_colors: [Hsla; 8] = + git_branch_colors.map(crate::text_color_for_background); + + Self { + dark_mode: false, + font_family: "Inter, ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", \"DejaVu Sans\", \"Liberation Sans\", sans-serif, \"Noto Color Emoji\", \"Apple Color Emoji\", \"Segoe UI Emoji\"".to_string(), + background: rgb(0xFFFFFF).into(), + primary_color: rgb(0xF8FAFC).into(), + primary_text_color: rgb(0x0F172A).into(), + primary_border_color: rgb(0x94A3B8).into(), + secondary_color: rgb(0xE2E8F0).into(), + tertiary_color: rgb(0xFFFFFF).into(), + line_color: rgb(0x64748B).into(), + text_color: rgb(0x0F172A).into(), + edge_label_background: rgb(0xFFFFFF).into(), + cluster_background: rgb(0xF1F5F9).into(), + cluster_border: rgb(0xCBD5E1).into(), + note_background: rgb(0xFFF7ED).into(), + note_border: rgb(0xFDBA74).into(), + actor_background: rgb(0xF8FAFC).into(), + actor_border: rgb(0x94A3B8).into(), + activation_background: rgb(0xE2E8F0).into(), + activation_border: rgb(0x94A3B8).into(), + git_branch_colors, + git_branch_label_colors, + er_attr_bg_odd: rgb(0x94A3B8).into(), + er_attr_bg_even: rgb(0x0F172A).into(), + error_color: rgb(0xDC2626).into(), + warning_color: rgb(0xD97706).into(), + accent_colors: Vec::new(), + } + } +} + +/// Formats a color as a CSS hex color for embedding in SVG/CSS. +/// +/// Emits `#rrggbb` for fully opaque colors and `#rrggbbaa` when the input +/// has any transparency, so translucent theme colors (e.g. `ghost_element_hover` +/// from Zed's UI palette) round-trip without silently losing their alpha. +pub(crate) fn css_color(color: Hsla) -> String { + let rgba = Rgba::from(color); + let r = (rgba.r.clamp(0.0, 1.0) * 255.0).round() as u8; + let g = (rgba.g.clamp(0.0, 1.0) * 255.0).round() as u8; + let b = (rgba.b.clamp(0.0, 1.0) * 255.0).round() as u8; + let a = (rgba.a.clamp(0.0, 1.0) * 255.0).round() as u8; + if a == 0xff { + format!("#{r:02x}{g:02x}{b:02x}") + } else { + format!("#{r:02x}{g:02x}{b:02x}{a:02x}") + } +} + +pub use postprocess::util::text_color_for_background; + +/// See the [module-level docs][crate] for more info. +pub fn render_to_svg(source: &str, theme: &MermaidTheme) -> Result { + let svg = render::render_mermaid(source, theme)?; + let svg = postprocess::postprocess(&svg, theme)?; + Ok(svg) +} diff --git a/crates/mermaid_render/src/postprocess.rs b/crates/mermaid_render/src/postprocess.rs new file mode 100644 index 00000000000..af1e61f3367 --- /dev/null +++ b/crates/mermaid_render/src/postprocess.rs @@ -0,0 +1,136 @@ +//! Post-processing of [`merman`]-produced SVGs for rasterization with `usvg`/`resvg`. +//! +//! Each submodule is a specific pass that tweaks the SVG event iterator in a particular way. +//! +//! We always produce and consume [`Event`]s with a short lifetime. +//! [`Event<'a>`] is backed internally by a [`Cow<'a, [u8]>`](std::borrow::Cow), +//! so we don't have lifetime issues when we need to mutate the text in an +//! [`Event`], but also don't force allocating a new [`String`] each time. +//! +//! Many modules contain internal structs that implement [`Iterator`] to make +//! reasoning about lifetimes simpler, but these are private implementation +//! details. + +mod accent_colors; +mod element_fixup; +mod fallback_fixup; +mod foreignobject_wrap; +mod inject_css; +mod strip_foreignobject; +mod strip_invalid_css; +pub(crate) mod util; + +use anyhow::{Context as _, Result}; +use quick_xml::Reader; +use quick_xml::events::Event; + +use crate::MermaidTheme; + +pub(super) fn postprocess(svg: &str, theme: &MermaidTheme) -> Result { + // Pass 1: foreignObject preparation (\n fix + word wrapping) + let svg = foreignobject_wrap::process(svg)?; + + // Add fallbacks alongside elements + let svg = merman::render::foreign_object_label_fallback_svg_text(&svg); + + // Extract SVG id for CSS scoping (quick scan of the first element) + let svg_id = extract_svg_id(&svg); + + // Pass 2: themed post-processing pipeline. + // Each adapter takes an iterator of events and returns an iterator of events. + // Events borrow from the `svg` string — no .into_owned() per event. + let mut reader = Reader::from_str(&svg); + reader.config_mut().check_end_names = false; + let events = ReaderIter::new(reader); + let events = strip_foreignobject::process(events); + let events = fallback_fixup::process(events, theme); + let events = element_fixup::process(events, theme); + + let events = accent_colors::process(events, theme); + let events = strip_invalid_css::process(events); + let events = inject_css::process(events, theme, &svg_id); + + let mut writer = quick_xml::Writer::new(Vec::with_capacity(svg.len())); + for event in events { + writer.write_event(event?)?; + } + String::from_utf8(writer.into_inner()).context("SVG output is not valid UTF-8") +} + +fn extract_svg_id(svg: &str) -> String { + let mut reader = Reader::from_str(svg); + reader.config_mut().check_end_names = false; + for event in ReaderIter::new(reader) { + let Ok(Event::Start(e) | Event::Empty(e)) = event else { + continue; + }; + if e.name().as_ref() == b"svg" { + return e + .try_get_attribute("id") + .ok() + .flatten() + .and_then(|a| a.unescape_value().ok()) + .map(|v| v.into_owned()) + .unwrap_or_default(); + } + } + String::new() +} + +struct ReaderIter<'a> { + reader: Reader<&'a [u8]>, + done: bool, +} + +impl<'a> ReaderIter<'a> { + fn new(reader: Reader<&'a [u8]>) -> Self { + Self { + reader, + done: false, + } + } +} + +impl<'a> Iterator for ReaderIter<'a> { + type Item = Result>; + + fn next(&mut self) -> Option { + if self.done { + return None; + } + match self.reader.read_event() { + Ok(Event::Eof) => { + self.done = true; + None + } + Ok(event) => Some(Ok(event)), + Err(e) => { + self.done = true; + Some(Err(e.into())) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn default_theme() -> MermaidTheme { + MermaidTheme::default() + } + + #[test] + fn strip_css_handles_style_element_with_attributes() { + let svg = r#""#; + let result = postprocess(svg, &default_theme()).unwrap(); + assert!( + !result.contains("@keyframes"), + "Unsupported @keyframes should be stripped from +//! +//! +//! +//! ``` + +use std::collections::VecDeque; +use std::fmt::Write; + +use anyhow::Result; +use quick_xml::events::{BytesText, Event}; + +use crate::MermaidTheme; + +/// Morally equivalent to `format!(".section-{i}")`, but without allocating +const MINDMAP_SECTION_SELECTORS: [&str; 11] = [ + ".section-0", + ".section-1", + ".section-2", + ".section-3", + ".section-4", + ".section-5", + ".section-6", + ".section-7", + ".section-8", + ".section-9", + ".section-10", +]; + +struct InjectCss<'a, I> { + inner: I, + injected_css: String, + in_style: bool, + injected: bool, + pending: VecDeque>, +} + +impl<'a, I: Iterator>>> Iterator for InjectCss<'a, I> { + type Item = Result>; + + fn next(&mut self) -> Option { + if let Some(event) = self.pending.pop_front() { + return Some(Ok(event)); + } + + let event = match self.inner.next()? { + Ok(ev) => ev, + Err(e) => return Some(Err(e)), + }; + + match &event { + Event::Start(e) if e.name().as_ref() == b"style" => { + self.in_style = true; + return Some(Ok(event)); + } + Event::End(e) if e.name().as_ref() == b"style" => { + self.in_style = false; + if !self.injected { + self.injected = true; + self.pending + .push_back(Event::Text(BytesText::from_escaped(std::mem::take( + &mut self.injected_css, + )))); + self.pending.push_back(event); + return self.pending.pop_front().map(Ok); + } + return Some(Ok(event)); + } + Event::Text(text) if self.in_style => { + self.injected = true; + let existing = match std::str::from_utf8(text.as_ref()) { + Ok(s) => s, + Err(e) => return Some(Err(e.into())), + }; + let mut combined = String::with_capacity(existing.len() + self.injected_css.len()); + combined.push_str(existing); + combined.push_str(&self.injected_css); + return Some(Ok(Event::Text(BytesText::from_escaped(combined)))); + } + _ => {} + } + + Some(Ok(event)) + } +} + +pub(super) fn process<'a>( + events: impl Iterator>>, + theme: &MermaidTheme, + svg_id: &str, +) -> impl Iterator>> { + let injected_css = build_injected_css(theme, svg_id); + InjectCss { + inner: events, + injected_css, + in_style: false, + injected: false, + pending: VecDeque::new(), + } +} + +fn mindmap_section_css(theme: &MermaidTheme) -> String { + let colors: Vec = theme + .git_branch_colors + .iter() + .map(|c| crate::css_color(*c)) + .collect(); + let fills: Vec = theme + .git_branch_colors + .iter() + .map(|c| { + crate::css_color(blend_over_background( + *c, + theme.background, + ACCENT_FILL_OPACITY, + )) + }) + .collect(); + let text = crate::css_color(theme.text_color); + let mut css = String::with_capacity(5_400); + + let emit = |css: &mut String, selector: &str, color: &str, fill: &str, txt: &str| { + let section_index = selector + .trim_start_matches(".section-root.section-") + .trim_start_matches(".section-"); + write!( + css, + "{selector} rect, {selector} path, {selector} circle, {selector} polygon \ + {{ fill: {fill} !important; stroke: {color} !important; }}\n\ + {selector} text, {selector} span, \ + text{selector}, tspan{selector} \ + {{ fill: {txt} !important; color: {txt} !important; }}\n\ + {selector} foreignObject div, {selector} foreignObject span, {selector} foreignObject p \ + {{ color: {txt} !important; }}\n\ + .section-edge{section_index} {{ stroke: {color} !important; }}\n", + ) + .expect("write to String cannot fail"); + }; + + emit( + &mut css, + ".section-root.section--1", + &colors[0], + &fills[0], + &text, + ); + emit(&mut css, ".section--1", &colors[1], &fills[1], &text); + for (i, selector) in MINDMAP_SECTION_SELECTORS.iter().enumerate() { + let ci = 2 + (i % 6); + emit(&mut css, selector, &colors[ci], &fills[ci], &text); + } + css +} + +fn git_branch_css(theme: &MermaidTheme) -> String { + let text = crate::css_color(theme.text_color); + let mut css = String::with_capacity(8 * 200); + for i in 0..8 { + let c = crate::css_color(theme.git_branch_colors[i]); + let label_fill = crate::css_color(blend_over_background( + theme.git_branch_colors[i], + theme.background, + ACCENT_FILL_OPACITY, + )); + write!( + css, + ".commit{i} {{ stroke: {c}; fill: {c}; }}\n\ + .arrow{i} {{ stroke: {c}; }}\n\ + .label{i} {{ fill: {label_fill}; stroke: {c}; }}\n\ + .branch-label{i} {{ fill: {text}; }}\n" + ) + .expect("write to String cannot fail"); + } + css +} + +fn adjust_lightness(color: &mut gpui::Hsla, dark_mode: bool) { + if dark_mode { + color.l = (color.l * 0.7).max(0.0); + } else { + color.l = (color.l * 1.3).min(1.0); + } +} + +const ACCENT_FILL_OPACITY: f32 = 0.15; + +fn blend_over_background( + foreground: gpui::Hsla, + background: gpui::Hsla, + opacity: f32, +) -> gpui::Hsla { + let fg = gpui::Rgba::from(foreground); + let bg = gpui::Rgba::from(background); + let blended = gpui::Rgba { + r: fg.r * opacity + bg.r * (1.0 - opacity), + g: fg.g * opacity + bg.g * (1.0 - opacity), + b: fg.b * opacity + bg.b * (1.0 - opacity), + a: 1.0, + }; + gpui::Hsla::from(blended) +} + +fn accent_css(theme: &MermaidTheme) -> String { + let mut css = String::with_capacity(theme.accent_colors.len() * 420); + let text = crate::css_color(theme.text_color); + + for (i, accent) in theme.accent_colors.iter().enumerate() { + let stroke = crate::css_color(accent.foreground); + let fill = crate::css_color(blend_over_background( + accent.background, + theme.background, + ACCENT_FILL_OPACITY, + )); + let class = format!(".zed-accent-{i}"); + write!( + css, + "{class} rect, {class} path, {class} circle, {class} polygon, {class} ellipse, \ + rect{class}, path{class}, circle{class}, polygon{class}, ellipse{class} \ + {{ fill: {fill} !important; stroke: {stroke} !important; }}\n\ + {class} text, {class} tspan, text{class}, tspan{class} \ + {{ fill: {text} !important; }}\n", + ) + .expect("write to String cannot fail"); + } + css +} + +fn chart_color_css(theme: &MermaidTheme) -> String { + // Each block is around 230 bytes, add some headroom + let mut css = String::with_capacity(8 * 250); + for i in 0..8 { + let color = crate::css_color(theme.git_branch_colors[i]); + let class = format!(".zed-chart-{i}"); + write!( + css, + "path.pieCircle{class} {{ fill: {color} !important; }}\n\ + .plot rect{class}, .legend rect{class} {{ fill: {color} !important; stroke: {color} !important; }}\n\ + .plot path{class} {{ stroke: {color} !important; }}\n" + ) + .expect("write to String cannot fail"); + } + css +} + +fn timeline_css(theme: &MermaidTheme) -> String { + let mut css = String::with_capacity(8 * 300); + let text = crate::css_color(theme.text_color); + for i in 0..8 { + let c = crate::css_color(theme.git_branch_colors[i]); + let fill = crate::css_color(blend_over_background( + theme.git_branch_colors[i], + theme.background, + ACCENT_FILL_OPACITY, + )); + write!( + css, + "rect.task-type-{i}, rect.section-type-{i} {{ fill: {fill} !important; stroke: {c} !important; }}\n" + ).expect("write to String cannot fail"); + } + for i in 0..4 { + let c = crate::css_color(theme.git_branch_colors[i % 8]); + let fill = crate::css_color(blend_over_background( + theme.git_branch_colors[i % 8], + theme.background, + ACCENT_FILL_OPACITY, + )); + write!( + css, + ".section{i} {{ fill: {fill} !important; }}\n\ + .task{i} {{ fill: {fill} !important; stroke: {c} !important; }}\n\ + .taskText{i} {{ fill: {text} !important; }}\n\ + .taskTextOutside{i} {{ fill: {text} !important; }}\n" + ) + .expect("write to String cannot fail"); + } + css +} + +fn should_scope_css_line(trimmed: &str) -> bool { + !trimmed.is_empty() + && (trimmed.starts_with('.') + || trimmed.starts_with("foreignObject") + || trimmed.starts_with("g.") + || trimmed.starts_with("text") + || trimmed.starts_with("tspan") + || trimmed.starts_with("rect.") + || trimmed.starts_with("path.") + || trimmed.starts_with("defs") + || trimmed.starts_with('#')) +} + +fn scoped_selector_count(raw_css: &str) -> usize { + raw_css.lines().fold(0, |count, line| { + let trimmed = line.trim(); + if !should_scope_css_line(trimmed) { + return count; + } + let Some((selectors, _)) = trimmed.split_once('{') else { + return count; + }; + count.saturating_add(selectors.split(',').count()) + }) +} + +fn scope_css(raw_css: &str, svg_id: &str) -> String { + let scoped_selector_prefix_len = svg_id.len().saturating_add(2); + let result_capacity = raw_css + .len() + .saturating_add(scoped_selector_count(raw_css).saturating_mul(scoped_selector_prefix_len)); + let mut result = String::with_capacity(result_capacity); + for line in raw_css.lines() { + let trimmed = line.trim(); + + if should_scope_css_line(trimmed) { + if let Some(brace) = trimmed.find('{') { + let (selectors, rest) = trimmed.split_at(brace); + let mut first = true; + for selector in selectors.split(',') { + if !first { + result.push_str(", "); + } + first = false; + write!(result, "#{svg_id} {}", selector.trim()) + .expect("write to String cannot fail"); + } + writeln!(result, "{rest}").expect("write to String cannot fail"); + continue; + } + } + writeln!(result, "{line}").expect("write to String cannot fail"); + } + result +} + +fn build_injected_css(theme: &MermaidTheme, svg_id: &str) -> String { + let font = &theme.font_family; + let text = crate::css_color(theme.text_color); + let line = crate::css_color(theme.line_color); + let primary = crate::css_color(theme.primary_color); + let border = crate::css_color(theme.primary_border_color); + let secondary = crate::css_color(theme.secondary_color); + let tertiary = crate::css_color(theme.tertiary_color); + let background = crate::css_color(theme.background); + let edge_label_bg = crate::css_color(theme.edge_label_background); + let actor_bg = crate::css_color(theme.actor_background); + let actor_border = crate::css_color(theme.actor_border); + let error_bg = { + let mut c = theme.error_color; + adjust_lightness(&mut c, theme.dark_mode); + c + }; + let error = crate::css_color(error_bg); + let error_text = crate::css_color(crate::postprocess::util::text_color_for_background( + error_bg, + )); + let warning_bg = { + let mut c = theme.warning_color; + adjust_lightness(&mut c, theme.dark_mode); + c + }; + let warning = crate::css_color(warning_bg); + let warning_text = crate::css_color(crate::postprocess::util::text_color_for_background( + warning_bg, + )); + let note_bg = crate::css_color(theme.note_background); + let note_border = crate::css_color(theme.note_border); + let er_odd = crate::css_color(theme.er_attr_bg_odd); + let er_even = crate::css_color(theme.er_attr_bg_even); + + let actor_text = &text; + let note_text = &text; + + let raw_css = format!( + r#" + text, tspan, foreignObject div, foreignObject span, foreignObject p {{ font-family: {font} !important; }} + foreignObject div, foreignObject span, foreignObject p {{ font-size: 16px; color: {text}; }} + foreignObject p {{ margin: 0; }} + foreignObject {{ overflow: visible; }} + foreignObject div {{ max-width: none !important; }} + .label-group foreignObject {{ font-weight: bold; }} + .node rect, .node path {{ fill: {primary}; stroke: {border}; }} + .node polygon {{ fill: {primary}; stroke: {border}; }} + .label-container path {{ fill: {primary}; stroke: {border}; }} + {mindmap_css} + .mindmap-node line, .timeline-node line {{ stroke: transparent !important; }} + g.stateGroup rect {{ fill: {primary} !important; stroke: {border} !important; }} + g.stateGroup text {{ fill: {text} !important; }} + g.stateGroup .state-title {{ fill: {text} !important; }} + .stateGroup .composit {{ fill: {background} !important; }} + .stateGroup .alt-composit {{ fill: {tertiary} !important; }} + .state-note {{ stroke: {note_border} !important; fill: {note_bg} !important; }} + .state-note text {{ fill: {note_text} !important; }} + .stateLabel .box {{ fill: {primary} !important; }} + .stateLabel text {{ fill: {text} !important; }} + .node circle.state-start {{ fill: {line} !important; stroke: {line} !important; }} + .node .fork-join {{ fill: {line} !important; stroke: {line} !important; }} + .node circle.state-end {{ fill: {border} !important; stroke: {background} !important; }} + .end-state-inner {{ fill: {background} !important; }} + .statediagram-cluster rect {{ fill: {primary} !important; stroke: {border} !important; }} + .statediagram-cluster.statediagram-cluster .inner {{ fill: {background} !important; }} + .statediagram-cluster.statediagram-cluster-alt .inner {{ fill: {tertiary} !important; }} + .statediagram-state rect.divider {{ fill: {tertiary} !important; }} + .statediagram-note rect {{ fill: {note_bg} !important; stroke: {note_border} !important; }} + .statediagram-note text {{ fill: {note_text} !important; }} + .statediagramTitleText {{ fill: {text} !important; }} + .transition {{ stroke: {line} !important; }} + .cluster-label, .nodeLabel {{ color: {text} !important; }} + defs #statediagram-barbEnd {{ fill: {line} !important; stroke: {line} !important; }} + #statediagram-barbEnd {{ fill: {line} !important; }} + .edgeLabel .label rect {{ fill: {primary} !important; }} + .edgeLabel rect {{ fill: {primary} !important; background-color: {primary} !important; }} + .edgeLabel .label text {{ fill: {text} !important; }} + .edgeLabel p {{ background-color: {primary} !important; }} + .edgeLabel {{ background-color: {primary} !important; }} + .actor {{ stroke: {actor_border}; fill: {actor_bg}; }} + text.actor {{ text-anchor: middle; }} + text.actor>tspan {{ fill: {actor_text} !important; stroke: none; }} + .labelText, .labelText>tspan {{ fill: {actor_text} !important; }} + .actor-line {{ stroke: {actor_border} !important; }} + .messageLine0 {{ stroke: {text} !important; }} + .messageLine1 {{ stroke: {text} !important; }} + #arrowhead path {{ fill: {text} !important; stroke: {text} !important; }} + #crosshead path {{ fill: {text} !important; stroke: {text} !important; }} + .messageText {{ fill: {text} !important; }} + .loopText, .loopText>tspan {{ fill: {text} !important; }} + .loopLine {{ stroke: {actor_border} !important; fill: {actor_border} !important; }} + .note {{ stroke: {note_border} !important; fill: {note_bg} !important; }} + .noteText, .noteText>tspan {{ fill: {note_text} !important; }} + .activation0, .activation1, .activation2 {{ fill: {secondary} !important; stroke: {border} !important; }} + .labelBox {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }} + .actor-man line {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }} + .actor-man circle {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }} + .pieTitleText {{ fill: {text} !important; }} + .slice {{ fill: {text} !important; }} + .legend text {{ fill: {text} !important; }} + .pieOuterCircle {{ stroke: {border} !important; }} + .pieCircle {{ stroke: {border} !important; }} + {timeline_css} + text.journey-section, text.task {{ fill: {text} !important; }} + .relationshipLabelBox {{ fill: {tertiary} !important; opacity: 0.7; background-color: {tertiary} !important; }} + .labelBkg {{ background-color: {tertiary} !important; }} + .edgeLabel .label {{ fill: {border} !important; }} + .label {{ color: {text} !important; }} + .relationshipLine {{ stroke: {line} !important; fill: none !important; }} + .entityBox {{ fill: {primary}; stroke: {border}; }} + .node .row-rect-odd path {{ fill: {er_odd} !important; }} + .node .row-rect-even path {{ fill: {er_even} !important; }} + .edge-thickness-normal {{ stroke-width: 1px; }} + .relation {{ stroke: {line}; stroke-width: 1; fill: none; }} + .edgePaths path {{ fill: none; }} + .marker {{ fill: {line} !important; stroke: {line} !important; }} + .marker.er {{ fill: none !important; stroke: {line} !important; }} + .composition {{ fill: {line} !important; stroke: {line} !important; stroke-width: 1; }} + .extension {{ fill: transparent !important; stroke: {line} !important; stroke-width: 1; }} + .aggregation {{ fill: transparent !important; stroke: {line} !important; stroke-width: 1; }} + .dependency {{ fill: {line} !important; stroke: {line} !important; stroke-width: 1; }} + .lollipop {{ fill: {primary} !important; stroke: {line} !important; stroke-width: 1; }} + .sectionTitle0, .sectionTitle1, .sectionTitle2, .sectionTitle3 {{ fill: {text} !important; }} + .sectionTitle {{ font-family: {font} !important; }} + .taskTextOutsideRight {{ fill: {text} !important; font-family: {font} !important; }} + .taskTextOutsideLeft {{ fill: {text} !important; }} + .active0, .active1, .active2, .active3 {{ fill: {secondary} !important; stroke: {border} !important; }} + .activeText0, .activeText1, .activeText2, .activeText3 {{ fill: {text} !important; }} + .done0, .done1, .done2, .done3 {{ stroke: {border} !important; fill: {secondary} !important; stroke-width: 2; }} + .doneText0, .doneText1, .doneText2, .doneText3 {{ fill: {text} !important; }} + .crit0, .crit1, .crit2, .crit3 {{ fill: {error} !important; stroke: {error} !important; }} + .critText0, .critText1, .critText2, .critText3 {{ fill: {error_text} !important; }} + .activeCrit0, .activeCrit1, .activeCrit2, .activeCrit3 {{ fill: {warning} !important; stroke: {warning} !important; }} + .activeCritText0, .activeCritText1, .activeCritText2, .activeCritText3 {{ fill: {warning_text} !important; }} + .doneCrit0, .doneCrit1, .doneCrit2, .doneCrit3 {{ fill: {error} !important; stroke: {border} !important; stroke-width: 2; }} + .doneCritText0, .doneCritText1, .doneCritText2, .doneCritText3 {{ fill: {error_text} !important; }} + .titleText {{ fill: {text} !important; font-family: {font} !important; }} + .grid .tick text {{ fill: {text} !important; font-family: {font} !important; }} + .grid .tick {{ stroke: {border} !important; }} + {git_branch_css} + .commit-merge {{ stroke: {primary}; fill: {primary}; }} + .commit-reverse {{ stroke: {primary}; fill: {primary}; stroke-width: 3; }} + .commit-highlight-inner {{ stroke: {primary}; fill: {primary}; }} + .tag-label {{ font-size: 10px; }} + .tag-label-bkg {{ fill: {primary}; stroke: {border}; }} + .tag-hole {{ fill: {line}; }} + .commit-label {{ fill: {text}; }} + .commit-label-bkg {{ fill: {edge_label_bg}; }} + .commit-id, .commit-msg, .branch-label {{ fill: {text}; color: {text}; font-family: {font}; }} + {accent_css} + .data-point text {{ fill: {text} !important; }} + {chart_color_css} + "#, + mindmap_css = mindmap_section_css(theme), + git_branch_css = git_branch_css(theme), + accent_css = accent_css(theme), + chart_color_css = chart_color_css(theme), + timeline_css = timeline_css(theme), + ); + + scope_css(&raw_css, svg_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scope_css_prefixes_selectors() { + let input = " .foo { color: red; }\n"; + let result = scope_css(input, "my-svg"); + assert!(result.contains("#my-svg .foo"), "got: {result}"); + } +} diff --git a/crates/mermaid_render/src/postprocess/strip_foreignobject.rs b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs new file mode 100644 index 00000000000..8935c73e392 --- /dev/null +++ b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs @@ -0,0 +1,114 @@ +//! Strips `` elements and their contents from the SVG, since +//! `usvg`/`resvg` does not support them. +//! +//! ```xml +//! +//!
Hello
+//! Hello +//! +//! +//! Hello +//! ``` + +use anyhow::Result; +use quick_xml::events::Event; + +struct StripForeignObject { + inner: I, + /// Depth inside a `` element being stripped. + foreign_depth: usize, + /// Depth inside a `` being stripped. + fallback_depth: usize, + /// Set to true once we see a `` element outside of foreignObjects + /// and fallback groups. When true, fallback groups are redundant and + /// should be stripped. + has_native_text: bool, +} + +impl<'a, I: Iterator>>> Iterator for StripForeignObject { + type Item = Result>; + + fn next(&mut self) -> Option { + loop { + let event = self.inner.next()?; + let event = match event { + Ok(event) => event, + Err(e) => return Some(Err(e)), + }; + + // Strip foreignObject elements and their contents. + match &event { + Event::Start(e) if e.name().as_ref() == b"foreignObject" => { + self.foreign_depth += 1; + continue; + } + Event::Start(_) if self.foreign_depth > 0 => { + self.foreign_depth += 1; + continue; + } + Event::End(_) if self.foreign_depth > 0 => { + self.foreign_depth -= 1; + continue; + } + Event::Empty(e) if e.name().as_ref() == b"foreignObject" => { + continue; + } + _ if self.foreign_depth > 0 => { + continue; + } + _ => {} + } + + // Strip fallback groups when native text exists. + match &event { + Event::Start(e) if e.name().as_ref() == b"g" && self.fallback_depth == 0 => { + if self.has_native_text { + if let Ok(Some(attr)) = e.try_get_attribute("data-merman-foreignobject") { + if attr.value.as_ref() == b"fallback" { + self.fallback_depth = 1; + continue; + } + } + } + } + Event::Start(_) if self.fallback_depth > 0 => { + self.fallback_depth += 1; + continue; + } + Event::End(_) if self.fallback_depth > 0 => { + self.fallback_depth -= 1; + continue; + } + _ if self.fallback_depth > 0 => { + continue; + } + _ => {} + } + + // Track whether the diagram has native elements. + if !self.has_native_text { + match &event { + Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"text" => { + if e.try_get_attribute("class").ok().flatten().is_some() { + self.has_native_text = true; + } + } + _ => {} + } + } + + return Some(Ok(event)); + } + } +} + +pub(super) fn process<'a>( + inner: impl Iterator>>, +) -> impl Iterator>> { + StripForeignObject { + inner, + foreign_depth: 0, + fallback_depth: 0, + has_native_text: false, + } +} diff --git a/crates/mermaid_render/src/postprocess/strip_invalid_css.rs b/crates/mermaid_render/src/postprocess/strip_invalid_css.rs new file mode 100644 index 00000000000..12efb99be2e --- /dev/null +++ b/crates/mermaid_render/src/postprocess/strip_invalid_css.rs @@ -0,0 +1,161 @@ +//! Removes CSS constructs that `usvg`/`resvg` cannot handle. +//! +//! - `@keyframes` and `@-webkit-keyframes` blocks +//! - `:root { ... }` blocks (CSS custom properties) +//! - `:not(...)` pseudo-selectors +//! - `deg` angle units (e.g. `rotate(45deg)` → `rotate(45)`) +//! +//! Also removes `!important` declarations (so that our injected theme CSS +//! always wins). + +use std::borrow::Cow; + +use anyhow::Result; +use quick_xml::events::{BytesText, Event}; + +struct StripInvalidCss { + inner: I, + in_style: bool, +} + +impl<'a, I: Iterator>>> Iterator for StripInvalidCss { + type Item = Result>; + + fn next(&mut self) -> Option { + let event = match self.inner.next()? { + Ok(ev) => ev, + Err(e) => return Some(Err(e)), + }; + + match &event { + Event::Start(e) if e.name().as_ref() == b"style" => { + self.in_style = true; + } + Event::End(e) if e.name().as_ref() == b"style" => { + self.in_style = false; + } + Event::Text(text) if self.in_style => { + let css_text = match std::str::from_utf8(text.as_ref()) { + Ok(s) => s, + Err(e) => return Some(Err(e.into())), + }; + return Some(match strip_unsupported_css(css_text) { + Cow::Borrowed(_) => Ok(event), + Cow::Owned(processed) => Ok(Event::Text(BytesText::from_escaped(processed))), + }); + } + _ => {} + } + + Some(Ok(event)) + } +} + +pub(super) fn process<'a>( + events: impl Iterator>>, +) -> impl Iterator>> { + StripInvalidCss { + inner: events, + in_style: false, + } +} + +fn strip_unsupported_css(css: &str) -> Cow<'_, str> { + let mut chars = css.char_indices().peekable(); + let mut result = None; + let mut copied_until = 0; + + while let Some((i, _)) = chars.next() { + let remaining = &css[i..]; + + if remaining.starts_with("@keyframes") + || remaining.starts_with("@-webkit-keyframes") + || remaining.starts_with(":root") + { + let result = result.get_or_insert_with(|| String::with_capacity(css.len())); + result.push_str(&css[copied_until..i]); + skip_css_block(&mut chars); + copied_until = chars.peek().map_or(css.len(), |&(i, _)| i); + } + } + + let mut result = if let Some(mut result) = result { + result.push_str(&css[copied_until..]); + Cow::Owned(result) + } else { + Cow::Borrowed(css) + }; + + strip_css_angle_units(&mut result); + strip_css_important(&mut result); + result +} + +fn skip_css_block(chars: &mut std::iter::Peekable) { + for (_, c) in chars.by_ref() { + if c == '{' { + break; + } + } + let mut depth = 1u32; + for (_, c) in chars.by_ref() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return; + } + } + _ => {} + } + } +} + +fn replace_all_in_place(css: &mut Cow<'_, str>, needle: &str, replacement: &str) { + while let Some(pos) = css.as_ref().find(needle) { + css.to_mut() + .replace_range(pos..pos + needle.len(), replacement); + } +} + +fn strip_css_angle_units(css: &mut Cow<'_, str>) { + replace_all_in_place(css, "deg)", ")"); +} + +/// Strip `!important` from mermaid's generated CSS so that our injected +/// theme CSS (which uses `!important`) always takes priority. This works +/// around a usvg cascade bug where competing `!important` rules are +/// resolved by first-wins rather than the CSS spec's last-wins. +fn strip_css_important(css: &mut Cow<'_, str>) { + replace_all_in_place(css, "!important", ""); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_keyframes() { + let input = "@keyframes bounce { 0% { transform: scale(1); } 100% { transform: scale(1.1); } } .node rect { fill: red; }"; + let result = strip_unsupported_css(input); + assert!(!result.contains("@keyframes"), "got: {result}"); + assert!(result.contains(".node rect"), "got: {result}"); + } + + #[test] + fn strips_root_blocks() { + let input = ":root { --bg: white; } .foo { color: red; }"; + let result = strip_unsupported_css(input); + assert!(!result.contains(":root"), "got: {result}"); + assert!(result.contains(".foo"), "got: {result}"); + } + + #[test] + fn strips_deg_units() { + let input = ".foo { transform: rotate(45deg); }"; + let result = strip_unsupported_css(input); + assert!(result.contains("rotate(45)"), "got: {result}"); + assert!(!result.contains("deg"), "got: {result}"); + } +} diff --git a/crates/mermaid_render/src/postprocess/util.rs b/crates/mermaid_render/src/postprocess/util.rs new file mode 100644 index 00000000000..70df1af058e --- /dev/null +++ b/crates/mermaid_render/src/postprocess/util.rs @@ -0,0 +1,148 @@ +use gpui::{Hsla, Rgba}; + +/// Produces a readable text color for a given background, subtly tinted by the +/// background's own hue using the OKLCH color space. +/// +/// The result keeps ~15% of the background's chroma so the text feels +/// harmonious with its surroundings rather than a flat black or white. +/// Lightness is set to ensure readable contrast against the background. +pub fn text_color_for_background(background: Hsla) -> Hsla { + let rgba = Rgba::from(background); + let r_lin = srgb_to_linear(rgba.r); + let g_lin = srgb_to_linear(rgba.g); + let b_lin = srgb_to_linear(rgba.b); + + let (_, ok_a, ok_b) = linear_rgb_to_oklab(r_lin, g_lin, b_lin); + let chroma = (ok_a * ok_a + ok_b * ok_b).sqrt(); + let hue = ok_b.atan2(ok_a); + + let bg_luminance = relative_luminance(rgba); + let text_l = if bg_luminance > 0.18 { 0.18 } else { 0.96 }; + let text_c = chroma * 0.15; + + let build = |c: f32| -> Rgba { + let (tr, tg, tb) = oklab_to_linear_rgb(text_l, c * hue.cos(), c * hue.sin()); + Rgba { + r: linear_to_srgb(tr.clamp(0.0, 1.0)), + g: linear_to_srgb(tg.clamp(0.0, 1.0)), + b: linear_to_srgb(tb.clamp(0.0, 1.0)), + a: 1.0, + } + }; + + let meets_contrast = + |fg: Rgba| contrast_ratio_between(bg_luminance, relative_luminance(fg)) >= 4.5; + + let candidate = build(text_c); + let result = if meets_contrast(candidate) { + candidate + } else { + // Binary search for the maximum chroma that still meets 4.5:1. + let mut lo = 0.0_f32; + let mut hi = text_c; + for _ in 0..16 { + let mid = (lo + hi) * 0.5; + if meets_contrast(build(mid)) { + lo = mid; + } else { + hi = mid; + } + } + let best = build(lo); + // Floating-point precision can leave the binary search result just + // below the 4.5:1 threshold. Fall back to pure black or white. + if meets_contrast(best) { + best + } else if bg_luminance > 0.18 { + Rgba { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + } + } else { + Rgba { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + } + } + }; + Hsla::from(result) +} + +fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +fn linear_to_srgb(c: f32) -> f32 { + if c <= 0.0031308 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + } +} + +fn linear_rgb_to_oklab(r: f32, g: f32, b: f32) -> (f32, f32, f32) { + let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt(); + let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt(); + let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt(); + ( + 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, + ) +} + +fn oklab_to_linear_rgb(l: f32, a: f32, b: f32) -> (f32, f32, f32) { + let l_ = l + 0.3963377774 * a + 0.2158037573 * b; + let m_ = l - 0.1055613458 * a - 0.0638541728 * b; + let s_ = l - 0.0894841775 * a - 1.2914855480 * b; + ( + 4.0767416621 * l_ * l_ * l_ - 3.3077115913 * m_ * m_ * m_ + 0.2309699292 * s_ * s_ * s_, + -1.2684380046 * l_ * l_ * l_ + 2.6097574011 * m_ * m_ * m_ - 0.3413193965 * s_ * s_ * s_, + -0.0041960863 * l_ * l_ * l_ - 0.7034186147 * m_ * m_ * m_ + 1.7076147010 * s_ * s_ * s_, + ) +} + +fn relative_luminance(c: Rgba) -> f32 { + 0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b) +} + +fn contrast_ratio_between(luminance_a: f32, luminance_b: f32) -> f32 { + let (lighter, darker) = if luminance_a > luminance_b { + (luminance_a, luminance_b) + } else { + (luminance_b, luminance_a) + }; + (lighter + 0.05) / (darker + 0.05) +} + +#[cfg(test)] +fn wcag_contrast_ratio(a: Rgba, b: Rgba) -> f32 { + contrast_ratio_between(relative_luminance(a), relative_luminance(b)) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::proptest::prelude::*; + + #[gpui::property_test] + fn sufficient_contrast_for_any_opaque_background( + #[strategy = Hsla::opaque_strategy()] bg: Hsla, + ) -> Result<(), TestCaseError> { + let text = text_color_for_background(bg); + let ratio = wcag_contrast_ratio(Rgba::from(bg), Rgba::from(text)); + prop_assert!( + ratio >= 4.5, + "WCAG AA contrast ratio {ratio:.2} < 4.5 for bg {bg:?} -> text {text:?}", + ); + Ok(()) + } +} diff --git a/crates/mermaid_render/src/render.rs b/crates/mermaid_render/src/render.rs new file mode 100644 index 00000000000..22c49d3f35f --- /dev/null +++ b/crates/mermaid_render/src/render.rs @@ -0,0 +1,122 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::{Context as _, Result, anyhow}; + +use crate::{MermaidTheme, css_color}; + +pub(super) fn render_mermaid(source: &str, theme: &MermaidTheme) -> Result { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let diagram_id = format!("merman-{id}"); + + let config = to_merman_config(theme); + let renderer = merman::render::HeadlessRenderer::new() + .with_site_config(config) + .with_vendored_text_measurer() + .with_diagram_id(&diagram_id); + + let svg = renderer + .render_svg_sync(source) + .context("merman render failed")? + .ok_or_else(|| anyhow!("merman returned no SVG for the given input"))?; + + Ok(svg) +} + +fn to_merman_config(theme: &MermaidTheme) -> merman::MermaidConfig { + let primary = css_color(theme.primary_color); + let primary_text = css_color(theme.primary_text_color); + let primary_border = css_color(theme.primary_border_color); + let line = css_color(theme.line_color); + let secondary = css_color(theme.secondary_color); + let tertiary = css_color(theme.tertiary_color); + let background = css_color(theme.background); + let cluster_bg = css_color(theme.cluster_background); + let cluster_border = css_color(theme.cluster_border); + let edge_label_bg = css_color(theme.edge_label_background); + let text = css_color(theme.text_color); + let note_bg = css_color(theme.note_background); + let note_border = css_color(theme.note_border); + let actor_bg = css_color(theme.actor_background); + let actor_border = css_color(theme.actor_border); + let activation_bg = css_color(theme.activation_background); + let activation_border = css_color(theme.activation_border); + let er_odd = css_color(theme.er_attr_bg_odd); + let er_even = css_color(theme.er_attr_bg_even); + let git: [String; 8] = theme.git_branch_colors.map(css_color); + let git_lbl: [String; 8] = theme.git_branch_label_colors.map(css_color); + + let mut theme_vars = serde_json::json!({ + "primaryColor": primary, + "primaryTextColor": primary_text, + "primaryBorderColor": primary_border, + "lineColor": line, + "secondaryColor": secondary, + "secondaryTextColor": text, + "tertiaryColor": tertiary, + "tertiaryTextColor": text, + "background": background, + "mainBkg": primary, + "nodeBorder": primary_border, + "nodeTextColor": primary_text, + "clusterBkg": cluster_bg, + "clusterBorder": cluster_border, + "titleColor": text, + "edgeLabelBackground": edge_label_bg, + "textColor": text, + "fontFamily": theme.font_family, + "noteBkgColor": note_bg, + "noteBorderColor": note_border, + "noteTextColor": text, + "actorBkg": actor_bg, + "actorBorder": actor_border, + "actorTextColor": primary_text, + "labelTextColor": text, + "loopTextColor": text, + "signalColor": text, + "signalTextColor": text, + "activationBkgColor": activation_bg, + "activationBorderColor": activation_border, + "classText": text, + "labelColor": primary_text, + "attributeBackgroundColorOdd": er_odd, + "attributeBackgroundColorEven": er_even, + "pieTitleTextColor": text, + "pieSectionTextColor": text, + "pieLegendTextColor": text, + "pieStrokeColor": primary_border, + "pieOuterStrokeColor": primary_border, + "quadrant1Fill": primary, + "quadrant2Fill": primary, + "quadrant3Fill": primary, + "quadrant4Fill": primary, + "quadrant1TextFill": text, + "quadrant2TextFill": text, + "quadrant3TextFill": text, + "quadrant4TextFill": text, + "quadrantPointFill": line, + "quadrantPointTextFill": text, + "quadrantTitleFill": text, + "quadrantXAxisTextFill": text, + "quadrantYAxisTextFill": text, + "quadrantExternalBorderStrokeFill": primary_border, + "quadrantInternalBorderStrokeFill": primary_border, + }); + + let map = theme_vars.as_object_mut().expect("just created as object"); + for i in 0..8 { + map.insert(format!("cScale{i}"), git[i].clone().into()); + map.insert(format!("cScaleLabel{i}"), git_lbl[i].clone().into()); + map.insert(format!("pie{}", i + 1), git[i].clone().into()); + } + + merman::MermaidConfig::from_value(serde_json::json!({ + "theme": "base", + "darkMode": theme.dark_mode, + "fontFamily": theme.font_family, + "flowchart": { + "padding": 16, + }, + "themeVariables": theme_vars, + })) +} diff --git a/crates/mermaid_render/tests/check_invalid_attrs.rs b/crates/mermaid_render/tests/check_invalid_attrs.rs new file mode 100644 index 00000000000..e4d49384bf2 --- /dev/null +++ b/crates/mermaid_render/tests/check_invalid_attrs.rs @@ -0,0 +1,394 @@ +use gpui::Hsla; +use mermaid_render::MermaidTheme; + +fn rgb(r: u8, g: u8, b: u8) -> Hsla { + gpui::Rgba { + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, + a: 1.0, + } + .into() +} + +const DIAGRAMS: &[(&str, &str)] = &[ + ( + "flowchart", + "flowchart TD\n A[Hello] --> B[World]\n B --> C{Decision}\n C -->|Yes| D[OK]\n C -->|No| E[Fail]", + ), + ( + "sequence", + "sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi\n Note over Alice,Bob: A note", + ), + ( + "state", + "stateDiagram-v2\n [*] --> Active\n Active --> [*]", + ), + ( + "er", + "erDiagram\n A { int id PK }\n B { int id PK }\n A ||--o{ B : has", + ), + ( + "class", + "classDiagram\n class Foo {\n +bar() void\n }", + ), + ("pie", "pie title Test\n \"A\" : 42\n \"B\" : 58"), + ( + "gantt", + "gantt\n title Test\n dateFormat YYYY-MM-DD\n section S\n Task :a1, 2025-01-01, 7d", + ), + ("mindmap", "mindmap\n root((Root))\n Child1\n Child2"), + ( + "journey", + "journey\n title Test\n section S\n Task: 5: Actor", + ), + ( + "gitgraph", + "gitGraph\n commit id: \"init\"\n branch dev\n commit id: \"feat\"\n checkout main\n merge dev", + ), + ( + "quadrant", + "quadrantChart\n title Test\n x-axis Low --> High\n y-axis Low --> High\n A: [0.3, 0.8]\n B: [0.7, 0.4]", + ), + ( + "timeline", + "timeline\n title Test\n section 2020s\n 2020 : Event A\n 2022 : Event B", + ), + ( + "xychart", + "xychart-beta\n title Test\n x-axis [\"A\", \"B\", \"C\"]\n y-axis \"Val\" 0 --> 10\n bar [3, 7, 5]", + ), +]; + +fn rgb_theme() -> MermaidTheme { + MermaidTheme { + dark_mode: true, + font_family: "system-ui".to_string(), + background: rgb(40, 44, 51), + primary_color: rgb(47, 52, 62), + primary_text_color: rgb(220, 224, 229), + primary_border_color: rgb(70, 75, 87), + secondary_color: rgb(46, 52, 62), + tertiary_color: rgb(54, 60, 70), + line_color: rgb(70, 75, 87), + text_color: rgb(220, 224, 229), + edge_label_background: rgb(40, 44, 51), + cluster_background: rgb(47, 52, 62), + cluster_border: rgb(54, 60, 70), + note_background: rgb(47, 52, 62), + note_border: rgb(54, 60, 70), + actor_background: rgb(46, 52, 62), + actor_border: rgb(70, 75, 87), + activation_background: rgb(54, 60, 70), + activation_border: rgb(70, 75, 87), + git_branch_colors: [ + rgb(116, 173, 232), + rgb(190, 80, 70), + rgb(191, 149, 106), + rgb(180, 119, 207), + rgb(110, 180, 191), + rgb(208, 114, 119), + rgb(222, 193, 132), + rgb(161, 193, 129), + ], + git_branch_label_colors: [ + rgb(116, 173, 232), + rgb(190, 80, 70), + rgb(191, 149, 106), + rgb(180, 119, 207), + rgb(110, 180, 191), + rgb(208, 114, 119), + rgb(222, 193, 132), + rgb(161, 193, 129), + ] + .map(mermaid_render::text_color_for_background), + er_attr_bg_odd: rgb(47, 52, 62), + er_attr_bg_even: rgb(46, 52, 62), + error_color: rgb(220, 38, 38), + warning_color: rgb(217, 119, 6), + accent_colors: vec![ + mermaid_render::AccentColor { + foreground: rgb(116, 173, 232), + background: rgb(116, 173, 232), + }, + mermaid_render::AccentColor { + foreground: rgb(190, 80, 70), + background: rgb(190, 80, 70), + }, + mermaid_render::AccentColor { + foreground: rgb(191, 149, 106), + background: rgb(191, 149, 106), + }, + mermaid_render::AccentColor { + foreground: rgb(180, 119, 207), + background: rgb(180, 119, 207), + }, + mermaid_render::AccentColor { + foreground: rgb(110, 180, 191), + background: rgb(110, 180, 191), + }, + mermaid_render::AccentColor { + foreground: rgb(208, 114, 119), + background: rgb(208, 114, 119), + }, + mermaid_render::AccentColor { + foreground: rgb(222, 193, 132), + background: rgb(222, 193, 132), + }, + mermaid_render::AccentColor { + foreground: rgb(161, 193, 129), + background: rgb(161, 193, 129), + }, + ], + } +} + +fn check_svg_issues(name: &str, svg: &str) -> Vec { + let bad_patterns = [ + "fill=\"\"", + "stroke=\"\"", + "width=\"\"", + "height=\"\"", + "NaN", + // Also check for empty values in style attributes + "fill: ;", + "fill:;", + "stroke: ;", + "stroke:;", + // Check for attributes with just whitespace + "fill=\" \"", + ]; + let mut issues = Vec::new(); + for pattern in &bad_patterns { + let mut start = 0; + while let Some(pos) = svg[start..].find(pattern) { + let abs = start + pos; + let ctx_start = abs.saturating_sub(100); + let ctx_end = (abs + pattern.len() + 60).min(svg.len()); + issues.push(format!( + "{name}: found `{pattern}` at byte {abs}:\n ...{}...\n", + &svg[ctx_start..ctx_end] + )); + start = abs + pattern.len(); + } + } + + // Parse with quick-xml to find ANY empty attribute values on visual elements + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_str(svg); + loop { + match reader.read_event() { + Ok(Event::Eof) => break, + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let tag = String::from_utf8_lossy(e.name().local_name().as_ref()).to_string(); + for attr in e.attributes().flatten() { + let key = String::from_utf8_lossy(attr.key.local_name().as_ref()).to_string(); + let val = attr.unescape_value().unwrap_or_default(); + let visual_attr = matches!( + key.as_str(), + "fill" + | "stroke" + | "width" + | "height" + | "x" + | "y" + | "r" + | "cx" + | "cy" + | "rx" + | "ry" + | "stroke-width" + ); + if visual_attr && val.is_empty() { + issues.push(format!("{name}: <{tag}> has empty {key}=\"\"\n")); + } + // Check for CSS length units that usvg can't parse + if visual_attr + && matches!(key.as_str(), "width" | "height") + && val.ends_with("px") + { + issues.push(format!("{name}: <{tag}> has {key}=\"{val}\" (px suffix)\n")); + } + } + } + Err(e) => { + issues.push(format!("{name}: XML parse error: {e}\n")); + break; + } + _ => {} + } + } + + issues +} + +#[test] +fn accent_colors_auto_applied_to_nodes() { + let theme = rgb_theme(); + + // A plain state diagram with no :::accent syntax should get + // automatic accent colors applied to its node groups. + let source = "stateDiagram-v2\n [*] --> Idle\n Idle --> Processing\n Processing --> Done\n Done --> [*]"; + + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + + // accent_fill_and_text darkens the background color for dark mode. + // The stroke colors are direct hex conversions of the accent rgb values. + // With 3 states (Idle, Processing, Done), we expect at least accent0 and + // accent1 stroke colors to appear. + let accent0_stroke = "#74ade8"; // rgb(116, 173, 232) -> hex + let accent1_stroke = "#be5046"; // rgb(190, 80, 70) -> hex + + assert!( + svg.contains(accent0_stroke), + "Expected accent0 stroke color ({accent0_stroke}) in auto-colored state diagram SVG.\n\ + This means auto-coloring did not apply accent colors to node groups.\n\ + SVG snippet: {}...", + &svg[..svg.len().min(2000)] + ); + assert!( + svg.contains(accent1_stroke), + "Expected accent1 stroke color ({accent1_stroke}) in auto-colored state diagram SVG." + ); +} + +#[test] +fn generics_not_double_escaped() { + let theme = rgb_theme(); + let source = "classDiagram\n class Shelter {\n -List~Animal~ animals\n +adopt(Animal a) bool\n }"; + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + assert!( + !svg.contains("&lt;"), + "Double-escaped &lt; found in SVG" + ); + assert!( + !svg.contains("&gt;"), + "Double-escaped &gt; found in SVG" + ); +} + +#[test] +fn backslash_n_converted_to_line_break() { + let theme = rgb_theme(); + let source = r#"graph TD + L7["Layer 7\nHTTP, FTP"] + L6["Layer 6\nEncryption"] + L7 --> L6"#; + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + assert!( + !svg.contains(r"\n"), + "Literal \\n should not appear in SVG output" + ); + assert!( + svg.contains(">Layer 7<") && svg.contains(">HTTP, FTP<"), + "Label lines should be split into separate elements" + ); +} + +#[test] +fn class_diagram_fallback_text_uses_accent_classes() { + let theme = rgb_theme(); + let source = r#"classDiagram + class Animal { + +String name + +makeSound() void + } + class Dog { + +String breed + +bark() void + } + Dog --|> Animal"#; + + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_str(&svg); + let mut in_fallback = false; + let mut accent_classes: Vec = Vec::new(); + loop { + match reader.read_event() { + Ok(Event::Eof) => break, + Ok(Event::Start(e)) => { + if e.name().as_ref() == b"g" { + if let Ok(Some(attr)) = e.try_get_attribute("data-merman-foreignobject") { + if attr.value.as_ref() == b"fallback" { + in_fallback = true; + } + } + } + if in_fallback && e.name().as_ref() == b"text" { + if let Ok(Some(class_attr)) = e.try_get_attribute("class") { + let class = class_attr.unescape_value().unwrap_or_default().to_string(); + for token in class.split_whitespace() { + if token.starts_with("zed-accent-") { + accent_classes.push(token.to_string()); + } + } + } + } + } + Ok(Event::End(e)) if e.name().as_ref() == b"g" => { + in_fallback = false; + } + _ => {} + } + } + + assert!( + !accent_classes.is_empty(), + "expected zed-accent-N classes on text elements in fallback groups", + ); +} + +#[test] +fn sequence_diagram_tspan_uses_accent_classes() { + let theme = rgb_theme(); + let source = "sequenceDiagram\n participant Database"; + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_str(&svg); + let mut accent_classes: Vec = Vec::new(); + loop { + match reader.read_event() { + Ok(Event::Eof) => break, + Ok(Event::Start(e)) if e.name().as_ref() == b"tspan" => { + if let Ok(Some(class_attr)) = e.try_get_attribute("class") { + let class = class_attr.unescape_value().unwrap_or_default().to_string(); + for token in class.split_whitespace() { + if token.starts_with("zed-accent-") { + accent_classes.push(token.to_string()); + } + } + } + } + _ => {} + } + } + + assert!( + !accent_classes.is_empty(), + "expected zed-accent-N classes on tspan elements in sequence diagram", + ); +} + +#[test] +fn no_empty_attributes_or_nan_with_rgb_theme() { + let theme = rgb_theme(); + let mut all_issues = Vec::new(); + + for (name, source) in DIAGRAMS { + match mermaid_render::render_to_svg(source, &theme) { + Ok(svg) => all_issues.extend(check_svg_issues(name, &svg)), + Err(e) => eprintln!("{name}: render failed (skipped): {e}"), + } + } + + if !all_issues.is_empty() { + panic!( + "Found {} issues in merman SVG output (rgb theme):\n\n{}", + all_issues.len(), + all_issues.join("\n") + ); + } +} diff --git a/typos.toml b/typos.toml index 22823e6b2d9..4f877457dc1 100644 --- a/typos.toml +++ b/typos.toml @@ -98,6 +98,10 @@ extend-ignore-re = [ # Yarn Plug'n'Play "PnP", # `image` crate method: Delay::from_numer_denom_ms - "numer" + "numer", + # Abbreviation for foreignObject in mermaid SVG processing + "fo", + # Mermaid CSS class name for state diagram composites + "composit" ] check-filename = true From 32f25930473ed0578dbe51e82a6e06e64dff1bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Wed, 27 May 2026 18:39:19 +0200 Subject: [PATCH 14/93] client: Refresh cloud models on websocket reconnect (#57528) Until now, the cloud-hosted model list was only refreshed in response to events that exercise the LLM token (a `UserUpdated` push, an organization change, or `PrivateUserInfoUpdated`). If a user wasn't actively using AI features around the time we shipped new models, the list could stay stale until they restarted Zed. This is the second step toward fixing that, after #57078 made the cloud websocket reconnect on its own. We now treat each successful (re)connect as a hint that the server state may have changed, so possibly new model definitions will be available, and trigger a model list refresh. The trigger is a new `Client::cloud_connection_id()` watch that bumps a counter each time the websocket handshake completes. `CloudLanguageModelProvider::State` subscribes to it and, on every tick after the initial `0`, schedules a debounced refresh (with jitter, so we don't have all active clients trying to reconnect at the same time after we deploy in cloud). Closes CLO-713. Release Notes: - The list of Zed hosted models is now refreshed automatically, without requiring a restart --- crates/client/src/client.rs | 18 ++++++++ crates/language_models/src/provider/cloud.rs | 46 ++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 093eb894483..36c5a49602a 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -333,6 +333,9 @@ impl Status { struct ClientState { credentials: Option, status: (watch::Sender, watch::Receiver), + /// Bumped each time the cloud websocket finishes its handshake. Starts at `0` so + /// subscribers can distinguish "no connection yet" from a real reconnect. + cloud_connection_id: (watch::Sender, watch::Receiver), _reconnect_task: Option>, _cloud_connection_task: Option>, } @@ -435,6 +438,7 @@ impl Default for ClientState { Self { credentials: None, status: watch::channel_with(Status::SignedOut), + cloud_connection_id: watch::channel_with(0), _reconnect_task: None, _cloud_connection_task: None, } @@ -668,6 +672,14 @@ impl Client { self.state.read().status.1.clone() } + /// Watches successful cloud websocket reconnections. + /// + /// The value is bumped each time the websocket handshake completes. The + /// initial `0` means no reconnection yet. + pub fn cloud_connection_id(&self) -> watch::Receiver { + self.state.read().cloud_connection_id.1.clone() + } + fn set_status(self: &Arc, status: Status, cx: &AsyncApp) { log::info!("set status on client {}: {:?}", self.id(), status); let mut state = self.state.write(); @@ -1006,6 +1018,12 @@ impl Client { let (mut messages, _cloud_io_task) = cx.update(|cx| connection.spawn(cx)); + { + let mut state = self.state.write(); + let mut cloud_connection_id = state.cloud_connection_id.0.borrow_mut(); + *cloud_connection_id = cloud_connection_id.saturating_add(1); + } + while let Some(message) = messages.next().await { if let Some(message) = message.log_err() { self.handle_message_to_client(message, cx); diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 78b4220eb03..7a4f292c2ac 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -14,16 +14,19 @@ use language_model::{ ZED_CLOUD_PROVIDER_ID, ZED_CLOUD_PROVIDER_NAME, }; use language_models_cloud::{CloudLlmTokenProvider, CloudModelProvider}; +use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; use release_channel::AppVersion; use settings::SettingsStore; pub use settings::ZedDotDevAvailableModel as AvailableModel; pub use settings::ZedDotDevAvailableProvider as AvailableProvider; use std::sync::Arc; +use std::time::Duration; use ui::{TintColor, prelude::*}; const PROVIDER_ID: LanguageModelProviderId = ZED_CLOUD_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = ZED_CLOUD_PROVIDER_NAME; +const MODELS_REFRESH_DEBOUNCE: Duration = Duration::from_secs(5 * 60); struct ClientTokenProvider { client: Arc, @@ -84,10 +87,12 @@ pub struct State { user_store: Entity, status: client::Status, provider: Entity>, + pending_models_refresh: Option>, _user_store_subscription: Subscription, _settings_subscription: Subscription, _llm_token_subscription: Subscription, _provider_subscription: Subscription, + _cloud_reconnect_task: Task<()>, } impl State { @@ -112,10 +117,32 @@ impl State { ) }); + let cloud_reconnect_task = cx.spawn({ + let client = client.clone(); + async move |this, cx| { + let mut connection_id_rx = client.cloud_connection_id(); + while let Some(connection_id) = connection_id_rx.next().await { + // The initial value `0` means no connection has been + // established since this `Client` was created; only real + // reconnects trigger a refresh. + if connection_id == 0 { + continue; + } + if this + .update(cx, |this, cx| this.schedule_debounced_models_refresh(cx)) + .is_err() + { + break; + } + } + } + }); + Self { client: client.clone(), user_store: user_store.clone(), status, + pending_models_refresh: None, _provider_subscription: cx.observe(&provider, |_, _, cx| cx.notify()), provider, _user_store_subscription: cx.subscribe( @@ -141,6 +168,7 @@ impl State { this.refresh_models(cx); }, ), + _cloud_reconnect_task: cloud_reconnect_task, } } @@ -167,6 +195,24 @@ impl State { provider.refresh_models(cx).detach_and_log_err(cx); }); } + + /// Schedules a model list refresh, replacing any previously scheduled + /// refresh. + fn schedule_debounced_models_refresh(&mut self, cx: &mut Context) { + self.pending_models_refresh = Some(cx.spawn(async move |this, cx| { + #[cfg(any(test, feature = "test-support"))] + let mut rng = StdRng::seed_from_u64(0); + #[cfg(not(any(test, feature = "test-support")))] + let mut rng = StdRng::from_os_rng(); + let jitter = Duration::from_millis( + rng.random_range(0..MODELS_REFRESH_DEBOUNCE.as_millis() as u64), + ); + cx.background_executor() + .timer(MODELS_REFRESH_DEBOUNCE + jitter) + .await; + this.update(cx, |this, cx| this.refresh_models(cx)).ok(); + })); + } } impl CloudLanguageModelProvider { From b49e5f19b961ae894ada9522692f6e7b671b00a1 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 27 May 2026 12:46:54 -0400 Subject: [PATCH 15/93] Avoid duplicate migrated skills (#57853) ## Summary - Track existing skill file contents and instruction bodies before migrating Rules to Skills - Skip writing migrated skills when an equivalent skill already exists - Cover duplicate detection across differently named skills ## Tests - cargo test -p prompt_store rules_to_skills_migration Release Notes: - Fixed duplicate Skills being created when migrating Rules. --- .../src/rules_to_skills_migration.rs | 125 +++++++++++++----- 1 file changed, 94 insertions(+), 31 deletions(-) diff --git a/crates/prompt_store/src/rules_to_skills_migration.rs b/crates/prompt_store/src/rules_to_skills_migration.rs index 89bc0541033..acc8509097c 100644 --- a/crates/prompt_store/src/rules_to_skills_migration.rs +++ b/crates/prompt_store/src/rules_to_skills_migration.rs @@ -34,14 +34,18 @@ //! still see and edit their Rules via the existing UI, and a user who //! downgrades to a Zed build without skills support won't lose anything. +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use agent_skills::{SKILL_FILE_NAME, global_skills_dir, slugify_skill_name}; +use agent_skills::{ + SKILL_FILE_NAME, global_skills_dir, parse_skill_file_content, slugify_skill_name, +}; use anyhow::{Context as _, Result}; use db::kvp::GlobalKeyValueStore; use fs::Fs; +use futures::StreamExt as _; use gpui::{App, AsyncApp, Entity, TaskExt as _}; use serde::{Deserialize, Serialize}; use util::ResultExt as _; @@ -241,6 +245,7 @@ async fn migrate_non_default_rules_to_skills( return Vec::new(); } let skills_dir = global_skills_dir(); + let mut existing_skill_contents = existing_skill_contents(fs, &skills_dir).await; let mut migrated = Vec::with_capacity(rules.len()); for (id, title) in rules { let body = match load_rule_body(prompt_store, cx, id, &title).await { @@ -254,7 +259,9 @@ async fn migrate_non_default_rules_to_skills( ); continue; }; - match write_migrated_skill(fs, &skills_dir, &slug, &body).await { + match write_migrated_skill(fs, &skills_dir, &slug, &body, &mut existing_skill_contents) + .await + { Ok(()) => migrated.push(title), Err(err) => { log::warn!("Failed to write skill for rule {title:?}: {err:#}"); @@ -412,11 +419,9 @@ async fn write_migration_result(result: &MigrationResult) { /// /// Three cases: /// -/// 1. `//SKILL.md` already exists with byte-identical -/// content to what we'd write — likely because the migration ran -/// successfully on a previous launch and is now being asked to -/// re-migrate the same source rule. Skip silently; don't create a -/// `-2` duplicate of the same content. +/// 1. Any existing skill file already matches the content we'd write, or +/// already has the same instruction body as this rule. Skip silently; +/// don't create a duplicate skill. /// 2. `//` doesn't exist — happy path. Create it and /// write the SKILL.md there. /// 3. `//` exists with *different* content (a real @@ -428,39 +433,59 @@ async fn write_migrated_skill( skills_dir: &Path, slug: &str, body: &str, + existing_skill_contents: &mut ExistingSkillContents, ) -> Result<()> { - let primary_dir = skills_dir.join(slug); - let primary_file = primary_dir.join(SKILL_FILE_NAME); - let primary_content = format_skill_file(slug, body); - - // Case 1: primary exists with identical content — nothing to do. - // Compare trimmed so a stray leading/trailing newline difference - // (which is meaningless inside a SKILL.md) doesn't trick us into - // generating a `-N` duplicate. - if fs.is_file(&primary_file).await - && fs - .load(&primary_file) - .await - .ok() - .is_some_and(|existing| existing.trim() == primary_content.trim()) - { + let trimmed_body = body.trim(); + if existing_skill_contents.bodies.contains(trimmed_body) { return Ok(()); } // Cases 2 and 3: find a free directory (the primary if free, // otherwise a `-N` suffix) and write the SKILL.md there. let (name, dir) = pick_available_skill_dir(fs, skills_dir, slug).await?; + let content = format_skill_file(&name, body); + let trimmed_content = content.trim(); + if existing_skill_contents.files.contains(trimmed_content) { + return Ok(()); + } + fs.create_dir(&dir).await?; - let content = if name == slug { - primary_content - } else { - format_skill_file(&name, body) - }; let skill_file_path = dir.join(SKILL_FILE_NAME); fs.write(&skill_file_path, content.as_bytes()).await?; + existing_skill_contents + .files + .insert(trimmed_content.to_string()); + existing_skill_contents + .bodies + .insert(trimmed_body.to_string()); Ok(()) } +#[derive(Default)] +struct ExistingSkillContents { + files: HashSet, + bodies: HashSet, +} + +async fn existing_skill_contents(fs: &dyn Fs, skills_dir: &Path) -> ExistingSkillContents { + let mut contents = ExistingSkillContents::default(); + let Ok(mut entries) = fs.read_dir(skills_dir).await else { + return contents; + }; + while let Some(entry) = entries.next().await { + let Ok(skill_dir) = entry else { continue }; + let Ok(file_content) = fs.load(&skill_dir.join(SKILL_FILE_NAME)).await else { + continue; + }; + contents.files.insert(file_content.trim().to_string()); + let Ok((_metadata, body)) = parse_skill_file_content(&file_content) else { + continue; + }; + contents.bodies.insert(body.trim().to_string()); + } + contents +} + /// Build the SKILL.md file contents for a migrated rule. fn format_skill_file(name: &str, body: &str) -> String { let mut output = format!( @@ -512,6 +537,16 @@ mod tests { use fs::FakeFs; use gpui::TestAppContext; + async fn write_migrated_skill_for_test( + fs: &dyn Fs, + skills_dir: &Path, + slug: &str, + body: &str, + ) -> Result<()> { + let mut existing_skill_contents = existing_skill_contents(fs, skills_dir).await; + write_migrated_skill(fs, skills_dir, slug, body, &mut existing_skill_contents).await + } + #[test] fn format_skill_file_includes_disable_model_invocation() { let content = format_skill_file("my-rule", "Body text."); @@ -576,7 +611,7 @@ mod tests { let skills_dir = PathBuf::from("/skills"); fs.create_dir(&skills_dir).await.unwrap(); - write_migrated_skill(fs.as_ref(), &skills_dir, "my-rule", "Body.") + write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Body.") .await .unwrap(); @@ -608,7 +643,7 @@ mod tests { ) .await; - write_migrated_skill(fs.as_ref(), &skills_dir, "my-rule", "Body.") + write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Body.") .await .unwrap(); @@ -640,7 +675,7 @@ mod tests { ) .await; - write_migrated_skill(fs.as_ref(), &skills_dir, "my-rule", "Body.") + write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Body.") .await .unwrap(); @@ -666,7 +701,7 @@ mod tests { ) .await; - write_migrated_skill(fs.as_ref(), &skills_dir, "my-rule", "Migrated body.") + write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Migrated body.") .await .unwrap(); @@ -684,6 +719,34 @@ mod tests { assert!(migrated.contains("disable-model-invocation: true")); } + #[gpui::test] + async fn write_migrated_skill_skips_when_any_existing_skill_has_same_body( + cx: &mut TestAppContext, + ) { + let fs = FakeFs::new(cx.executor()); + let skills_dir = PathBuf::from("/skills"); + fs.create_dir(&skills_dir.join("unrelated-skill")) + .await + .unwrap(); + let existing = format_skill_file("unrelated-skill", "Migrated body."); + fs.insert_file( + &skills_dir.join("unrelated-skill").join(SKILL_FILE_NAME), + existing.as_bytes().to_vec(), + ) + .await; + + write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Migrated body.") + .await + .unwrap(); + + assert!(!fs.is_dir(&skills_dir.join("my-rule")).await); + let unrelated = fs + .load(&skills_dir.join("unrelated-skill").join(SKILL_FILE_NAME)) + .await + .unwrap(); + assert_eq!(unrelated, existing); + } + #[test] fn format_default_rules_section_renders_headings_and_bodies() { let rules = vec![ From 9a7a5d685de40bcb5ad44c27c3490f961f6ec43f Mon Sep 17 00:00:00 2001 From: Thomas Boom Date: Wed, 27 May 2026 18:56:21 +0200 Subject: [PATCH 16/93] docs: Update tracking issue link to discussion in README (#57793) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d0e87696ae8..414eb33fd25 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ On macOS, Linux, and Windows you can [download Zed directly](https://zed.dev/dow Other platforms are not yet available: -- Web ([tracking issue](https://github.com/zed-industries/zed/issues/5396)) +- Web ([tracking discussion](https://github.com/zed-industries/zed/discussions/26195)) ### Developing Zed From 9b4737b0d78b17ee33f8d916a946bc6c34cd9742 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Wed, 27 May 2026 19:06:30 +0200 Subject: [PATCH 17/93] Introduce a skill for `#[gpui::test]` (#57806) Release Notes: - N/A or Added/Fixed/Improved ... --- .agents/skills/gpui-test/SKILL.md | 160 ++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 .agents/skills/gpui-test/SKILL.md diff --git a/.agents/skills/gpui-test/SKILL.md b/.agents/skills/gpui-test/SKILL.md new file mode 100644 index 00000000000..3d92659a552 --- /dev/null +++ b/.agents/skills/gpui-test/SKILL.md @@ -0,0 +1,160 @@ +--- +name: gpui-test +description: >- + Use when writing, debugging, or reproducing GPUI tests in Zed, including + gpui::test arguments, TestAppContext parameters, scheduler seeds, + ITERATIONS/SEED reproduction, parking failures, and pending task traces. +--- + +# GPUI Test Debugging + +Use this skill when the user asks about `#[gpui::test]`, GPUI test seeds or iterations, deterministic scheduler failures, parking/pending task failures, or how to reproduce a flaky GPUI test. + +## What `#[gpui::test]` does + +`#[gpui::test]` expands to a normal Rust `#[test]`, so it runs under standard Rust test runners such as `cargo test` and `cargo nextest`. + +It wraps the body in GPUI's deterministic test dispatcher/scheduler and can run the same test multiple times with different seeds. The seed controls scheduler task interleavings and any `StdRng` argument injected into the test. + +The macro supports both synchronous and asynchronous tests. + +### Supported function arguments + +The macro recognizes arguments by type name: + +| Test kind | Supported arguments | +| --- | --- | +| Sync and async | `&TestAppContext`, `&mut TestAppContext`, `StdRng` | +| Async only | `BackgroundExecutor` | +| Sync only | `&App`, `&mut App` | + +`StdRng` is seeded from the current GPUI test seed, and `BackgroundExecutor` is backed by the same deterministic test dispatcher. + +### Attribute arguments + +Use these forms on `#[gpui::test(arguments)]`: + +- No arguments: runs once with seed `0`, unless `SEED` is set. +- `seed = N`: adds a single explicit seed. +- `seeds(...)`: adds multiple explicit seeds. +- `iterations = N`: runs sequential seeds starting at `0` by default. +- `retries = N`: retries a failing run up to `N` times before surfacing the failure. +- `on_failure = "path::to::function"`: calls the function after final failure, before resuming the panic. +- `iterations` can be combined with explicit `seed` / `seeds`; explicit seeds are appended to the `0..iterations` range. +- If the `SEED` environment variable is set, it takes precedence over explicit seeds. +- With `SEED=N` and `ITERATIONS=M` or `iterations = M`, the harness runs seeds `N..N+M`. + +## Environment variables + +### GPUI test macro / scheduler execution + +- `SEED=` — chooses the scheduler seed. Use this to reproduce a failure printed as `failing seed: N`. It also seeds injected `StdRng` arguments. For `#[gpui::property_test]`, it controls the scheduler seed and GPUI applies it to the proptest config for deterministic case generation. +- `ITERATIONS=` — overrides the `iterations = ...` value at runtime. Use to sweep many seeds without editing the test. +- `PENDING_TRACES=1` or `PENDING_TRACES=true` — captures and prints pending task traces when the test scheduler panics with `Parking forbidden`. Use this when `run_until_parked()` or teardown reports pending work. +- `GPUI_RUN_UNTIL_PARKED_LOG=1` — logs when `allow_parking()` is enabled. Use to find tests that explicitly permit parking/pending work. +- `DEBUG_SCHEDULER=1` — prints scheduler clock/timer debugging from `scheduler::TestScheduler`. + +### Lower-level scheduler tests + +- `SCHEDULER_NONINTERACTIVE=1` — suppresses interactive seed progress output in `scheduler::TestScheduler::many`. This does not affect the `#[gpui::test]` harness path. + +### General Rust test debugging vars often useful with GPUI tests + +- `RUST_BACKTRACE=1` or `RUST_BACKTRACE=full` — show panic backtraces. +- `RUST_LOG=` — enable logs when the test initializes logging. +- `ZED_HEADLESS=1` — forces GPUI platform guessing toward headless mode; useful for tests that otherwise interact with platform/window setup. + +Prefer env vars over editing the test when narrowing a reproduction. + +## Reproducing a specific GPUI test + +1. Identify the crate/package and test name. + +2. Run the narrowest test filter first, skip to 3. if a failing seed is known. + + ```sh + cargo -q test -p -- --nocapture + ``` + +3. If the failure mentions a seed, rerun exactly that seed. + + ```sh + SEED= cargo -q test -p -- --nocapture + ``` + +4. If the failure is flaky and no seed is known, sweep seeds. + + ```sh + ITERATIONS=100 cargo -q test -p -- --nocapture + ``` + + When the harness prints `failing seed: `, switch to `SEED=` for all future debugging. + +5. If the failure is `Parking forbidden`, rerun with pending traces. + + ```sh + PENDING_TRACES=1 cargo -q test -p -- --nocapture + ``` + + If a failing seed was printed or is already known, include it too: + + ```sh + SEED= PENDING_TRACES=1 cargo -q test -p -- --nocapture + ``` + + Inspect the pending traces for a task that was spawned but not awaited, detached, completed, or intentionally allowed to park. + +6. If timing or timer advancement is involved, prefer GPUI scheduler timers in tests: + + ```rust + cx.background_executor().timer(duration).await; + ``` + + Avoid `smol::Timer::after(...)` in GPUI tests that rely on `run_until_parked()`, because GPUI's scheduler may not track it. + +7. Minimize the reproduction. + - Keep the failing `SEED` fixed. + - Reduce `ITERATIONS` to `1` or remove it once a seed is known. + - Remove unrelated setup only after confirming the same seed still fails. + - Preserve scheduler-sensitive awaits/yields; removing them can mask the bug. + - If randomness is test-controlled via `StdRng`, log or assert the generated scenario after fixing the scheduler seed. + +8. Validate the fix. + - Run the fixed seed. + - Run a modest seed sweep, e.g. `ITERATIONS=20`, if the failure was scheduler-sensitive. + - Run the relevant crate's test filter or broader suite if the touched code has shared behavior. + +## Common diagnosis patterns + +### Seed-dependent assertion failure + +Likely caused by a scheduler interleaving or by `StdRng`-driven test data. Fix `SEED`, reproduce, and inspect which task or generated scenario differs. + +### `Parking forbidden` + +Usually means a foreground/background task is still pending when the scheduler expected the test to make progress or finish. Look for: + +- A task that should be awaited but was dropped. +- A task that should be detached with error logging. +- A timer or receiver that is waiting forever. +- A missing `cx.run_until_parked()` after triggering async work in a test. +- A missing `cx.advance_clock(...)` to wait for debounced work in a test. +- Use of non-GPUI timers or executors that the test scheduler cannot drive. + +Rerun with `PENDING_TRACES=1` before changing code. + +### Non-determinism / wrong thread + +The scheduler can report activity from an unexpected thread. Look for work escaping GPUI's foreground/background executors, direct thread spawns, or external async runtimes not controlled by the test dispatcher. + +### Tests pass alone but fail in sweeps + +Use the failing seed from sweep output. Avoid assuming test order unless the runner is explicitly serial. Check globals, leaked entities/tasks, and state not reset by test initialization. + +## Writing GPUI tests + +- Prefer `#[gpui::test]` for tests that need `TestAppContext`, deterministic executors, fake time, or scheduler interleaving coverage. +- Add `iterations = N` when the test is intentionally checking interleavings. +- Use `StdRng` as a test argument when randomized test data should follow the same seed as the scheduler. +- Use `cx.background_executor().timer(duration).await` for delays/timeouts in GPUI tests. +- Do not add or increase `retries` while fixing a test unless the user explicitly asks or the test already documents why probabilistic tolerance is intentional. Retries can mask the failure instead of fixing it. From 6afac23e4a9f2ce87f4baff678dd523d45b05aa1 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 27 May 2026 13:41:11 -0400 Subject: [PATCH 18/93] Speed up StreamingDiff::push_new by 30-36% (#57772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation Miniprofs of Zed dropping frames showed foreground thread stalls of 711.56 ms, 641.01 ms, and 221.99 ms from the `edit_file_tool`. The new benchmark isolated `StreamingDiff::push_new` as one of the expensive phases so this PR aims to speed it up so we can avoid dropping more frames in the future. ## Benchmark methodology I had an agent create a Criterion suite with four deterministic Rust edit fixtures: tiny localized rewrite, small localized rewrite, many small changes, and helper block insertions. Benchmarked with: ```sh cargo bench -p streaming_diff --bench streaming_diff --profile release-fast -- --warm-up-time 1 --measurement-time 2 ``` The benchmark binary was also recorded under `xctrace` CPU Counters to inspect CPU samples before and after the change. ## Results `StreamingDiff::push_new` improved across all fixtures: | Benchmark | Before | After | Improvement | |---|---:|---:|---:| | `tiny_function_rewrite` | ~10.81 ms | ~6.91 ms | ~36% faster | | `small_function_rewrite` | ~51.02 ms | ~35.39 ms | ~31% faster | | `medium_many_small_changes` | ~130.71 ms | ~83.83 ms | ~36% faster | | `medium_insertions` | ~120.52 ms | ~79.90 ms | ~34% faster | The `xctrace` baseline showed samples in `Hash::hash`, `RandomState::hash_one`, `HashMap::insert`, and `RawTable::reserve_rehash`. After replacing the map with two `Vec` row buffers, the hash table frames disappeared from the top samples. The speedup comes from removing hash work from the DP inner loop and replacing scattered hash table probes with contiguous row buffer access (better cache hits 🏎️). The trace did not include direct cache miss counts, but this layout is likely more cache friendly because it replaces hash table access with a continuous line of memory (the `Vec`s). Release Notes: - N/A --- Cargo.lock | 1 + crates/streaming_diff/Cargo.toml | 5 + .../streaming_diff/benches/streaming_diff.rs | 321 ++++++++++++++++++ crates/streaming_diff/src/streaming_diff.rs | 22 +- 4 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 crates/streaming_diff/benches/streaming_diff.rs diff --git a/Cargo.lock b/Cargo.lock index ccddd99f6b1..c28ddcfe762 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17769,6 +17769,7 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" name = "streaming_diff" version = "0.1.0" dependencies = [ + "criterion", "ordered-float 2.10.1", "rand 0.9.4", "rope", diff --git a/crates/streaming_diff/Cargo.toml b/crates/streaming_diff/Cargo.toml index b3645a182c3..d3365d5793a 100644 --- a/crates/streaming_diff/Cargo.toml +++ b/crates/streaming_diff/Cargo.toml @@ -16,5 +16,10 @@ ordered-float.workspace = true rope.workspace = true [dev-dependencies] +criterion.workspace = true rand.workspace = true util = { workspace = true, features = ["test-support"] } + +[[bench]] +name = "streaming_diff" +harness = false diff --git a/crates/streaming_diff/benches/streaming_diff.rs b/crates/streaming_diff/benches/streaming_diff.rs new file mode 100644 index 00000000000..cbf83b7557e --- /dev/null +++ b/crates/streaming_diff/benches/streaming_diff.rs @@ -0,0 +1,321 @@ +use criterion::{ + BatchSize, BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main, +}; +use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; +use streaming_diff::StreamingDiff; + +const SEED: u64 = 0x5EED_5EED; +const CHUNK_SIZE: usize = 512; + +#[derive(Clone)] +struct EditFixture { + name: &'static str, + old_text: String, + new_text: String, +} + +fn streaming_diff_push_new(criterion: &mut Criterion) { + let fixtures = fixtures(); + let mut group = criterion.benchmark_group("streaming_diff_push_new"); + group.sample_size(10); + + for fixture in fixtures { + group.throughput(Throughput::Bytes(fixture.new_text.len() as u64)); + group.bench_with_input( + BenchmarkId::new(fixture.name, fixture.old_text.len()), + &fixture, + |bench, fixture| { + bench.iter_batched( + || StreamingDiff::new(fixture.old_text.clone()), + |mut diff| { + let mut operation_count = 0; + for chunk in chunk_text(&fixture.new_text, CHUNK_SIZE) { + operation_count += black_box(diff.push_new(chunk)).len(); + } + black_box(operation_count); + }, + BatchSize::SmallInput, + ); + }, + ); + } + + group.finish(); +} + +fn streaming_diff_finish(criterion: &mut Criterion) { + let fixtures = fixtures(); + let mut group = criterion.benchmark_group("streaming_diff_finish"); + group.sample_size(10); + + for fixture in fixtures { + group.throughput(Throughput::Bytes(fixture.new_text.len() as u64)); + group.bench_with_input( + BenchmarkId::new(fixture.name, fixture.old_text.len()), + &fixture, + |bench, fixture| { + bench.iter_batched( + || { + let mut diff = StreamingDiff::new(fixture.old_text.clone()); + for chunk in chunk_text(&fixture.new_text, CHUNK_SIZE) { + black_box(diff.push_new(chunk)); + } + diff + }, + |diff| { + black_box(diff.finish()); + }, + BatchSize::SmallInput, + ); + }, + ); + } + + group.finish(); +} + +fn fixtures() -> Vec { + // Keep fixtures modest because `StreamingDiff` is intentionally stressed here and + // can become very slow on tens of kilobytes of replacement text. These sizes still + // represent realistic `edit_file` old/new text blocks and are large enough to cross + // frame-budget-sized CPU work. + vec![ + make_fixture( + "tiny_function_rewrite", + 2, + EditPattern::LocalizedRewrite { + start_line: 12, + line_count: 6, + }, + SEED, + ), + make_fixture( + "small_function_rewrite", + 5, + EditPattern::LocalizedRewrite { + start_line: 22, + line_count: 12, + }, + SEED + 1, + ), + make_fixture( + "medium_many_small_changes", + 8, + EditPattern::ManySmallChanges { every_nth_line: 7 }, + SEED + 2, + ), + make_fixture( + "medium_insertions", + 8, + EditPattern::InsertHelperBlocks { every_nth_line: 9 }, + SEED + 3, + ), + ] +} + +enum EditPattern { + LocalizedRewrite { + start_line: usize, + line_count: usize, + }, + ManySmallChanges { + every_nth_line: usize, + }, + InsertHelperBlocks { + every_nth_line: usize, + }, +} + +fn make_fixture( + name: &'static str, + function_count: usize, + pattern: EditPattern, + seed: u64, +) -> EditFixture { + let mut rng = StdRng::seed_from_u64(seed); + let mut lines = random_rust_module(&mut rng, function_count); + let old_text = lines.join("\n"); + + match pattern { + EditPattern::LocalizedRewrite { + start_line, + line_count, + } => rewrite_local_block(&mut lines, start_line, line_count, &mut rng), + EditPattern::ManySmallChanges { every_nth_line } => { + rewrite_many_small_lines(&mut lines, every_nth_line, &mut rng) + } + EditPattern::InsertHelperBlocks { every_nth_line } => { + insert_helper_blocks(&mut lines, every_nth_line, &mut rng) + } + } + + EditFixture { + name, + old_text, + new_text: lines.join("\n"), + } +} + +fn random_rust_module(rng: &mut StdRng, function_count: usize) -> Vec { + let mut lines = vec![ + "use anyhow::{Context as _, Result};".to_string(), + "use collections::HashMap;".to_string(), + "".to_string(), + "#[derive(Clone, Debug)]".to_string(), + "pub struct WorkspaceSnapshot {".to_string(), + " buffers: HashMap,".to_string(), + " version: usize,".to_string(), + "}".to_string(), + "".to_string(), + "impl WorkspaceSnapshot {".to_string(), + ]; + + for function_index in 0..function_count { + let function_name = identifier(rng, function_index); + let argument_name = identifier(rng, function_index + 1_000); + let local_name = identifier(rng, function_index + 2_000); + let branch_name = identifier(rng, function_index + 3_000); + let multiplier = rng.random_range(2..17); + let offset = rng.random_range(1..128); + + lines.extend([ + format!( + " pub fn {function_name}(&mut self, {argument_name}: usize) -> Result {{" + ), + format!(" let mut {local_name} = {argument_name}.saturating_mul({multiplier});"), + format!(" if {local_name} % 2 == 0 {{"), + format!( + " {local_name} = {local_name}.saturating_add(self.version + {offset});" + ), + " } else {".to_string(), + format!(" {local_name} = {local_name}.saturating_sub({offset});"), + " }".to_string(), + format!(" let {branch_name} = self.buffers.len().saturating_add({local_name});"), + format!(" self.version = self.version.saturating_add({branch_name});"), + format!(" Ok({branch_name})"), + " }".to_string(), + "".to_string(), + ]); + } + + lines.push("}".to_string()); + lines.push("".to_string()); + lines.push("pub fn normalize_path(path: &str) -> String {".to_string()); + lines.push(" path.replace('\\\\', \"/\")".to_string()); + lines.push("}".to_string()); + lines +} + +fn rewrite_local_block( + lines: &mut [String], + start_line: usize, + line_count: usize, + rng: &mut StdRng, +) { + let end_line = (start_line + line_count).min(lines.len()); + for (relative_index, line) in lines[start_line..end_line].iter_mut().enumerate() { + let suffix = identifier(rng, relative_index + 10_000); + if line.contains("saturating_add") { + *line = format!( + " let {suffix} = self.version.checked_add({relative_index}).context(\"version overflow\")?;" + ); + } else if line.contains("saturating_sub") { + *line = format!( + " {suffix}.saturating_sub({});", + rng.random_range(8..256) + ); + } else if line.trim().is_empty() { + *line = format!( + " tracing::trace!(target: \"agent_bench\", value = {relative_index});" + ); + } else { + *line = format!("{line} // updated {suffix}"); + } + } +} + +fn rewrite_many_small_lines(lines: &mut [String], every_nth_line: usize, rng: &mut StdRng) { + for (line_index, line) in lines.iter_mut().enumerate() { + if line_index % every_nth_line != 0 || line.trim().is_empty() { + continue; + } + + if line.contains("let mut") { + *line = line.replace("let mut", "let mut updated"); + } else if line.contains("Ok(") { + *line = line.replace("Ok(", "Ok(black_box_value("); + } else if line.ends_with('{') { + *line = format!("{line} // scenario {}", identifier(rng, line_index)); + } else { + *line = format!("{line} // touched {}", identifier(rng, line_index)); + } + } +} + +fn insert_helper_blocks(lines: &mut Vec, every_nth_line: usize, rng: &mut StdRng) { + let mut line_index = every_nth_line; + while line_index < lines.len() { + if lines[line_index].trim() == "}" { + let helper_name = identifier(rng, line_index + 20_000); + lines.splice( + line_index..line_index, + [ + format!(" let {helper_name} = self.buffers.len();"), + format!(" tracing::trace!(target: \"agent_bench\", {helper_name});"), + ], + ); + line_index += 2; + } + line_index += every_nth_line; + } +} + +fn identifier(rng: &mut StdRng, salt: usize) -> String { + const WORDS: &[&str] = &[ + "buffer", + "workspace", + "snapshot", + "version", + "project", + "entry", + "path", + "cursor", + "anchor", + "edit", + "thread", + "message", + "context", + "store", + "diff", + "range", + "token", + "parser", + "semantic", + "format", + "completion", + "diagnostic", + "terminal", + "channel", + ]; + + let first = WORDS[(rng.random_range(0..WORDS.len()) + salt) % WORDS.len()]; + let second = WORDS[(rng.random_range(0..WORDS.len()) + salt / 3) % WORDS.len()]; + format!("{first}_{second}_{salt}") +} + +fn chunk_text(text: &str, max_chunk_size: usize) -> Vec<&str> { + let mut chunks = Vec::new(); + let mut start = 0; + while start < text.len() { + let mut end = (start + max_chunk_size).min(text.len()); + while end < text.len() && !text.is_char_boundary(end) { + end += 1; + } + chunks.push(&text[start..end]); + start = end; + } + chunks +} + +criterion_group!(benches, streaming_diff_push_new, streaming_diff_finish); +criterion_main!(benches); diff --git a/crates/streaming_diff/src/streaming_diff.rs b/crates/streaming_diff/src/streaming_diff.rs index 5677981b0dc..2205a1dec5e 100644 --- a/crates/streaming_diff/src/streaming_diff.rs +++ b/crates/streaming_diff/src/streaming_diff.rs @@ -1,6 +1,6 @@ use ordered_float::OrderedFloat; use rope::{Point, Rope, TextSummary}; -use std::collections::{BTreeSet, HashMap}; +use std::collections::BTreeSet; use std::{ cmp, fmt::{self, Debug}, @@ -103,7 +103,8 @@ pub struct StreamingDiff { scores: Matrix, old_text_ix: usize, new_text_ix: usize, - equal_runs: HashMap<(usize, usize), u32>, + previous_equal_runs: Vec, + current_equal_runs: Vec, } impl StreamingDiff { @@ -114,9 +115,10 @@ impl StreamingDiff { pub fn new(old: String) -> Self { let old = old.chars().collect::>(); + let old_len = old.len(); let mut scores = Matrix::new(); - scores.resize(old.len() + 1, 1); - for i in 0..=old.len() { + scores.resize(old_len + 1, 1); + for i in 0..=old_len { scores.set(i, 0, i as f64 * Self::DELETION_SCORE); } Self { @@ -125,7 +127,8 @@ impl StreamingDiff { scores, old_text_ix: 0, new_text_ix: 0, - equal_runs: Default::default(), + previous_equal_runs: vec![0; old_len + 1], + current_equal_runs: vec![0; old_len + 1], } } @@ -134,9 +137,9 @@ impl StreamingDiff { self.scores.swap_columns(0, self.scores.cols - 1); self.scores .resize(self.old.len() + 1, self.new.len() - self.new_text_ix + 1); - self.equal_runs.retain(|(_i, j), _| *j == self.new_text_ix); for j in self.new_text_ix + 1..=self.new.len() { + self.current_equal_runs.fill(0); let relative_j = j - self.new_text_ix; self.scores @@ -145,9 +148,8 @@ impl StreamingDiff { let insertion_score = self.scores.get(i, relative_j - 1) + Self::INSERTION_SCORE; let deletion_score = self.scores.get(i - 1, relative_j) + Self::DELETION_SCORE; let equality_score = if self.old[i - 1] == self.new[j - 1] { - let mut equal_run = self.equal_runs.get(&(i - 1, j - 1)).copied().unwrap_or(0); - equal_run += 1; - self.equal_runs.insert((i, j), equal_run); + let equal_run = self.previous_equal_runs[i - 1] + 1; + self.current_equal_runs[i] = equal_run; let exponent = cmp::min(equal_run as i32 / 4, Self::MAX_EQUALITY_EXPONENT); self.scores.get(i - 1, relative_j - 1) + Self::EQUALITY_BASE.powi(exponent) @@ -158,6 +160,8 @@ impl StreamingDiff { let score = insertion_score.max(deletion_score).max(equality_score); self.scores.set(i, relative_j, score); } + + std::mem::swap(&mut self.previous_equal_runs, &mut self.current_equal_runs); } let mut max_score = f64::NEG_INFINITY; From 710228e3ddd737b16ab320c48d3c7954db394764 Mon Sep 17 00:00:00 2001 From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 17:43:55 +0000 Subject: [PATCH 19/93] Bump Zed to v1.6.0 (#57858) Release Notes: - N/A Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c28ddcfe762..c0ced45dfcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23212,7 +23212,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.5.0" +version = "1.6.0" dependencies = [ "acp_thread", "acp_tools", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 79bb5cca724..7d0e9b46364 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "1.5.0" +version = "1.6.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From 7ae24463e189861071f7cc1101df04f9984b0ecf Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Wed, 27 May 2026 20:57:15 +0300 Subject: [PATCH 20/93] Fix multibuffer chunk splitting at UTF-8 boundaries (#57641) Don't panic when a diff transform boundary falls inside a multi-byte UTF-8 character. Round split points up to the next char boundary and advance past any transform boundaries skipped by that adjustment. Closes FR-16 Release Notes: - Fixed a crash when deleting words near inline diff boundaries containing multi-byte characters --- crates/multi_buffer/src/multi_buffer.rs | 19 +++++++--- crates/multi_buffer/src/multi_buffer_tests.rs | 36 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 74eaeef53eb..4b1231af45a 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -7934,7 +7934,11 @@ impl<'a> Iterator for MultiBufferChunks<'a> { if self.range.start >= self.range.end { return None; } - if self.range.start == self.diff_transforms.end().0 { + while self + .diff_transforms + .item() + .is_some_and(|_| self.range.start >= self.diff_transforms.end().0) + { self.diff_transforms.next(); } @@ -7961,10 +7965,17 @@ impl<'a> Iterator for MultiBufferChunks<'a> { let chunk_end = self.range.start + chunk.text.len(); let diff_transform_end = diff_transform_end.min(self.range.end); - if diff_transform_end < chunk_end { - let split_idx = diff_transform_end - self.range.start; + let split_idx = if diff_transform_end < chunk_end { + chunk + .text + .ceil_char_boundary(diff_transform_end - self.range.start) + } else { + chunk.text.len() + }; + + if split_idx < chunk.text.len() { let (before, after) = chunk.text.split_at(split_idx); - self.range.start = diff_transform_end; + self.range.start += split_idx; let mask = 1u128.unbounded_shl(split_idx as u32).wrapping_sub(1); let chars = chunk.chars & mask; let tabs = chunk.tabs & mask; diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 3fa5ac19d1a..b90b3425616 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -1527,6 +1527,42 @@ async fn test_basic_diff_hunks(cx: &mut TestAppContext) { ); } +#[gpui::test] +fn test_text_for_range_with_diff_transform_boundary_inside_multibyte_character(cx: &mut App) { + let buffer = cx.new(|cx| Buffer::local("タx", cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let mut snapshot = multibuffer.read(cx).snapshot(cx); + + fn ascii_summary_with_byte_len(byte_len: usize) -> MBTextSummary { + let text = "x".repeat(byte_len); + MBTextSummary::from(TextSummary::from(text.as_str())) + } + + // FR-16 shown a diff transform boundary two bytes into the leading 'タ'. + // Build that transform tree directly so this test stays focused on chunk iteration. + let mut diff_transforms = SumTree::default(); + diff_transforms.push( + DiffTransform::BufferContent { + summary: ascii_summary_with_byte_len(2), + inserted_hunk_info: None, + }, + (), + ); + diff_transforms.push( + DiffTransform::BufferContent { + summary: ascii_summary_with_byte_len("タx".len() - 2), + inserted_hunk_info: None, + }, + (), + ); + snapshot.diff_transforms = diff_transforms; + + let text = snapshot + .text_for_range(MultiBufferOffset(0)..snapshot.len()) + .collect::(); + assert_eq!(text, "タx"); +} + #[gpui::test] async fn test_repeatedly_expand_a_diff_hunk(cx: &mut TestAppContext) { let text = indoc!( From 6472f018a68cda90e97f21f3ddc605828513e7ad Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 27 May 2026 15:09:52 -0300 Subject: [PATCH 21/93] skill_creator: Use the status toast for confirming creation (#57855) This PR uses the status toast for the Skill Creator confirming action as opposed the regular message notification. Aside from it looking a bit better, it's also auto-dismissed, which is preferred in this case. Release Notes: - Improved skill creation toast confirmation by making it auto-dismissed. --------- Co-authored-by: Martin Ye --- Cargo.lock | 1 + assets/keymaps/default-linux.json | 2 + assets/keymaps/default-macos.json | 2 + assets/keymaps/default-windows.json | 2 + crates/skill_creator/Cargo.toml | 1 + crates/skill_creator/src/skill_creator.rs | 144 +++++++++++++--------- 6 files changed, 93 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0ced45dfcd..18e1162b1bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17229,6 +17229,7 @@ dependencies = [ "http_client", "language", "menu", + "notifications", "platform_title_bar", "release_channel", "serde_json", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index c66d20f13f5..ba1ed2fcd8e 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1563,6 +1563,7 @@ "context": "SkillCreator", "bindings": { "ctrl-w": "workspace::CloseWindow", + "ctrl-enter": "skill_creator::SaveSkill", "tab": "skill_creator::FocusNextField", "shift-tab": "skill_creator::FocusPreviousField", }, @@ -1571,6 +1572,7 @@ "context": "SkillCreator > Editor", "bindings": { "ctrl-w": "workspace::CloseWindow", + "ctrl-enter": "skill_creator::SaveSkill", "tab": "skill_creator::FocusNextField", "shift-tab": "skill_creator::FocusPreviousField", }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 61e719d7124..efb2ecd01ea 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1657,6 +1657,7 @@ "use_key_equivalents": true, "bindings": { "cmd-w": "workspace::CloseWindow", + "cmd-enter": "skill_creator::SaveSkill", "tab": "skill_creator::FocusNextField", "shift-tab": "skill_creator::FocusPreviousField", }, @@ -1666,6 +1667,7 @@ "use_key_equivalents": true, "bindings": { "cmd-w": "workspace::CloseWindow", + "cmd-enter": "skill_creator::SaveSkill", "tab": "skill_creator::FocusNextField", "shift-tab": "skill_creator::FocusPreviousField", }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 56a3bc49726..ce8b775229b 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1583,6 +1583,7 @@ "use_key_equivalents": true, "bindings": { "ctrl-w": "workspace::CloseWindow", + "ctrl-enter": "skill_creator::SaveSkill", "tab": "skill_creator::FocusNextField", "shift-tab": "skill_creator::FocusPreviousField", }, @@ -1592,6 +1593,7 @@ "use_key_equivalents": true, "bindings": { "ctrl-w": "workspace::CloseWindow", + "ctrl-enter": "skill_creator::SaveSkill", "tab": "skill_creator::FocusNextField", "shift-tab": "skill_creator::FocusPreviousField", }, diff --git a/crates/skill_creator/Cargo.toml b/crates/skill_creator/Cargo.toml index 1bda7bfb6eb..8512c4d50b2 100644 --- a/crates/skill_creator/Cargo.toml +++ b/crates/skill_creator/Cargo.toml @@ -21,6 +21,7 @@ gpui.workspace = true http_client.workspace = true language.workspace = true menu.workspace = true +notifications.workspace = true platform_title_bar.workspace = true release_channel.workspace = true serde_yaml_ng.workspace = true diff --git a/crates/skill_creator/src/skill_creator.rs b/crates/skill_creator/src/skill_creator.rs index 599764e4c7a..3ed3ee49b33 100644 --- a/crates/skill_creator/src/skill_creator.rs +++ b/crates/skill_creator/src/skill_creator.rs @@ -13,6 +13,7 @@ use gpui::{ }; use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request, StatusCode, Url}; use language::{Buffer, LanguageRegistry, language_settings::SoftWrap}; +use notifications::status_toast::StatusToast; use platform_title_bar::PlatformTitleBar; use release_channel::ReleaseChannel; use settings::{ActionSequence, Settings}; @@ -22,14 +23,12 @@ use std::sync::Arc; use std::time::Duration; use theme_settings::ThemeSettings; use ui::{ - ContextMenu, Divider, DropdownMenu, DropdownStyle, Headline, HeadlineSize, SwitchField, + Banner, ContextMenu, Divider, DropdownMenu, DropdownStyle, Headline, HeadlineSize, SwitchField, WithScrollbar, prelude::*, }; use ui_input::{ErasedEditorEvent, InputField}; use util::ResultExt; -use workspace::{ - Toast, Workspace, WorkspaceSettings, client_side_decorations, notifications::NotificationId, -}; +use workspace::{Workspace, WorkspaceSettings, client_side_decorations}; use worktree::WorktreeId; actions!( @@ -43,6 +42,8 @@ const DESCRIPTION_FIELD_TAB_INDEX: isize = 3; const DISABLE_MODEL_INVOCATION_TAB_INDEX: isize = 4; const SCOPE_FIELD_TAB_INDEX: isize = 5; const BODY_FIELD_TAB_INDEX: isize = 6; +const CANCEL_BUTTON_TAB_INDEX: isize = 7; +const SAVE_BUTTON_TAB_INDEX: isize = 8; const URL_IMPORT_DEBOUNCE: Duration = Duration::from_millis(100); const URL_IMPORT_ERROR_BODY_MAX_LEN: usize = 2048; @@ -83,13 +84,6 @@ enum ScopeChoice { } impl ScopeChoice { - fn label(&self) -> SharedString { - match self { - ScopeChoice::Global => "Global".into(), - ScopeChoice::Project { root_name, .. } => root_name.clone(), - } - } - fn key(&self) -> SharedString { match self { ScopeChoice::Global => "global".into(), @@ -256,6 +250,8 @@ pub struct SkillCreator { // Held so replacing it or switching back to the form cancels an in-flight import. url_import_task: Option>, scroll_handle: ScrollHandle, + cancel_button_focus_handle: FocusHandle, + save_button_focus_handle: FocusHandle, _subscriptions: Vec, } @@ -432,6 +428,8 @@ impl SkillCreator { url_import_debounce_task: None, url_import_task: None, scroll_handle: ScrollHandle::new(), + cancel_button_focus_handle: cx.focus_handle(), + save_button_focus_handle: cx.focus_handle(), _subscriptions: subscriptions, } } @@ -728,7 +726,10 @@ impl SkillCreator { let disable_model_invocation = self.disable_model_invocation; let fs = self.fs.clone(); let workspace = self.workspace.clone(); - let scope_label = scope.label(); + let scope_description: SharedString = match &scope { + ScopeChoice::Global => "your global skills".into(), + ScopeChoice::Project { root_name, .. } => root_name.clone(), + }; self.saving = true; self.save_error = None; @@ -749,22 +750,23 @@ impl SkillCreator { this.saving = false; this.save_task = None; match result { - Ok(path) => { + Ok(_) => { if let Some(on_saved) = &this.on_saved { on_saved(cx); } if let Some(workspace) = workspace.as_ref().and_then(|w| w.upgrade()) { workspace.update(cx, |workspace, cx| { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - format!( - "Saved skill \"{name}\" to {scope_label} ({})", - path.display() - ), - ), - cx, - ); + let message = + format!("Saved skill \"{name}\" to {scope_description}"); + let status_toast = StatusToast::new(message, cx, |this, _cx| { + this.icon( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ) + .dismiss_button(true) + }); + workspace.toggle_status_toast(status_toast, cx); }); } window.remove_window(); @@ -1016,45 +1018,77 @@ impl SkillCreator { )) } - fn render_action_bar(&self, cx: &mut Context) -> impl IntoElement { + fn render_footer(&self, window: &Window, cx: &mut Context) -> impl IntoElement { let valid = self.is_valid(cx); let saving = self.saving; let main_action = if saving { "Saving…" } else { "Save Skill" }; - h_flex() + // Draw a faint outline around whichever button currently holds + // keyboard focus, so tabbing to Cancel/Save is clearly visible. The + // ring border is always present (transparent when unfocused) so + // focusing a button never shifts the surrounding layout. + let focus_ring = |focus_handle: &FocusHandle| { + let focused = focus_handle.is_focused(window) && window.last_input_was_keyboard(); + let border_color = if focused { + cx.theme().colors().border_focused + } else { + cx.theme().colors().border_transparent + }; + div().rounded_sm().border_1().border_color(border_color) + }; + + v_flex() .w_full() - .map(|this| { - if self.save_error.is_some() { - this.justify_between() - } else { - this.justify_end() - } + .p_2p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) + .when(self.save_error.is_some(), |this| { + this.gap_2().child( + Banner::new() + .severity(Severity::Error) + .children(self.save_error.clone().map(|err| Label::new(err))), + ) }) - .gap_2() - .children( - self.save_error - .clone() - .map(|err| Label::new(err).size(LabelSize::Small).color(Color::Error)), - ) .child( h_flex() + .w_full() .gap_1() + .justify_end() .child( - Button::new("cancel-skill", "Cancel") - .disabled(saving) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(Cancel), cx); - }), + focus_ring(&self.cancel_button_focus_handle).child( + Button::new("cancel-skill", "Cancel") + .track_focus( + &self + .cancel_button_focus_handle + .clone() + .tab_index(CANCEL_BUTTON_TAB_INDEX) + .tab_stop(true), + ) + .disabled(saving) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(Cancel), cx); + }), + ), ) .child( - Button::new("save-skill", main_action) - .style(ButtonStyle::Filled) - .layer(ui::ElevationIndex::ModalSurface) - .disabled(!valid || saving) - .loading(saving) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(SaveSkill), cx); - }), + focus_ring(&self.save_button_focus_handle).child( + Button::new("save-skill", main_action) + .track_focus( + &self + .save_button_focus_handle + .clone() + .tab_index(SAVE_BUTTON_TAB_INDEX) + .tab_stop(true), + ) + .style(ButtonStyle::Filled) + .layer(ui::ElevationIndex::ModalSurface) + .disabled(!valid || saving) + .loading(saving) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(SaveSkill), cx); + }), + ), ), ) } @@ -1165,15 +1199,7 @@ impl Render for SkillCreator { .child(self.render_form_fields(window, cx)), ), ) - .child( - h_flex() - .w_full() - .p_2p5() - .border_t_1() - .border_color(theme.colors().border_variant) - .bg(theme.colors().panel_background) - .child(self.render_action_bar(cx)), - ), + .child(self.render_footer(window, cx)), window, cx, Tiling::default(), From bf4e559347d6afe7a72fc424785ccad25b9aee96 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 27 May 2026 14:16:04 -0400 Subject: [PATCH 22/93] feature_flags: Only respect feature flag overrides for Zed staff (#57860) This PR is a follow-up to #54206 to make it so we only respect the feature flag overrides for Zed staff (or when running a development build). Previously just the UI portion of the feature flags was disabled for non-staff, but manipulating the settings file by hand would still allow setting overrides that would be respected. The only other setting on the "Developer" page of the settings UI was the performance profiler. This was previously only available for staff, as the entire "Developer" page was limited to staff, but it seems reasonable to allow non-staff to see this. Release Notes: - N/A --- crates/feature_flags/src/feature_flags.rs | 9 +++ crates/feature_flags/src/store.rs | 37 ++++++--- crates/settings_ui/src/page_data.rs | 96 ++++++++++++----------- 3 files changed, 85 insertions(+), 57 deletions(-) diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index dadcab383f0..f983917ae25 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -215,6 +215,10 @@ pub trait FeatureFlagAppExt { fn flag_value(&self) -> T::Value; fn is_staff(&self) -> bool; + /// Whether feature flag overrides from settings are honored for the + /// current user. Overrides are a staff-only affordance. + fn feature_flag_overrides_enabled(&self) -> bool; + fn on_flags_ready(&mut self, callback: F) -> Subscription where F: FnMut(OnFlagsReady, &mut App) + 'static; @@ -253,6 +257,11 @@ impl FeatureFlagAppExt for App { .unwrap_or(false) } + fn feature_flag_overrides_enabled(&self) -> bool { + self.try_global::() + .map_or(false, |store| store.overrides_enabled()) + } + fn on_flags_ready(&mut self, mut callback: F) -> Subscription where F: FnMut(OnFlagsReady, &mut App) + 'static, diff --git a/crates/feature_flags/src/store.rs b/crates/feature_flags/src/store.rs index a8376de7e3a..552d025f01e 100644 --- a/crates/feature_flags/src/store.rs +++ b/crates/feature_flags/src/store.rs @@ -96,6 +96,16 @@ impl FeatureFlagStore { self.staff } + /// Whether feature flag overrides from settings should be honored. + /// + /// Overrides are a staff-only affordance, so non-staff users in release + /// builds can't flip flags through `settings.json` or the settings UI. + /// Debug builds are always treated as staff, and `ZED_DISABLE_STAFF` + /// forces the user to be treated as non-staff for testing. + pub fn overrides_enabled(&self) -> bool { + (cfg!(debug_assertions) || self.staff) && !*ZED_DISABLE_STAFF + } + pub fn server_flags_received(&self) -> bool { self.server_flags_received } @@ -158,8 +168,12 @@ impl FeatureFlagStore { return Some(T::Value::on_variant()); } - if let Some(override_key) = FeatureFlagsSettings::get_global(cx).overrides.get(T::NAME) { - return variant_from_key::(override_key); + // Only apply overrides when they are specifically enabled. + if self.overrides_enabled() { + if let Some(override_key) = FeatureFlagsSettings::get_global(cx).overrides.get(T::NAME) + { + return variant_from_key::(override_key); + } } // Staff default: resolve to the enabled variant. @@ -194,15 +208,18 @@ impl FeatureFlagStore { return on_variant_key; } - if let Some(requested) = FeatureFlagsSettings::get_global(cx) - .overrides - .get(descriptor.name) - { - if let Some(variant) = (descriptor.variants)() - .into_iter() - .find(|v| v.override_key == requested.as_str()) + // Only apply overrides when they are specifically enabled. + if self.overrides_enabled() { + if let Some(requested) = FeatureFlagsSettings::get_global(cx) + .overrides + .get(descriptor.name) { - return variant.override_key; + if let Some(variant) = (descriptor.variants)() + .into_iter() + .find(|v| v.override_key == requested.as_str()) + { + return variant.override_key; + } } } diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 3eb1ec94512..1a38337fb49 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -62,7 +62,7 @@ macro_rules! concat_sections { } pub(crate) fn settings_data(cx: &App) -> Vec { - let mut pages = vec![ + vec![ general_page(cx), appearance_page(), keymap_page(), @@ -77,56 +77,58 @@ pub(crate) fn settings_data(cx: &App) -> Vec { collaboration_page(), ai_page(cx), network_page(), - ]; - - use feature_flags::FeatureFlagAppExt as _; - if cx.is_staff() || cfg!(debug_assertions) { - pages.push(developer_page()); - } - - pages + developer_page(cx), + ] } -fn developer_page() -> SettingsPage { +fn developer_page(cx: &App) -> SettingsPage { + use feature_flags::FeatureFlagAppExt as _; + + let mut items: Vec = Vec::new(); + + // Feature flag overrides are a staff-only affordance, so only surface the section when the overrides are enabled. + if cx.feature_flag_overrides_enabled() { + items.push(SettingsPageItem::SectionHeader("Feature Flags")); + items.push(SettingsPageItem::SubPageLink(SubPageLink { + title: "Feature Flags".into(), + r#type: Default::default(), + description: None, + json_path: Some("feature_flags"), + in_json: true, + files: USER, + render: crate::pages::render_feature_flags_page, + })); + } + + items.push(SettingsPageItem::SectionHeader("Instrumentation")); + items.push(SettingsPageItem::SettingItem(SettingItem { + title: "Performance Profiler", + description: "Collect timing data for foreground and background executor tasks so they can be inspected via `zed: open performance profiler`. May lead to increased memory usage.", + field: Box::new(SettingField { + json_path: Some("instrumentation.performance_profiler.enabled"), + pick: |settings_content| { + settings_content + .instrumentation + .as_ref() + .and_then(|i| i.performance_profiler.as_ref()) + .and_then(|p| p.enabled.as_ref()) + }, + write: |settings_content, value, _| { + settings_content + .instrumentation + .get_or_insert_default() + .performance_profiler + .get_or_insert_default() + .enabled = value; + }, + }), + metadata: None, + files: USER, + })); + SettingsPage { title: "Developer", - items: Box::new([ - SettingsPageItem::SectionHeader("Feature Flags"), - SettingsPageItem::SubPageLink(SubPageLink { - title: "Feature Flags".into(), - r#type: Default::default(), - description: None, - json_path: Some("feature_flags"), - in_json: true, - files: USER, - render: crate::pages::render_feature_flags_page, - }), - SettingsPageItem::SectionHeader("Instrumentation"), - SettingsPageItem::SettingItem(SettingItem { - title: "Performance Profiler", - description: "Collect timing data for foreground and background executor tasks so they can be inspected via `zed: open performance profiler`. May lead to increased memory usage.", - field: Box::new(SettingField { - json_path: Some("instrumentation.performance_profiler.enabled"), - pick: |settings_content| { - settings_content - .instrumentation - .as_ref() - .and_then(|i| i.performance_profiler.as_ref()) - .and_then(|p| p.enabled.as_ref()) - }, - write: |settings_content, value, _| { - settings_content - .instrumentation - .get_or_insert_default() - .performance_profiler - .get_or_insert_default() - .enabled = value; - }, - }), - metadata: None, - files: USER, - }), - ]), + items: items.into_boxed_slice(), } } From 1d029c5ff5654fb1b1e8caf4462993c8ee13a133 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 27 May 2026 19:17:59 +0100 Subject: [PATCH 23/93] gpui: Accesskit support (#56065) GPUI AccessKit integration This PR is replacing #51097 , and is much more limited in scope. This PR *ONLY* adds AccessKit support to GPUI, and doesn't touch Zed. Once this lands, we can start adding aria attributes to Zed's components. This PR is the first step to addressing #41138 . Release Notes: - N/A or Added/Fixed/Improved ... --------- Co-authored-by: John Tur Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> Co-authored-by: Bennet Bo Fenner Co-authored-by: Agus Zubiaga --- .gitignore | 3 + Cargo.lock | 388 +++++++++++++++--- Cargo.toml | 4 + crates/gpui/Cargo.toml | 7 + crates/gpui/README.md | 1 + crates/gpui/examples/a11y.rs | 264 ++++++++++++ crates/gpui/src/_accessibility.rs | 243 +++++++++++ crates/gpui/src/element.rs | 49 +++ crates/gpui/src/elements/div.rs | 240 +++++++++++ crates/gpui/src/elements/text.rs | 264 +++++++++++- crates/gpui/src/gpui.rs | 5 + crates/gpui/src/platform.rs | 19 + crates/gpui/src/window.rs | 193 ++++++++- crates/gpui/src/window/a11y.rs | 281 +++++++++++++ crates/gpui_linux/Cargo.toml | 2 + crates/gpui_linux/src/linux/wayland/window.rs | 59 +++ crates/gpui_linux/src/linux/x11/window.rs | 85 ++++ crates/gpui_macos/Cargo.toml | 2 + crates/gpui_macos/src/window.rs | 65 +++ crates/gpui_windows/Cargo.toml | 2 + crates/gpui_windows/src/events.rs | 29 ++ crates/gpui_windows/src/window.rs | 65 +++ crates/title_bar/src/title_bar.rs | 4 +- nix/modules/devshells.nix | 9 + nix/modules/packages.nix | 12 +- nix/tests/a11y.nix | 296 +++++++++++++ nix/tests/a11y_atspi_test.py | 205 +++++++++ 27 files changed, 2738 insertions(+), 58 deletions(-) create mode 100644 crates/gpui/examples/a11y.rs create mode 100644 crates/gpui/src/_accessibility.rs create mode 100644 crates/gpui/src/window/a11y.rs create mode 100644 nix/tests/a11y.nix create mode 100644 nix/tests/a11y_atspi_test.py diff --git a/.gitignore b/.gitignore index becb768d270..cf75babbcdd 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ crates/docs_preprocessor/actions.json # Local documentation audit files /december-2025-releases.md /docs/december-2025-documentation-gaps.md + +# NixOS integration test state +.nixos-test-history diff --git a/Cargo.lock b/Cargo.lock index 18e1162b1bd..8398ff00bde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,85 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842fd8203e6dfcf531d24f5bac792088edfba7d6b35844fead191603fb32a260" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf 0.13.1", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534bc3fdc89a64a1db3c46b33c198fde2b7c3c7d094e5809c8c8bf2970c18243" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90e549dd7c6562b6a2ea807b44726e6241707db054a817dc4c7e2b8d3b39bfac" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel 2.5.0", + "async-executor", + "async-task", + "atspi", + "futures-lite 2.6.1", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "acp_thread" version = "0.1.0" @@ -1235,6 +1314,43 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "audio" version = "0.1.0" @@ -2183,13 +2299,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.3", ] [[package]] @@ -3066,7 +3191,7 @@ dependencies = [ "http_client_tls", "httparse", "log", - "objc2-foundation", + "objc2-foundation 0.3.2", "parking_lot", "paths", "postage", @@ -4002,13 +4127,13 @@ dependencies = [ "ndk-context", "num-derive", "num-traits", - "objc2", + "objc2 0.6.3", "objc2-audio-toolbox", "objc2-avf-audio", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -5209,9 +5334,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", ] [[package]] @@ -7964,6 +8089,7 @@ dependencies = [ name = "gpui" version = "0.2.2" dependencies = [ + "accesskit", "anyhow", "async-channel 2.5.0", "async-task", @@ -8007,8 +8133,8 @@ dependencies = [ "metal", "num_cpus", "objc", - "objc2", - "objc2-metal", + "objc2 0.6.3", + "objc2-metal 0.3.2", "parking", "parking_lot", "pathfinder_geometry", @@ -8054,6 +8180,8 @@ dependencies = [ name = "gpui_linux" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_unix", "anyhow", "as-raw-xcb-connection", "ashpd", @@ -8102,6 +8230,8 @@ dependencies = [ name = "gpui_macos" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_macos", "anyhow", "async-task", "block", @@ -8128,7 +8258,7 @@ dependencies = [ "media", "metal", "objc", - "objc2-app-kit", + "objc2-app-kit 0.3.1", "parking_lot", "pathfinder_geometry", "raw-window-handle", @@ -8246,6 +8376,8 @@ dependencies = [ name = "gpui_windows" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_windows", "anyhow", "collections", "etagere", @@ -12076,6 +12208,22 @@ dependencies = [ "objc_id", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.3" @@ -12085,14 +12233,30 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + [[package]] name = "objc2-app-kit" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", ] [[package]] @@ -12103,11 +12267,11 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ "bitflags 2.10.0", "libc", - "objc2", + "objc2 0.6.3", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -12116,8 +12280,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", ] [[package]] @@ -12127,10 +12291,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2", + "objc2 0.6.3", "objc2-core-audio-types", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -12140,7 +12304,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -12150,10 +12326,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "dispatch2", "libc", - "objc2", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] @@ -12162,6 +12350,18 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" @@ -12169,9 +12369,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", ] @@ -12185,6 +12385,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-metal" version = "0.3.2" @@ -12192,11 +12404,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "dispatch2", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] @@ -12206,10 +12431,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", ] [[package]] @@ -14642,6 +14867,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -14953,10 +15188,10 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -19204,7 +19439,7 @@ dependencies = [ "toml_datetime 0.7.3", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -19236,7 +19471,7 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -19248,7 +19483,7 @@ dependencies = [ "indexmap 2.11.4", "toml_datetime 0.7.3", "toml_parser", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -19257,7 +19492,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "winnow", + "winnow 0.7.13", ] [[package]] @@ -19506,8 +19741,8 @@ dependencies = [ "chrono", "libc", "log", - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", "once_cell", "percent-encoding", "scopeguard", @@ -21404,7 +21639,7 @@ dependencies = [ "ash", "bit-set 0.9.1", "bitflags 2.10.0", - "block2", + "block2 0.6.2", "bytemuck", "cfg-if", "cfg_aliases 0.2.1", @@ -21420,11 +21655,11 @@ dependencies = [ "log", "naga", "ndk-sys", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", "once_cell", "ordered-float 4.6.0", "parking_lot", @@ -22359,6 +22594,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" @@ -23179,12 +23423,36 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 0.7.13", "zbus_macros", "zbus_names", "zvariant", ] +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + [[package]] name = "zbus_macros" version = "5.13.2" @@ -23202,12 +23470,24 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow", + "winnow 1.0.2", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" +dependencies = [ + "quick-xml 0.39.3", + "serde", + "zbus_names", "zvariant", ] @@ -23859,24 +24139,24 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.9.2" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" dependencies = [ "endi", "enumflags2", "serde", "serde_bytes", - "winnow", + "winnow 1.0.2", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.9.2" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -23887,13 +24167,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" dependencies = [ "proc-macro2", "quote", "serde", "syn 2.0.117", - "winnow", + "winnow 1.0.2", ] diff --git a/Cargo.toml b/Cargo.toml index 4441be03839..05f551db006 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -503,6 +503,10 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # +accesskit = "0.24.0" +accesskit_macos = "0.26.0" +accesskit_unix = "0.21.0" +accesskit_windows = "0.32.1" agent-client-protocol = { version = "=0.12.1", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index a2f4bf8127e..64b568a22ff 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -45,6 +45,7 @@ path = "src/gpui.rs" doctest = false [dependencies] +accesskit.workspace = true anyhow.workspace = true async-task = "4.7" backtrace = { workspace = true, optional = true } @@ -175,6 +176,8 @@ cbindgen = { version = "0.28.0", default-features = false } + + [[example]] name = "hello_world" path = "examples/hello_world.rs" @@ -250,3 +253,7 @@ path = "examples/list_example.rs" [[example]] name = "mouse_pressure" path = "examples/mouse_pressure.rs" + +[[example]] +name = "a11y" +path = "examples/a11y.rs" diff --git a/crates/gpui/README.md b/crates/gpui/README.md index e7950de23a8..6d4a37932ff 100644 --- a/crates/gpui/README.md +++ b/crates/gpui/README.md @@ -12,6 +12,7 @@ gpui = { version = "*" } ``` - [Ownership and data flow](_ownership_and_data_flow) +- [Accessibility](_accessibility) Everything in GPUI starts with an `Application`. You can create one with `Application::new()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example. diff --git a/crates/gpui/examples/a11y.rs b/crates/gpui/examples/a11y.rs new file mode 100644 index 00000000000..c406f4d85cd --- /dev/null +++ b/crates/gpui/examples/a11y.rs @@ -0,0 +1,264 @@ +//! Accessibility (AccessKit) demo app. +//! +//! Run with: `cargo run -p gpui --example a11y` +//! +//! Or on Linux: `cargo run -p gpui --features gpui_platform/wayland,gpui_platform/x11 --example a11y` +//! +//! This app uses GPUI's accessibility APIs to attach structured information to +//! the element tree, which allows assistive technology to see and interact with +//! the UI programmatically. +//! +//! The app behaves as follows: +//! - It opens a single window. +//! - The window's title is "GPUI Accessibility Demo". +//! - The window has a sequence of UI elements, stacked vertically: +//! - A heading with the text "Accessibility Demo". +//! - A row containing two elements: +//! - A spin button (role `SpinButton`) labelled "Counter: ", where +//! `` is the current count. It supports `Increment` and `Decrement` +//! accessible actions, and also increments on click. The numeric value +//! is clamped to a minimum of 0. +//! - A button labelled "Reset counter" that resets the count to 0. +//! - A row containing two elements: +//! - A switch, that can be toggled, and starts disabled. Toggling the switch +//! does nothing. +//! - The text "Enable feature". +//! - A "to-do" list, with three items, each represented with a `Text` element: +//! - "1. Write code" +//! - "2. Run tests" +//! - "3. Ship it" + +use gpui::{ + AccessibleAction, App, Bounds, Context, FocusHandle, KeyBinding, Role, SharedString, Toggled, + Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, text, +}; +use gpui_platform::application; + +actions!(a11y_example, [Tab, TabPrev]); + +struct A11yDemo { + focus_handle: FocusHandle, + count: i32, + enabled: bool, +} + +impl A11yDemo { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + window.focus(&focus_handle, cx); + Self { + focus_handle, + count: 0, + enabled: false, + } + } +} + +impl Render for A11yDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .id("root") + .role(Role::Application) + .aria_label("Accessibility Demo") + .track_focus(&self.focus_handle) + .on_action(cx.listener(|_, _: &Tab, window, cx| window.focus_next(cx))) + .on_action(cx.listener(|_, _: &TabPrev, window, cx| window.focus_prev(cx))) + .size_full() + .flex() + .flex_col() + .gap_4() + .p_4() + .bg(rgb(0x1e1e2e)) + .text_color(rgb(0xcdd6f4)) + // Heading + .child( + div() + .id("heading") + .role(Role::Heading) + .aria_level(1) + .aria_label("Accessibility Demo") + .text_xl() + .font_weight(gpui::FontWeight::BOLD) + .child(text!("Accessibility Demo")), + ) + // Counter — uses a SpinButton role with Increment/Decrement + // actions so screen readers can adjust the value directly. + // Click also works via the built-in handler. + .child( + div() + .flex() + .items_center() + .gap_3() + .child( + div() + .id("counter") + .focusable() + .tab_stop(true) + .role(Role::SpinButton) + .aria_label(SharedString::from(format!("Counter: {}", self.count))) + .aria_numeric_value(self.count as f64) + .aria_min_numeric_value(0.0) + .on_a11y_action(AccessibleAction::Increment, { + let this = cx.entity().downgrade(); + move |_, _, cx| { + this.update(cx, |this, cx| { + this.count += 1; + cx.notify(); + }) + .ok(); + } + }) + .on_a11y_action(AccessibleAction::Decrement, { + let this = cx.entity().downgrade(); + move |_, _, cx| { + this.update(cx, |this, cx| { + this.count = (this.count - 1).max(0); + cx.notify(); + }) + .ok(); + } + }) + .on_click(cx.listener(|this, _, _, cx| { + this.count += 1; + cx.notify(); + })) + .px_3() + .py_1() + .rounded_md() + .bg(rgb(0x89b4fa)) + .text_color(rgb(0x1e1e2e)) + .cursor_pointer() + .child(text!(format!("Count: {}", self.count))), + ) + .child( + div() + .id("reset") + .focusable() + .tab_stop(true) + .role(Role::Button) + .aria_label("Reset counter") + .px_3() + .py_1() + .rounded_md() + .bg(rgb(0x585b70)) + .cursor_pointer() + .on_click(cx.listener(|this, _, _, cx| { + this.count = 0; + cx.notify(); + })) + .child(text!("Reset")), + ), + ) + // A toggle switch + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .id("toggle") + .focusable() + .tab_stop(true) + .role(Role::Switch) + .aria_label("Enable feature") + .aria_toggled(if self.enabled { + Toggled::True + } else { + Toggled::False + }) + .w(px(44.)) + .h(px(24.)) + .rounded_full() + .cursor_pointer() + .when(self.enabled, |el| el.bg(rgb(0x89b4fa))) + .when(!self.enabled, |el| el.bg(rgb(0x585b70))) + .child( + div() + .size(px(20.)) + .rounded_full() + .bg(gpui::white()) + .mt(px(2.)) + .when(self.enabled, |el| el.ml(px(22.))) + .when(!self.enabled, |el| el.ml(px(2.))), + ) + .on_click(cx.listener(|this, _, _, cx| { + this.enabled = !this.enabled; + cx.notify(); + })), + ) + .child(text!("Enable feature")), + ) + // A short list + .child( + div() + .id("task-list") + .role(Role::List) + .aria_label("Tasks") + .flex() + .flex_col() + .gap_1() + .children( + ["Write code", "Run tests", "Ship it"] + .iter() + .enumerate() + .map(|(i, label)| { + div() + .id(("task", i)) + .role(Role::ListItem) + .aria_label(SharedString::from(*label)) + .aria_position_in_set(i + 1) + .aria_size_of_set(3) + .py_1() + .px_2() + // Note: even though this `text!` macro + // produces multiple elements, it doesn't + // need its own unique ID because the parent + // div has different IDs for each string. + .child(text!(format!("{}. {}", i + 1, label))) + }), + ), + ) + } +} + +fn run_example() { + application().run(|cx: &mut App| { + cx.bind_keys([ + KeyBinding::new("tab", Tab, None), + KeyBinding::new("shift-tab", TabPrev, None), + ]); + + let bounds = Bounds::centered(None, size(px(500.), px(400.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: Some(gpui::TitlebarOptions { + title: Some("GPUI Accessibility Demo".into()), + ..Default::default() + }), + ..Default::default() + }, + |window, cx| cx.new(|cx| A11yDemo::new(window, cx)), + ) + .unwrap(); + + cx.activate(true); + }); +} + +#[cfg(not(target_family = "wasm"))] +fn main() { + env_logger::builder() + .filter_level(log::LevelFilter::Warn) + .filter_module("gpui", log::LevelFilter::Info) + .init(); + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/src/_accessibility.rs b/crates/gpui/src/_accessibility.rs new file mode 100644 index 00000000000..155dd6506eb --- /dev/null +++ b/crates/gpui/src/_accessibility.rs @@ -0,0 +1,243 @@ +//! # Accessibility in GPUI +//! +//! "Accessibility" refers to the ability of your application to be used by all +//! users, regardless of disability status. There are many aspects, all important, including: +//! - Ensuring sufficient text contrast. +//! - Providing a mechanism to disable animations. +//! - Providing a mechanism to increase text sizes. +//! - etc. +//! +//! This guide is focused on **programmatic accessibility**. This allows +//! assistive technology, such as screen readers or Braille displays, to inspect +//! and interact with your app. +//! +//! GPUI integrates with [AccessKit] to provide programmatic accessibility +//! features (referred to as simply "accessibility" for the rest of this guide). +//! +//! A minimal example can be found in the `examples/a11y` directory. +//! +//! ## Background +//! +//! Accessibility support is based on two key capabilities: +//! - Exposing information about the current UI state to assistive technology. +//! - Responding to actions requested by assistive technology. +//! +//! For example, a screen reader might want to announce to the user that a new +//! button has appeared. The user may then want to use a voice control program +//! to press that button. +//! +//! ### IDs in GPUI - [`ElementId`] and [`GlobalElementId`] +//! +//! In GPUI, each [`Element`] can have an [`id`][Element::id]: +//! ```rust +//! # use gpui::*; +//! let div_with_id = div().id("my-id").child(text!("hello")); +//! +//! // IDs are optional +//! let div_without_id = div().child(text!("hello")); +//! ``` +//! +//! [`Element`]s with IDs are also assigned a [`GlobalElementId`]. This global +//! ID is formed by composing all the non-`None` IDs of its ancestors. For +//! example: +//! ```rust +//! # use gpui::*; +//! let inner = div().id("inner-id"); +//! let middle = div().child(inner); // no ID +//! let outer = div().id("outer-id").child(middle); +//! ``` +//! In this example, `inner`s global ID is (roughly speaking) `["outer-id", +//! "inner-id"]`. +//! +//! Since `middle` doesn't have an ID itself, it has no global ID. +//! +//! [`GlobalElementId`]s should be unique per-frame. Duplicate global IDs in the +//! same frame will likely cause bugs. +//! +//! ### IDs and accessibility +//! +//! When GPUI renders a frame, it walks your UI tree, and finds nodes with +//! global IDs, and informs assistive technology about this node. +//! +//! In order for nodes to be reported, they must also have a non-`None` +//! [`role`][Element::a11y_role]. This is used to inform assistive technology +//! what *sort* of node it is (button, label, table, etc.). You can use +//! [`div().id(...).role()`][StatefulInteractiveElement::role] to set the role. +//! +//! Nodes with the same global ID *across frames* are considered to be "the +//! same" node. For example: +//! ```rust +//! # use gpui::*; +//! // The UI in frame 1 +//! let frame_1 = div() +//! .id("parent") +//! .role(Role::Button) +//! .child( +//! div() +//! .id("id-1") +//! .role(Role::Label) +//! .child(text!("hello")) +//! ); +//! +//! // The UI on the next frame +//! let frame_2 = div() +//! .id("parent") +//! .role(Role::Button) +//! .child( +//! div() +//! .id("id-2") // <- different ID +//! .role(Role::Label) +//! .child(text!("hello")) +//! ); +//! ``` +//! Logically, the UI has not changed. But the screen reader has no way of +//! knowing that both child [`div`]s are "the same". So assistive technology +//! will interpret this as one node being removed, and another node being added. +//! This can be very disorienting for users, since announcements typically only +//! happen when something has *meaningfully* changed. +//! +//! In other words, by controlling the ID of an element, you can control whether +//! a change to a UI element is considered meaningful. You can also control +//! whether elements are reported to assistive technology *at all* by setting +//! the [`role`][Element::a11y_role], since nodes with no role are not reported. +//! +//! #### IDs and text +//! +//! Special care must be taken when dealing with text. +//! +//! GPUI provides the [`text!`] macro, which wraps strings in the [`Text`] type, +//! but automatically derives an ID. Usually, this is what you want. However, +//! the way it generates its ID is subtle and perhaps surprising. +//! +//! The ID of an invocation of the [`text!`] macro is derived from the +//! **location in the source code of that invocation**. For example: +//! +//! ```rust +//! # use gpui::*; +//! let a = text!("a"); +//! let b = text!("b"); +//! +//! // Different source locations, different IDs +//! assert_ne!(a.id(), b.id()); +//! +//! // However: +//! +//! fn make_text(s: &str) -> Text { text!(s) } +//! +//! let a = make_text("a"); +//! let b = make_text("b"); +//! +//! // Both `a` and `b` are produced by the same `text!` invocation, so the IDs +//! // are the same +//! assert_eq!(a.id(), b.id()); +//! ``` +//! This can produce surprising behaviour. For example, this footgun: +//! ```rust +//! # use gpui::*; +//! let todos = vec!["eat lunch", "drink water", "go to gym"]; +//! let todo_divs = todos.into_iter().map(|todo| { +//! text!(todo) +//! }); +//! +//! div() +//! .id("todo-list") +//! .role(Role::Document) +//! .children(todo_divs); // ERROR: multiple nodes with the same global ID +//! ``` +//! +//! Here, when we map the iterator, since we have only written [`text!`] once, +//! there is only one ID. And since they have the same ancestors and the same +//! ID, they will have the same global ID. In release builds, this will mean +//! some nodes get silently dropped! +//! +//! To fix this, you can set an ID: +//! ```rust +//! # use gpui::*; +//! let todos = vec!["eat lunch", "drink water", "go to gym"]; +//! let todo_divs = todos.into_iter().enumerate().map(|(index, todo)| { +//! text!(todo).with_id(index) // OR `text(id = index, todo)` +//! }); +//! +//! div() +//! .id("todo-list") +//! .role(Role::Document) +//! .children(todo_divs); +//! ``` +//! Another possible solution is to wrap the [`text!`] in another node that +//! *does* have a unique global ID. For example: +//! ```rust +//! # use gpui::*; +//! let todos = vec!["eat lunch", "drink water", "go to gym"]; +//! let todo_divs = todos.into_iter().enumerate().map(|(index, todo)| { +//! div().id(index).child(text!(todo)) +//! }); +//! +//! div() +//! .id("todo-list") +//! .role(Role::Document) +//! .children(todo_divs); +//! ``` +//! Since the AccessKit [`NodeId`][accesskit::NodeId] is derived from the global +//! ID, and the global ID takes into account the IDs of all ancestors, this +//! works too. +//! +//! Occasionally, you will need to create a [`Text`] element with *no* ID. You +//! can achieve this with [`Text::new_inaccessible`]. If you are creating a +//! custom UI component (e.g. a button), you may want this so that you can set a +//! label property on a parent [`div`] without duplicating the text in the +//! accessibility tree. +//! +//! ### Handling actions +//! +//! Assistive technology can dispatch actions to the UI. While many users of +//! assistive technology use traditional input devices (e.g. a keyboard), some +//! use more specialized systems. For example, users with limited mobility may +//! use voice control to interact with your app. +//! +//! When a user dispatches an action, it is dispatched *to a specific node*. It +//! is your responsibility to tell the UI elements how they should respond when +//! a request comes in. +//! +//! Note, these actions are **totally unrelated** to GPUI's [`Action`] trait. +//! AccessKit exposes [`accesskit::Action`]. In GPUI, this is re-exported as +//! [`AccessibleAction`]. +//! +//! To respond to an accessible action, use +//! [`div().on_a11y_action()`][InteractiveElement::on_a11y_action]: +//! ```rust,ignore +//! div() +//! .id("my-slider") +//! .role(Role::Slider) +//! .on_a11y_action(AccessibleAction::Increment, |_extra, _window, _cx| { +//! position += 1; +//! cx.notify(); +//! }) +//! .child(my_cool_slider()); +//! ``` +//! +//! Note that some common actions are automatically registered. For example, +//! [`.on_click()`][StatefulInteractiveElement::on_click] adds an +//! [`AccessibleAction::Click`] handler that calls the click handler. +//! +//! ## Further reading +//! +//! Designing high-quality accessible interfaces can be challenging, in the same +//! way that designing high-quality traditional interfaces can be. The +//! following pages have useful information: +//! +//! - [AccessKit]: The cross-platform accessibility toolkit GPUI uses +//! internally. +//! - [MDN WAI-ARIA basics][mdn-aria]: Introduction to roles, properties, and +//! states. +//! - [ARIA Authoring Practices Guide][apg]: W3C patterns for accessible +//! widgets. +//! +//! Note that, while GPUI mimics web APIs, it doesn't necessarily behave +//! *exactly* as a web browser would with the same attributes. +//! +//! [AccessKit]: https://accesskit.dev/ +//! [mdn-aria]: https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Accessibility/WAI-ARIA_basics +//! [apg]: https://www.w3.org/WAI/ARIA/apg/ + +#[cfg(doc)] +use crate::*; // so I don't have to qualify every type :) diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index 7b78e9b0ad7..e05d6e97f45 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -103,6 +103,22 @@ pub trait Element: 'static + IntoElement { cx: &mut App, ); + /// Returns the accessible role for this element, if any. + /// Elements that return `None` are not included in the accessibility tree. + /// + /// Note: inclusion in accessibility tree requires non-`None` [`id`][Element::id]. + /// + /// See the [accessibility guide](crate::_accessibility) for an overview. + fn a11y_role(&self) -> Option { + None + } + + /// Write accessibility properties to the given node. + /// Called only when `a11y_role()` returns `Some`. + /// + /// See the [accessibility guide](crate::_accessibility) for an overview. + fn write_a11y_info(&self, _node: &mut accesskit::Node) {} + /// Convert this element into a dynamically-typed [`AnyElement`]. fn into_any(self) -> AnyElement { AnyElement::new(self) @@ -302,6 +318,15 @@ impl Display for GlobalElementId { } } +impl GlobalElementId { + pub(crate) fn accesskit_node_id(&self) -> accesskit::NodeId { + use std::hash::{Hash, Hasher}; + let mut hasher = std::hash::DefaultHasher::default(); + self.hash(&mut hasher); + accesskit::NodeId(hasher.finish()) + } +} + trait ElementObject { fn inner_element(&mut self) -> &mut dyn Any; @@ -431,6 +456,26 @@ impl Drawable { } let bounds = window.layout_bounds(layout_id); + let mut pushed_a11y_node = false; + if window.a11y.is_active() { + if let Some(global_id) = global_id.as_ref() { + if let Some(role) = self.element.a11y_role() { + let node_id = global_id.accesskit_node_id(); + let mut node = accesskit::Node::new(role); + let scale = window.scale_factor(); + node.set_bounds(accesskit::Rect { + x0: (bounds.origin.x.0 * scale) as f64, + y0: (bounds.origin.y.0 * scale) as f64, + x1: ((bounds.origin.x.0 + bounds.size.width.0) * scale) as f64, + y1: ((bounds.origin.y.0 + bounds.size.height.0) * scale) as f64, + }); + self.element.write_a11y_info(&mut node); + window.a11y.node_bounds.insert(node_id, bounds); + pushed_a11y_node = window.a11y.nodes.push(node_id, node); + } + } + } + let node_id = window.next_frame.dispatch_tree.push_node(); let prepaint = self.element.prepaint( global_id.as_ref(), @@ -442,6 +487,10 @@ impl Drawable { ); window.next_frame.dispatch_tree.pop_node(); + if pushed_a11y_node { + window.a11y.nodes.pop(); + } + if global_id.is_some() { window.element_id_stack.pop(); } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 44a1bc4b382..6e280883562 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1175,6 +1175,124 @@ pub trait InteractiveElement: Sized { /// A trait for elements that want to use the standard GPUI interactivity features /// that require state. pub trait StatefulInteractiveElement: InteractiveElement { + /// Set the accessible role for this element. + /// + /// See the [accessibility guide](crate::_accessibility) for an overview. + fn role(mut self, role: accesskit::Role) -> Self { + debug_assert!( + role != accesskit::Role::GenericContainer, + "GenericContainer is filtered out of the a11y tree and has no effect" + ); + self.interactivity().override_role = Some(role); + self + } + + /// Set the accessible label for this element. + fn aria_label(mut self, label: impl Into) -> Self { + self.interactivity().aria_label = Some(label.into()); + self + } + + /// Set the selected state for this element. + fn aria_selected(mut self, selected: bool) -> Self { + self.interactivity().aria_selected = Some(selected); + self + } + + /// Set the expanded state for this element. + fn aria_expanded(mut self, expanded: bool) -> Self { + self.interactivity().aria_expanded = Some(expanded); + self + } + + /// Set the toggled state for this element. + fn aria_toggled(mut self, toggled: accesskit::Toggled) -> Self { + self.interactivity().aria_toggled = Some(toggled); + self + } + + /// Set the numeric value for this element. + fn aria_numeric_value(mut self, value: f64) -> Self { + self.interactivity().aria_numeric_value = Some(value); + self + } + + /// Set the minimum numeric value for this element. + fn aria_min_numeric_value(mut self, value: f64) -> Self { + self.interactivity().aria_min_numeric_value = Some(value); + self + } + + /// Set the maximum numeric value for this element. + fn aria_max_numeric_value(mut self, value: f64) -> Self { + self.interactivity().aria_max_numeric_value = Some(value); + self + } + + /// Set the orientation of this element. + fn aria_orientation(mut self, orientation: accesskit::Orientation) -> Self { + self.interactivity().aria_orientation = Some(orientation); + self + } + + /// Set the heading level of this element. + fn aria_level(mut self, level: usize) -> Self { + self.interactivity().aria_level = Some(level); + self + } + + /// Set the position in set of this element. + fn aria_position_in_set(mut self, position: usize) -> Self { + self.interactivity().aria_position_in_set = Some(position); + self + } + + /// Set the size of set for this element. + fn aria_size_of_set(mut self, size: usize) -> Self { + self.interactivity().aria_size_of_set = Some(size); + self + } + + /// Set the row index for this element. + fn aria_row_index(mut self, index: usize) -> Self { + self.interactivity().aria_row_index = Some(index); + self + } + + /// Set the column index for this element. + fn aria_column_index(mut self, index: usize) -> Self { + self.interactivity().aria_column_index = Some(index); + self + } + + /// Set the row count for this element. + fn aria_row_count(mut self, count: usize) -> Self { + self.interactivity().aria_row_count = Some(count); + self + } + + /// Set the column count for this element. + fn aria_column_count(mut self, count: usize) -> Self { + self.interactivity().aria_column_count = Some(count); + self + } + + /// Register a handler for an accessibility action on this element. + /// The handler is called when a screen reader requests the given action. + /// + /// See the [accessibility guide](crate::_accessibility) for an overview. + fn on_a11y_action( + mut self, + action: accesskit::Action, + listener: impl FnMut(Option<&accesskit::ActionData>, &mut crate::Window, &mut crate::App) + + 'static, + ) -> Self { + self.interactivity() + .a11y_action_listeners + .push((action, Box::new(listener))); + self + } + /// Set this element to focusable. fn focusable(mut self) -> Self { self.interactivity().focusable = true; @@ -1474,6 +1592,18 @@ impl Element for Div { self.interactivity.source_location() } + fn a11y_role(&self) -> Option { + // Nodes with `GenericContainer` should never be reported to accesskit. + // Equivalent to an HTML div with no role. + self.interactivity + .override_role + .filter(|role| *role != accesskit::Role::GenericContainer) + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + self.interactivity.write_a11y_info(node); + } + #[stacksafe] fn request_layout( &mut self, @@ -1710,6 +1840,25 @@ pub struct Interactivity { pub(crate) tab_group: bool, pub(crate) tab_stop: bool, + pub(crate) a11y_action_listeners: + Vec<(accesskit::Action, crate::window::a11y::A11yActionListener)>, + pub(crate) override_role: Option, + pub(crate) aria_label: Option, + pub(crate) aria_selected: Option, + pub(crate) aria_expanded: Option, + pub(crate) aria_toggled: Option, + pub(crate) aria_numeric_value: Option, + pub(crate) aria_min_numeric_value: Option, + pub(crate) aria_max_numeric_value: Option, + pub(crate) aria_orientation: Option, + pub(crate) aria_level: Option, + pub(crate) aria_position_in_set: Option, + pub(crate) aria_size_of_set: Option, + pub(crate) aria_row_index: Option, + pub(crate) aria_column_index: Option, + pub(crate) aria_row_count: Option, + pub(crate) aria_column_count: Option, + #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) source_location: Option<&'static core::panic::Location<'static>>, @@ -1830,6 +1979,16 @@ impl Interactivity { if let Some(focus_handle) = self.tracked_focus_handle.as_ref() { window.set_focus_handle(focus_handle, cx); + + if window.a11y.is_active() { + if let Some(global_id) = global_id { + let node_id = global_id.accesskit_node_id(); + window.a11y.focus_ids.insert(node_id, focus_handle.id); + if focus_handle.is_focused(window) { + window.a11y.nodes.set_focus(node_id); + } + } + } } window.with_optional_element_state::( global_id, @@ -2054,6 +2213,22 @@ impl Interactivity { } self.paint_keyboard_listeners(window, cx); + + if window.a11y.is_active() { + if let Some(global_id) = global_id { + if !self.a11y_action_listeners.is_empty() { + let node_id = global_id.accesskit_node_id(); + for (action, listener) in + self.a11y_action_listeners.drain(..) + { + window.on_a11y_action( + node_id, action, listener, + ); + } + } + } + } + f(&style, window, cx); if let Some(_hitbox) = hitbox { @@ -2857,6 +3032,63 @@ impl Interactivity { style } + + pub(crate) fn write_a11y_info(&self, node: &mut accesskit::Node) { + if let Some(label) = &self.aria_label { + node.set_label(label.to_string()); + } + if let Some(selected) = self.aria_selected { + node.set_selected(selected); + } + if let Some(expanded) = self.aria_expanded { + node.set_expanded(expanded); + } + if let Some(toggled) = self.aria_toggled { + node.set_toggled(toggled); + } + if let Some(value) = self.aria_numeric_value { + node.set_numeric_value(value); + } + if let Some(value) = self.aria_min_numeric_value { + node.set_min_numeric_value(value); + } + if let Some(value) = self.aria_max_numeric_value { + node.set_max_numeric_value(value); + } + if let Some(orientation) = self.aria_orientation { + node.set_orientation(orientation); + } + if let Some(level) = self.aria_level { + node.set_level(level); + } + if let Some(position) = self.aria_position_in_set { + node.set_position_in_set(position); + } + if let Some(size) = self.aria_size_of_set { + node.set_size_of_set(size); + } + if let Some(index) = self.aria_row_index { + node.set_row_index(index); + } + if let Some(index) = self.aria_column_index { + node.set_column_index(index); + } + if let Some(count) = self.aria_row_count { + node.set_row_count(count); + } + if let Some(count) = self.aria_column_count { + node.set_column_count(count); + } + if !self.click_listeners.is_empty() { + node.add_action(accesskit::Action::Click); + } + if self.tracked_focus_handle.is_some() || self.focusable { + node.add_action(accesskit::Action::Focus); + } + for (action, _) in &self.a11y_action_listeners { + node.add_action(*action); + } + } } /// The per-frame state of an interactive element. Used for tracking stateful interactions like clicks @@ -3263,6 +3495,14 @@ where self.element.source_location() } + fn a11y_role(&self) -> Option { + self.element.a11y_role() + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + self.element.write_a11y_info(node); + } + fn request_layout( &mut self, id: Option<&GlobalElementId>, diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 7c268c8b197..82d23c83b75 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -13,11 +13,244 @@ use std::{ borrow::Cow, cell::{Cell, RefCell}, mem, - ops::Range, + ops::{Deref, DerefMut, Range}, rc::Rc, sync::Arc, }; +/// An [`Element`] that renders text. +/// +/// In general, [`Text`] objects should be created via the [`text`] macro: +/// ```rust +/// # use gpui::*; +/// # fn render() -> impl IntoElement { +/// div().child(text!("hello")) +/// # } +/// ``` +/// ## IDs and Accessibility +/// +/// [`Text`] elements have an ID. This ID is primarily used to produce nodes in +/// the accessibility tree, which allows the text to be visible to screen +/// readers and other assistive technologies. +/// +/// This ID is stable across frames. If the same text, with the same ID, is +/// present in two consecutive frames, no updates are reported to the screen +/// reader. If the text changes, but the ID stays the same, then the screen +/// reader will be notified that a text node's content has changed. **However**, +/// if the ID changes, then the screen reader will be notified that a node has +/// been removed, and a new node has been added. +/// +/// When using the [`text`] macro, each invocation of the macro will get a +/// unique ID, derived from its position in the source code (filename, line, and +/// column). For example: +/// ```rust +/// # use gpui::*; +/// let x = text!("hello"); +/// let y = text!("hello"); +/// // not equal, because different `text!` invocations produced them +/// assert_ne!(x.id(), y.id()); +/// +/// fn make_text(s: &str) -> Text { text!(s) } +/// let x = make_text("hello"); +/// let y = make_text("hello"); +/// // equal, because the same `text!` invocation produced them +/// assert_eq!(x.id(), y.id()); +/// ``` +/// When the contents of an invocation of [`text`] do not change, this +/// distinction is less relevant (with the caveat that you still need to take +/// care to ensure that duplicate IDs do not appear). +/// +/// However, when a [`text`] invocation's argument *does* change, you should +/// consider whether this change should be reported as a node "updating its +/// contents", or an old node being destroyed and a new node being created. +#[derive(Debug, Clone)] +pub struct Text { + id: Option, + text: SharedString, +} + +impl Text { + /// Create a new [`Text`] element with a specific ID. + /// + /// If you want a unique ID to be assigned automatically, use the [`text`] + /// macro. The docs for [`Text`] have more detail about choosing IDs. + #[inline] + pub const fn new(id: ElementId, text: SharedString) -> Self { + Self { id: Some(id), text } + } + + /// Create a new [`Text`] element that is inaccessible to screen readers. + /// + /// In order for text to be accessible to screen readers, it must have an ID + /// provided. If you want text to be accessible, either use [`text`] to have + /// an ID automatically assigned, or use [`Text::new`] to manually assign an + /// ID. + /// + /// This function is intended for use inside custom UI components, where + /// accessible properties may be set on parent containers. + #[inline] + pub const fn new_inaccessible(text: SharedString) -> Self { + Self { id: None, text } + } + + /// The ID of this [`Text`] element. + #[inline] + pub const fn id(&self) -> Option<&ElementId> { + self.id.as_ref() + } + + /// Produce a new [`Text`] with the given `id`. + pub fn with_id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// The text that this [`Text`] element will display. + #[inline] + pub const fn text(&self) -> &SharedString { + &self.text + } +} + +impl Deref for Text { + type Target = SharedString; + fn deref(&self) -> &Self::Target { + &self.text + } +} + +impl DerefMut for Text { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.text + } +} + +/// Trivial hash function for the location information produced by the [`text`] +/// macro. Not covered by semver guarantees. Performance is not particularly +/// significant because it's only used on small strings in const contexts. +#[doc(hidden)] +pub const fn __hash_text_macro_location_unstable_do_not_use(s: &'static str) -> u64 { + const BASIS: u64 = 0xcbf29ce484222325; + const PRIME: u64 = 0x100000001b3; + + let bytes = s.as_bytes(); + let mut hash = BASIS; + let mut i = 0; + while i < bytes.len() { + hash ^= bytes[i] as u64; + hash = hash.wrapping_mul(PRIME); + i += 1; + } + hash +} + +/// Create a new [`Text`] element. +/// +/// ```rust +/// # use gpui::*; +/// let a = text!("hello"); +/// let b = text!(id = "farewell-message", "hello"); +/// +/// ``` +/// +/// Text created with this macro is *accessible*. The macro generates an ID +/// based on the source location. See the docs for [`Text`] for a more in-depth +/// explanation of the significance of the ID of a [`Text`] element. +#[macro_export] +macro_rules! text { + (id = $id:expr, $text:expr) => {{ $crate::Text::new($id.into(), $text.into()) }}; + ($text:expr) => {{ + const ID: &'static str = concat!(file!(), "/", line!(), ":", column!()); + const HASH: u64 = $crate::__hash_text_macro_location_unstable_do_not_use(ID); + $crate::Text::new($crate::ElementId::Integer(HASH), $text.into()) + }}; +} + +impl IntoElement for Text { + type Element = Self; + #[inline] + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for Text { + type RequestLayoutState = TextLayout; + type PrepaintState = (); + + fn id(&self) -> Option { + self.id.clone() + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn a11y_role(&self) -> Option { + if self.id.is_some() { + Some(accesskit::Role::Label) + } else { + None + } + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + node.set_value(self.text.to_string()); + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + ::request_layout(&mut self.text, id, inspector_id, window, cx) + } + + fn prepaint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + ::prepaint( + &mut self.text, + id, + inspector_id, + bounds, + request_layout, + window, + cx, + ) + } + + fn paint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + ::paint( + &mut self.text, + id, + inspector_id, + bounds, + request_layout, + prepaint, + window, + cx, + ); + } +} + impl Element for &'static str { type RequestLayoutState = TextLayout; type PrepaintState = (); @@ -807,6 +1040,14 @@ impl Element for InteractiveText { None } + fn a11y_role(&self) -> Option { + Some(accesskit::Role::Label) + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + node.set_value(self.text.text.to_string()); + } + fn request_layout( &mut self, _id: Option<&GlobalElementId>, @@ -1009,6 +1250,8 @@ impl IntoElement for InteractiveText { #[cfg(test)] mod tests { + use super::*; + #[test] fn test_into_element_for() { use crate::{ParentElement as _, SharedString, div}; @@ -1019,4 +1262,23 @@ mod tests { let _ = div().child(Cow::Borrowed("Cow")); let _ = div().child(SharedString::from("SharedString")); } + + #[test] + fn text_macro_id() { + // one call to `text!` = one id + fn make_text_stable_id(happy: bool) -> Text { + text!(if happy { "happy" } else { "sad" }) + } + + // two calls to `text!` = two ids + fn make_text_unstable_id(happy: bool) -> Text { + if happy { text!("happy") } else { text!("sad") } + } + + assert_eq!(make_text_stable_id(false).id, make_text_stable_id(true).id); + assert_ne!( + make_text_unstable_id(false).id, + make_text_unstable_id(true).id + ); + } } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 5f1e9a95bcb..12aeebfb956 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -56,6 +56,8 @@ mod window; #[cfg(any(test, feature = "test-support"))] pub use proptest; +#[cfg(doc)] +pub mod _accessibility; #[cfg(doc)] pub mod _ownership_and_data_flow; @@ -75,6 +77,9 @@ mod seal { pub trait Sealed {} } +pub use accesskit; +pub use accesskit::Action as AccessibleAction; +pub use accesskit::{Orientation, Role, Toggled}; pub use action::*; pub use anyhow::Result; pub use app::*; diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 00cd9d13a47..fe93222a505 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -591,6 +591,16 @@ impl Tiling { } } +/// Callbacks for the accessibility adapter. +pub struct A11yCallbacks { + /// Called when the adapter is activated (a screen reader connects). + pub activation: Box Option + Send + 'static>, + /// Called when an action is requested by the screen reader. + pub action: Box, + /// Called when the adapter is deactivated (screen reader disconnects). + pub deactivation: Box, +} + #[derive(Debug, Copy, Clone, Eq, PartialEq, Default)] #[expect(missing_docs)] pub struct RequestFrameOptions { @@ -700,6 +710,15 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn play_system_bell(&self) {} + /// Initialize the accessibility adapter with callbacks. + fn a11y_init(&self, _callbacks: A11yCallbacks) {} + + /// Provide a TreeUpdate to the accessibility adapter. + fn a11y_tree_update(&self, _tree_update: accesskit::TreeUpdate) {} + + /// Inform the adapter of updated window bounds. + fn a11y_update_window_bounds(&self) {} + #[cfg(any(test, feature = "test-support"))] fn as_test(&mut self) -> Option<&mut TestWindow> { None diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 61257ea404a..93106792312 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -52,14 +52,18 @@ use std::{ rc::Rc, sync::{ Arc, Weak, - atomic::{AtomicUsize, Ordering::SeqCst}, + atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst}, }, time::Duration, }; use uuid::Uuid; +pub(crate) mod a11y; mod prompts; +use self::a11y::A11y; +#[cfg(not(target_family = "wasm"))] +use self::a11y::ROOT_NODE_ID; use crate::util::{ atomic_incr_if_not_zero, ceil_to_device_pixel, floor_to_device_pixel, round_half_toward_zero, round_half_toward_zero_f64, round_stroke_to_device_pixel, round_to_device_pixel, @@ -1021,6 +1025,7 @@ pub struct Window { captured_hitbox: Option, #[cfg(any(feature = "inspector", debug_assertions))] inspector: Option>, + pub(crate) a11y: A11y, } #[derive(Clone, Debug, Default)] @@ -1325,6 +1330,85 @@ impl Window { WindowBounds::Windowed(_) => {} } + let a11y_active_flag = Arc::new(AtomicBool::new(false)); + + #[cfg(not(target_family = "wasm"))] + { + let initial_tree = accesskit::TreeUpdate { + nodes: vec![(ROOT_NODE_ID, accesskit::Node::new(accesskit::Role::Window))], + tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), + tree_id: accesskit::TreeId::ROOT, + focus: ROOT_NODE_ID, + }; + let (activation_sender, activation_receiver) = async_channel::unbounded::<()>(); + let (deactivation_sender, deactivation_receiver) = async_channel::unbounded::<()>(); + let (action_sender, action_receiver) = + async_channel::unbounded::(); + + platform_window.a11y_init(crate::A11yCallbacks { + activation: { + let active_flag = a11y_active_flag.clone(); + Box::new(move || { + log::info!("Accessibility activated"); + active_flag.store(true, SeqCst); + activation_sender.send_blocking(()).log_err(); + Some(initial_tree.clone()) + }) + }, + action: Box::new(move |request| { + action_sender.send_blocking(request).log_err(); + }), + deactivation: { + let active_flag = a11y_active_flag.clone(); + Box::new(move || { + log::info!("Accessibility deactivated"); + active_flag.store(false, SeqCst); + deactivation_sender.send_blocking(()).log_err(); + }) + }, + }); + + // A11y can be activated at any time, and so we cannot compute a + // correct `TreeUpdate` on-demand. When this happens, we return a + // default empty `TreeUpdate`. + // + // So we force a new frame, which will then send a correct `TreeUpdate`. + let mut async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { + while activation_receiver.recv().await.is_ok() { + handle + .update(&mut async_cx, |_, window, _| window.refresh()) + .log_err(); + } + }) + .detach(); + + let mut async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { + while deactivation_receiver.recv().await.is_ok() { + handle + .update(&mut async_cx, |_, window, _| window.refresh()) + .log_err(); + } + }) + .detach(); + + let mut async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { + while let Ok(request) = action_receiver.recv().await { + handle + .update(&mut async_cx, |_, window, cx| { + window.handle_a11y_action(request, cx); + }) + .log_err(); + } + }) + .detach(); + } + platform_window.on_close(Box::new({ let window_id = handle.window_id(); let mut cx = cx.to_async(); @@ -1633,6 +1717,7 @@ impl Window { captured_hitbox: None, #[cfg(any(feature = "inspector", debug_assertions))] inspector: None, + a11y: A11y::new(a11y_active_flag), }) } @@ -2620,6 +2705,11 @@ impl Window { self.invalidator.set_phase(DrawPhase::Prepaint); self.tooltip_bounds.take(); + self.a11y.sync_active_flag(); + if self.a11y.is_active() { + self.a11y.begin_frame(); + } + let _inspector_width: Pixels = rems(30.0).to_pixels(self.rem_size()); let root_size = { #[cfg(any(feature = "inspector", debug_assertions))] @@ -2686,6 +2776,26 @@ impl Window { #[cfg(any(feature = "inspector", debug_assertions))] self.paint_inspector_hitbox(cx); + + // a11y may have been activated/deactivated halfway through the frame + let a11y_active_start_of_frame = self.a11y.is_active(); + self.a11y.sync_active_flag(); + let a11y_active_end_of_frame = self.a11y.is_active(); + + let should_send_a11y_update = a11y_active_start_of_frame && a11y_active_end_of_frame; + + if a11y_active_start_of_frame { + // clear the builder state regardless + let tree_update = self.a11y.end_frame(); + + if should_send_a11y_update { + log::debug!( + "Sending a11y tree update: {} nodes", + tree_update.nodes.len() + ); + self.platform_window.a11y_tree_update(tree_update); + } + } } fn prepaint_tooltip(&mut self, cx: &mut App) -> Option { @@ -5296,6 +5406,87 @@ impl Window { self.platform_window.play_system_bell() } + /// Register a listener for an accessibility action on a specific node. + /// The listener will be called when a screen reader requests the given + /// action on the node identified by `node_id`. + /// + /// See the [accessibility guide](crate::_accessibility) for an overview. + pub fn on_a11y_action( + &mut self, + node_id: accesskit::NodeId, + action: accesskit::Action, + listener: impl FnMut(Option<&accesskit::ActionData>, &mut Window, &mut App) + 'static, + ) { + self.a11y + .action_listeners + .entry(node_id) + .or_default() + .push((action, Box::new(listener))); + } + + #[cfg(not(target_family = "wasm"))] + pub(crate) fn handle_a11y_action(&mut self, request: accesskit::ActionRequest, cx: &mut App) { + // Take listeners out temporarily so the closures can borrow Window + // mutably, then restore them afterward. + if let Some(mut listeners) = self.a11y.action_listeners.remove(&request.target_node) { + let extra_data = request.data.as_ref(); + let mut matched = false; + for (action, listener) in &mut listeners { + if *action == request.action { + listener(extra_data, self, cx); + matched = true; + } + } + self.a11y + .action_listeners + .insert(request.target_node, listeners); + if matched { + return; + } + } + + // Fall back to built-in action handling. + match request.action { + accesskit::Action::Click => { + if let Some(bounds) = self.a11y.node_bounds.get(&request.target_node).copied() { + let center = bounds.center(); + let mouse_down = PlatformInput::MouseDown(crate::MouseDownEvent { + button: MouseButton::Left, + position: center, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + let mouse_up = PlatformInput::MouseUp(MouseUpEvent { + button: MouseButton::Left, + position: center, + modifiers: Modifiers::default(), + click_count: 1, + }); + self.dispatch_event(mouse_down, cx); + self.dispatch_event(mouse_up, cx); + } + } + accesskit::Action::Focus => { + if let Some(focus_id) = self.a11y.focus_ids.get(&request.target_node).copied() + && let Some(handle) = FocusHandle::for_id(focus_id, &cx.focus_handles) + { + self.focus(&handle, cx); + } + } + accesskit::Action::Blur => { + self.blur(); + } + _ => { + log::debug!( + "Unhandled a11y action: {:?} on {:?}", + request.action, + request.target_node + ); + } + } + } + /// Toggles the inspector mode on this window. #[cfg(any(feature = "inspector", debug_assertions))] pub fn toggle_inspector(&mut self, cx: &mut App) { diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs new file mode 100644 index 00000000000..2e0e57a7dd7 --- /dev/null +++ b/crates/gpui/src/window/a11y.rs @@ -0,0 +1,281 @@ +//! Accessibility support, provided by [AccessKit][accesskit]. +//! +//! There are user-facing guide-level docs [here](crate::_accessibility). +//! +//! ## Architecture +//! +//! ```text +//! ┌────────────────────────────────┐ ┌─────────────────────┐ +//! ┌─▶│ AccessKit Adapter (MacOS) │◀─▶│ MacOS System APIs │ +//! │ └────────────────────────────────┘ └─────────────────────┘ +//! │ +//! ┌──────┐ ┌───────────┐ │ ┌────────────────────────────────┐ ┌─────────────────────┐ +//! │ GPUI │◀─▶│ AccessKit │◀─┼─▶│ AccessKit Adapter (Windows) │◀─▶│ Windows System APIs │ +//! └──────┘ └───────────┘ │ └────────────────────────────────┘ └─────────────────────┘ +//! │ +//! │ ┌────────────────────────────────┐ ┌─────────────────────┐ +//! └─▶│ AccessKit Adapter (Linux) │◀─▶│ dbus │ +//! └────────────────────────────────┘ └─────────────────────┘ +//! ``` +//! +//! In order for GPUI apps to be usable for people using assistive technology, +//! we must do a few things: +//! - Inform the system when the UI changes meaningfully. This includes: +//! - Reporting new/removed/changed UI elements +//! - *Not* reporting irrelevant UI changes, e.g. an invisible `div()` being +//! added. +//! - Reporting the appearance and capabilities of each UI element. For example: +//! - What does this piece of text say? +//! - How far along is this progress bar? +//! - Can this node be focused? +//! - Can this node have a value directly assigned? (e.g. a slider) +//! - Allowing the system to interact with the UI by dispatching actions to +//! nodes. Note that AccessKit has its own [`Action`] type, which is not the +//! [`crate::Action`] trait. +//! - Activate and deactivate accessibility features when requested by the +//! system. +//! +//! Activating and deactivating at the right time is trivial, so I won't go into +//! detail here. The other two are almost orthogonal in implementation. +//! +//! The state for both lives in the [`A11y`] struct in this module. +//! +//! ### Reporting UI changes +//! +//! Every frame, we build a [`TreeUpdate`] and send it to the platform-specific +//! adapter. A [`TreeUpdate`] is a representation of a subset of the UI tree. +//! When the adapter receives the update, it diffs it against the previous +//! update, and calls platform-specific APIs to inform screen readers about the +//! changes. Nodes may have been created, destroyed, or updated. +//! +//! Each node has an ID, and this ID *should* be stable across frames. If a +//! node's ID changes, then, from AccessKit's point of view, it is a different +//! node. +//! +//! We derive the node ID from the [`GlobalElementId`] in +//! [`GlobalElementId::accesskit_node_id`]. Nodes without [`GlobalElementId`]s +//! cannot produce an AccessKit [`NodeId`], and so are not included in the +//! accessibility tree. We try to warn when using accessibility APIs on +//! [`div()`] without setting an ID. +//! +//! This all happens in [`Drawable::prepaint`]. The [`A11y`] struct maintains a +//! stack of nodes during prepainting, which we can use to calculate the +//! [`NodeId`]s, and record parent-child relationships. Once all [`Element`]s in +//! a frame have been prepainted, we send the resulting [`TreeUpdate`] object to +//! the adapter and the screen reader can announce the changes. +//! +//! ### Responding to actions +//! +//! On adapter creation, we provide a callback to the adapter, which can be used +//! to dispatch actions. This callback forwards to [`A11y::action_listeners`], a +//! mapping from [`NodeId`]s to action handlers (basically just `Box`). +//! +//! This is populated in: +//! - [`Window::on_a11y_action`], which is called by: +//! - [`Interactivity::paint`], which is called by: +//! - [`InteractiveElement::on_a11y_action`], which is a public-facing API +//! +//! These are cleared at the start of a frame, and re-populated during painting. +//! +//! [`Element`]: crate::Element +//! [`GlobalElementId`]: crate::GlobalElementId +//! [`div()`]: crate::div +//! [`Interactivity::paint`]: crate::Interactivity::paint +//! [`InteractiveElement::on_a11y_action`]: crate::InteractiveElement::on_a11y_action +//! [`NodeId`]: accesskit::NodeId +//! [`Drawable::prepaint`]: crate::Drawable::prepaint + +use crate::{App, Bounds, FocusId, Pixels, Window}; +use accesskit::{Action, NodeId, TreeUpdate}; +use collections::{FxHashMap, FxHashSet}; +use smallvec::SmallVec; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +/// The fixed AccessKit node ID used for the root of every window's a11y tree. +pub(crate) const ROOT_NODE_ID: NodeId = NodeId(0); + +/// A listener for an accessibility action on a specific node. +pub(crate) type A11yActionListener = + Box, &mut Window, &mut App) + 'static>; + +/// Per-window accessibility state. +/// +/// Manages the AccessKit tree that is built each frame and the mappings +/// needed to dispatch incoming action requests back to the right elements. +pub(crate) struct A11y { + /// Whether a11y features have been requested by the system. + /// + /// Updated by AccessKit using callbacks provided to the adapter. Can change + /// halfway through a frame. + active_flag: Arc, + /// Whether a11y features are active for *this specific frame*. + /// + /// At the start of each frame, we load [`Self::active_flag`] (using + /// [`Self::sync_active_flag`]) and use this to determine whether we + /// should construct a [`TreeUpdate`] for this frame. It's important that + /// this value is stable within a frame, because the builder API exposed by + /// this type maintains a stack of nodes and each must be pushed and popped + /// exactly once. + /// + /// At the end of the frame, we re-call [`Self::sync_active_flag`] to + /// determine whether we should actually send the finished [`TreeUpdate`]. + active_this_frame: bool, + pub(crate) nodes: A11yNodeBuilder, + pub(crate) focus_ids: FxHashMap, + pub(crate) node_bounds: FxHashMap>, + pub(crate) action_listeners: FxHashMap>, +} + +impl A11y { + pub(crate) fn new(active_flag: Arc) -> Self { + Self { + active_flag, + active_this_frame: false, + nodes: A11yNodeBuilder::new(), + focus_ids: FxHashMap::default(), + node_bounds: FxHashMap::default(), + action_listeners: FxHashMap::default(), + } + } + + /// Ensures that [`Self::is_active`] returns up to date information. + /// + /// See the docs for [`Self::active_flag`] and [`Self::active_this_frame`] + /// for more commentary. + pub(crate) fn sync_active_flag(&mut self) { + self.active_this_frame = self.active_flag.load(Ordering::SeqCst); + } + + pub(crate) fn is_active(&self) -> bool { + self.active_this_frame + } + + /// Clear per-frame state and push the root node to start a new frame. + pub(crate) fn begin_frame(&mut self) { + self.focus_ids.clear(); + self.node_bounds.clear(); + self.action_listeners.clear(); + self.nodes.begin_frame(); + } + + /// Finalize the tree and produce a [`TreeUpdate`] for the platform adapter. + pub(crate) fn end_frame(&mut self) -> TreeUpdate { + self.nodes.finalize() + } +} + +pub(crate) struct A11yNodeBuilder { + ids_stack: SmallVec<[NodeId; 16]>, + nodes_stack: SmallVec<[accesskit::Node; 16]>, + /// This is the exact type required by accesskit, so we can't just make it a + /// `HashMap` to remove the need for `seen_ids` + all_nodes: Vec<(NodeId, accesskit::Node)>, + seen_ids: FxHashSet, + focus: NodeId, + #[cfg(debug_assertions)] + has_set_focus: bool, +} + +impl A11yNodeBuilder { + fn new() -> Self { + Self { + ids_stack: SmallVec::new(), + nodes_stack: SmallVec::new(), + all_nodes: Vec::new(), + seen_ids: FxHashSet::default(), + focus: ROOT_NODE_ID, + #[cfg(debug_assertions)] + has_set_focus: false, + } + } + + /// Push a new node onto the stack. It becomes a child of the current + /// top-of-stack node. + /// + /// Returns `true` if the node was successfully pushed. + pub(crate) fn push(&mut self, id: NodeId, node: accesskit::Node) -> bool { + debug_assert!(!self.ids_stack.is_empty(), "push called before push_root"); + + if !self.seen_ids.insert(id) { + debug_assert!( + false, + "Duplicate a11y node id: {id:?}. In a release build, this node would be silently discarded from the a11y tree." + ); + // We need to return `false` here because inserting a duplicate + // node will cause a panic in accesskit + return false; + } + + if let Some(parent) = self.nodes_stack.last_mut() { + parent.push_child(id); + } + self.ids_stack.push(id); + self.nodes_stack.push(node); + true + } + + /// Pop the current node off the stack and finalize it into the all_nodes + /// list. + pub(crate) fn pop(&mut self) { + debug_assert!(self.ids_stack.len() > 1, "pop would remove the root node"); + + if let (Some(id), Some(node)) = (self.ids_stack.pop(), self.nodes_stack.pop()) { + self.all_nodes.push((id, node)); + } + } + + /// Push the root node to start a new frame. + fn begin_frame(&mut self) { + self.all_nodes.clear(); + self.ids_stack.clear(); + self.nodes_stack.clear(); + self.seen_ids.clear(); + #[cfg(debug_assertions)] + { + self.has_set_focus = false; + } + let root_node = accesskit::Node::new(accesskit::Role::Window); + + self.ids_stack.push(ROOT_NODE_ID); + self.nodes_stack.push(root_node); + self.focus = ROOT_NODE_ID; + } + + /// Set the focused node for this frame. + pub(crate) fn set_focus(&mut self, id: NodeId) { + #[cfg(debug_assertions)] + { + debug_assert!( + !self.has_set_focus, + "set_focus called more than once in a single frame" + ); + self.has_set_focus = true; + } + self.focus = id; + } + + fn finalize(&mut self) -> TreeUpdate { + // Stack should contain only the root node + debug_assert_eq!(self.ids_stack.len(), 1); + debug_assert_eq!(self.ids_stack[0], ROOT_NODE_ID); + + // Pop remaining nodes (should just be the root). + while !self.ids_stack.is_empty() { + if let (Some(id), Some(node)) = (self.ids_stack.pop(), self.nodes_stack.pop()) { + self.all_nodes.push((id, node)); + } + } + + let nodes = std::mem::take(&mut self.all_nodes); + TreeUpdate { + nodes, + tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), + tree_id: accesskit::TreeId::ROOT, + focus: self.focus, + } + } +} diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index a088a5205fd..0e9de0ebc0c 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -51,6 +51,8 @@ screen-capture = [ [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +accesskit.workspace = true +accesskit_unix.workspace = true anyhow.workspace = true bytemuck = "1" collections.workspace = true diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 73a2bda279f..d8cf35686eb 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -123,6 +123,7 @@ pub struct WaylandWindowState { in_progress_window_controls: Option, window_controls: WindowControls, client_inset: Option, + accesskit_adapter: Option, } pub enum WaylandSurfaceState { @@ -398,6 +399,7 @@ impl WaylandWindowState { in_progress_window_controls: None, window_controls: WindowControls::default(), client_inset: None, + accesskit_adapter: None, }) } @@ -1047,6 +1049,9 @@ impl WaylandWindowStatePtr { fun(focus); self.callbacks.borrow_mut().active_status_change = Some(fun); } + if let Some(adapter) = self.state.borrow_mut().accesskit_adapter.as_mut() { + adapter.update_window_focus_state(focus); + } } pub fn set_hovered(&self, focus: bool) { @@ -1519,6 +1524,60 @@ impl PlatformWindow for WaylandWindow { bell.ring(surface); } } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let activation_handler = TrivialActivationHandler { + callback: callbacks.activation, + }; + let action_handler = TrivialActionHandler(callbacks.action); + let deactivation_handler = TrivialDeactivationHandler { + callback: callbacks.deactivation, + }; + + let adapter = + accesskit_unix::Adapter::new(activation_handler, action_handler, deactivation_handler); + + self.borrow_mut().accesskit_adapter = Some(adapter); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let mut state = self.borrow_mut(); + if let Some(adapter) = state.accesskit_adapter.as_mut() { + adapter.update_if_active(|| tree_update); + } + } + + fn a11y_update_window_bounds(&self) { + // Wayland doesn't expose window position, so this is a no-op + } +} + +struct TrivialActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for TrivialActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct TrivialActionHandler(Box); + +impl accesskit::ActionHandler for TrivialActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } +} + +struct TrivialDeactivationHandler { + callback: Box, +} + +impl accesskit::DeactivationHandler for TrivialDeactivationHandler { + fn deactivate_accessibility(&mut self) { + (self.callback)(); + } } fn update_window(mut state: RefMut) { diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 0e402a7d63b..1f3ca51155d 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -285,6 +285,7 @@ pub struct X11WindowState { edge_constraints: Option, pub handle: AnyWindowHandle, last_insets: [u32; 4], + accesskit_adapter: Option, } impl X11WindowState { @@ -801,6 +802,7 @@ impl X11WindowState { decorations: WindowDecorations::Server, last_insets: [0, 0, 0, 0], edge_constraints: None, + accesskit_adapter: None, counter_id: sync_request_counter, last_sync_counter: None, }) @@ -1277,6 +1279,9 @@ impl X11WindowStatePtr { fun(focus); self.callbacks.borrow_mut().active_status_change = Some(fun); } + if let Some(adapter) = self.state.borrow_mut().accesskit_adapter.as_mut() { + adapter.update_window_focus_state(focus); + } } pub fn set_hovered(&self, focus: bool) { @@ -1886,4 +1891,84 @@ impl PlatformWindow for X11Window { // Volume 0% means don't increase or decrease from system volume let _ = self.0.xcb.bell(0); } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let activation_handler = TrivialActivationHandler { + callback: callbacks.activation, + }; + let action_handler = TrivialActionHandler(callbacks.action); + let deactivation_handler = TrivialDeactivationHandler { + callback: callbacks.deactivation, + }; + + let adapter = + accesskit_unix::Adapter::new(activation_handler, action_handler, deactivation_handler); + + self.0.state.borrow_mut().accesskit_adapter = Some(adapter); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let mut state = self.0.state.borrow_mut(); + if let Some(adapter) = state.accesskit_adapter.as_mut() { + adapter.update_if_active(|| tree_update); + } + } + + fn a11y_update_window_bounds(&self) { + let mut state = self.0.state.borrow_mut(); + let scale = state.scale_factor; + let bounds = state.bounds; + let [left, right, top, bottom] = state.last_insets; + + let x = f32::from(bounds.origin.x); + let y = f32::from(bounds.origin.y); + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + + let outer = accesskit::Rect { + x0: (x * scale) as f64, + y0: (y * scale) as f64, + x1: ((x + width) * scale) as f64, + y1: ((y + height) * scale) as f64, + }; + + let inner = accesskit::Rect { + x0: (x * scale) as f64 + left as f64, + y0: (y * scale) as f64 + top as f64, + x1: ((x + width) * scale) as f64 - right as f64, + y1: ((y + height) * scale) as f64 - bottom as f64, + }; + + if let Some(adapter) = state.accesskit_adapter.as_mut() { + adapter.set_root_window_bounds(outer, inner); + } + } +} + +struct TrivialActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for TrivialActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct TrivialActionHandler(Box); + +impl accesskit::ActionHandler for TrivialActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } +} + +struct TrivialDeactivationHandler { + callback: Box, +} + +impl accesskit::DeactivationHandler for TrivialDeactivationHandler { + fn deactivate_accessibility(&mut self) { + (self.callback)(); + } } diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml index 84bde263f25..2c9446401b0 100644 --- a/crates/gpui_macos/Cargo.toml +++ b/crates/gpui_macos/Cargo.toml @@ -22,6 +22,8 @@ screen-capture = ["gpui/screen-capture"] gpui.workspace = true [target.'cfg(target_os = "macos")'.dependencies] +accesskit.workspace = true +accesskit_macos.workspace = true anyhow.workspace = true async-task = "4.7" block = "0.1" diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index daf4de8fb64..68cb31d9ea5 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -500,6 +500,7 @@ struct MacWindowState { toggle_tab_bar_callback: Option>, activated_least_once: bool, closed: Arc, + accesskit_adapter: Option, // The parent window if this window is a sheet (Dialog kind) sheet_parent: Option, } @@ -829,6 +830,7 @@ impl MacWindow { toggle_tab_bar_callback: None, activated_least_once: false, closed: Arc::new(AtomicBool::new(false)), + accesskit_adapter: None, sheet_parent: None, }))); @@ -1730,6 +1732,59 @@ impl PlatformWindow for MacWindow { let mut this = self.0.lock(); this.renderer.render_to_image(scene) } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let mut lock = self.0.lock(); + + let activation_handler = A11yActivationHandler { + callback: callbacks.activation, + }; + let action_handler = A11yActionHandler(callbacks.action); + + let adapter = unsafe { + accesskit_macos::SubclassingAdapter::for_window( + lock.native_window as *mut c_void, + activation_handler, + action_handler, + ) + }; + + lock.accesskit_adapter = Some(adapter); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let events = { + let mut lock = self.0.lock(); + lock.accesskit_adapter + .as_mut() + .and_then(|adapter| adapter.update_if_active(|| tree_update)) + }; + if let Some(events) = events { + events.raise(); + } + } + + fn a11y_update_window_bounds(&self) { + // macOS handles window bounds tracking automatically via NSAccessibility. + } +} + +struct A11yActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for A11yActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct A11yActionHandler(Box); + +impl accesskit::ActionHandler for A11yActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } } impl rwh::HasWindowHandle for MacWindow { @@ -2341,6 +2396,16 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) let executor = lock.foreground_executor.clone(); drop(lock); + let a11y_events = { + let mut lock = window_state.lock(); + lock.accesskit_adapter + .as_mut() + .and_then(|adapter| adapter.update_view_focus_state(is_active)) + }; + if let Some(events) = a11y_events { + events.raise(); + } + // When a window becomes active, trigger an immediate synchronous frame request to prevent // tab flicker when switching between windows in native tabs mode. // diff --git a/crates/gpui_windows/Cargo.toml b/crates/gpui_windows/Cargo.toml index 992a47dce9e..f5a93e7f5aa 100644 --- a/crates/gpui_windows/Cargo.toml +++ b/crates/gpui_windows/Cargo.toml @@ -20,6 +20,8 @@ screen-capture = ["gpui/screen-capture", "scap"] gpui.workspace = true [target.'cfg(target_os = "windows")'.dependencies] +accesskit.workspace = true +accesskit_windows.workspace = true anyhow.workspace = true collections.workspace = true etagere = "0.2" diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 77c4cde9788..b04b819a02b 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -112,6 +112,7 @@ impl WindowsWindowInner { WM_GPUI_FORCE_UPDATE_WINDOW => self.draw_window(handle, true), WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam), DM_POINTERHITTEST => self.handle_dm_pointer_hit_test(wparam), + WM_GETOBJECT => self.handle_wm_getobject(wparam, lparam), _ => None, }; if let Some(n) = handled { @@ -728,6 +729,17 @@ impl WindowsWindowInner { fn handle_activate_msg(self: &Rc, wparam: WPARAM) -> Option { let activated = wparam.loword() > 0; + + let events = self + .state + .a11y + .try_borrow_mut() + .ok() + .and_then(|mut a11y| a11y.as_mut()?.adapter.update_window_focus_state(activated)); + if let Some(events) = events { + events.raise(); + } + let this = self.clone(); if !activated { @@ -764,6 +776,23 @@ impl WindowsWindowInner { None } + fn handle_wm_getobject(&self, wparam: WPARAM, lparam: LPARAM) -> Option { + let result = { + let mut a11y = self.state.a11y.borrow_mut(); + let a11y = a11y.as_mut()?; + a11y.adapter.handle_wm_getobject( + accesskit_windows::WPARAM(wparam.0), + accesskit_windows::LPARAM(lparam.0), + &mut a11y.activation_handler, + )? + }; + // The borrow above must be dropped before calling `.into()`, because + // it calls `UiaReturnRawElementProvider` which may send a nested + // `WM_GETOBJECT` back into this window procedure. + let lresult: accesskit_windows::LRESULT = result.into(); + Some(lresult.0) + } + fn handle_create_msg(&self, handle: HWND) -> Option { if self.hide_title_bar { notify_frame_changed(handle); diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 178d750024f..b33d8702f2f 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -83,6 +83,7 @@ pub struct WindowsWindowState { fullscreen: Cell>, initial_placement: Cell>, hwnd: HWND, + pub(crate) a11y: RefCell>, } pub(crate) struct WindowsWindowInner { @@ -176,6 +177,7 @@ impl WindowsWindowState { hwnd, invalidate_devices, direct_manipulation, + a11y: RefCell::new(None), }) } @@ -972,6 +974,69 @@ impl PlatformWindow for WindowsWindow { // MB_OK: The sound specified as the Windows Default Beep sound. let _ = unsafe { MessageBeep(MB_OK) }; } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let action_handler = A11yActionHandler(callbacks.action); + let is_focused = unsafe { GetForegroundWindow() } == self.0.hwnd; + + let adapter = accesskit_windows::Adapter::new( + accesskit_windows::HWND(self.0.hwnd.0), + is_focused, + action_handler, + ); + + let activation_handler = A11yActivationHandler { + callback: callbacks.activation, + }; + + *self.state.a11y.borrow_mut() = Some(A11yState { + adapter, + activation_handler, + }); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let events = { + let mut a11y = self.state.a11y.borrow_mut(); + a11y.as_mut() + .and_then(|a11y| a11y.adapter.update_if_active(|| tree_update)) + }; + // The borrow must be dropped before raising events, because + // `events.raise()` calls `UiaRaiseAutomationPropertyChangedEvent` + // which may send a nested `WM_GETOBJECT` back into this window + // procedure, re-entering `handle_wm_getobject` which also borrows + // `self.state.a11y`. + if let Some(events) = events { + events.raise(); + } + } + + fn a11y_update_window_bounds(&self) { + // Windows UIA handles window bounds tracking automatically. + } +} + +pub(crate) struct A11yState { + pub(crate) adapter: accesskit_windows::Adapter, + pub(crate) activation_handler: A11yActivationHandler, +} + +pub(crate) struct A11yActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for A11yActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct A11yActionHandler(Box); + +impl accesskit::ActionHandler for A11yActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } } #[implement(IDropTarget)] diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index b2f2b4f436d..dea3e066ae7 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -184,7 +184,7 @@ impl Render for TitleBar { let show_menus = show_menus(cx); - let mut children = >::new(); + let mut children = >::new(); let mut project_name = None; let mut repository = None; @@ -238,6 +238,8 @@ impl Render for TitleBar { } } + children.push(gpui::text!("Hello from a11y").into_any_element()); + children.push( h_flex() .h_full() diff --git a/nix/modules/devshells.nix b/nix/modules/devshells.nix index ab58d37fff2..d898d62541d 100644 --- a/nix/modules/devshells.nix +++ b/nix/modules/devshells.nix @@ -51,6 +51,15 @@ # we'll just put it on `$PATH`: nodejs_22 zig + + # A11y testing infra + gobject-introspection + at-spi2-core + (python3.withPackages (ps: [ + ps.pyatspi + ps.pygobject3 + ])) + accerciser ]; env = diff --git a/nix/modules/packages.nix b/nix/modules/packages.nix index 1d2228c8cfa..9c6cf5e10ce 100644 --- a/nix/modules/packages.nix +++ b/nix/modules/packages.nix @@ -1,7 +1,12 @@ { inputs, ... }: { perSystem = - { pkgs, ... }: + { + pkgs, + lib, + system, + ... + }: let mkZed = import ../toolchain.nix { inherit inputs; }; zed-editor = mkZed pkgs; @@ -11,5 +16,10 @@ default = zed-editor; debug = zed-editor.override { profile = "dev"; }; }; + } + // lib.optionalAttrs (lib.hasSuffix "linux" system) { + checks.a11y-test = import ../tests/a11y.nix { + inherit pkgs inputs; + }; }; } diff --git a/nix/tests/a11y.nix b/nix/tests/a11y.nix new file mode 100644 index 00000000000..f16deac8660 --- /dev/null +++ b/nix/tests/a11y.nix @@ -0,0 +1,296 @@ +# NixOS VM integration test for GPUI AccessKit (X11). +# +# Interactive use: +# nix run .#checks.x86_64-linux.a11y-test.driverInteractive +# +# Then in the Python REPL: +# start_all() +# machine.wait_for_x() +# machine.succeed("su - user -c 'DISPLAY=:0 gpui-a11y-example &'") +# +# Automated run: +# nix build .#checks.x86_64-linux.a11y-test +{ + pkgs, + inputs, +}: +let + lib = pkgs.lib; + + rustBin = inputs.rust-overlay.lib.mkRustBin { } pkgs; + rustToolchain = rustBin.fromRustupToolchainFile ../../rust-toolchain.toml; + craneLib = (inputs.crane.mkLib pkgs).overrideToolchain rustToolchain; + + gpui-a11y-example = + let + src = builtins.path { + path = ../../.; + filter = + path: type: + let + root = toString ../../. + "/"; + relPath = lib.removePrefix root path; + firstComp = builtins.head (lib.path.subpath.components relPath); + in + builtins.elem firstComp [ + "crates" + "assets" + "extensions" + "script" + "tooling" + "Cargo.toml" + ".config" + ".cargo" + ]; + name = "gpui-a11y-source"; + }; + commonArgs = { + pname = "gpui-a11y-example"; + version = "0.0.0"; + inherit src; + cargoLock = ../../Cargo.lock; + cargoExtraArgs = "-p gpui --example a11y --locked --features=gpui_platform/runtime_shaders"; + CARGO_PROFILE = "dev"; + + nativeBuildInputs = with pkgs; [ + cmake + pkg-config + rustPlatform.bindgenHook + ]; + + buildInputs = with pkgs; [ + fontconfig + freetype + openssl + zlib + zstd + alsa-lib + libxkbcommon + wayland + vulkan-loader + libglvnd + libx11 + libxcb + libdrm + libgbm + libxcomposite + libxdamage + libxext + libxfixes + libxrandr + ]; + + cargoVendorDir = craneLib.vendorCargoDeps { + inherit src; + cargoLock = ../../Cargo.lock; + }; + + env = { + ZSTD_SYS_USE_PKG_CONFIG = true; + FONTCONFIG_FILE = pkgs.makeFontsConf { + fontDirectories = [ + ../../assets/fonts/lilex + ../../assets/fonts/ibm-plex-sans + ]; + }; + }; + + doCheck = false; + + stdenv = + let + base = pkgs.llvmPackages.stdenv; + addBinTools = old: { + cc = old.cc.override { + inherit (pkgs.llvmPackages) bintools; + }; + }; + in + lib.pipe base [ + (s: s.override addBinTools) + pkgs.stdenvAdapters.useMoldLinker + ]; + }; + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + in + craneLib.buildPackage ( + lib.recursiveUpdate commonArgs { + inherit cargoArtifacts; + dontUseCmakeConfigure = true; + + installPhase = '' + runHook preInstall + mkdir -p $out/bin + cp target/debug/examples/a11y $out/bin/gpui-a11y-example + runHook postInstall + ''; + + NIX_LDFLAGS = "-rpath ${ + lib.makeLibraryPath [ + pkgs.vulkan-loader + pkgs.wayland + ] + }"; + dontPatchELF = true; + + meta = { + description = "GPUI accessibility (AccessKit) example app"; + platforms = lib.platforms.linux; + }; + } + ); + + atspiTestScript = pkgs.writeTextFile { + name = "a11y-atspi-test"; + text = builtins.readFile ./a11y_atspi_test.py; + destination = "/bin/a11y-atspi-test"; + executable = true; + checkPhase = '' + ${pkgs.python3.interpreter} -m py_compile $target + ''; + }; + + testPython = pkgs.python3.withPackages (ps: [ ps.pyatspi ps.pygobject3 ]); + + giTypelibPath = lib.makeSearchPath "lib/girepository-1.0" [ + pkgs.at-spi2-core + pkgs.glib + pkgs.gtk3 + pkgs.gobject-introspection + ]; +in +pkgs.testers.nixosTest { + name = "gpui-a11y-x11"; + + nodes.machine = + { pkgs, ... }: + { + imports = [ ]; + + # Minimal X11 desktop + services.xserver = { + enable = true; + desktopManager.xfce.enable = true; + displayManager.lightdm.enable = true; + }; + + # Auto-login so the test doesn't need to type a password + services.displayManager.autoLogin = { + enable = true; + user = "user"; + }; + + # AT-SPI2 accessibility bus + services.gnome.at-spi2-core.enable = true; + + # dconf + GSettings schemas required for Orca / AT-SPI + programs.dconf = { + enable = true; + profiles.user.databases = [ + { + settings = { + "org/gnome/desktop/interface".toolkit-accessibility = true; + "org/gnome/desktop/a11y/applications".screen-reader-enabled = true; + }; + } + ]; + }; + + # Environment variables for debugging + environment.variables = { + RUST_BACKTRACE = "1"; + }; + + # Start Orca automatically on login + systemd.user.services.orca = { + description = "Orca screen reader"; + wantedBy = [ "graphical-session.target" ]; + partOf = [ "graphical-session.target" ]; + after = [ "graphical-session.target" ]; + serviceConfig = { + ExecStart = "${pkgs.orca}/bin/orca --debug"; + Restart = "on-failure"; + }; + environment = { + DISPLAY = ":0"; + }; + }; + + # Accessibility tools available in the VM + environment.systemPackages = [ + gpui-a11y-example + atspiTestScript + testPython + pkgs.accerciser + pkgs.gsettings-desktop-schemas + pkgs.orca + pkgs.xdotool + ]; + + # Test user + users.users.user = { + isNormalUser = true; + password = "pass"; + extraGroups = [ "wheel" ]; + }; + + # Give the VM enough resources for a GUI + virtualisation = { + memorySize = 4096; + cores = 2; + qemu.options = [ + "-vga virtio" + ]; + }; + }; + + testScript = '' +machine.wait_for_x() +machine.wait_for_unit("graphical.target") + +# Let the desktop and Orca settle +machine.sleep(5) + +# Launch the a11y example, capturing logs to a file +machine.succeed( + "su - user -c 'DISPLAY=:0 WAYLAND_DISPLAY= RUST_LOG=gpui=info gpui-a11y-example > /tmp/gpui.log 2>&1 &'" +) + +# Wait for the window to appear +machine.wait_until_succeeds("su - user -c 'DISPLAY=:0 xdotool search --name \"GPUI Accessibility Demo\"'", timeout=15) + +# Wait for accessibility activation +machine.wait_until_succeeds("grep -q 'Accessibility activated' /tmp/gpui.log", timeout=15) +machine.log("Accessibility activation confirmed in logs") + +# Give AccessKit time to register on AT-SPI +machine.sleep(3) + +# Run the AT-SPI test script +machine.succeed( + "su - user -c 'DISPLAY=:0 GI_TYPELIB_PATH=${giTypelibPath} ${testPython}/bin/python3 ${atspiTestScript}/bin/a11y-atspi-test'" +) +machine.log("AT-SPI tests passed (first run)") + +# Kill the app, restart Orca, and re-run +machine.execute("pkill -f gpui-a11y-example") +machine.sleep(1) +machine.succeed("su - user -c 'XDG_RUNTIME_DIR=/run/user/1000 systemctl --user restart orca'") +machine.sleep(3) + +# Relaunch the app +machine.succeed( + "su - user -c 'DISPLAY=:0 WAYLAND_DISPLAY= RUST_LOG=gpui=info gpui-a11y-example > /tmp/gpui2.log 2>&1 &'" +) +machine.wait_until_succeeds("su - user -c 'DISPLAY=:0 xdotool search --name \"GPUI Accessibility Demo\"'", timeout=15) +machine.wait_until_succeeds("grep -q 'Accessibility activated' /tmp/gpui2.log", timeout=15) +machine.log("Accessibility activation confirmed after Orca restart") +machine.sleep(3) + +# Run the AT-SPI test script again +machine.succeed( + "su - user -c 'DISPLAY=:0 GI_TYPELIB_PATH=${giTypelibPath} ${testPython}/bin/python3 ${atspiTestScript}/bin/a11y-atspi-test'" +) +machine.log("AT-SPI tests passed (second run, after Orca restart)") + ''; +} diff --git a/nix/tests/a11y_atspi_test.py b/nix/tests/a11y_atspi_test.py new file mode 100644 index 00000000000..b9caf59a1db --- /dev/null +++ b/nix/tests/a11y_atspi_test.py @@ -0,0 +1,205 @@ +"""AT-SPI integration test for the GPUI a11y example app. + +Walks the AT-SPI tree, finds the GPUI app, and exercises the counter +(spin button with increment/decrement), reset button, and toggle switch +— asserting accessible state after each interaction. +""" + +import sys +import time +import pyatspi + + +def find_app(): + """Find the GPUI a11y example in the AT-SPI desktop.""" + desktop = pyatspi.Registry.getDesktop(0) + for app in desktop: + if "gpui" in app.name.lower() or "a11y" in app.name.lower(): + return app + names = [a.name for a in desktop] + raise AssertionError(f"GPUI app not found in AT-SPI desktop. Apps: {names}") + + +def find_by_role_and_label(root, role, label_substring): + """Depth-first search for a node matching role and label substring.""" + for child in root: + if child.getRole() == role and label_substring in (child.name or ""): + return child + result = find_by_role_and_label(child, role, label_substring) + if result is not None: + return result + return None + + +def find_by_role(root, role): + """Depth-first search for all nodes matching role.""" + results = [] + for child in root: + if child.getRole() == role: + results.append(child) + results.extend(find_by_role(child, role)) + return results + + +def do_action_by_name(node, action_name): + """Perform a named action on a node.""" + actions = node.queryAction() + for i in range(actions.nActions): + if actions.getName(i).lower() == action_name.lower(): + actions.doAction(i) + time.sleep(0.5) + return + available = [actions.getName(i) for i in range(actions.nActions)] + raise AssertionError( + f"No '{action_name}' action on node: {node.name} " + f"(role={node.getRoleName()}). Available: {available}" + ) + + +def click(node): + """Perform the Click action on a node.""" + do_action_by_name(node, "click") + + +def get_toggled_state(node): + """Return whether the node is in a 'checked'/'pressed' state.""" + state_set = node.getState() + return state_set.contains(pyatspi.STATE_CHECKED) or state_set.contains(pyatspi.STATE_PRESSED) + + +def get_counter(app): + counter = find_by_role_and_label(app, pyatspi.ROLE_SPIN_BUTTON, "Counter:") + assert counter is not None, "Counter (spin button) not found" + return counter + + +def get_reset_button(app): + button = find_by_role_and_label(app, pyatspi.ROLE_PUSH_BUTTON, "Reset counter") + assert button is not None, "Reset button not found" + return button + + +def get_toggle_switch(app): + switches = find_by_role(app, pyatspi.ROLE_TOGGLE_BUTTON) + if not switches: + raise AssertionError( + f"No toggle switch found. Roles present: " + f"{[(c.getRoleName(), c.name) for c in find_by_role(app, None) if True]}" + ) + switch = None + for s in switches: + if "feature" in (s.name or "").lower() or "enable" in (s.name or "").lower(): + switch = s + break + if switch is None: + switch = switches[0] + return switch + + +def assert_count(app, expected): + """Assert the counter's label contains the expected count.""" + counter = get_counter(app) + expected_str = f"Counter: {expected}" + assert expected_str in counter.name, ( + f"Expected label to contain '{expected_str}', got: '{counter.name}'" + ) + print(f" OK: count is {expected}") + + +def get_numeric_value(node): + """Get the current numeric value from the AT-SPI Value interface.""" + value = node.queryValue() + return value.currentValue + + +def run_tests(): + print("Finding GPUI app in AT-SPI tree...") + app = find_app() + print(f"Found app: {app.name}") + + # --- Counter (spin button) --- + print("\n--- Counter spin button tests ---") + + print("Checking initial count is 0...") + assert_count(app, 0) + + # Verify the Value interface reports 0 + counter = get_counter(app) + val = get_numeric_value(counter) + print(f" Value interface reports: {val}") + assert val == 0.0, f"Expected numeric value 0.0, got {val}" + print(" OK: numeric value is 0") + + # Test click (increments) + for i in range(1, 4): + print(f"Clicking counter (expecting {i})...") + counter = get_counter(app) + click(counter) + assert_count(app, i) + + # Verify the Value interface tracks the count + counter = get_counter(app) + val = get_numeric_value(counter) + assert val == 3.0, f"Expected numeric value 3.0, got {val}" + print(" OK: numeric value is 3 after 3 clicks") + + # List available actions for diagnostics + counter = get_counter(app) + actions = counter.queryAction() + available = [actions.getName(i) for i in range(actions.nActions)] + print(f" Available actions on counter: {available}") + + # Test reset button + print("Clicking reset...") + reset = get_reset_button(app) + click(reset) + assert_count(app, 0) + + # --- Toggle switch --- + print("\n--- Toggle switch tests ---") + + switch = get_toggle_switch(app) + print(f"Switch: role={switch.getRoleName()}, name={switch.name}") + + toggled = get_toggled_state(switch) + print(f"Initial toggle state: {toggled}") + assert not toggled, f"Expected switch to be OFF initially, got {toggled}" + print(" OK: switch is OFF") + + print("Toggling switch ON...") + click(switch) + switch = get_toggle_switch(app) + toggled = get_toggled_state(switch) + assert toggled, f"Expected switch to be ON after toggle, got {toggled}" + print(" OK: switch is ON") + + print("Toggling switch OFF...") + click(switch) + switch = get_toggle_switch(app) + toggled = get_toggled_state(switch) + assert not toggled, f"Expected switch to be OFF after second toggle, got {toggled}" + print(" OK: switch is OFF") + + # --- Window bounds / Component extents --- + print("\n--- Component extents tests ---") + + counter = get_counter(app) + component = counter.queryComponent() + extents = component.getExtents(pyatspi.DESKTOP_COORDS) + print(f" Counter extents (desktop coords): x={extents.x}, y={extents.y}, " + f"width={extents.width}, height={extents.height}") + assert extents.width > 0 and extents.height > 0, ( + f"Expected non-zero extents from Component interface, got {extents}. " + f"This likely means a11y_update_window_bounds is not reporting bounds." + ) + print(" OK: counter has non-zero extents") + + print("\n=== ALL TESTS PASSED ===") + + +if __name__ == "__main__": + try: + run_tests() + except Exception as e: + print(f"\nFAILED: {e}", file=sys.stderr) + sys.exit(1) From c19c89a30a2a74b1bab83252d1c6ee4558dd9f35 Mon Sep 17 00:00:00 2001 From: Vlad Ionescu Date: Wed, 27 May 2026 21:55:23 +0300 Subject: [PATCH 24/93] opencode: Model updates (#57792) Two model updates: - **Free**: Big Pickle now has a max output tokens of 32k as per https://github.com/anomalyco/models.dev/commit/49991c8f8f98eacb0ad66c7fad0e3b12a7465246 - **Go**: added Qwen3.7 Max as per https://github.com/anomalyco/models.dev/commit/4f1d5c511ae983024385e03f089b8e21e1eb4548 and https://github.com/anomalyco/opencode/commit/1554a5a82e32c30d2665ed6b59ebd33f79f9b9e3#diff-a7ee7cf35b40335095a44cfaac16041313c36322cd14544ddad0289ce69131ab. Tested this successfully by running a simple "_rename this variable for me. add a function. delete the function_" test Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - OpenCode: updated models (added Qwen3.7 Max, updated Big Pickle token counts) --- crates/opencode/src/opencode.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/opencode/src/opencode.rs b/crates/opencode/src/opencode.rs index ec4f927ec5e..c34644eb683 100644 --- a/crates/opencode/src/opencode.rs +++ b/crates/opencode/src/opencode.rs @@ -149,6 +149,8 @@ pub enum Model { Qwen3_5Plus, #[serde(rename = "qwen3.6-plus")] Qwen3_6Plus, + #[serde(rename = "qwen3.7-max")] + Qwen3_7Max, // -- Custom model -- #[serde(rename = "custom")] @@ -201,7 +203,8 @@ impl Model { | Self::MimoV2_5Pro | Self::MimoV2_5 | Self::DeepSeekV4Pro - | Self::DeepSeekV4Flash => &[OpenCodeSubscription::Go], + | Self::DeepSeekV4Flash + | Self::Qwen3_7Max => &[OpenCodeSubscription::Go], // Free models Self::Nemotron3SuperFree | Self::BigPickle => &[OpenCodeSubscription::Free], @@ -260,6 +263,7 @@ impl Model { Self::MimoV2_5 => "mimo-v2.5", Self::Qwen3_5Plus => "qwen3.5-plus", Self::Qwen3_6Plus => "qwen3.6-plus", + Self::Qwen3_7Max => "qwen3.7-max", Self::BigPickle => "big-pickle", Self::Nemotron3SuperFree => "nemotron-3-super-free", @@ -313,6 +317,7 @@ impl Model { Self::MimoV2_5 => "MiMo V2.5", Self::Qwen3_5Plus => "Qwen3.5 Plus", Self::Qwen3_6Plus => "Qwen3.6 Plus", + Self::Qwen3_7Max => "Qwen3.7 Max", Self::BigPickle => "Big Pickle", Self::Nemotron3SuperFree => "Nemotron 3 Super Free", @@ -363,6 +368,8 @@ impl Model { Self::Gemini3_1Pro | Self::Gemini3Flash | Self::Gemini3_5Flash => ApiProtocol::Google, + Self::Qwen3_7Max => ApiProtocol::Anthropic, + Self::Glm5 | Self::Glm5_1 | Self::GrokBuild0_1 @@ -445,6 +452,7 @@ impl Model { Self::MimoV2_5Pro => 1_048_576, Self::MimoV2_5 => 1_000_000, Self::Qwen3_5Plus | Self::Qwen3_6Plus => 262_144, + Self::Qwen3_7Max => 1_000_000, Self::BigPickle => 200_000, Self::Nemotron3SuperFree => 204_800, Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => 1_000_000, @@ -502,10 +510,10 @@ impl Model { Some(131_072) } } - Self::BigPickle => Some(128_000), + Self::BigPickle => Some(32_000), Self::KimiK2_6 | Self::KimiK2_5 => Some(65_536), Self::GrokBuild0_1 => Some(256_000), - Self::Qwen3_5Plus | Self::Qwen3_6Plus => Some(65_536), + Self::Qwen3_7Max | Self::Qwen3_6Plus | Self::Qwen3_5Plus => Some(65_536), Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(384_000), Self::Nemotron3SuperFree => Some(128_000), Self::MimoV2_5Pro | Self::MimoV2_5 => Some(128_000), @@ -572,6 +580,7 @@ impl Model { | Self::MimoV2_5Pro | Self::DeepSeekV4Pro | Self::DeepSeekV4Flash + | Self::Qwen3_7Max | Self::BigPickle | Self::Nemotron3SuperFree => false, From cbaf8e4d9d1462e2919a926b85142ab5bc349667 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 27 May 2026 15:33:04 -0400 Subject: [PATCH 25/93] Speed up StreamingDiff::push_new part two (#57849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #57772. While comparing the `edit_file_tool_streaming/medium_insertions` benchmark in Criterion and xctrace, I noticed the DP scoring loop was spending most of its time on inlined `Matrix::get`/`Matrix::set` access in `StreamingDiff::push_new`. This changes the loop to split the previous and current score columns once per DP column and then index those column slices directly. This keeps the scoring algorithm the same, but hoists the column offset calculation out of the inner loop and gives the compiler clearer aliasing information between the immutable previous column and mutable current column. In xctrace, the hottest DP scoring line dropped from 10,407 samples before to 9,409 samples after on `edit_file_tool_streaming/medium_insertions`, and total `StreamingDiff::push_new` leaf samples dropped from 10,800 to 9,822. This is consistently faster in the end-to-end `edit_file_tool` benchmark, and gives a small speed-up for the targeted workload without changing the scoring algorithm. ### Results Benchmarked with `release-fast` against a clean baseline using Criterion’s `before_slice` baseline. #### `streaming_diff_push_new` | Fixture | Before median | After median | Change | | --- | ---: | ---: | ---: | | `tiny_function_rewrite` | `7.6317 ms` | `6.4951 ms` | 10.8% faster | | `small_function_rewrite` | `45.961 ms` | `44.839 ms` | No significant change | | `medium_many_small_changes` | `99.694 ms` | `104.83 ms` | 5.2% slower | | `medium_insertions` | `95.909 ms` | `83.093 ms` | 13.5% faster | #### `edit_file_tool_streaming` | Fixture | Before median | After median | Change | | --- | ---: | ---: | ---: | | `tiny_function_rewrite` | `1.1110 ms` | `1.0617 ms` | 7.1% faster | | `small_function_rewrite` | `2.4554 ms` | `2.2751 ms` | 7.1% faster | | `medium_many_small_changes` | `87.272 ms` | `77.065 ms` | 11.5% faster | | `medium_insertions` | `92.469 ms` | `87.478 ms` | 5.9% faster | The lower-level `streaming_diff_push_new` benchmark is mixed, but the end-to-end `edit_file_tool_streaming` workload improves across all fixtures. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A or Added/Fixed/Improved ... --- Cargo.lock | 1 + crates/agent/Cargo.toml | 6 + crates/agent/benches/edit_file_tool.rs | 519 ++++++++++++++++++++ crates/streaming_diff/src/streaming_diff.rs | 39 +- 4 files changed, 554 insertions(+), 11 deletions(-) create mode 100644 crates/agent/benches/edit_file_tool.rs diff --git a/Cargo.lock b/Cargo.lock index 8398ff00bde..10ad05d6611 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -242,6 +242,7 @@ dependencies = [ "cloud_llm_client", "collections", "context_server", + "criterion", "ctor", "db", "editor", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 77a9cd33f42..f88d8286535 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -82,6 +82,7 @@ agent_servers = { workspace = true, "features" = ["test-support"] } client = { workspace = true, "features" = ["test-support"] } clock = { workspace = true, "features" = ["test-support"] } context_server = { workspace = true, "features" = ["test-support"] } +criterion.workspace = true ctor.workspace = true db = { workspace = true, "features" = ["test-support"] } editor = { workspace = true, "features" = ["test-support"] } @@ -106,3 +107,8 @@ theme = { workspace = true, "features" = ["test-support"] } unindent = { workspace = true } zlog.workspace = true + +[[bench]] +name = "edit_file_tool" +harness = false +required-features = ["test-support"] diff --git a/crates/agent/benches/edit_file_tool.rs b/crates/agent/benches/edit_file_tool.rs new file mode 100644 index 00000000000..5a26d0a3d4d --- /dev/null +++ b/crates/agent/benches/edit_file_tool.rs @@ -0,0 +1,519 @@ +use std::{ + future::Future, + path::Path, + sync::Arc, + task::{Context, Poll}, +}; + +use action_log::ActionLog; +use agent::{ + AgentTool, ContextServerRegistry, EditFileTool, EditFileToolInput, EditFileToolOutput, + Templates, Thread, ToolCallEventStream, ToolInput, +}; +use agent_settings::{AgentSettings, ToolRules}; +use criterion::{ + BatchSize, BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main, +}; +use futures::{pin_mut, task::noop_waker}; +use gpui::{AppContext as _, BackgroundExecutor, Entity, TestAppContext, UpdateGlobal as _}; +use language_model::fake_provider::FakeLanguageModel; +use project::{FakeFs, Project}; +use prompt_store::ProjectContext; +use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; +use serde_json::{Value, json}; +use settings::{Settings as _, SettingsStore}; + +const SEED: u64 = 0x5EED_5EED; +const OLD_TEXT_CHUNK_SIZE: usize = 512; +const NEW_TEXT_CHUNK_SIZE: usize = 512; + +#[derive(Clone)] +struct EditFixture { + name: &'static str, + old_file_text: String, + expected_file_text: String, + old_text: String, + new_text: String, +} + +struct BenchmarkHarness { + cx: Option, + edit_tool: Option>, + thread: Option>, + partial_payloads: Vec, + final_payload: Value, + expected_file_text: String, +} + +impl Drop for BenchmarkHarness { + fn drop(&mut self) { + // Release our handles to the entities first. + self.edit_tool.take(); + self.thread.take(); + + if let Some(cx) = self.cx.take() { + // `ActionLog` holds buffers strongly via `tracked_buffers`, and spawns a background + // diff-maintenance task that also captures a strong `Entity`. Releasing the + // last handle to the action log only marks its entity for deferred release; the + // entity's value (and the buffer handles inside) is not actually dropped until + // `flush_effects` runs `release_dropped_entities`. Even then, the cancelled task's + // captured handle does not drop until the executor pumps the cancellation through. + // + // Without this two-step teardown, GPUI's test leak detector panics on + // `TestAppContext` drop because the buffer still appears alive. See + // `ActionLog::track_buffer_internal` and `LeakDetector::drop` in + // `crates/gpui/src/app/entity_map.rs`. + cx.update(|_| {}); + cx.executor().run_until_parked(); + cx.quit(); + } + } +} + +fn edit_file_tool_streaming(c: &mut Criterion) { + let fixtures = fixtures(); + let mut group = c.benchmark_group("edit_file_tool_streaming"); + group.sample_size(10); + + for fixture in fixtures { + group.throughput(Throughput::Bytes(fixture.new_text.len() as u64)); + group.bench_with_input( + BenchmarkId::new(fixture.name, fixture.old_text.len()), + &fixture, + |bench, fixture| { + bench.iter_batched( + || setup_harness(fixture.clone()), + |mut harness| { + let output = run_streamed_edit(&mut harness); + let EditFileToolOutput::Success { new_text, .. } = &output else { + panic!("expected edit_file tool to succeed"); + }; + assert_eq!(new_text, &harness.expected_file_text); + // Return the harness as part of the output so its teardown (which has + // to pump the executor to release `Entity` handles captured by + // background tasks) runs in criterion's drop phase after the timer has + // stopped, rather than inside the timed region. + (black_box(output), harness) + }, + BatchSize::SmallInput, + ); + }, + ); + } + + group.finish(); +} + +fn setup_harness(fixture: EditFixture) -> BenchmarkHarness { + let mut cx = init_context(); + let executor = cx.executor(); + let (edit_tool, thread) = block_on_executor( + &executor, + setup_edit_tool(&mut cx, fixture.old_file_text.clone()), + ); + let partial_payloads = streamed_partial_payloads(&fixture.old_text, &fixture.new_text); + let final_payload = json!({ + "path": "root/src/workspace_snapshot.rs", + "edits": [{ + "old_text": fixture.old_text, + "new_text": fixture.new_text, + }], + }); + + BenchmarkHarness { + cx: Some(cx), + edit_tool: Some(edit_tool), + thread: Some(thread), + partial_payloads, + final_payload, + expected_file_text: fixture.expected_file_text, + } +} + +fn init_context() -> TestAppContext { + let cx = TestAppContext::single(); + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings + .project + .all_languages + .defaults + .ensure_final_newline_on_save = Some(false); + }); + }); + + let mut agent_settings = AgentSettings::get_global(cx).clone(); + agent_settings.tool_permissions.tools.insert( + EditFileTool::NAME.into(), + ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + AgentSettings::override_global(agent_settings, cx); + }); + cx +} + +async fn setup_edit_tool( + cx: &mut TestAppContext, + file_text: String, +) -> (Arc, Entity) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "src": { + "workspace_snapshot.rs": file_text, + }, + }), + ) + .await; + + let project = Project::test(fs, [Path::new("/root")], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let action_log: Entity = + thread.read_with(cx, |thread, _cx| thread.action_log().clone()); + + let edit_tool = Arc::new(EditFileTool::new( + project, + thread.downgrade(), + action_log, + language_registry, + )); + (edit_tool, thread) +} + +fn run_streamed_edit(harness: &mut BenchmarkHarness) -> EditFileToolOutput { + let (mut sender, input): (_, ToolInput) = ToolInput::test(); + for payload in &harness.partial_payloads { + sender.send_partial(payload.clone()); + } + sender.send_full(harness.final_payload.clone()); + + let (event_stream, _event_rx) = ToolCallEventStream::test(); + let cx = harness + .cx + .as_ref() + .expect("benchmark harness should have a cx"); + let task = cx.update(|cx| { + harness + .edit_tool + .as_ref() + .expect("benchmark harness should have an edit tool") + .clone() + .run(input, event_stream, cx) + }); + + let executor = harness + .cx + .as_ref() + .expect("benchmark harness should have a cx") + .executor(); + block_on_executor(&executor, task).unwrap() +} + +fn block_on_executor(executor: &BackgroundExecutor, future: impl Future) -> R { + pin_mut!(future); + let waker = noop_waker(); + let mut task_context = Context::from_waker(&waker); + + for _ in 0..10_000 { + if let Poll::Ready(output) = future.as_mut().poll(&mut task_context) { + return output; + } + executor.run_until_parked(); + } + + panic!("future did not complete while running edit_file_tool benchmark"); +} + +fn streamed_partial_payloads(old_text: &str, new_text: &str) -> Vec { + let path = "root/src/workspace_snapshot.rs"; + let mut payloads = Vec::new(); + + payloads.push(json!({ "path": path })); + payloads.push(json!({ "path": path })); + + for old_end in chunk_ends(old_text, OLD_TEXT_CHUNK_SIZE) { + payloads.push(json!({ + "path": path, + "edits": [{ "old_text": &old_text[..old_end] }], + })); + } + + payloads.push(json!({ + "path": path, + "edits": [{ "old_text": old_text, "new_text": "" }], + })); + + for new_end in chunk_ends(new_text, NEW_TEXT_CHUNK_SIZE) { + payloads.push(json!({ + "path": path, + "edits": [{ + "old_text": old_text, + "new_text": &new_text[..new_end], + }], + })); + } + + payloads +} + +fn chunk_ends(text: &str, chunk_size: usize) -> impl Iterator + '_ { + let mut end = 0; + std::iter::from_fn(move || { + if end == text.len() { + return None; + } + + end = (end + chunk_size).min(text.len()); + while !text.is_char_boundary(end) { + end -= 1; + } + Some(end) + }) +} + +fn fixtures() -> Vec { + vec![ + make_fixture( + "tiny_function_rewrite", + 2, + EditPattern::LocalizedRewrite { + start_line: 12, + line_count: 6, + }, + SEED, + ), + make_fixture( + "small_function_rewrite", + 5, + EditPattern::LocalizedRewrite { + start_line: 22, + line_count: 12, + }, + SEED + 1, + ), + make_fixture( + "medium_many_small_changes", + 8, + EditPattern::ManySmallChanges { every_nth_line: 7 }, + SEED + 2, + ), + make_fixture( + "medium_insertions", + 8, + EditPattern::InsertHelperBlocks { every_nth_line: 9 }, + SEED + 3, + ), + ] +} + +enum EditPattern { + LocalizedRewrite { + start_line: usize, + line_count: usize, + }, + ManySmallChanges { + every_nth_line: usize, + }, + InsertHelperBlocks { + every_nth_line: usize, + }, +} + +fn make_fixture( + name: &'static str, + function_count: usize, + pattern: EditPattern, + seed: u64, +) -> EditFixture { + let mut rng = StdRng::seed_from_u64(seed); + let old_lines = random_rust_module(&mut rng, function_count); + let edit_range = edit_range(&old_lines, &pattern); + let old_text = old_lines[edit_range.clone()].join("\n"); + let mut new_lines = old_lines.clone(); + + match pattern { + EditPattern::LocalizedRewrite { .. } => { + rewrite_local_block(&mut new_lines[edit_range.clone()], &mut rng) + } + EditPattern::ManySmallChanges { every_nth_line } => { + rewrite_many_small_lines(&mut new_lines[edit_range.clone()], every_nth_line, &mut rng) + } + EditPattern::InsertHelperBlocks { every_nth_line } => { + insert_helper_blocks(&mut new_lines, edit_range.clone(), every_nth_line, &mut rng) + } + } + + let new_text_end = edit_range.end + new_lines.len().saturating_sub(old_lines.len()); + let old_file_text = old_lines.join("\n"); + let expected_file_text = new_lines.join("\n"); + let new_text = new_lines[edit_range.start..new_text_end].join("\n"); + + EditFixture { + name, + old_file_text, + expected_file_text, + old_text, + new_text, + } +} + +fn edit_range(lines: &[String], pattern: &EditPattern) -> std::ops::Range { + let mut range = match pattern { + EditPattern::LocalizedRewrite { + start_line, + line_count, + } => *start_line..(*start_line + *line_count).min(lines.len()), + EditPattern::ManySmallChanges { .. } | EditPattern::InsertHelperBlocks { .. } => { + 10..lines.len().saturating_sub(5) + } + }; + + while range.end > range.start && lines[range.end - 1].is_empty() { + range.end -= 1; + } + + range +} + +fn random_rust_module(rng: &mut StdRng, function_count: usize) -> Vec { + let mut lines = vec![ + "use anyhow::{Context as _, Result};".to_string(), + "use collections::HashMap;".to_string(), + "".to_string(), + "#[derive(Clone, Debug)]".to_string(), + "pub struct WorkspaceSnapshot {".to_string(), + " buffers: HashMap,".to_string(), + " version: usize,".to_string(), + "}".to_string(), + "".to_string(), + "impl WorkspaceSnapshot {".to_string(), + ]; + + for function_index in 0..function_count { + let function_name = identifier(rng, function_index); + let argument_name = identifier(rng, function_index + 1_000); + let local_name = identifier(rng, function_index + 2_000); + let branch_name = identifier(rng, function_index + 3_000); + let multiplier = rng.random_range(2..17); + let offset = rng.random_range(1..128); + + lines.extend([ + format!( + " pub fn {function_name}(&mut self, {argument_name}: usize) -> Result {{" + ), + format!(" let mut {local_name} = {argument_name}.saturating_mul({multiplier});"), + format!(" if {local_name} % 2 == 0 {{"), + format!( + " {local_name} = {local_name}.saturating_add(self.version + {offset});" + ), + " } else {".to_string(), + format!(" {local_name} = {local_name}.saturating_sub({offset});"), + " }".to_string(), + format!(" let {branch_name} = self.buffers.len().saturating_add({local_name});"), + format!(" self.version = self.version.saturating_add({branch_name});"), + format!(" Ok({branch_name})"), + " }".to_string(), + "".to_string(), + ]); + } + + lines.push("}".to_string()); + lines.push("".to_string()); + lines.push("pub fn normalize_path(path: &str) -> String {".to_string()); + lines.push(" path.replace('\\\\', \"/\")".to_string()); + lines.push("}".to_string()); + lines +} + +fn rewrite_local_block(lines: &mut [String], rng: &mut StdRng) { + for (line_index, line) in lines.iter_mut().enumerate() { + let suffix = identifier(rng, line_index + 10_000); + if line.contains("saturating_add") { + *line = format!( + " let {suffix} = self.version.checked_add({line_index}).context(\"version overflow\")?;" + ); + } else if line.contains("saturating_sub") { + *line = format!( + " {suffix}.saturating_sub({});", + rng.random_range(8..256) + ); + } else if line.trim().is_empty() { + *line = + format!(" tracing::trace!(target: \"agent_bench\", value = {line_index});"); + } else { + *line = format!("{line} // updated {suffix}"); + } + } +} + +fn rewrite_many_small_lines(lines: &mut [String], every_nth_line: usize, rng: &mut StdRng) { + for (line_index, line) in lines.iter_mut().enumerate() { + if line_index.is_multiple_of(every_nth_line) || line.trim().is_empty() { + continue; + } + + let suffix = identifier(rng, line_index + 20_000); + *line = format!("{line} // audited {suffix}"); + } +} + +fn insert_helper_blocks( + lines: &mut Vec, + range: std::ops::Range, + every_nth_line: usize, + rng: &mut StdRng, +) { + let mut line_index = range.start; + while line_index < range.end.min(lines.len()) { + if line_index.is_multiple_of(every_nth_line) && !lines[line_index].trim().is_empty() { + let suffix = identifier(rng, line_index + 30_000); + lines.splice( + line_index..line_index, + [ + format!(" let {suffix}_before = self.version;"), + format!(" tracing::debug!(version = {suffix}_before);"), + ], + ); + line_index += 2; + } + line_index += 1; + } +} + +fn identifier(rng: &mut StdRng, salt: usize) -> String { + const PARTS: &[&str] = &[ + "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "theta", "lambda", "sigma", "omega", + ]; + format!( + "{}_{}_{}", + PARTS[rng.random_range(0..PARTS.len())], + salt, + rng.random_range(0..10_000) + ) +} + +criterion_group!(benches, edit_file_tool_streaming); +criterion_main!(benches); diff --git a/crates/streaming_diff/src/streaming_diff.rs b/crates/streaming_diff/src/streaming_diff.rs index 2205a1dec5e..6d455a236bd 100644 --- a/crates/streaming_diff/src/streaming_diff.rs +++ b/crates/streaming_diff/src/streaming_diff.rs @@ -74,6 +74,20 @@ impl Matrix { self.cells[col * self.rows + row] = value; } + + fn adjacent_columns_mut(&mut self, current_col: usize) -> (&[f64], &mut [f64]) { + if current_col == 0 || current_col >= self.cols { + panic!("column out of bounds"); + } + + let current_col_start = current_col * self.rows; + let previous_col_start = current_col_start - self.rows; + let (before_current, current_and_after) = self.cells.split_at_mut(current_col_start); + ( + &before_current[previous_col_start..current_col_start], + &mut current_and_after[..self.rows], + ) + } } impl Debug for Matrix { @@ -141,24 +155,27 @@ impl StreamingDiff { for j in self.new_text_ix + 1..=self.new.len() { self.current_equal_runs.fill(0); let relative_j = j - self.new_text_ix; + let new_char = self.new[j - 1]; + let old = &self.old; + let previous_equal_runs = &self.previous_equal_runs; + let current_equal_runs = &mut self.current_equal_runs; + let (previous_scores, current_scores) = self.scores.adjacent_columns_mut(relative_j); - self.scores - .set(0, relative_j, j as f64 * Self::INSERTION_SCORE); - for i in 1..=self.old.len() { - let insertion_score = self.scores.get(i, relative_j - 1) + Self::INSERTION_SCORE; - let deletion_score = self.scores.get(i - 1, relative_j) + Self::DELETION_SCORE; - let equality_score = if self.old[i - 1] == self.new[j - 1] { - let equal_run = self.previous_equal_runs[i - 1] + 1; - self.current_equal_runs[i] = equal_run; + current_scores[0] = j as f64 * Self::INSERTION_SCORE; + for i in 1..=old.len() { + let insertion_score = previous_scores[i] + Self::INSERTION_SCORE; + let deletion_score = current_scores[i - 1] + Self::DELETION_SCORE; + let equality_score = if old[i - 1] == new_char { + let equal_run = previous_equal_runs[i - 1] + 1; + current_equal_runs[i] = equal_run; let exponent = cmp::min(equal_run as i32 / 4, Self::MAX_EQUALITY_EXPONENT); - self.scores.get(i - 1, relative_j - 1) + Self::EQUALITY_BASE.powi(exponent) + previous_scores[i - 1] + Self::EQUALITY_BASE.powi(exponent) } else { f64::NEG_INFINITY }; - let score = insertion_score.max(deletion_score).max(equality_score); - self.scores.set(i, relative_j, score); + current_scores[i] = insertion_score.max(deletion_score).max(equality_score); } std::mem::swap(&mut self.previous_equal_runs, &mut self.current_equal_runs); From 1226e28dd3b0401f5379e959db7271fcfb7e76a7 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 27 May 2026 15:46:15 -0400 Subject: [PATCH 26/93] Add zed-cherry-pick agent skill (#57833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a project-scoped agent skill at `.agents/skills/zed-cherry-pick/SKILL.md` that walks an agent through cherry-picking a merged PR into Zed's `preview` or `stable` release branch — including the case where the automated `cherry_pick` GitHub Actions workflow failed due to a merge conflict. Captures the conventions enforced by `script/cherry-pick` (branch naming, PR title/body format) and the gotchas around discovering the current channel→branch mapping, non-interactive git in agent environments, and worktree index locks. Release Notes: - N/A --------- Co-authored-by: Martin Ye Co-authored-by: Ben Kunkle --- .agents/skills/zed-cherry-pick/SKILL.md | 175 ++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 .agents/skills/zed-cherry-pick/SKILL.md diff --git a/.agents/skills/zed-cherry-pick/SKILL.md b/.agents/skills/zed-cherry-pick/SKILL.md new file mode 100644 index 00000000000..0f0cd02b929 --- /dev/null +++ b/.agents/skills/zed-cherry-pick/SKILL.md @@ -0,0 +1,175 @@ +--- +name: zed-cherry-pick +description: Cherry-pick one or more merged PRs and/or commits into Zed's `preview` or `stable` release branch. Use this whenever the user mentions cherry-picking to preview/stable, a failed cherry-pick run, or wants to manually port fix(es) into a release branch. +--- + +# Zed Cherry-Pick + +Zed ships from two long-lived release branches that live on `origin`: + +- `preview` channel → branch like `v1.4.x` +- `stable` channel → branch like `v1.3.x` + +The version numbers change with each release. **Never hardcode them — always discover the current mapping** (see [Finding the target branch](#finding-the-target-branch)). + +A merged PR on `main` gets ported to a release branch by `script/cherry-pick`, normally driven by the `cherry_pick` GitHub Actions workflow. When that workflow fails (almost always a merge conflict), use this skill to finish the job locally and open the cherry-pick PR by hand. + +## When to use + +Use this when the user asks to cherry-pick one or more commits and/or Pull Requests (by number or URL) to `preview` or `stable`. +Optionally, the user may specify whether to resolve merge conflicts; if unspecified, attempt the cherry-pick, and then if there are merge conflicts in practice, stop and inform the user that there are merge conflicts and offer to resolve them. (Users may prefer to resolve the merge conflicts themselves before continuing.) + +## The script you're emulating + +The canonical procedure lives in `script/cherry-pick` and the `cherry_pick` GitHub Actions workflow. Read the script first if anything looks off — your local steps must produce the same branch name, PR title, and PR body it would. + +Signature: `script/cherry-pick ` + +- `` is the release branch (e.g. `v1.4.x`), **not** the channel name. +- `` is `preview` or `stable`, used only for display text in the PR title/body. + +It creates a local branch named `cherry-pick--` (the short SHA is the first 8 chars of the commit), force-pushes it to `origin`, and opens a PR. + +## Finding the target branch + +The channel→branch mapping changes every release. Find the current one by inspecting the most recent `cherry_pick` workflow runs: + +``` +gh run list --workflow=cherry_pick.yml --limit 30 --json displayTitle,databaseId +# pick a recent run for the channel you want, then: +gh run view --log 2>&1 | grep -E "BRANCH:|CHANNEL:" +``` + +A successful run prints both `BRANCH:` and `CHANNEL:` env vars; that's your mapping. + +## Procedure + +### 1. Gather context + +You need three things: the **merge commit SHA**, the **target branch**, and the **channel name**. + +If the user requested multiple PRs and/or commits, gather the metadata for all of them first and cherry-pick them in the order they landed on `main`, oldest to newest. For PRs, order by `mergedAt`; for raw commits, use their order on `main` when available, otherwise commit date. This tends to reduce avoidable conflicts because later changes may depend on earlier ones, but it does not guarantee a conflict-free cherry-pick when the release branch has diverged. + +``` +gh pr view --json title,number,mergeCommit,mergedAt,url +``` + +If the user said the workflow failed, fetch its log to see exactly which command failed and which file conflicted: + +``` +gh run list --workflow=cherry_pick.yml --limit 10 --json databaseId,displayTitle,status,conclusion +gh run view --log-failed +``` + +The failed-run log also confirms the `BRANCH` and `COMMIT` the workflow used — handy if there's any ambiguity. + +### 2. Reproduce the script's setup locally + +The repository may be a worktree (check `.git` — if it's a file, you're in a worktree pointing at a shared gitdir). That's fine; just operate normally. + +``` +git --no-pager fetch origin +git checkout --force origin/ -B cherry-pick-- +git cherry-pick +``` + +The branch name **must** match `cherry-pick--` exactly (script convention; reviewers and tooling expect it). + +### 3. Check for missing prerequisite cherry-picks + +If the cherry-pick conflicts, do not immediately resolve the conflicts manually. + +First determine whether the conflict is likely caused by other PRs or commits that are already on `main` but missing from the release branch. If so, point out those candidate prerequisite PRs/commits to the user, including PR links, and offer to either resolve the conflicts manually or let the user run the GitHub cherry-pick workflow for those commits first. + +If the user wants to run the workflow for the missing prerequisites, stop here. This often keeps cherry-picks clean and eligible for automatic approval. + +Only resolve conflicts manually if: +- no likely missing prerequisites are found, or +- the user chooses manual conflict resolution instead of cherry-picking the prerequisites first. + +### 4. Resolve the conflicts manually + +Do this only after checking for missing prerequisite cherry-picks. + +- Inspect every conflicted file with `grep -n '<<<<<<<\\|>>>>>>>\\|=======' ` to find the markers. +- Conflicts are usually `diff3` style with three sections: HEAD (release branch), `||||||| parent of ` (merge base on `main`), and the incoming change. +- Read the **original commit** (`git --no-pager show -- `) to understand the author's intent, then pick the resolution that produces the equivalent end state on the release branch. +- Don't grab unrelated changes from `main` that happen to surround the conflict — keep the cherry-pick minimal. + +### 5. Validate + +Always build and (if reasonable) test the affected crate(s) before continuing the cherry-pick. + +``` +cargo check -p +cargo test -p +``` + +If validation fails, fix the resolution — do **not** continue with a broken build. If you can't reach a clean state, abort with `git cherry-pick --abort` and report back to the user. + +### 6. Finish the cherry-pick + +`git cherry-pick --continue` opens an editor by default. Prevent that: + +``` +git add +GIT_EDITOR=true git cherry-pick --continue +``` + +This preserves the original commit message verbatim, which is what the script does. + +### 7. Push and open the PR + +``` +git push origin -f cherry-pick-- +``` + +Then create the PR with the **exact** title and body format `script/cherry-pick` uses, so it's indistinguishable from an automated one. + +**Title:** + +``` + (cherry-pick to ) +``` + +The original commit subject already ends in ` (#)`; keep it. + +**Body** (when the original commit title ends in `(#)`, which is the normal case): + +``` +Cherry-pick of # to + +---- + +``` + +Create it with `gh pr create`, writing the body to a temp file to keep formatting intact: + +``` +git --no-pager log -1 --pretty=format:"%b" > /tmp/cp-body-tail.md +printf 'Cherry-pick of #%s to %s\n\n----\n' | cat - /tmp/cp-body-tail.md > /tmp/cp-body.md +gh pr create --base --head cherry-pick-- \\ + --title " (cherry-pick to )" \\ + --body-file /tmp/cp-body.md +``` + +Do **not** add a `Release Notes:` section — the original commit body already has one (or already says `N/A`), and you don't want it duplicated. + +## Final report to the user + +Tell the user: +- The new PR URL. +- A one-line summary of the conflict and how you resolved it. +- What validation you ran (commands + result). +- That their local branch is now `cherry-pick--`, in case they want you to switch back. + +## Gotchas + +- **`--no-pager` and `GIT_EDITOR=true`**: required for non-interactive git in this environment. Forgetting `GIT_EDITOR=true` on `cherry-pick --continue` hangs the terminal. +- **Worktree index lock**: if a previous git command was interrupted, you may see `index.lock` errors. The lock lives at `/index.lock` where `` is what `cat .git` points to (for a worktree). Remove it only if you're sure no git process is running. +- **Don't expand the cherry-pick's scope**: when resolving conflicts, never pull in unrelated changes from `main` just because they sit next to the conflict region. The PR should be the smallest diff that reproduces the original commit's intent on the release branch. +- **Channel branches are not called `preview`/`stable`**: don't try to `git fetch origin preview`. Look up the actual `vX.Y.x` branch name first. + +## When Finished + +After everything is finished, the last thing to do is to provide a link to the opened pull request(s) for the cherry-pick(s). From 652f13c7ceec305f41f2e5bda28756a5cbe0f367 Mon Sep 17 00:00:00 2001 From: morgankrey Date: Wed, 27 May 2026 15:01:57 -0500 Subject: [PATCH 27/93] Document Gemini caching behavior (#57864) ## Summary - Document that Zed-hosted Gemini models do not use Google context caching - Clarify that Gemini usage is billed only as input and output tokens, with no cached-input price - Link to Google's Vertex AI context caching and zero-data-retention documentation for background ## Validation - `script/generate-action-metadata` - `mdbook build docs` Release Notes: - N/A --- docs/src/ai/models.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/ai/models.md b/docs/src/ai/models.md index e2d0c1c83cd..f582f25af01 100644 --- a/docs/src/ai/models.md +++ b/docs/src/ai/models.md @@ -91,6 +91,8 @@ As of February 19, 2026, Zed Pro serves newer model versions in place of the ret ## Usage {#usage} +Because Zed-hosted Gemini models do not use Google context caching, Gemini usage is billed only as input and output tokens; there is no separate cached-input price for these models. This preserves zero-data-retention behavior for hosted Gemini requests. For background, see Google's Vertex AI documentation on [context caching](https://cloud.google.com/vertex-ai/generative-ai/docs/context-cache/context-cache-overview) and [zero data retention](https://cloud.google.com/vertex-ai/generative-ai/docs/vertex-ai-zero-data-retention). + Any usage of a Zed-hosted model will be billed at the Zed Price (rightmost column above). See [Plans and Usage](./plans-and-usage.md) for details on Zed's plans and limits for use of hosted models. > LLMs can enter unproductive loops that require user intervention. Monitor longer-running tasks and interrupt if needed. From 887e3782a825f69b9fde22e7b1aa485a10ff288d Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 27 May 2026 17:02:48 -0300 Subject: [PATCH 28/93] Remove leftover test a11y label (#57868) Removes a leftover test label added by #56065 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/title_bar/src/title_bar.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index dea3e066ae7..73c183289a2 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -238,8 +238,6 @@ impl Render for TitleBar { } } - children.push(gpui::text!("Hello from a11y").into_any_element()); - children.push( h_flex() .h_full() From 6e58a927139545d9189067e7c653d212715abd9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Gait=C3=A1n?= Date: Wed, 27 May 2026 17:03:27 -0300 Subject: [PATCH 29/93] Fix typo in command palette reference (#57869) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- docs/src/ai/skills.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/ai/skills.md b/docs/src/ai/skills.md index f6093262aed..997e4e56f9f 100644 --- a/docs/src/ai/skills.md +++ b/docs/src/ai/skills.md @@ -17,7 +17,7 @@ Zed includes a built-in `create-skill` skill — invoke it with `/create-skill` You can also open the Skill Creator from the Agent Panel using {#kb agent::OpenRulesLibrary}, or by clicking `...` and selecting **Skills**. Outside the panel, use the {#action agent::OpenSkillCreator} action from the command palette. It opens a window where you fill in the skill's name, description, scope (global or project-local), body, and optionally toggle `disable-model-invocation`. -Lastly, it's also possible to add a skill through importing it from an existing GitHub Markdown file. Open the command paeltte and look for the {#action agent::CreateSkillFromUrl} action. If your clipboard contains a supported GitHub `.md` URL, Zed pre-fills and fetches it automatically. +Lastly, it's also possible to add a skill through importing it from an existing GitHub Markdown file. Open the command palette and look for the {#action agent::CreateSkillFromUrl} action. If your clipboard contains a supported GitHub `.md` URL, Zed pre-fills and fetches it automatically. See [Skill format](#skill-format) below for the full format reference. From 5ec1ce7cd09ea8120f2530c4ca9ad4058c3f436b Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 27 May 2026 16:35:32 -0400 Subject: [PATCH 30/93] Add sandbox crate with macOS Seatbelt integration (#57429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `sandbox` crate that wraps shell invocations under macOS's `sandbox-exec(1)` with a Seatbelt policy built from per-command permissions: - Reads are unrestricted. - Writes are restricted to a caller-provided list of directories (plus the standard `/dev/*` write targets). - Network access and unrestricted filesystem writes must be opted into per command. `wrap_invocation(program, args, writable_dirs, permissions)` returns the new program/args plus a `SeatbeltConfigFile` RAII handle that deletes the on-disk policy file when dropped — callers hold it for the lifetime of the spawned command. No callers yet — this is the first of three stacked PRs. The second wires the sandbox state into the agent's system prompt behind a feature flag; the third wires the actual wrapping into the agent terminal tool. The macOS-only dependencies (`tempfile`, `anyhow`) are gated by `target.'cfg(target_os = "macos")'` so the crate is empty on other platforms. Includes 14 tests covering both the generated Seatbelt policy text and end-to-end behavior (actually invoking `sandbox-exec` and asserting reads/writes succeed or fail per policy). Release Notes: - N/A --- Cargo.lock | 8 + Cargo.toml | 2 + crates/sandbox/Cargo.toml | 16 + crates/sandbox/LICENSE-GPL | 1 + crates/sandbox/src/macos_seatbelt.rs | 600 +++++++++++++++++++++++++++ crates/sandbox/src/sandbox.rs | 12 + 6 files changed, 639 insertions(+) create mode 100644 crates/sandbox/Cargo.toml create mode 120000 crates/sandbox/LICENSE-GPL create mode 100644 crates/sandbox/src/macos_seatbelt.rs create mode 100644 crates/sandbox/src/sandbox.rs diff --git a/Cargo.lock b/Cargo.lock index 10ad05d6611..eea3e113f85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16417,6 +16417,14 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sandbox" +version = "0.1.0" +dependencies = [ + "anyhow", + "tempfile", +] + [[package]] name = "scc" version = "3.5.6" diff --git a/Cargo.toml b/Cargo.toml index 05f551db006..d3ba378098e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -173,6 +173,7 @@ members = [ "crates/rope", "crates/rpc", "crates/rules_library", + "crates/sandbox", "crates/skill_creator", "crates/scheduler", "crates/schema_generator", @@ -436,6 +437,7 @@ rpc = { path = "crates/rpc" } rules_library = { path = "crates/rules_library" } skill_creator = { path = "crates/skill_creator" } scheduler = { path = "crates/scheduler" } +sandbox = { path = "crates/sandbox" } search = { path = "crates/search" } session = { path = "crates/session" } sidebar = { path = "crates/sidebar" } diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml new file mode 100644 index 00000000000..9e7e4aa1e0d --- /dev/null +++ b/crates/sandbox/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sandbox" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/sandbox.rs" + +[target.'cfg(target_os = "macos")'.dependencies] +anyhow.workspace = true +tempfile.workspace = true diff --git a/crates/sandbox/LICENSE-GPL b/crates/sandbox/LICENSE-GPL new file mode 120000 index 00000000000..89e542f750c --- /dev/null +++ b/crates/sandbox/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/sandbox/src/macos_seatbelt.rs b/crates/sandbox/src/macos_seatbelt.rs new file mode 100644 index 00000000000..0357ecb361b --- /dev/null +++ b/crates/sandbox/src/macos_seatbelt.rs @@ -0,0 +1,600 @@ +//! macOS Seatbelt sandbox integration. +//! +//! This module is specifically about Apple's Seatbelt sandbox API — the +//! macOS-only kernel-level sandboxing framework, accessed via the +//! `sandbox-exec(1)` command-line tool and a Seatbelt-specific config +//! file (a Scheme-like policy language documented in Apple's +//! `sandbox.h` and the `sandbox-exec` man page). +//! +//! The integration wraps a shell invocation by: +//! +//! 1. Generating a Seatbelt config file (a string of Scheme-like rules) +//! from the requested [`SandboxPermissions`]. +//! 2. Writing it to a temporary file on disk (a [`SeatbeltConfigFile`], +//! which cleans itself up when dropped). +//! 3. Returning the program/args needed to launch the original command +//! under `sandbox-exec -f `. +//! +//! Reads are permitted by default; writes are restricted to a caller- +//! provided list of directories; network access and unrestricted writes +//! must be opted into per command. + +use std::path::Path; +use std::{io::Write, path::PathBuf}; + +use anyhow::{Context, Result}; +use tempfile::NamedTempFile; + +/// Per-command relaxations of the default Seatbelt sandbox. +/// +/// All-false is the default, fully-sandboxed run. Setting any field +/// requires user approval before the command is launched. +/// +/// There are some baseline OS operations (e.g. arbitrary hardware access) +/// that are disallowed by Seatbelt's baseline policy regardless of these +/// flags; even with everything `true` here those operations stay denied. +/// The only way to allow them is to skip the sandbox entirely (which this +/// module deliberately doesn't expose). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SandboxPermissions { + /// Allow network access for the command. + pub allow_network: bool, + /// Allow unrestricted filesystem writes. + pub allow_fs_write: bool, +} + +/// A Seatbelt config file written to a temporary path on disk, suitable +/// for `sandbox-exec -f `. The file is deleted when this is dropped. +/// +/// The config-file content is the Scheme-like Seatbelt policy language +/// (see `sandbox-exec(1)` and the comments in macOS's `sandbox.h`); it's +/// generated from a [`SandboxPermissions`] by [`generate_seatbelt_config`]. +pub struct SeatbeltConfigFile { + /// The temporary file containing the Seatbelt config. + /// Kept alive so the file exists for the duration of the command. + _file: NamedTempFile, + /// Path to the temporary config file on disk. + path: PathBuf, +} + +impl SeatbeltConfigFile { + /// Generate a Seatbelt config from `permissions` and write it to a + /// fresh temporary file. + /// + /// `writable_directories` lists every directory subtree where the + /// command is allowed to write when `permissions.allow_fs_write` is + /// false. Pass the project's worktree paths here — not the working + /// directory of the command, since that is model-controlled and would + /// let the model widen its own writable scope. + pub fn new(writable_directories: &[&Path], permissions: SandboxPermissions) -> Result { + let mut file = + NamedTempFile::new().context("failed to create temporary Seatbelt config file")?; + + let config = generate_seatbelt_config(writable_directories, permissions)?; + file.write_all(config.as_bytes()) + .context("failed to write Seatbelt config")?; + file.flush().context("failed to flush Seatbelt config")?; + + let path = file.path().to_path_buf(); + + Ok(Self { _file: file, path }) + } +} + +/// Wrap a process invocation so it runs under macOS's `sandbox-exec(1)` +/// with a Seatbelt config built from `permissions`. +/// +/// Returns the new program and arguments to execute, along with a +/// [`SeatbeltConfigFile`] that **must** be kept alive for the duration of +/// the command (the file is deleted when dropped, and `sandbox-exec` reads +/// it lazily when the child process starts up). +/// +/// # Arguments +/// * `program` - The program to invoke (typically a shell, e.g. `"/bin/sh"`, +/// but anything that takes its arguments via `argv` works). +/// * `args` - The full argument list that would have been passed to +/// `program`. +/// * `writable_directories` - Directory subtrees where the command is +/// allowed to write when `permissions.allow_fs_write` is false. Pass +/// the project's worktree paths here, not the working directory of the +/// command (the working directory is model-controlled, and using it as +/// the writable scope would let the model write outside the project). +/// * `permissions` - Sandbox relaxations requested for this command. +/// +/// # Returns +/// A tuple of `(program, args, config_file)` where `config_file` must be +/// kept alive. +pub fn wrap_invocation( + program: &str, + args: &[String], + writable_directories: &[&Path], + permissions: SandboxPermissions, +) -> Result<(String, Vec, SeatbeltConfigFile)> { + let config_file = SeatbeltConfigFile::new(writable_directories, permissions)?; + + let mut wrapped_args = vec![ + "-f".to_string(), + config_file + .path + .to_str() + .with_context(|| { + format!( + "Seatbelt config file path contains invalid UTF-8: {}", + config_file.path.display() + ) + })? + .to_string(), + program.to_string(), + ]; + wrapped_args.extend(args.iter().cloned()); + + Ok(( + "/usr/bin/sandbox-exec".to_string(), + wrapped_args, + config_file, + )) +} + +/// Generate a Seatbelt config string that reads everywhere by default. +/// Writes to each entry in `writable_directories` (typically the project's +/// worktree paths plus any per-command scratch directory the caller wants +/// allowed) and the standard `/dev/*` write targets are also allowed by +/// default; network access and unrestricted filesystem writes must be +/// requested via [`SandboxPermissions`]. +/// +/// The returned string is the textual content to write to the +/// [`SeatbeltConfigFile`] passed to `sandbox-exec -f`. +fn generate_seatbelt_config( + writable_directories: &[&Path], + permissions: SandboxPermissions, +) -> Result { + // Canonicalize each writable path to resolve symlinks (e.g., + // /var -> /private/var on macOS). Fall back to the original path if + // canonicalization fails. + let canonical_writable_directories: Vec = writable_directories + .iter() + .map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf())) + .collect(); + + let mut config = r#"(version 1) + +; Start by denying everything +(deny default) + +; Allow reading from the entire filesystem +(allow file-read*) + +; Allow process execution +(allow process-exec*) +(allow process-fork) + +; Allow signal handling +(allow signal) + +; Allow sysctl reads (needed for many system calls) +(allow sysctl-read) + +; Allow mach lookups (needed for IPC) +(allow mach-lookup) + +; Allow pseudo-terminal operations +(allow pseudo-tty) +"# + .to_string(); + + if permissions.allow_fs_write { + config.push_str( + r#" +; Allow unrestricted filesystem writes +(allow file-write*) +"#, + ); + } else { + for canonical_path in &canonical_writable_directories { + let escaped_path = escape_sandbox_path(canonical_path)?; + config.push_str(&format!( + r#" +; Allow writing to a permitted directory +(allow file-write* + (subpath "{escaped_path}")) +"# + )); + } + + config.push_str( + r#" +; Allow writing to common /dev paths (needed for redirections like 2>/dev/null) +(allow file-write* + (literal "/dev/null") + (literal "/dev/zero") + (literal "/dev/tty") + (literal "/dev/stdin") + (literal "/dev/stdout") + (literal "/dev/stderr") + (subpath "/dev/fd")) +"#, + ); + } + + if permissions.allow_network { + config.push_str( + r#" +; Allow network access +(allow network*) +"#, + ); + } + + Ok(config) +} + +/// Escape a path for use in a Seatbelt config string. +/// +/// Seatbelt configs use a Scheme-like syntax where certain characters need +/// to be handled carefully. +fn escape_sandbox_path(path: &Path) -> Result { + let path_str = path + .to_str() + .with_context(|| format!("path contains invalid UTF-8: {}", path.display()))?; + Ok(path_str.replace('\\', "\\\\").replace('"', "\\\"")) +} + +#[cfg(test)] +#[allow( + clippy::disallowed_methods, + reason = "tests run sandbox-exec synchronously to verify the generated Seatbelt config" +)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_generate_seatbelt_config_contains_read_and_project_write_permissions_by_default() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let config = + generate_seatbelt_config(&[dir.as_path()], SandboxPermissions::default()).unwrap(); + + assert!(config.contains("(allow file-read*)")); + assert!(config.contains("/Users/test/projects/myproject")); + assert!(config.contains("(allow file-write*")); + assert!(!config.contains("; Allow unrestricted filesystem writes")); + assert!(!config.contains("(allow network*)")); + } + + #[test] + fn test_generate_seatbelt_config_allows_unrestricted_writes_when_fs_writes_allowed() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let config = generate_seatbelt_config( + &[dir.as_path()], + SandboxPermissions { + allow_network: false, + allow_fs_write: true, + }, + ) + .unwrap(); + + assert!(config.contains("(allow file-read*)")); + assert!(config.contains("; Allow unrestricted filesystem writes")); + assert!(config.contains("(allow file-write*)")); + assert!(!config.contains("/Users/test/projects/myproject")); + assert!(!config.contains("(allow network*)")); + } + + #[test] + fn test_generate_seatbelt_config_contains_network_when_allowed() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let config = generate_seatbelt_config( + &[dir.as_path()], + SandboxPermissions { + allow_network: true, + allow_fs_write: false, + }, + ) + .unwrap(); + + assert!(config.contains("(allow network*)")); + assert!(config.contains("/Users/test/projects/myproject")); + assert!(config.contains("(allow file-write*")); + assert!(!config.contains("; Allow unrestricted filesystem writes")); + } + + #[test] + fn test_generate_seatbelt_config_emits_one_subpath_per_writable_directory() { + let project_dir = PathBuf::from("/Users/test/projects/myproject"); + let scratch_dir = PathBuf::from("/private/tmp/zed-agent-command"); + let config = generate_seatbelt_config( + &[project_dir.as_path(), scratch_dir.as_path()], + SandboxPermissions::default(), + ) + .unwrap(); + + assert!(config.contains("/Users/test/projects/myproject")); + assert!(config.contains("/private/tmp/zed-agent-command")); + assert!(!config.contains("; Allow unrestricted filesystem writes")); + assert!(!config.contains("(allow network*)")); + } + + #[test] + fn test_escape_sandbox_path_handles_special_chars() { + let path = PathBuf::from("/path/with\"quotes"); + let escaped = escape_sandbox_path(&path).unwrap(); + assert_eq!(escaped, "/path/with\\\"quotes"); + } + + #[cfg(unix)] + #[test] + fn test_escape_sandbox_path_rejects_invalid_utf8() { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + let path = PathBuf::from(OsString::from_vec(b"/path/with/invalid/\xFF".to_vec())); + let error = escape_sandbox_path(&path).unwrap_err(); + + assert!(error.to_string().contains("invalid UTF-8")); + } + + #[test] + fn test_wrap_invocation_structure() { + let temp_dir = tempfile::tempdir().unwrap(); + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &["-c".to_string(), "echo hello".to_string()], + &[temp_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + assert_eq!(program, "/usr/bin/sandbox-exec"); + assert_eq!(args[0], "-f"); + // args[1] is the temp file path + assert_eq!(args[2], "/bin/sh"); + assert_eq!(args[3], "-c"); + assert_eq!(args[4], "echo hello"); + } + + #[test] + fn test_sandbox_allows_read_everywhere() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &["-c".to_string(), "cat /etc/hosts".to_string()], + &[temp_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow reading /etc/hosts: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn test_sandbox_allows_dev_null_redirection_by_default() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &["-c".to_string(), "echo test 2>/dev/null".to_string()], + &[temp_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow redirecting to /dev/null by default: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn test_sandbox_allows_dev_null_redirection_when_fs_writes_allowed() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &["-c".to_string(), "echo test 2>/dev/null".to_string()], + &[temp_dir.path()], + SandboxPermissions { + allow_network: false, + allow_fs_write: true, + }, + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow redirecting to /dev/null: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn test_sandbox_allows_write_to_project_directory_when_fs_writes_allowed() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let test_file = temp_dir.path().join("test_write.txt"); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", test_file.display()), + ], + &[temp_dir.path()], + SandboxPermissions { + allow_network: false, + allow_fs_write: true, + }, + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow writing to project dir: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(test_file.exists(), "file should have been created"); + } + + #[test] + fn test_sandbox_allows_write_to_any_listed_writable_directory() { + use std::process::Command; + + let project_dir = tempfile::tempdir().unwrap(); + let scratch_dir = tempfile::tempdir().unwrap(); + let test_file = scratch_dir.path().join("test_write.txt"); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", test_file.display()), + ], + &[project_dir.path(), scratch_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow writing to a non-first writable directory: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(test_file.exists(), "file should have been created"); + } + + #[test] + fn test_sandbox_allows_write_to_project_directory_by_default() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let test_file = temp_dir.path().join("test_write.txt"); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", test_file.display()), + ], + &[temp_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow writing to project dir by default: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(test_file.exists(), "file should have been created"); + } + + #[test] + fn test_sandbox_allows_write_to_system_tmp_when_fs_writes_allowed() { + use std::process::Command; + + let project_dir = tempfile::tempdir().unwrap(); + let test_file = PathBuf::from("/tmp/zed-sandbox-write-test"); + let _ = std::fs::remove_file(&test_file); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", test_file.display()), + ], + &[project_dir.path()], + SandboxPermissions { + allow_network: false, + allow_fs_write: true, + }, + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow writing to system tmp when filesystem writes are allowed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(test_file.exists(), "file should have been created"); + let _ = std::fs::remove_file(&test_file); + } + + #[test] + fn test_sandbox_denies_write_outside_project_directory_by_default() { + use std::process::Command; + + let project_dir = tempfile::tempdir().unwrap(); + let forbidden_file = std::env::home_dir() + .unwrap() + .join(".zed-sandbox-forbidden-write-test"); + let _ = std::fs::remove_file(&forbidden_file); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", forbidden_file.display()), + ], + &[project_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + !output.status.success(), + "sandbox should deny writing outside project dir when filesystem writes are not allowed" + ); + assert!( + !forbidden_file.exists(), + "file should not have been created" + ); + } +} diff --git a/crates/sandbox/src/sandbox.rs b/crates/sandbox/src/sandbox.rs new file mode 100644 index 00000000000..64095d11da2 --- /dev/null +++ b/crates/sandbox/src/sandbox.rs @@ -0,0 +1,12 @@ +//! Per-OS sandbox integrations for terminal commands run on behalf of the +//! agent. +//! +//! Each supported operating system has its own module here, gated behind +//! its `target_os` cfg so callers reach for the right one explicitly and +//! non-host targets don't carry dead code. +//! +//! Today only macOS has an integration ([`macos_seatbelt`]), wrapping +//! Apple's Seatbelt / `sandbox-exec` framework. + +#[cfg(target_os = "macos")] +pub mod macos_seatbelt; From c3b9cacc0e90276495f372cf6ecc76f49cfbdea6 Mon Sep 17 00:00:00 2001 From: Albert Bogusz Date: Wed, 27 May 2026 21:59:01 +0100 Subject: [PATCH 31/93] Update git2 to 0.21.0 and add support for SHA-256 object formatted repos (#57587) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] ~Unsafe blocks (if any) have justifying comments~ (N/A) - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior (didn't add new test for parsing SHA-256 - not sure if would be desired) - [x] Performance impact has been considered and is acceptable Closes #24070 Upgrades git2 from 0.20.1 to 0.21.0 with the `unstable-sha256` feature - adds ability to open and work with git repositories using the SHA-256 object format. `Oid::from_str` now detects 64-char hex strings to parse SHA-256 OIDs correctly. Also adapts to breaking API changes in 0.21.0: `Remote::url()` and `Commit::message()` both now return `Result`. Release Notes: - Added support for opening SHA-256 object format git repositories --- Cargo.lock | 9 ++++----- Cargo.toml | 2 +- crates/git/src/git.rs | 11 +++++++---- crates/git/src/repository.rs | 2 +- crates/project/tests/integration/project_tests.rs | 2 +- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eea3e113f85..f94dc9ab436 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7642,15 +7642,14 @@ dependencies = [ [[package]] name = "git2" -version = "0.20.4" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ "bitflags 2.10.0", "libc", "libgit2-sys", "log", - "url", ] [[package]] @@ -10415,9 +10414,9 @@ dependencies = [ [[package]] name = "libgit2-sys" -version = "0.18.3+1.9.2" +version = "0.18.4+1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +checksum = "9b26f66f35e1871b22efcf7191564123d2a446ca0538cde63c23adfefa9b15b7" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index d3ba378098e..521128c90df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -599,7 +599,7 @@ futures = "0.3.32" futures-concurrency = "7.7.1" futures-lite = "1.13" gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "37f3c0575d379c218a9c455ee67585184e40d43f" } -git2 = { version = "0.20.1", default-features = false, features = ["vendored-libgit2"] } +git2 = { version = "0.21.0", default-features = false, features = ["vendored-libgit2", "unstable-sha256"] } globset = "0.4" heapless = "0.9.2" handlebars = "4.3" diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index cc3fbe1e259..73ba6275756 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -179,9 +179,12 @@ impl FromStr for Oid { type Err = anyhow::Error; fn from_str(s: &str) -> std::prelude::v1::Result { - libgit::Oid::from_str(s) - .context("parsing git oid") - .map(Self) + let oid = if s.len() == 64 { + libgit::Oid::from_str_ext(s, libgit::ObjectFormat::Sha256) + } else { + libgit::Oid::from_str_ext(s, libgit::ObjectFormat::Sha1) + }; + oid.context("parsing git oid").map(Self) } } @@ -218,7 +221,7 @@ impl<'de> Deserialize<'de> for Oid { impl Default for Oid { fn default() -> Self { - Self(libgit::Oid::zero()) + Self(libgit::Oid::ZERO_SHA1) } } diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 6bd5e63d82d..88db950669c 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1672,7 +1672,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let repo = repo.lock(); let remote = repo.find_remote(&name).ok()?; - remote.url().map(|url| url.to_string()) + remote.url().ok().map(|url| url.to_string()) }) .boxed() } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 340d32cea13..e95e3b7e6c5 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -12738,7 +12738,7 @@ fn git_reset(offset: usize, repo: &git2::Repository) { let new_head = commit .parents() .inspect(|parnet| { - parnet.message(); + let _ = parnet.message(); }) .nth(offset) .expect("Not enough history"); From ffbda20db176f65402ab9f61b723fa1e435df81e Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 27 May 2026 17:11:27 -0400 Subject: [PATCH 32/93] Render terminal sandbox section in system prompt behind feature flag (#57430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on top of #57429. Adds a new `sandboxing` feature flag (off for staff by default) and a single source-of-truth helper `sandboxing_enabled(cx) = cfg!(target_os = "macos") && cx.has_flag::()`. When the helper returns true, the agent's system prompt gains a new `## Terminal sandbox` section that: - Lists each worktree's absolute path as a writable directory. - Describes the per-command `$TMPDIR` scratch directory. - States that outbound network access is blocked. - Documents the three per-command flags (`allow_network`, `allow_fs_write`, `unsandboxed`) the model can request to relax the sandbox. - Tells the model the section is stable for the duration of the conversation. When the flag is off, the section is omitted entirely — no mention of sandboxing at all. No behavior change to terminal execution yet; that's the next PR in the stack. Three new tests cover: section omitted when `sandboxing: false`, section rendered with all worktrees + flag docs when `sandboxing: true`, and the zero-worktrees case. Release Notes: - N/A --------- Co-authored-by: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com> Co-authored-by: Martin Ye --- crates/agent/src/agent.rs | 1 + crates/agent/src/sandboxing.rs | 25 ++++++ crates/agent/src/templates.rs | 84 +++++++++++++++++++ crates/agent/src/templates/system_prompt.hbs | 18 ++++ crates/agent/src/thread.rs | 1 + crates/agent/src/tools/evals/edit_file.rs | 1 + crates/agent/src/tools/evals/terminal_tool.rs | 1 + crates/agent/src/tools/evals/write_file.rs | 1 + crates/feature_flags/src/flags.rs | 15 ++++ 9 files changed, 147 insertions(+) create mode 100644 crates/agent/src/sandboxing.rs diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index c08517d7df3..d1b6cfd305a 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -3,6 +3,7 @@ mod legacy_thread; mod native_agent_server; pub mod outline; mod pattern_extraction; +mod sandboxing; mod templates; #[cfg(test)] mod tests; diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs new file mode 100644 index 00000000000..a0a6a17377f --- /dev/null +++ b/crates/agent/src/sandboxing.rs @@ -0,0 +1,25 @@ +//! Agent-side glue for the [`sandbox`] crate. +//! +//! Centralizes the "should agent-run terminal commands be sandboxed for this +//! process?" check so the system prompt, the terminal tool, and any other +//! caller see the same answer (and so the `target_os` gate lives in one +//! place instead of scattered across the agent crate). +//! +//! The current policy is: enabled iff we're on macOS *and* the user has the +//! `sandboxing` feature flag turned on. There's deliberately no settings or +//! env-var override yet — the flag is the only switch. +//! +//! On non-macOS hosts we don't have a sandbox integration today, so this +//! returns `false` regardless of the flag. +//! +//! Naming note: this module is about agent terminal sandboxing specifically. +//! Other agent operations (e.g. file edits) are gated separately. + +use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag}; +use gpui::App; + +/// Whether agent-run terminal commands should be wrapped in an OS-level +/// sandbox for this process. See module docs for the policy. +pub(crate) fn sandboxing_enabled(cx: &App) -> bool { + cfg!(target_os = "macos") && cx.has_flag::() +} diff --git a/crates/agent/src/templates.rs b/crates/agent/src/templates.rs index 2f8dda39c75..f576578d824 100644 --- a/crates/agent/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -43,6 +43,12 @@ pub struct SystemPromptTemplate<'a> { /// Contents of the user-global `~/.config/zed/AGENTS.md` file (or the /// platform equivalent), if present and non-empty. pub user_agents_md: Option, + /// Whether agent-run terminal commands are wrapped in an OS-level + /// sandbox for this conversation. When `true`, the rendered prompt + /// describes the sandbox's read/write/network rules and the + /// per-command flags the model can request to relax them. When + /// `false`, the prompt omits the sandbox section entirely. + pub sandboxing: bool, } impl Template for SystemPromptTemplate<'_> { @@ -87,6 +93,7 @@ mod tests { model_name: Some("test-model".to_string()), date: "2026-01-01".to_string(), user_agents_md: None, + sandboxing: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); @@ -119,6 +126,7 @@ mod tests { model_name: Some("test-model".to_string()), date: "2026-01-01".to_string(), user_agents_md: Some("always be concise".into()), + sandboxing: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); @@ -136,6 +144,80 @@ mod tests { ); } + #[test] + fn test_system_prompt_omits_sandbox_section_when_sandboxing_disabled() { + let project = prompt_store::ProjectContext::default(); + let template = SystemPromptTemplate { + project: &project, + available_tools: vec!["echo".into()], + model_name: Some("test-model".to_string()), + date: "2026-01-01".to_string(), + user_agents_md: None, + sandboxing: false, + }; + let templates = Templates::new(); + let rendered = template.render(&templates).unwrap(); + assert!(!rendered.contains("## Terminal sandbox")); + assert!(!rendered.contains("allow_network")); + } + + #[test] + fn test_system_prompt_renders_sandbox_section_with_worktrees_when_enabled() { + use prompt_store::{ProjectContext, WorktreeContext}; + + let worktrees = vec![ + WorktreeContext { + root_name: "alpha".to_string(), + abs_path: std::path::Path::new("/tmp/alpha").into(), + rules_file: None, + }, + WorktreeContext { + root_name: "beta".to_string(), + abs_path: std::path::Path::new("/tmp/beta").into(), + rules_file: None, + }, + ]; + let project = ProjectContext::new(worktrees); + let template = SystemPromptTemplate { + project: &project, + available_tools: vec!["echo".into()], + model_name: Some("test-model".to_string()), + date: "2026-01-01".to_string(), + user_agents_md: None, + sandboxing: true, + }; + let templates = Templates::new(); + let rendered = template.render(&templates).unwrap(); + + assert!(rendered.contains("## Terminal sandbox")); + assert!(rendered.contains("`/tmp/alpha`")); + assert!(rendered.contains("`/tmp/beta`")); + assert!(rendered.contains("allow_network: true")); + assert!(rendered.contains("allow_fs_write: true")); + assert!(rendered.contains("unsandboxed: true")); + // The model is told the section is stable so it doesn't re-check + // sandbox state every turn. + assert!(rendered.contains("remain in effect for the entire duration")); + } + + #[test] + fn test_system_prompt_sandbox_section_handles_zero_worktrees() { + let project = prompt_store::ProjectContext::default(); + let template = SystemPromptTemplate { + project: &project, + available_tools: vec!["echo".into()], + model_name: Some("test-model".to_string()), + date: "2026-01-01".to_string(), + user_agents_md: None, + sandboxing: true, + }; + let templates = Templates::new(); + let rendered = template.render(&templates).unwrap(); + + assert!(rendered.contains("## Terminal sandbox")); + assert!(rendered.contains("No project directories are currently writable")); + } + #[test] fn test_system_prompt_omits_user_agents_md_section_when_absent() { let project = prompt_store::ProjectContext::default(); @@ -145,6 +227,7 @@ mod tests { model_name: Some("test-model".to_string()), date: "2026-01-01".to_string(), user_agents_md: None, + sandboxing: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); @@ -160,6 +243,7 @@ mod tests { model_name: Some("test-model".to_string()), date: "2026-01-01".to_string(), user_agents_md: None, + sandboxing: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 8e90d0970eb..b955879f180 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -187,6 +187,24 @@ The current project contains the following root directories: - `{{abs_path}}` {{/each}} +{{#if sandboxing}} +## Terminal sandbox + +The `terminal` tool runs commands inside a sandbox with these permissions: + +- Reads: any path on the filesystem is readable. +- Writes: a per-command temporary directory exposed via `$TMPDIR`, `$TMP`, and `$TEMP` is writable{{#if worktrees}}, along with these project directories: +{{#each worktrees}} + - `{{abs_path}}` +{{/each}} + Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} +- Network: outbound network access is blocked. + +You can request elevated permissions on individual `terminal` calls by setting `allow_network: true`, `allow_fs_write: true`, or `unsandboxed: true`. The user will be prompted to approve before the command runs. + +These sandbox settings are guaranteed to remain in effect for the entire duration of this conversation. If they ever change, you'll be told. + +{{/if}} {{#if model_name}} ## Model Information diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 593a9f6de4c..4c1aa3877e0 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -3171,6 +3171,7 @@ impl Thread { model_name: self.model.as_ref().map(|m| m.name().0.to_string()), date: Local::now().format("%Y-%m-%d").to_string(), user_agents_md, + sandboxing: crate::sandboxing::sandboxing_enabled(cx), } .render(&self.templates) .context("failed to build system prompt") diff --git a/crates/agent/src/tools/evals/edit_file.rs b/crates/agent/src/tools/evals/edit_file.rs index 8a5802c8182..3a821737fad 100644 --- a/crates/agent/src/tools/evals/edit_file.rs +++ b/crates/agent/src/tools/evals/edit_file.rs @@ -372,6 +372,7 @@ impl EditToolTest { model_name: None, date: chrono::Local::now().format("%Y-%m-%d").to_string(), user_agents_md: None, + sandboxing: false, }; let templates = Templates::new(); template.render(&templates)? diff --git a/crates/agent/src/tools/evals/terminal_tool.rs b/crates/agent/src/tools/evals/terminal_tool.rs index 80856b5a8c8..2441b5014f5 100644 --- a/crates/agent/src/tools/evals/terminal_tool.rs +++ b/crates/agent/src/tools/evals/terminal_tool.rs @@ -231,6 +231,7 @@ impl TerminalToolTest { model_name: None, date: chrono::Local::now().format("%Y-%m-%d").to_string(), user_agents_md: None, + sandboxing: false, }; template.render(&Templates::new())? }; diff --git a/crates/agent/src/tools/evals/write_file.rs b/crates/agent/src/tools/evals/write_file.rs index cc998c7da9e..b038bf1f8bf 100644 --- a/crates/agent/src/tools/evals/write_file.rs +++ b/crates/agent/src/tools/evals/write_file.rs @@ -202,6 +202,7 @@ impl WriteToolTest { model_name: None, date: chrono::Local::now().format("%Y-%m-%d").to_string(), user_agents_md: None, + sandboxing: false, }; let templates = Templates::new(); template.render(&templates)? diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 1c24ac71f7d..c6f9c6fc51c 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -135,3 +135,18 @@ impl FeatureFlag for AutoWatchFeatureFlag { type Value = PresenceFlag; } register_feature_flag!(AutoWatchFeatureFlag); + +/// Wraps agent-run terminal commands in an OS-level sandbox where supported +/// (currently macOS Seatbelt only). When off, terminal commands run with the +/// agent's full ambient permissions, as they always have. +pub struct SandboxingFeatureFlag; + +impl FeatureFlag for SandboxingFeatureFlag { + const NAME: &'static str = "sandboxing"; + type Value = PresenceFlag; + + fn enabled_for_staff() -> bool { + false + } +} +register_feature_flag!(SandboxingFeatureFlag); From f0341c96a17630e5dfb441701441945dbb2a605a Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 27 May 2026 17:38:17 -0400 Subject: [PATCH 33/93] Wrap agent terminal commands in macOS Seatbelt sandbox (#57431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on top of #57430. When the `sandboxing` feature flag is on (macOS only), agent-run terminal commands are launched under `/usr/bin/sandbox-exec` with a per-command Seatbelt policy: - **Reads:** any path on the filesystem. - **Writes:** each project worktree plus a per-command `$TMPDIR`. - **Network:** blocked. The model can request relaxations on individual calls via three new input flags on the `terminal` tool: - `allow_network: true` - `allow_fs_write: true` - `unsandboxed: true` Each one triggers a separate always-prompt user approval (bypassing any `always_allow` rules, since escalation is a stronger trust boundary than the baseline command approval). The flags are silently ignored when sandboxing is off, so the model can't surreptitiously change runtime behavior by setting them in the no-op case. The per-command tempdir is provisioned regardless of sandbox state so the model can't infer whether the sandbox is in effect by looking at `$TMPDIR`. The Seatbelt config file's lifetime is tied to the `Terminal` entity via an opaque RAII handle (`SandboxConfigHandle = Box`), so cancellation paths drop it automatically. ### Notes for review - The writable scope passed to the sandbox is the project's worktree paths plus the per-command tempdir, **not** the resolved `cd` working directory — `cd` is model-controlled, and using it as the writable scope would let the model widen its own write permissions outside the project. - The new tool flags are always present in the JSON schema (with `#[serde(default)]`), even when the sandbox prompt section isn't rendered. The system prompt only documents them when the section is present, so the model shouldn't try to use them when it's not — but the schema doesn't actively reject them, just ignores them. - I opted not to add an end-to-end test for the escalation prompt path: the existing `FakeThreadEnvironment` ignores the new params and toggling the feature flag in tests is more wiring than felt worth it for a first cut. The pure-function pieces (`sandbox_approval_title`, schema, deserialization) are covered, and the sandbox crate itself has end-to-end tests that actually invoke `sandbox-exec`. Release Notes: - N/A --------- Co-authored-by: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com> --- Cargo.lock | 1 + crates/acp_thread/Cargo.toml | 1 + crates/acp_thread/src/acp_thread.rs | 7 + crates/acp_thread/src/terminal.rs | 82 ++++++++ crates/agent/Cargo.toml | 2 +- crates/agent/src/agent.rs | 12 +- crates/agent/src/tests/mod.rs | 10 + crates/agent/src/thread.rs | 2 + crates/agent/src/tools/terminal_tool.rs | 236 +++++++++++++++++++++++- 9 files changed, 348 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f94dc9ab436..0411aa04340 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,7 @@ dependencies = [ "project", "prompt_store", "rand 0.9.4", + "sandbox", "serde", "serde_json", "settings", diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 9123c301079..d115b29b1f6 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -39,6 +39,7 @@ image.workspace = true portable-pty.workspace = true project.workspace = true prompt_store.workspace = true +sandbox.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 4e6be0fe6a1..3270bac05f4 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -2955,6 +2955,7 @@ impl AcpThread { extra_env: Vec, cwd: Option, output_byte_limit: Option, + sandbox_wrap: Option, cx: &mut Context, ) -> Task>> { let env = match &cwd { @@ -2995,6 +2996,8 @@ impl AcpThread { ShellBuilder::new(&Shell::Program(shell), is_windows) .redirect_stdin_to_dev_null() .build(Some(command.clone()), &args); + let (task_command, task_args, sandbox_config) = + apply_sandbox_wrap(task_command, task_args, sandbox_wrap)?; let terminal = project .update(cx, |project, cx| { project.create_terminal_task( @@ -3018,6 +3021,7 @@ impl AcpThread { output_byte_limit.map(|l| l as usize), terminal, language_registry, + sandbox_config, cx, ) })) @@ -3097,6 +3101,9 @@ impl AcpThread { output_byte_limit.map(|l| l as usize), terminal, language_registry, + // External terminal providers manage their own sandboxing + // (if any). We don't wrap their commands. + None, cx, ) }); diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 2fe769cb737..32496b8f446 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -17,6 +17,82 @@ use std::{ use task::Shell; use util::get_default_system_shell_preferring_bash; +/// Request to run a terminal command inside an OS-level sandbox. +/// +/// Passed to [`super::AcpThread::create_terminal`]. The actual sandboxing +/// mechanism is platform-specific (today: macOS Seatbelt; nothing on other +/// platforms — the wrap is silently a no-op there), so callers describe the +/// *intent* with plain data here rather than constructing platform-specific +/// types directly. +/// +/// All-zero defaults are the fully-sandboxed run. Setting `allow_network` / +/// `allow_fs_write` requests a relaxation; the caller is responsible for +/// having obtained user approval before reaching this point. +#[derive(Clone, Debug, Default)] +pub struct SandboxWrap { + /// Directory subtrees the sandbox should allow writes to. Pass the + /// project's worktree paths (and any per-command scratch directory) + /// here — *not* the command's working directory, which is model- + /// controlled and would let the model widen its own writable scope. + pub writable_paths: Vec, + /// Allow outbound network access for this command. + pub allow_network: bool, + /// Allow unrestricted filesystem writes (ignores `writable_paths`). + pub allow_fs_write: bool, +} + +/// Opaque RAII handle the sandbox implementation hands back to keep its +/// per-command resources (e.g. an on-disk Seatbelt config file) alive for +/// the duration of the spawned command. `Terminal` holds it in a field +/// whose only job is to drop with the entity. +pub type SandboxConfigHandle = Box; + +/// Apply a [`SandboxWrap`] to a `(program, args)` pair, substituting the +/// platform's sandbox-launcher invocation in place of the original. The +/// returned `SandboxConfigHandle` (when `Some`) must be kept alive for the +/// duration of the spawned command — dropping it deletes any on-disk +/// config the launcher reads at startup. +/// +/// On non-macOS hosts this is a no-op: the inputs pass through unchanged +/// and the returned handle is `None`. (We don't yet have a sandbox +/// integration for other platforms.) +pub(crate) fn apply_sandbox_wrap( + program: String, + args: Vec, + sandbox_wrap: Option, +) -> anyhow::Result<(String, Vec, Option)> { + let Some(sandbox_wrap) = sandbox_wrap else { + return Ok((program, args, None)); + }; + + #[cfg(target_os = "macos")] + { + let writable: Vec<&std::path::Path> = sandbox_wrap + .writable_paths + .iter() + .map(|p| p.as_path()) + .collect(); + let permissions = sandbox::macos_seatbelt::SandboxPermissions { + allow_network: sandbox_wrap.allow_network, + allow_fs_write: sandbox_wrap.allow_fs_write, + }; + let (new_program, new_args, config_file) = + sandbox::macos_seatbelt::wrap_invocation(&program, &args, &writable, permissions)?; + Ok(( + new_program, + new_args, + Some(Box::new(config_file) as SandboxConfigHandle), + )) + } + #[cfg(not(target_os = "macos"))] + { + // No sandbox integration available; ignore the wrap request and + // let the command run with the agent's ambient permissions. + let _ = sandbox_wrap; + Ok((program, args, None)) + } +} + pub struct Terminal { id: acp::TerminalId, command: Entity, @@ -30,6 +106,10 @@ pub struct Terminal { /// (e.g., clicking the Stop button). This is set before kill() is called /// so that code awaiting wait_for_exit() can check it deterministically. user_stopped: Arc, + /// RAII handle kept alive for the duration of the sandboxed command. + /// `None` when the command isn't sandboxed (the common case for + /// terminals not created by the agent). + _sandbox_config: Option, } pub struct TerminalOutput { @@ -48,11 +128,13 @@ impl Terminal { output_byte_limit: Option, terminal: Entity, language_registry: Arc, + sandbox_config: Option, cx: &mut Context, ) -> Self { let command_task = terminal.read(cx).wait_for_completed_task(cx); Self { id, + _sandbox_config: sandbox_config, command: cx.new(|cx| { Markdown::new( format!("```\n{}\n```", command_label).into(), diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index f88d8286535..8c36cff81d0 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -65,6 +65,7 @@ streaming_diff.workspace = true strsim.workspace = true task.workspace = true telemetry.workspace = true +tempfile.workspace = true text.workspace = true thiserror.workspace = true ui.workspace = true @@ -100,7 +101,6 @@ project = { workspace = true, "features" = ["test-support"] } rand.workspace = true reqwest_client.workspace = true settings = { workspace = true, "features" = ["test-support"] } -tempfile.workspace = true theme = { workspace = true, "features" = ["test-support"] } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index d1b6cfd305a..dd240317606 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -2610,12 +2610,22 @@ impl ThreadEnvironment for NativeThreadEnvironment { fn create_terminal( &self, command: String, + extra_env: Vec, cwd: Option, output_byte_limit: Option, + sandbox_wrap: Option, cx: &mut AsyncApp, ) -> Task>> { let task = self.acp_thread.update(cx, |thread, cx| { - thread.create_terminal(command, vec![], vec![], cwd, output_byte_limit, cx) + thread.create_terminal( + command, + vec![], + extra_env, + cwd, + output_byte_limit, + sandbox_wrap, + cx, + ) }); let acp_thread = self.acp_thread.clone(); diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index e75cfc4fac4..16faa56c786 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -200,8 +200,10 @@ impl crate::ThreadEnvironment for FakeThreadEnvironment { fn create_terminal( &self, _command: String, + _extra_env: Vec, _cwd: Option, _output_byte_limit: Option, + _sandbox_wrap: Option, _cx: &mut AsyncApp, ) -> Task>> { self.terminal_creations.fetch_add(1, Ordering::SeqCst); @@ -242,8 +244,10 @@ impl crate::ThreadEnvironment for MultiTerminalEnvironment { fn create_terminal( &self, _command: String, + _extra_env: Vec, _cwd: Option, _output_byte_limit: Option, + _sandbox_wrap: Option, cx: &mut AsyncApp, ) -> Task>> { let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx))); @@ -320,6 +324,7 @@ async fn test_terminal_tool_timeout_kills_handle(cx: &mut TestAppContext) { command: "sleep 1000".to_string(), cd: ".".to_string(), timeout_ms: Some(5), + ..Default::default() }), event_stream, cx, @@ -387,6 +392,7 @@ async fn test_terminal_tool_without_timeout_does_not_kill_handle(cx: &mut TestAp command: "sleep 1000".to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -4892,6 +4898,7 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) { command: "rm -rf /".to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -4944,6 +4951,7 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) { command: "echo hello".to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -5002,6 +5010,7 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) { command: "sudo rm file".to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -5049,6 +5058,7 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) { command: "echo hello".to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 4c1aa3877e0..0202e2efa8b 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -668,8 +668,10 @@ pub trait ThreadEnvironment { fn create_terminal( &self, command: String, + extra_env: Vec, cwd: Option, output_byte_limit: Option, + sandbox_wrap: Option, cx: &mut AsyncApp, ) -> Task>>; diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 4f0c6b48c80..cb20662526b 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -14,6 +14,7 @@ use std::{ time::Duration, }; +use crate::sandboxing::sandboxing_enabled; use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream, ToolInput}; const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; @@ -39,7 +40,7 @@ const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; /// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`). /// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`). /// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly. -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct TerminalToolInput { /// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user. /// @@ -49,6 +50,35 @@ pub struct TerminalToolInput { pub cd: String, /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed. pub timeout_ms: Option, + /// Request network access for this command. + /// + /// Only meaningful when the system prompt's "Terminal sandbox" section + /// is present — ignored otherwise. By default sandboxed commands + /// cannot make outbound network connections; set this to `true` only + /// when the command needs network access. The user will be prompted + /// to approve before the command runs. + #[serde(default)] + pub allow_network: Option, + /// Request unrestricted filesystem-write access for this command. + /// + /// Only meaningful when the system prompt's "Terminal sandbox" section + /// is present — ignored otherwise. By default sandboxed commands can + /// only write to the project worktree directories and a per-command + /// temporary directory; set this to `true` only when the command + /// needs to write elsewhere. The user will be prompted to approve + /// before the command runs. + #[serde(default)] + pub allow_fs_write: Option, + /// Request to run this command outside the sandbox entirely. + /// + /// Only meaningful when the system prompt's "Terminal sandbox" section + /// is present — ignored otherwise. Prefer `allow_network: true` or + /// `allow_fs_write: true` when one of those is enough. Set this to + /// `true` ONLY when the command needs behavior that the sandbox can't + /// grant on a per-permission basis. The user will be prompted to + /// approve before the command runs without sandbox restrictions. + #[serde(default)] + pub unsandboxed: Option, } pub struct TerminalTool { @@ -96,28 +126,123 @@ impl AgentTool for TerminalTool { cx.spawn(async move |cx| { let input = input.recv().await.map_err(|e| e.to_string())?; - let (working_dir, authorize) = cx.update(|cx| { + let (working_dir, authorize, sandboxing) = cx.update(|cx| { let working_dir = working_dir(&input, &self.project, cx).map_err(|err| err.to_string())?; let context = crate::ToolPermissionContext::new(Self::NAME, vec![input.command.clone()]); let authorize = event_stream.authorize(self.initial_title(Ok(input.clone()), cx), context, cx); - Result::<_, String>::Ok((working_dir, authorize)) + let sandboxing = sandboxing_enabled(cx); + Result::<_, String>::Ok((working_dir, authorize, sandboxing)) })?; authorize.await.map_err(|e| e.to_string())?; + // Sandbox flags only do anything when sandboxing is on. When + // off, we treat them as `None` so the model can't surreptitiously + // change runtime behavior by setting flags described as a no-op + // in the system prompt. + let want_network = sandboxing && input.allow_network == Some(true); + let want_fs_write = sandboxing && input.allow_fs_write == Some(true); + let want_unsandboxed = sandboxing && input.unsandboxed == Some(true); + + // `unsandboxed: true` bypasses the wrap entirely; per-permission + // requests are only meaningful when the command is still being + // sandboxed. + let escalate = !want_unsandboxed && (want_network || want_fs_write); + + if want_unsandboxed || escalate { + let title = sandbox_approval_title(want_network, want_fs_write, want_unsandboxed); + let approve = cx.update(|cx| { + let context = crate::ToolPermissionContext::new( + Self::NAME, + vec![input.command.clone()], + ); + // Sandbox escalations always prompt, even if the user + // has `always_allow` rules for this command — the + // escalation is a stronger trust boundary than the + // baseline command approval. + event_stream.authorize_always_prompt(title, context, cx) + }); + if let Err(error) = approve.await { + return Ok(if want_unsandboxed { + format!( + "Command cancelled: user denied permission to run outside the sandbox ({error})." + ) + } else { + format!( + "Command cancelled: user denied the requested sandbox permissions ({error})." + ) + }); + } + } + + // Always provision a per-command temporary directory and point + // TMPDIR/TMP/TEMP at it. This is independent of sandbox state: + // even an unsandboxed command (or a command on a non-macOS + // host) gets a clean, isolated scratch directory that's + // auto-cleaned when the task ends, rather than polluting the + // user's shared `/tmp`. Decoupling it from sandbox state also + // means the model can't infer the sandbox state by looking at + // `$TMPDIR`. + let temp_dir = tempfile::Builder::new() + .prefix("zed-agent-terminal-") + .tempdir() + .map_err(|e| { + format!("failed to create per-command temporary directory: {e}") + })?; + let temp_dir_path = temp_dir + .path() + .canonicalize() + .unwrap_or_else(|_| temp_dir.path().to_path_buf()); + let temp_dir_string = temp_dir_path.to_string_lossy().into_owned(); + let extra_env = vec![ + acp::EnvVariable::new("TMPDIR", &temp_dir_string), + acp::EnvVariable::new("TMP", &temp_dir_string), + acp::EnvVariable::new("TEMP", &temp_dir_string), + ]; + + // Build the writable scope from the project's worktrees plus + // this command's temporary directory. Crucially we do *not* + // include the resolved `cd` working directory — that's + // model-controlled, and using it as the writable scope would + // let the model widen its own write permissions outside the + // project. + let sandbox_wrap = if sandboxing && !want_unsandboxed { + let mut writable_paths: Vec = cx.update(|cx| { + self.project + .read(cx) + .worktrees(cx) + .map(|w| w.read(cx).abs_path().to_path_buf()) + .collect::>() + }); + writable_paths.push(temp_dir_path.clone()); + Some(acp_thread::SandboxWrap { + writable_paths, + allow_network: want_network, + allow_fs_write: want_fs_write, + }) + } else { + None + }; + let terminal = self .environment .create_terminal( input.command.clone(), + extra_env, working_dir, Some(COMMAND_OUTPUT_LIMIT), + sandbox_wrap, cx, ) .await .map_err(|e| e.to_string())?; + // Hold the TempDir until the spawned command has finished so + // its scratch contents (and `$TMPDIR`) stay valid for the + // command's lifetime. + let _temp_dir = temp_dir; let terminal_id = terminal.id(cx).map_err(|e| e.to_string())?; event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ @@ -182,6 +307,29 @@ impl AgentTool for TerminalTool { } } +/// User-facing title for the sandbox-escalation approval prompt. +/// +/// `want_unsandboxed` wins over the per-permission flags because +/// `unsandboxed: true` bypasses the per-permission machinery entirely. +fn sandbox_approval_title( + want_network: bool, + want_fs_write: bool, + want_unsandboxed: bool, +) -> &'static str { + if want_unsandboxed { + "Allow this command to run outside the sandbox?" + } else { + match (want_network, want_fs_write) { + (true, true) => "Allow network access and arbitrary filesystem writes?", + (true, false) => "Allow network access?", + (false, true) => "Allow arbitrary filesystem writes?", + // Caller only invokes this when at least one flag is set, so + // this fallback is unreachable in practice. + (false, false) => "Allow this command to run?", + } + } +} + fn process_content( output: acp::TerminalOutputResponse, command: &str, @@ -310,6 +458,7 @@ mod tests { .to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }; let title = format_initial_title(Ok(input)); @@ -369,6 +518,7 @@ mod tests { command: cmd.to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }; let title = format_initial_title(Ok(input)); @@ -406,6 +556,7 @@ mod tests { command: "echo 'hello world'".to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }; let title = format_initial_title(Ok(input)); @@ -435,6 +586,7 @@ mod tests { command: long_command, cd: ".".to_string(), timeout_ms: None, + ..Default::default() }; let title = format_initial_title(Ok(input)); @@ -641,6 +793,7 @@ mod tests { command: "echo $HOME".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -708,6 +861,7 @@ mod tests { command: "echo $HOME".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -769,6 +923,7 @@ mod tests { command: "echo $(rm -rf /)".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -838,6 +993,7 @@ mod tests { command: "PAGER=blah git log --oneline".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -911,6 +1067,7 @@ mod tests { command: "PAGER=blah git log".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -1018,6 +1175,7 @@ mod tests { command: command.to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -1185,6 +1343,7 @@ mod tests { command: "echo $(whoami)".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -1257,6 +1416,7 @@ mod tests { command: "PAGER=other git log".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -1323,6 +1483,7 @@ mod tests { command: "A=1 B=2 git log".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -1400,6 +1561,7 @@ mod tests { command: "PAGER=\"less -R\" git log".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -1428,4 +1590,72 @@ mod tests { "unexpected terminal result: {result}" ); } + + #[test] + fn test_sandbox_approval_title_unsandboxed_wins() { + // `unsandboxed: true` skips the sandbox entirely, so the title should + // reflect that even when other flags are also set — they're moot. + assert_eq!( + sandbox_approval_title(true, true, true), + "Allow this command to run outside the sandbox?" + ); + assert_eq!( + sandbox_approval_title(false, false, true), + "Allow this command to run outside the sandbox?" + ); + } + + #[test] + fn test_sandbox_approval_title_per_permission_flags() { + assert_eq!( + sandbox_approval_title(true, true, false), + "Allow network access and arbitrary filesystem writes?" + ); + assert_eq!( + sandbox_approval_title(true, false, false), + "Allow network access?" + ); + assert_eq!( + sandbox_approval_title(false, true, false), + "Allow arbitrary filesystem writes?" + ); + } + + #[test] + fn test_input_schema_includes_sandbox_flags() { + // The model only sees these fields when the sandboxing prompt + // section is rendered, but they're always present in the schema so + // input validation doesn't reject them when sent. Guard against + // accidentally renaming or removing them. + let schema = serde_json::to_string(&schemars::schema_for!(TerminalToolInput)) + .expect("input schema should serialize"); + assert!( + schema.contains("allow_network"), + "schema should advertise allow_network: {schema}" + ); + assert!( + schema.contains("allow_fs_write"), + "schema should advertise allow_fs_write: {schema}" + ); + assert!( + schema.contains("unsandboxed"), + "schema should advertise unsandboxed: {schema}" + ); + } + + #[test] + fn test_sandbox_flags_default_to_none_when_absent() { + // The model is expected to omit the sandbox fields entirely on most + // calls. Make sure deserialization doesn't reject the minimal + // payload and that the fields default to `None` (which the tool + // interprets as "no escalation requested"). + let input: TerminalToolInput = serde_json::from_value(serde_json::json!({ + "command": "echo hi", + "cd": ".", + })) + .expect("minimal input should deserialize"); + assert_eq!(input.allow_network, None); + assert_eq!(input.allow_fs_write, None); + assert_eq!(input.unsandboxed, None); + } } From 777e16dd1f38f573c264eb15c783743ec37d7e1e Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Thu, 28 May 2026 01:02:37 +0100 Subject: [PATCH 34/93] gpui: Fix panic with invalid focus IDs (#57885) We were missing a check when setting the focused node ID, causing a panic in accesskit's internal validation logic. Fixes the bug, and adds some defensive logic to help mitigate potential future issues. You can reproduce the panic by: - turning on a screenreader - launching zed - opening a menu and pressing tab This change fixes it. Release Notes: - N/A or Added/Fixed/Improved ... --- crates/gpui/src/elements/div.rs | 2 +- crates/gpui/src/window/a11y.rs | 63 ++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 6e280883562..5538d3d92a0 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1984,7 +1984,7 @@ impl Interactivity { if let Some(global_id) = global_id { let node_id = global_id.accesskit_node_id(); window.a11y.focus_ids.insert(node_id, focus_handle.id); - if focus_handle.is_focused(window) { + if focus_handle.is_focused(window) && window.a11y.nodes.has_node(node_id) { window.a11y.nodes.set_focus(node_id); } } diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs index 2e0e57a7dd7..226a4e8d4cc 100644 --- a/crates/gpui/src/window/a11y.rs +++ b/crates/gpui/src/window/a11y.rs @@ -245,6 +245,11 @@ impl A11yNodeBuilder { self.focus = ROOT_NODE_ID; } + /// Returns whether a node with the given ID has been pushed in this frame. + pub(crate) fn has_node(&self, id: NodeId) -> bool { + id == ROOT_NODE_ID || self.seen_ids.contains(&id) + } + /// Set the focused node for this frame. pub(crate) fn set_focus(&mut self, id: NodeId) { #[cfg(debug_assertions)] @@ -263,6 +268,14 @@ impl A11yNodeBuilder { debug_assert_eq!(self.ids_stack.len(), 1); debug_assert_eq!(self.ids_stack[0], ROOT_NODE_ID); + if self.ids_stack.len() != 1 { + log::error!( + "a11y: Stack imbalance at end of frame: expected 1 (root), got {}. \ + Some elements may have pushed without popping.", + self.ids_stack.len() + ); + } + // Pop remaining nodes (should just be the root). while !self.ids_stack.is_empty() { if let (Some(id), Some(node)) = (self.ids_stack.pop(), self.nodes_stack.pop()) { @@ -271,11 +284,59 @@ impl A11yNodeBuilder { } let nodes = std::mem::take(&mut self.all_nodes); - TreeUpdate { + let update = TreeUpdate { nodes, tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), tree_id: accesskit::TreeId::ROOT, focus: self.focus, + }; + + Self::repair_tree_update(update) + } + + /// Accesskit panics on invalid [`TreeUpdate`]s. This function defensively + /// checks invariants that accesskit panics on, and tries to fix them. + fn repair_tree_update(mut update: TreeUpdate) -> TreeUpdate { + let node_ids: FxHashSet = update.nodes.iter().map(|(id, _)| *id).collect(); + + // Focus must point to a node in the tree. + if !node_ids.contains(&update.focus) { + log::error!( + "a11y: Focused node {:?} is not in the tree ({} nodes). \ + Falling back to root. This is a bug in the a11y tree builder.", + update.focus, + update.nodes.len() + ); + update.focus = ROOT_NODE_ID; } + + // Every child reference must point to a node in the update. + for (id, node) in &mut update.nodes { + let has_invalid_child = node + .children() + .iter() + .any(|child_id| !node_ids.contains(child_id)); + if has_invalid_child { + let children = node.children(); + let invalid_count = children + .iter() + .filter(|child_id| !node_ids.contains(child_id)) + .count(); + log::error!( + "a11y: Node {:?} references {} children not present in the tree. \ + Stripping invalid child references.", + id, + invalid_count + ); + let valid: Vec = children + .iter() + .copied() + .filter(|child_id| node_ids.contains(child_id)) + .collect(); + node.set_children(valid); + } + } + + update } } From b141288fb046fe2fd24901792bdfa3feb055731c Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 27 May 2026 20:58:56 -0400 Subject: [PATCH 35/93] Persist terminal tempdirs by thread (#57878) Summary: - Store a lazily-created sandboxed terminal temp directory on each agent thread. - Persist the tempdir path and remove it when deleting archived threads. - Update sandbox prompt wording to describe per-thread tempdir reuse. Tests: - cargo fmt --package agent --package agent_ui --package zed - cargo test -p agent db::tests::test_sandboxed_terminal_temp_dir -- --nocapture - cargo test -p agent db::tests::test_delete_thread_removes_sandboxed_terminal_temp_dir -- --nocapture - cargo check -p agent_ui -p zed Release Notes: - Improved agent terminal sandboxing to preserve temporary files across commands in the same thread --------- Co-authored-by: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com> Co-authored-by: Martin Ye --- crates/agent/src/agent.rs | 32 ++++ crates/agent/src/db.rs | 182 +++++++++++++++++-- crates/agent/src/templates.rs | 2 - crates/agent/src/templates/system_prompt.hbs | 4 +- crates/agent/src/thread.rs | 32 +++- crates/agent/src/thread_store.rs | 1 + crates/agent/src/tools/terminal_tool.rs | 47 ++--- crates/agent_ui/src/agent_panel.rs | 1 + crates/agent_ui/src/thread_metadata_store.rs | 1 + crates/zed/src/visual_test_runner.rs | 1 + 10 files changed, 244 insertions(+), 59 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index dd240317606..9f8dc9f242e 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -2616,6 +2616,38 @@ impl ThreadEnvironment for NativeThreadEnvironment { sandbox_wrap: Option, cx: &mut AsyncApp, ) -> Task>> { + // Use a per-thread temp directory for all terminal commands, even when + // sandboxing is disabled, so the model can't infer sandbox state from + // `$TMPDIR` changing between conversations. + let mut extra_env = extra_env; + let mut sandbox_wrap = sandbox_wrap; + match self + .thread + .update(cx, |thread, cx| thread.sandboxed_terminal_temp_dir(cx)) + { + Ok(Ok(temp_dir)) => { + // Canonicalize so the path matches what the sandbox resolves + // symlinks to (e.g. `/var` -> `/private/var` on macOS). + // `$TMPDIR` and the writable-scope entry below must agree, and + // they must agree with the path the kernel actually checks. + let temp_dir = temp_dir.canonicalize().unwrap_or(temp_dir); + let temp_dir_string = temp_dir.to_string_lossy().into_owned(); + extra_env.extend([ + acp::EnvVariable::new("TMPDIR", &temp_dir_string), + acp::EnvVariable::new("TMP", &temp_dir_string), + acp::EnvVariable::new("TEMP", &temp_dir_string), + ]); + // The command's `$TMPDIR` must live inside the sandbox's + // writable scope. The per-thread temp directory is owned here + // (not in the terminal tool that assembles the rest of the + // writable set), so add it whenever the command is sandboxed. + if let Some(sandbox_wrap) = &mut sandbox_wrap { + sandbox_wrap.writable_paths.push(temp_dir); + } + } + Ok(Err(error)) => return Task::ready(Err(error)), + Err(error) => return Task::ready(Err(error)), + }; let task = self.acp_thread.update(cx, |thread, cx| { thread.create_terminal( command, diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 1b2f7639cd2..aeeca37c170 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -16,7 +16,7 @@ use sqlez::{ connection::Connection, statement::Statement, }; -use std::sync::Arc; +use std::{io::ErrorKind, path::PathBuf, sync::Arc}; use ui::{App, SharedString}; use util::path_list::PathList; use zed_env_vars::ZED_STATELESS; @@ -81,6 +81,8 @@ pub struct DbThread { pub draft_prompt: Option>, #[serde(default)] pub ui_scroll_position: Option, + #[serde(default)] + pub sandboxed_terminal_temp_dir: Option, } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -130,6 +132,7 @@ impl SharedThread { thinking_effort: None, draft_prompt: None, ui_scroll_position: None, + sandboxed_terminal_temp_dir: None, } } @@ -309,6 +312,7 @@ impl DbThread { thinking_effort: None, draft_prompt: None, ui_scroll_position: None, + sandboxed_terminal_temp_dir: None, }) } } @@ -569,15 +573,7 @@ impl ThreadsDatabase { let rows = select(id.0)?; if let Some((data_type, data)) = rows.into_iter().next() { - let json_data = match data_type { - DataType::Zstd => { - let decompressed = zstd::decode_all(&data[..])?; - String::from_utf8(decompressed)? - } - DataType::Json => String::from_utf8(data)?, - }; - let thread = DbThread::from_json(json_data.as_bytes())?; - Ok(Some(thread)) + Ok(Some(Self::deserialize_thread(data_type, data)?)) } else { Ok(None) } @@ -596,17 +592,71 @@ impl ThreadsDatabase { .spawn(async move { Self::save_thread_sync(&connection, id, thread, &folder_paths) }) } + fn deserialize_thread(data_type: DataType, data: Vec) -> Result { + let json_data = match data_type { + DataType::Zstd => { + let decompressed = zstd::decode_all(&data[..])?; + String::from_utf8(decompressed)? + } + DataType::Json => String::from_utf8(data)?, + }; + DbThread::from_json(json_data.as_bytes()) + } + + fn sandboxed_terminal_temp_dir(data_type: DataType, data: Vec) -> Option { + match Self::deserialize_thread(data_type, data) { + Ok(thread) => thread.sandboxed_terminal_temp_dir, + Err(error) => { + log::warn!("failed to deserialize thread before deleting it: {error:#}"); + None + } + } + } + + fn remove_sandboxed_terminal_temp_dir(temp_dir: PathBuf) { + match std::fs::remove_dir_all(&temp_dir) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + log::warn!( + "failed to remove sandboxed terminal temp directory {}: {error}", + temp_dir.display() + ); + } + } + } + pub fn delete_thread(&self, id: acp::SessionId) -> Task> { let connection = self.connection.clone(); self.executor.spawn(async move { - let connection = connection.lock(); + let sandboxed_terminal_temp_dir = { + let connection = connection.lock(); - let mut delete = connection.exec_bound::>(indoc! {" - DELETE FROM threads WHERE id = ? - "})?; + let mut select = + connection.select_bound::, (DataType, Vec)>(indoc! {" + SELECT data_type, data FROM threads WHERE id = ? LIMIT 1 + "})?; - delete(id.0)?; + let sandboxed_terminal_temp_dir = select(id.0.clone())? + .into_iter() + .next() + .and_then(|(data_type, data)| { + Self::sandboxed_terminal_temp_dir(data_type, data) + }); + + let mut delete = connection.exec_bound::>(indoc! {" + DELETE FROM threads WHERE id = ? + "})?; + + delete(id.0)?; + + sandboxed_terminal_temp_dir + }; + + if let Some(temp_dir) = sandboxed_terminal_temp_dir { + Self::remove_sandboxed_terminal_temp_dir(temp_dir); + } Ok(()) }) @@ -616,13 +666,32 @@ impl ThreadsDatabase { let connection = self.connection.clone(); self.executor.spawn(async move { - let connection = connection.lock(); + let sandboxed_terminal_temp_dirs = { + let connection = connection.lock(); - let mut delete = connection.exec_bound::<()>(indoc! {" - DELETE FROM threads - "})?; + let mut select = connection.select_bound::<(), (DataType, Vec)>(indoc! {" + SELECT data_type, data FROM threads + "})?; - delete(())?; + let sandboxed_terminal_temp_dirs = select(())? + .into_iter() + .filter_map(|(data_type, data)| { + Self::sandboxed_terminal_temp_dir(data_type, data) + }) + .collect::>(); + + let mut delete = connection.exec_bound::<()>(indoc! {" + DELETE FROM threads + "})?; + + delete(())?; + + sandboxed_terminal_temp_dirs + }; + + for temp_dir in sandboxed_terminal_temp_dirs { + Self::remove_sandboxed_terminal_temp_dir(temp_dir); + } Ok(()) }) @@ -694,6 +763,7 @@ mod tests { thinking_effort: None, draft_prompt: None, ui_scroll_position: None, + sandboxed_terminal_temp_dir: None, } } @@ -797,6 +867,78 @@ mod tests { ); } + #[test] + fn test_sandboxed_terminal_temp_dir_defaults_to_none() { + let json = r#"{ + "title": "Old Thread", + "messages": [], + "updated_at": "2024-01-01T00:00:00Z" + }"#; + + let db_thread: DbThread = serde_json::from_str(json).expect("Failed to deserialize"); + + assert!( + db_thread.sandboxed_terminal_temp_dir.is_none(), + "Legacy threads without sandboxed_terminal_temp_dir should default to None" + ); + } + + #[gpui::test] + async fn test_sandboxed_terminal_temp_dir_roundtrips_through_save_load( + cx: &mut TestAppContext, + ) { + let database = ThreadsDatabase::new(cx.executor()).unwrap(); + let thread_id = session_id("sandbox-temp-dir-thread"); + let temp_dir = tempfile::Builder::new() + .prefix("zed-agent-terminal-test-") + .tempdir() + .unwrap() + .keep(); + let mut thread = make_thread( + "Sandbox Temp Dir Thread", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + thread.sandboxed_terminal_temp_dir = Some(temp_dir.clone()); + + database + .save_thread(thread_id.clone(), thread, PathList::default()) + .await + .unwrap(); + + let loaded = database + .load_thread(thread_id) + .await + .unwrap() + .expect("thread should exist"); + assert_eq!(loaded.sandboxed_terminal_temp_dir, Some(temp_dir.clone())); + std::fs::remove_dir_all(temp_dir).unwrap(); + } + + #[gpui::test] + async fn test_delete_thread_removes_sandboxed_terminal_temp_dir(cx: &mut TestAppContext) { + let database = ThreadsDatabase::new(cx.executor()).unwrap(); + let thread_id = session_id("sandbox-temp-dir-delete-thread"); + let temp_dir = tempfile::Builder::new() + .prefix("zed-agent-terminal-test-") + .tempdir() + .unwrap() + .keep(); + std::fs::write(temp_dir.join("sentinel"), b"content").unwrap(); + let mut thread = make_thread( + "Sandbox Temp Dir Delete Thread", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + thread.sandboxed_terminal_temp_dir = Some(temp_dir.clone()); + + database + .save_thread(thread_id.clone(), thread, PathList::default()) + .await + .unwrap(); + database.delete_thread(thread_id).await.unwrap(); + + assert!(!temp_dir.exists()); + } + #[gpui::test] async fn test_subagent_context_roundtrips_through_save_load(cx: &mut TestAppContext) { let database = ThreadsDatabase::new(cx.executor()).unwrap(); diff --git a/crates/agent/src/templates.rs b/crates/agent/src/templates.rs index f576578d824..0d3e617d01c 100644 --- a/crates/agent/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -195,8 +195,6 @@ mod tests { assert!(rendered.contains("allow_network: true")); assert!(rendered.contains("allow_fs_write: true")); assert!(rendered.contains("unsandboxed: true")); - // The model is told the section is stable so it doesn't re-check - // sandbox state every turn. assert!(rendered.contains("remain in effect for the entire duration")); } diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index b955879f180..7326937ab09 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -193,7 +193,7 @@ The current project contains the following root directories: The `terminal` tool runs commands inside a sandbox with these permissions: - Reads: any path on the filesystem is readable. -- Writes: a per-command temporary directory exposed via `$TMPDIR`, `$TMP`, and `$TEMP` is writable{{#if worktrees}}, along with these project directories: +- Writes: a per-thread temporary directory exposed via `$TMPDIR`, `$TMP`, and `$TEMP` is writable and persists across `terminal` calls in this conversation{{#if worktrees}}, along with these project directories: {{#each worktrees}} - `{{abs_path}}` {{/each}} @@ -202,7 +202,7 @@ The `terminal` tool runs commands inside a sandbox with these permissions: You can request elevated permissions on individual `terminal` calls by setting `allow_network: true`, `allow_fs_write: true`, or `unsandboxed: true`. The user will be prompted to approve before the command runs. -These sandbox settings are guaranteed to remain in effect for the entire duration of this conversation. If they ever change, you'll be told. +These sandbox settings are guaranteed to remain in effect for the entire duration of this conversation. If they ever change, you will be told. {{/if}} {{#if model_name}} diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 0202e2efa8b..5eb2c170a12 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -51,16 +51,16 @@ use serde::{Deserialize, Serialize}; use settings::{ LanguageModelSelection, Settings, SettingsStore, ToolPermissionMode, update_settings_file, }; +use std::fmt::Write; use std::{ collections::BTreeMap, marker::PhantomData, ops::RangeInclusive, - path::Path, + path::{Path, PathBuf}, rc::Rc, sync::Arc, time::{Duration, Instant}, }; -use std::{fmt::Write, path::PathBuf}; use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock, paths::PathStyle}; use uuid::Uuid; @@ -1007,6 +1007,7 @@ pub struct Thread { /// Weak references to running subagent threads for cancellation propagation running_subagents: Vec>, inherits_parent_model_settings: bool, + sandboxed_terminal_temp_dir: Option, } impl Thread { @@ -1133,6 +1134,7 @@ impl Thread { ui_scroll_position: None, running_subagents: Vec::new(), inherits_parent_model_settings: true, + sandboxed_terminal_temp_dir: None, } } @@ -1176,6 +1178,30 @@ impl Thread { &self.id } + pub(crate) fn sandboxed_terminal_temp_dir( + &mut self, + cx: &mut Context, + ) -> Result { + if let Some(temp_dir) = &self.sandboxed_terminal_temp_dir { + std::fs::create_dir_all(temp_dir).with_context(|| { + format!( + "failed to recreate sandboxed terminal temp directory {}", + temp_dir.display() + ) + })?; + return Ok(temp_dir.clone()); + } + + let temp_dir = tempfile::Builder::new() + .prefix("zed-agent-terminal-") + .tempdir() + .context("failed to create sandboxed terminal temp directory")?; + let temp_dir = temp_dir.keep(); + self.sandboxed_terminal_temp_dir = Some(temp_dir.clone()); + cx.notify(); + Ok(temp_dir) + } + /// Returns true if this thread was imported from a shared thread. pub fn is_imported(&self) -> bool { self.imported @@ -1451,6 +1477,7 @@ impl Thread { }), running_subagents: Vec::new(), inherits_parent_model_settings: true, + sandboxed_terminal_temp_dir: db_thread.sandboxed_terminal_temp_dir, } } @@ -1481,6 +1508,7 @@ impl Thread { offset_in_item: lo.offset_in_item.as_f32(), } }), + sandboxed_terminal_temp_dir: self.sandboxed_terminal_temp_dir.clone(), }; cx.background_spawn(async move { diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index e3c8b186454..f5aecdbf682 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -167,6 +167,7 @@ mod tests { thinking_effort: None, draft_prompt: None, ui_scroll_position: None, + sandboxed_terminal_temp_dir: None, } } diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index cb20662526b..9944b57e0bd 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -178,46 +178,31 @@ impl AgentTool for TerminalTool { } } - // Always provision a per-command temporary directory and point - // TMPDIR/TMP/TEMP at it. This is independent of sandbox state: - // even an unsandboxed command (or a command on a non-macOS - // host) gets a clean, isolated scratch directory that's - // auto-cleaned when the task ends, rather than polluting the - // user's shared `/tmp`. Decoupling it from sandbox state also - // means the model can't infer the sandbox state by looking at - // `$TMPDIR`. - let temp_dir = tempfile::Builder::new() - .prefix("zed-agent-terminal-") - .tempdir() - .map_err(|e| { - format!("failed to create per-command temporary directory: {e}") - })?; - let temp_dir_path = temp_dir - .path() - .canonicalize() - .unwrap_or_else(|_| temp_dir.path().to_path_buf()); - let temp_dir_string = temp_dir_path.to_string_lossy().into_owned(); - let extra_env = vec![ - acp::EnvVariable::new("TMPDIR", &temp_dir_string), - acp::EnvVariable::new("TMP", &temp_dir_string), - acp::EnvVariable::new("TEMP", &temp_dir_string), - ]; + // The per-thread scratch directory (and the `$TMPDIR`/`TMP`/ + // `TEMP` environment variables pointing at it) is provisioned by + // the thread environment in `create_terminal`, which also adds it + // to the sandbox's writable scope. We must not set `$TMPDIR` here: + // the environment overrides it with the per-thread directory, so a + // per-command directory set here would never be the `$TMPDIR` the + // command actually sees and would be left out of the writable + // scope, breaking writes into `$TMPDIR`. + let extra_env = Vec::new(); - // Build the writable scope from the project's worktrees plus - // this command's temporary directory. Crucially we do *not* - // include the resolved `cd` working directory — that's + // Build the writable scope from the project's worktrees. The + // per-thread temp directory is appended by the thread environment + // (which owns it and points `$TMPDIR` at it). Crucially we do + // *not* include the resolved `cd` working directory — that's // model-controlled, and using it as the writable scope would // let the model widen its own write permissions outside the // project. let sandbox_wrap = if sandboxing && !want_unsandboxed { - let mut writable_paths: Vec = cx.update(|cx| { + let writable_paths: Vec = cx.update(|cx| { self.project .read(cx) .worktrees(cx) .map(|w| w.read(cx).abs_path().to_path_buf()) .collect::>() }); - writable_paths.push(temp_dir_path.clone()); Some(acp_thread::SandboxWrap { writable_paths, allow_network: want_network, @@ -239,10 +224,6 @@ impl AgentTool for TerminalTool { ) .await .map_err(|e| e.to_string())?; - // Hold the TempDir until the spawned command has finished so - // its scratch contents (and `$TMPDIR`) stay valid for the - // command's lifetime. - let _temp_dir = temp_dir; let terminal_id = terminal.id(cx).map_err(|e| e.to_string())?; event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 0894c68685c..309552c1a29 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -9748,6 +9748,7 @@ mod tests { thinking_effort: None, draft_prompt: None, ui_scroll_position: None, + sandboxed_terminal_temp_dir: None, }; let thread_store = cx.update(|cx| ThreadStore::global(cx)); diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index f3bbdaa0c9e..80bf512147f 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -1831,6 +1831,7 @@ mod tests { thinking_effort: None, draft_prompt: None, ui_scroll_position: None, + sandboxed_terminal_temp_dir: None, } } diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 8f85fcd3c86..d8553c1bd14 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -2734,6 +2734,7 @@ fn run_multi_workspace_sidebar_visual_tests( thinking_effort: None, ui_scroll_position: None, draft_prompt: None, + sandboxed_terminal_temp_dir: None, }, path_list, cx, From 60374460f263aaf56b8c1ced54f7731c0324b1f5 Mon Sep 17 00:00:00 2001 From: Pulkit Saraf <146842937+Pulkit7070@users.noreply.github.com> Date: Thu, 28 May 2026 08:06:38 +0530 Subject: [PATCH 36/93] helix: Fix a cursor position in helix_select mode` (#57610) Closes #56493 In Helix select mode, pressing `a` after a selection (e.g. `v a`) placed the cursor one column too far to the right. Bound `a` to `vim::HelixAppend` in the `helix_select` keymap so it matches the behavior in `helix_normal`, and added a regression test for the `v a` case. Release Notes: - Fixed cursor placement after pressing `a` in Helix select mode. --------- Co-authored-by: Smit Barmase --- assets/keymaps/vim.json | 2 +- crates/vim/src/helix.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 1e731efd96c..ef414018681 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -338,7 +338,7 @@ "ctrl-x": "vim::Decrement", "shift-j": "vim::JoinLines", "i": "vim::InsertBefore", - "a": "vim::InsertAfter", + "a": "vim::HelixAppend", "o": "vim::InsertLineBelow", "shift-o": "vim::InsertLineAbove", "p": "vim::Paste", diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index b1ce35431c5..5c4b26cdaa0 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -2572,6 +2572,16 @@ mod test { assert_eq!(cx.mode(), Mode::HelixNormal); } + #[gpui::test] + async fn test_helix_select_append(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + cx.set_state("aˇbcd", Mode::HelixNormal); + cx.simulate_keystrokes("v a"); + cx.assert_state("abˇcd", Mode::Insert); + } + #[gpui::test] async fn test_goto_last_modification(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; From 58f84cf04101b095ddc02d4fbd511523e39710d6 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 28 May 2026 06:49:12 +0200 Subject: [PATCH 37/93] agent_ui: Basic terminal agent telemetry (#57259) Some best-effort tracking of an allowed-list of agents (to avoid grabbing sensitive data) just to get basic data on general usage patterns. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: morgankrey --- crates/agent_ui/src/agent_panel.rs | 137 +++++++++++++++++++- crates/terminal/src/pty_info.rs | 5 + crates/terminal/src/terminal.rs | 196 ++++++++++++++++++++++++++++- 3 files changed, 334 insertions(+), 4 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 309552c1a29..540abc040f2 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -99,6 +99,45 @@ const MIN_PANEL_WIDTH: Pixels = px(300.); const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; const LAST_CREATED_ENTRY_KIND_KEY: &str = "agent_panel__last_created_entry_kind"; const TERMINAL_AGENT_TELEMETRY_ID: &str = "terminal"; +const KNOWN_TERMINAL_AGENT_COMMANDS: &[&str] = &[ + "agent", // Unfortunately, both Cursor cli + grok + "agy", + "aider", + "amp", + "claude", + "codex", + "copilot", + "crush", + "devin", + "droid", + "gemini", + "goose", + "grok", + "openhands", + "opencode", + "pi", + "qwen", +]; + +fn is_known_terminal_agent_command(command: &str) -> bool { + KNOWN_TERMINAL_AGENT_COMMANDS.contains(&command) +} + +fn terminal_program_to_report( + last_observed_program: &mut Option, + current_program: Option, +) -> Option { + let current_program = + current_program.filter(|program| is_known_terminal_agent_command(program)); + let program_to_report = + if current_program.is_some() && current_program != *last_observed_program { + current_program.clone() + } else { + None + }; + *last_observed_program = current_program; + program_to_report +} /// Maximum number of idle threads kept in the agent panel's retained list. /// Set as a GPUI global to override; otherwise defaults to 5. @@ -831,6 +870,7 @@ struct AgentTerminal { title_editor_initial_title: Option, title_editor_subscription: Option, last_known_title: String, + last_observed_program: Option, working_directory: Option, created_at: DateTime, has_notification: bool, @@ -889,6 +929,34 @@ impl AgentTerminal { fn custom_title(&self, cx: &App) -> Option { self.view.read(cx).custom_title().map(SharedString::from) } + + fn report_started_terminal_program( + &mut self, + terminal_id: TerminalId, + source: AgentThreadSource, + cx: &App, + ) { + let current_program = self + .view + .read(cx) + .terminal() + .read(cx) + .foreground_process_command_name(); + + if let Some(program) = + terminal_program_to_report(&mut self.last_observed_program, current_program) + { + telemetry::event!( + "Agent Terminal Program Started", + agent = TERMINAL_AGENT_TELEMETRY_ID, + terminal_id = terminal_id.to_key_string(), + program = program, + source = source.as_str(), + side = crate::agent_sidebar_side(cx), + thread_location = "current_worktree", + ); + } + } } enum BaseView { @@ -1900,6 +1968,7 @@ impl AgentPanel { | TerminalEvent::Wakeup | TerminalEvent::BreadcrumbsChanged => { this.refresh_terminal_metadata(terminal_id, cx); + this.report_terminal_program(terminal_id, source, cx); } TerminalEvent::Bell => this.mark_terminal_notification(terminal_id, window, cx), TerminalEvent::CloseTerminal => { @@ -1920,6 +1989,7 @@ impl AgentPanel { last_known_title: initial_title .map(|title| title.to_string()) .unwrap_or_default(), + last_observed_program: None, working_directory, created_at: created_at.unwrap_or_else(Utc::now), has_notification: false, @@ -1931,9 +2001,10 @@ impl AgentPanel { self.pending_terminal_spawn = None; } terminal.refresh_metadata(cx); + terminal.report_started_terminal_program(terminal_id, source, cx); self.terminals.insert(terminal_id, terminal); self.persist_terminal_metadata(terminal_id, cx); - self.emit_terminal_thread_started(source, cx); + self.emit_terminal_thread_started(terminal_id, source, cx); if select { self.set_base_view(BaseView::Terminal { terminal_id }, focus, window, cx); } @@ -2028,10 +2099,16 @@ impl AgentPanel { self.close_terminal_internal(terminal_id, false, metadata, window, cx); } - fn emit_terminal_thread_started(&self, source: AgentThreadSource, cx: &App) { + fn emit_terminal_thread_started( + &self, + terminal_id: TerminalId, + source: AgentThreadSource, + cx: &App, + ) { telemetry::event!( "Agent Thread Started", agent = TERMINAL_AGENT_TELEMETRY_ID, + terminal_id = terminal_id.to_key_string(), source = source.as_str(), side = crate::agent_sidebar_side(cx), thread_location = "current_worktree", @@ -2048,6 +2125,17 @@ impl AgentPanel { } } + fn report_terminal_program( + &mut self, + terminal_id: TerminalId, + source: AgentThreadSource, + cx: &mut Context, + ) { + if let Some(terminal) = self.terminals.get_mut(&terminal_id) { + terminal.report_started_terminal_program(terminal_id, source, cx); + } + } + fn persist_all_terminal_metadata(&self, cx: &mut Context) { let terminal_ids = self.terminals.keys().copied().collect::>(); for terminal_id in terminal_ids { @@ -6240,6 +6328,51 @@ mod tests { use std::sync::Arc; use std::time::Instant; + #[test] + fn test_is_known_terminal_agent_command() { + assert!(is_known_terminal_agent_command("claude")); + assert!(is_known_terminal_agent_command("codex")); + assert!(!is_known_terminal_agent_command("cargo")); + assert!(!is_known_terminal_agent_command("internal-agent")); + } + + #[test] + fn test_terminal_program_reports_known_agent_transitions() { + let mut last_observed_program = None; + + assert_eq!( + terminal_program_to_report(&mut last_observed_program, Some("codex".to_string())), + Some("codex".to_string()) + ); + assert_eq!( + terminal_program_to_report(&mut last_observed_program, Some("codex".to_string())), + None + ); + assert_eq!( + terminal_program_to_report(&mut last_observed_program, Some("zsh".to_string())), + None + ); + assert_eq!( + terminal_program_to_report( + &mut last_observed_program, + Some("customer-data-export".to_string()) + ), + None + ); + assert_eq!( + terminal_program_to_report(&mut last_observed_program, Some("codex".to_string())), + Some("codex".to_string()) + ); + assert_eq!( + terminal_program_to_report(&mut last_observed_program, None), + None + ); + assert_eq!( + terminal_program_to_report(&mut last_observed_program, Some("codex".to_string())), + Some("codex".to_string()) + ); + } + #[derive(Clone, Default)] struct SessionTrackingConnection { next_session_number: Arc>, diff --git a/crates/terminal/src/pty_info.rs b/crates/terminal/src/pty_info.rs index 96908479afe..560a24ca547 100644 --- a/crates/terminal/src/pty_info.rs +++ b/crates/terminal/src/pty_info.rs @@ -185,6 +185,11 @@ impl PtyProcessInfo { Some(info) } + #[cfg(all(test, unix))] + pub(crate) fn load_for_test(&self) -> Option { + self.load() + } + /// Updates the cached process info, emitting a [`Event::TitleChanged`] event if the Zed-relevant info has changed pub fn emit_title_changed_if_changed(self: &Arc, cx: &mut Context<'_, Terminal>) { if self.task.lock().is_some() { diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 00aa2cbb01b..271e2df0e9a 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -60,7 +60,7 @@ use std::{ cmp::{self, min}, fmt::Display, ops::{Deref, RangeInclusive}, - path::PathBuf, + path::{Path, PathBuf}, process::ExitStatus, sync::Arc, time::{Duration, Instant}, @@ -2137,6 +2137,18 @@ impl Terminal { } } + /// Normalizes the command name of the foreground process, if one is known. + pub fn foreground_process_command_name(&self) -> Option { + match &self.terminal_type { + TerminalType::Pty { info, .. } => info + .current + .read() + .as_ref() + .and_then(|process| foreground_process_command_from_argv(&process.argv)), + TerminalType::DisplayOnly => None, + } + } + /// Returns the working directory of the process that's connected to the PTY. /// That means it returns the working directory of the local shell or program /// that's running inside the terminal. @@ -2461,6 +2473,77 @@ impl Drop for Terminal { impl EventEmitter for Terminal {} +fn normalize_path_command_name(command: &str) -> Option { + const MAX_COMMAND_NAME_LENGTH: usize = 64; + + let command = command.trim(); + if command.is_empty() + || command.len() > MAX_COMMAND_NAME_LENGTH + || command.starts_with('.') + || command.starts_with('-') + || command.contains('/') + || command.contains('\\') + { + return None; + } + + let mut command = command.to_ascii_lowercase(); + for suffix in [".exe", ".cmd", ".bat", ".ps1"] { + if command.ends_with(suffix) { + command.truncate(command.len() - suffix.len()); + break; + } + } + + if command.is_empty() + || !command.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + { + return None; + } + + Some(command) +} + +fn foreground_process_command_from_argv(argv: &[String]) -> Option { + let command = argv + .first() + .and_then(|command| normalize_path_command_name(command)); + + if !matches!( + command.as_deref(), + Some("node" | "python" | "python3" | "bun" | "deno") + ) { + return command; + } + + argv.iter() + .skip(1) + .filter_map(|argument| normalize_script_command_name(argument)) + .next() + .or(command) +} + +fn normalize_script_command_name(argument: &str) -> Option { + let path = Path::new(argument); + let file_stem = path + .file_stem() + .and_then(|file_stem| file_stem.to_str()) + .and_then(normalize_path_command_name)?; + + if file_stem != "index" { + return Some(file_stem); + } + + path.parent() + .and_then(|parent| parent.parent()) + .and_then(|package_path| package_path.file_name()) + .and_then(|package_name| package_name.to_str()) + .and_then(|package_name| package_name.strip_suffix("-cli").or(Some(package_name))) + .and_then(normalize_path_command_name) +} + fn make_selection(range: &RangeInclusive) -> Selection { let mut selection = Selection::new(SelectionType::Simple, *range.start(), AlacDirection::Left); selection.update(*range.end(), AlacDirection::Right); @@ -2597,6 +2680,54 @@ mod tests { use rand::{Rng, distr, rngs::StdRng}; use task::{Shell, ShellBuilder}; + #[test] + fn test_normalize_path_command_name() { + assert_eq!(normalize_path_command_name("claude"), Some("claude".into())); + assert_eq!(normalize_path_command_name("Cargo"), Some("cargo".into())); + assert_eq!(normalize_path_command_name("node.exe"), Some("node".into())); + assert_eq!( + normalize_path_command_name("my-agent_cli.1"), + Some("my-agent_cli.1".into()) + ); + assert_eq!(normalize_path_command_name("./local-agent"), None); + assert_eq!(normalize_path_command_name("../local-agent"), None); + assert_eq!(normalize_path_command_name("/usr/local/bin/cargo"), None); + assert_eq!( + normalize_path_command_name("target\\debug\\agent.exe"), + None + ); + assert_eq!(normalize_path_command_name(".hidden-agent"), None); + assert_eq!(normalize_path_command_name("agent with spaces"), None); + assert_eq!(normalize_path_command_name("zsh"), Some("zsh".into())); + assert_eq!(normalize_path_command_name("-zsh"), None); + assert_eq!(normalize_path_command_name("pwsh.exe"), Some("pwsh".into())); + } + + #[test] + fn test_foreground_process_command_from_interpreter_wrapper() { + assert_eq!( + foreground_process_command_from_argv(&[ + "node".to_string(), + "/opt/homebrew/lib/node_modules/@google/gemini-cli/dist/index.js".to_string(), + ]), + Some("gemini".to_string()) + ); + assert_eq!( + foreground_process_command_from_argv(&[ + "python3".to_string(), + "/Users/me/.local/bin/codex.py".to_string(), + ]), + Some("codex".to_string()) + ); + assert_eq!( + foreground_process_command_from_argv(&[ + "node".to_string(), + "/Users/me/private-project/scripts/customer-data-export.js".to_string(), + ]), + Some("customer-data-export".to_string()) + ); + } + #[cfg(not(target_os = "windows"))] fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -2613,10 +2744,18 @@ mod tests { command: &str, args: &[&str], ) -> (Entity, Receiver>) { - let (completion_tx, completion_rx) = async_channel::unbounded(); let args: Vec = args.iter().map(|s| s.to_string()).collect(); let (program, args) = ShellBuilder::new(&Shell::System, false).build(Some(command.to_owned()), &args); + build_test_terminal_with_arguments(cx, program, args).await + } + + async fn build_test_terminal_with_arguments( + cx: &mut TestAppContext, + program: String, + args: Vec, + ) -> (Entity, Receiver>) { + let (completion_tx, completion_rx) = async_channel::unbounded(); let builder = cx .update(|cx| { TerminalBuilder::new( @@ -2755,6 +2894,23 @@ mod tests { ); } + #[cfg(unix)] + #[gpui::test] + async fn test_foreground_process_command_tracks_path_command(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + + let (terminal, completion_rx) = + build_test_terminal_with_arguments(cx, "sleep".to_string(), vec!["1".to_string()]) + .await; + + assert_foreground_process_command_eventually(&terminal, "sleep", cx).await; + + assert!( + completion_rx.recv().await.is_ok(), + "expected terminal completion after sleep exits" + ); + } + // TODO should be tested on Linux too, but does not work there well #[cfg(target_os = "macos")] #[gpui::test(iterations = 10)] @@ -3359,6 +3515,42 @@ mod tests { panic!("Expected terminal content to contain {expected:?}, got: {content}"); } + #[cfg(unix)] + async fn assert_foreground_process_command_eventually( + terminal: &Entity, + expected: &str, + cx: &mut TestAppContext, + ) { + let mut command_name = None; + for _ in 0..100 { + terminal.update(cx, |terminal, _| { + if let TerminalType::Pty { info, .. } = &terminal.terminal_type { + info.load_for_test(); + } + }); + command_name = + terminal.update(cx, |terminal, _| terminal.foreground_process_command_name()); + if command_name.as_deref() == Some(expected) { + return; + } + cx.background_executor + .timer(Duration::from_millis(10)) + .await; + } + let process_info = terminal.update(cx, |terminal, _| match &terminal.terminal_type { + TerminalType::Pty { info, .. } => format!( + "pid={:?}, fallback_pid={:?}, has_current_info={}", + info.pid(), + info.pid_getter().fallback_pid(), + info.current.read().is_some() + ), + TerminalType::DisplayOnly => "display-only".to_string(), + }); + panic!( + "Expected foreground process command name to be {expected:?}, got {command_name:?}; process info: {process_info:?}" + ); + } + /// Test that kill_active_task properly terminates both the foreground process /// and the shell, allowing wait_for_completed_task to complete and output to be captured. #[cfg(unix)] From bedfe32fdc51df8272663481de3e2f7cc8fb541c Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 28 May 2026 10:19:11 +0200 Subject: [PATCH 38/93] agent_ui: Clarify multi-root agent warning text (#57874) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/agent_ui/src/conversation_view/thread_view.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 5fabb8f4569..a90fef49bd9 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -9464,17 +9464,15 @@ impl ThreadView { .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| "one folder".to_string()); - let description = format!( - "This agent only operates on \"{}\". Other folders in this workspace are not accessible to it.", - active_dir - ); - Some( Callout::new() .severity(Severity::Warning) .icon(IconName::Warning) - .title("External Agents currently don't support multi-root workspaces") - .description(description) + .title("This agent doesn't currently support multi-root workspaces") + .description(format!( + "It currently only operates by default on \"{}\".", + active_dir + )) .border_position(ui::BorderPosition::Bottom) .dismiss_action( IconButton::new("dismiss-multi-root-callout", IconName::Close) From 6726b15fce90ab6f6a650204b7da23a0a398320b Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 28 May 2026 15:04:57 +0530 Subject: [PATCH 39/93] terminal: Forward Shift navigation keys to alternate-screen programs (#57479) I ran into this while using lazygit: the Shift navigation keys were being captured by Zed to scroll the terminal buffer instead of being passed through to the program. Capturing them makes sense in the normal terminal case, where the user wants to scroll and Shift has no other meaning. But in alternate-screen TUIs like lazygit, less, or neovim, terminal scrollback isn't relevant, so we can forward these keys to the program while it's open. Release Notes: - Fixed Shift+Up, Shift+Down, Shift+Home, and Shift+End in terminal TUIs like lazygit, less, and neovim. --------- Co-authored-by: John Tur --- crates/terminal/src/mappings/keys.rs | 10 + crates/terminal_view/src/terminal_view.rs | 233 ++++++++++++++++++---- 2 files changed, 202 insertions(+), 41 deletions(-) diff --git a/crates/terminal/src/mappings/keys.rs b/crates/terminal/src/mappings/keys.rs index 8345c417280..5983ce5d63d 100644 --- a/crates/terminal/src/mappings/keys.rs +++ b/crates/terminal/src/mappings/keys.rs @@ -303,8 +303,18 @@ mod test { assert_eq!(to_esc_str(&home, &app_cursor, false), Some("\x1bOH".into())); assert_eq!(to_esc_str(&end, &app_cursor, false), Some("\x1bOF".into())); + let shift_up = Keystroke::parse("shift-up").unwrap(); + let shift_down = Keystroke::parse("shift-down").unwrap(); let shift_home = Keystroke::parse("shift-home").unwrap(); let shift_end = Keystroke::parse("shift-end").unwrap(); + assert_eq!( + to_esc_str(&shift_up, &none, false), + Some("\x1b[1;2A".into()) + ); + assert_eq!( + to_esc_str(&shift_down, &none, false), + Some("\x1b[1;2B".into()) + ); assert_eq!( to_esc_str(&shift_home, &none, false), Some("\x1b[1;2H".into()) diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index cc0430bf853..68c4281441a 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -673,7 +673,20 @@ impl TerminalView { }); } + fn is_alt_screen(&self, cx: &App) -> bool { + self.terminal + .read(cx) + .last_content + .mode + .contains(TermMode::ALT_SCREEN) + } + fn scroll_line_up(&mut self, _: &ScrollLineUp, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + let terminal_content = self.terminal.read(cx).last_content(); if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 @@ -689,6 +702,11 @@ impl TerminalView { } fn scroll_line_down(&mut self, _: &ScrollLineDown, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + let terminal_content = self.terminal.read(cx).last_content(); if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 { let max_scroll_top = self.max_scroll_top(cx); @@ -704,6 +722,11 @@ impl TerminalView { } fn scroll_page_up(&mut self, _: &ScrollPageUp, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + if self.scroll_top == Pixels::ZERO { self.terminal.update(cx, |term, _| term.scroll_page_up()); } else { @@ -729,6 +752,11 @@ impl TerminalView { } fn scroll_page_down(&mut self, _: &ScrollPageDown, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + self.terminal.update(cx, |term, _| term.scroll_page_down()); let terminal = self.terminal.read(cx); if terminal.last_content().display_offset < terminal.viewport_lines() { @@ -738,11 +766,21 @@ impl TerminalView { } fn scroll_to_top(&mut self, _: &ScrollToTop, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + self.terminal.update(cx, |term, _| term.scroll_to_top()); cx.notify(); } fn scroll_to_bottom(&mut self, _: &ScrollToBottom, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + self.terminal.update(cx, |term, _| term.scroll_to_bottom()); if self.block_below_cursor.is_some() { self.scroll_top = self.max_scroll_top(cx); @@ -2060,7 +2098,7 @@ fn first_project_directory(workspace: &Workspace, cx: &App) -> Option { #[cfg(test)] mod tests { use super::*; - use gpui::TestAppContext; + use gpui::{TestAppContext, VisualTestContext}; use project::{Entry, Project, ProjectPath, Worktree}; use remote::RemoteClient; use std::path::{Path, PathBuf}; @@ -2104,6 +2142,88 @@ mod tests { assert_eq!(written, expected_text); } + // DEC private mode 1049: a program writes this to enter the alternate screen buffer. + const ENTER_ALT_SCREEN: &[u8] = b"\x1b[?1049h"; + + // CSI `1;2A` = cursor-up with the xterm Shift modifier (`1 + 1` for Shift). + const SHIFT_UP_ESCAPE: &[u8] = b"\x1b[1;2A"; + + #[gpui::test] + async fn shift_up_scrolls_history_in_normal_screen(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + cx.update(load_default_keymap); + let (_pane, terminal, _terminal_view) = + add_display_only_terminal(&project, window_handle, true, cx); + + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.run_until_parked(); + + let output = (0..200) + .map(|line| format!("line {line}\n")) + .collect::(); + cx.update(|window, cx| { + terminal.update(cx, |terminal, cx| { + terminal.write_output(output.as_bytes(), cx); + terminal.sync(window, cx); + }); + }); + terminal.read_with(&cx, |terminal, _| { + assert!(!terminal.last_content.mode.contains(TermMode::ALT_SCREEN)); + assert_eq!(terminal.last_content.display_offset, 0); + }); + + cx.simulate_keystrokes("shift-up"); + cx.update(|window, cx| { + terminal.update(cx, |terminal, cx| terminal.sync(window, cx)); + }); + + assert_eq!( + terminal.read_with(&cx, |terminal, _| terminal.last_content.display_offset), + 1, + "shift-up should scroll terminal history in the normal screen", + ); + assert!( + terminal + .update(&mut cx, |terminal, _| terminal.take_input_log()) + .is_empty(), + "shift-up in the normal screen should not be forwarded to the shell", + ); + } + + #[gpui::test] + async fn shift_up_is_forwarded_to_program_in_alt_screen(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + cx.update(load_default_keymap); + let (_pane, terminal, _terminal_view) = + add_display_only_terminal(&project, window_handle, true, cx); + + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.run_until_parked(); + + cx.update(|window, cx| { + terminal.update(cx, |terminal, cx| { + terminal.write_output(ENTER_ALT_SCREEN, cx); + terminal.sync(window, cx); + }); + }); + terminal.read_with(&cx, |terminal, _| { + assert!(terminal.last_content.mode.contains(TermMode::ALT_SCREEN)); + }); + + cx.simulate_keystrokes("shift-up"); + assert_eq!( + terminal.update(&mut cx, |terminal, _| terminal.take_input_log()), + vec![SHIFT_UP_ESCAPE.to_vec()], + "shift-up should be forwarded to the program in the alternate screen", + ); + } + // Working directory calculation tests // No Worktrees in project -> home_dir() @@ -2276,6 +2396,72 @@ mod tests { (project, workspace) } + fn load_default_keymap(cx: &mut App) { + cx.bind_keys( + settings::KeymapFile::load_asset_allow_partial_failure( + settings::DEFAULT_KEYMAP_PATH, + cx, + ) + .unwrap(), + ); + } + + fn add_display_only_terminal( + project: &Entity, + window_handle: gpui::WindowHandle, + focus: bool, + cx: &mut TestAppContext, + ) -> (Entity, Entity, Entity) { + let project = project.clone(); + window_handle + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + let active_pane = workspace.read(cx).active_pane().clone(); + + let terminal = cx.new(|cx| { + terminal::TerminalBuilder::new_display_only( + CursorShape::default(), + terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .unwrap() + .subscribe(cx) + }); + let terminal_view = cx.new(|cx| { + TerminalView::new( + terminal.clone(), + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }); + + active_pane.update(cx, |pane, cx| { + pane.add_item( + Box::new(terminal_view.clone()), + true, + false, + None, + window, + cx, + ); + }); + + if focus { + let focus_handle = terminal_view.read(cx).focus_handle.clone(); + focus_handle.focus(window, cx); + } + + (active_pane, terminal, terminal_view) + }) + .unwrap() + } + /// Creates a worktree with 1 file /root.txt and returns the project, workspace, and window handle. async fn init_test_with_window( cx: &mut TestAppContext, @@ -2476,45 +2662,11 @@ mod tests { }) .unwrap(); - let (active_pane, terminal, terminal_view, tab_item) = window_handle - .update(cx, |multi_workspace, window, cx| { - let workspace = multi_workspace.workspace().clone(); - let active_pane = workspace.read(cx).active_pane().clone(); - - let terminal = cx.new(|cx| { - terminal::TerminalBuilder::new_display_only( - CursorShape::default(), - terminal::terminal_settings::AlternateScroll::On, - None, - 0, - cx.background_executor(), - PathStyle::local(), - ) - .unwrap() - .subscribe(cx) - }); - let terminal_view = cx.new(|cx| { - TerminalView::new( - terminal.clone(), - workspace.downgrade(), - None, - project.downgrade(), - window, - cx, - ) - }); - - active_pane.update(cx, |pane, cx| { - pane.add_item( - Box::new(terminal_view.clone()), - true, - false, - None, - window, - cx, - ); - }); + let (active_pane, terminal, terminal_view) = + add_display_only_terminal(&project, window_handle, false, cx); + let tab_item = window_handle + .update(cx, |_, window, cx| { let tab_project_item = cx.new(|_| TestProjectItem { entry_id: Some(second_entry.id), project_path: Some(ProjectPath { @@ -2528,8 +2680,7 @@ mod tests { active_pane.update(cx, |pane, cx| { pane.add_item(Box::new(tab_item.clone()), true, false, None, window, cx); }); - - (active_pane, terminal, terminal_view, tab_item) + tab_item }) .unwrap(); From 6049ceaecf397765104080545ab41d578a6109f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Mart=C3=ADn?= Date: Thu, 28 May 2026 11:55:14 +0200 Subject: [PATCH 40/93] Auto-scroll hover popup while selecting text (#57518) Selecting text inside the hover documentation or git popups did not scroll the popup when the drag passed the visible area, so any text below the area could not be selected with the mouse. The popup's container already had a `ScrollHandle` wired to its scrollbar and wheel scrolling, but the inner `MarkdownElement` was constructed without one. That left it in the default `AutoscrollBehavior::Propagate` mode, which routes drag-autoscroll requests to the editor-wide autoscroll listener, which is a listener that does not exist inside a floating popover, so the requests were silently dropped. Passing the popup's existing `ScrollHandle` into the `MarkdownElement` switches it to `AutoscrollBehavior::Controlled`, which scrolls the popup's own container directly during a drag. The markdown preview view already uses this same pattern. Release Notes: - Fixed hover documentation and git popups not scrolling while selecting text with the mouse Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- crates/editor/src/hover_popover.rs | 1 + crates/git_ui/src/blame_ui.rs | 6 +++++- crates/git_ui/src/commit_tooltip.rs | 6 +++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 5f256f2197b..5c5eb651e35 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -1088,6 +1088,7 @@ impl InfoPopover { .track_scroll(&self.scroll_handle) .child( MarkdownElement::new(markdown, hover_markdown_style(window, cx)) + .scroll_handle(self.scroll_handle.clone()) .code_block_renderer(markdown::CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index 47d781c4870..34bf205b57a 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -238,7 +238,11 @@ impl BlameRenderer for GitBlameRenderer { let message = details .as_ref() - .map(|_| MarkdownElement::new(markdown.clone(), markdown_style).into_any()) + .map(|_| { + MarkdownElement::new(markdown.clone(), markdown_style) + .scroll_handle(scroll_handle.clone()) + .into_any() + }) .unwrap_or("".into_any()); let pull_request = details diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index a355d7355b9..13ac1c759cf 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -258,7 +258,11 @@ impl Render for CommitTooltip { .commit .message .as_ref() - .map(|_| MarkdownElement::new(self.markdown.clone(), markdown_style).into_any()) + .map(|_| { + MarkdownElement::new(self.markdown.clone(), markdown_style) + .scroll_handle(self.scroll_handle.clone()) + .into_any() + }) .unwrap_or("".into_any()); let pull_request = self From 518502e5efe8f5f984fca30c22563df42e2f43cf Mon Sep 17 00:00:00 2001 From: KyleBarton Date: Thu, 28 May 2026 04:22:41 -0700 Subject: [PATCH 41/93] Respect override command in devcontainer (#57204) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #56357 Release Notes: - Fixed bug where devcontainers were not respecting override_command --- crates/dev_container/src/devcontainer_json.rs | 2 +- .../src/devcontainer_manifest.rs | 113 ++++++++++++++---- 2 files changed, 89 insertions(+), 26 deletions(-) diff --git a/crates/dev_container/src/devcontainer_json.rs b/crates/dev_container/src/devcontainer_json.rs index 42e6c6f316c..6033ca29f0b 100644 --- a/crates/dev_container/src/devcontainer_json.rs +++ b/crates/dev_container/src/devcontainer_json.rs @@ -211,7 +211,7 @@ pub(crate) struct DevContainer { #[serde(rename = "updateRemoteUserUID")] pub(crate) update_remote_user_uid: Option, user_env_probe: Option, - override_command: Option, + pub(crate) override_command: Option, shutdown_action: Option, init: Option, pub(crate) privileged: Option, diff --git a/crates/dev_container/src/devcontainer_manifest.rs b/crates/dev_container/src/devcontainer_manifest.rs index c88c4df995a..cc8fbedb14d 100644 --- a/crates/dev_container/src/devcontainer_manifest.rs +++ b/crates/dev_container/src/devcontainer_manifest.rs @@ -794,24 +794,30 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true let privileged = dev_container.privileged.unwrap_or(false) || self.features.iter().any(|f| f.privileged()); - let mut entrypoint_script_lines = vec![ - "echo Container started".to_string(), - "trap \"exit 0\" 15".to_string(), - ]; + let entrypoint_script = if dev_container.override_command == Some(false) { + None + } else { + let mut entrypoint_script_lines = vec![ + "echo Container started".to_string(), + "trap \"exit 0\" 15".to_string(), + ]; - for entrypoint in self.features.iter().filter_map(|f| f.entrypoint()) { - entrypoint_script_lines.push(entrypoint.clone()); - } - entrypoint_script_lines.append(&mut vec![ - "exec \"$@\"".to_string(), - "while sleep 1 & wait $!; do :; done".to_string(), - ]); + for entrypoint in self.features.iter().filter_map(|f| f.entrypoint()) { + entrypoint_script_lines.push(entrypoint.clone()); + } + entrypoint_script_lines.append(&mut vec![ + "exec \"$@\"".to_string(), + "while sleep 1 & wait $!; do :; done".to_string(), + ]); + + Some(entrypoint_script_lines.join("\n").trim().to_string()) + }; Ok(DockerBuildResources { image: base_image, additional_mounts: mounts, privileged, - entrypoint_script: entrypoint_script_lines.join("\n").trim().to_string(), + entrypoint_script, }) } @@ -1255,13 +1261,17 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true }) .collect(); - let mut main_service = DockerComposeService { - entrypoint: Some(vec![ + let entrypoint = resources.entrypoint_script.map(|script| { + vec![ "/bin/sh".to_string(), "-c".to_string(), - resources.entrypoint_script, + script, "-".to_string(), - ]), + ] + }); + + let mut main_service = DockerComposeService { + entrypoint, cap_add: Some(vec!["SYS_PTRACE".to_string()]), security_opt: Some(vec!["seccomp=unconfined".to_string()]), labels: Some(runtime_labels), @@ -1977,13 +1987,16 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true command.arg(app_port); } - command.arg("--entrypoint"); - command.arg("/bin/sh"); - command.arg(&build_resources.image.id); - command.arg("-c"); - - command.arg(build_resources.entrypoint_script); - command.arg("-"); + if let Some(entrypoint_script) = build_resources.entrypoint_script { + command.arg("--entrypoint"); + command.arg("/bin/sh"); + command.arg(&build_resources.image.id); + command.arg("-c"); + command.arg(entrypoint_script); + command.arg("-"); + } else { + command.arg(&build_resources.image.id); + } Ok(command) } @@ -2409,7 +2422,7 @@ struct DockerBuildResources { image: DockerInspect, additional_mounts: Vec, privileged: bool, - entrypoint_script: String, + entrypoint_script: Option, } #[derive(Debug)] @@ -3166,7 +3179,7 @@ mod test { }, additional_mounts: vec![], privileged: false, - entrypoint_script: "echo Container started\n trap \"exit 0\" 15\n exec \"$@\"\n while sleep 1 & wait $!; do :; done".to_string(), + entrypoint_script: Some("echo Container started\n trap \"exit 0\" 15\n exec \"$@\"\n while sleep 1 & wait $!; do :; done".to_string()), }; let docker_run_command = devcontainer_manifest.create_docker_run_command(build_resources); @@ -3212,6 +3225,56 @@ mod test { ) } + #[gpui::test] + async fn should_not_override_entrypoint_when_override_command_is_false( + cx: &mut TestAppContext, + ) { + let (_, mut devcontainer_manifest) = init_default_devcontainer_manifest( + cx, + r#"{ + "name": "test", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "overrideCommand": false + }"#, + ) + .await + .unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let base_image = DockerInspect { + id: "mcr.microsoft.com/devcontainers/base:ubuntu".to_string(), + config: DockerInspectConfig { + labels: DockerConfigLabels { metadata: None }, + image_user: None, + env: Vec::new(), + }, + mounts: None, + state: None, + }; + + let resources = devcontainer_manifest + .build_merged_resources(base_image) + .unwrap(); + assert!( + resources.entrypoint_script.is_none(), + "overrideCommand: false must not produce an entrypoint script" + ); + + let docker_run_command = devcontainer_manifest + .create_docker_run_command(resources) + .unwrap(); + let args: Vec<&OsStr> = docker_run_command.get_args().collect(); + assert!( + !args.contains(&OsStr::new("--entrypoint")), + "overrideCommand: false must not pass --entrypoint to docker run" + ); + assert!( + args.contains(&OsStr::new("mcr.microsoft.com/devcontainers/base:ubuntu")), + "image id must still be present in docker run command" + ); + } + #[gpui::test] async fn should_find_primary_service_in_docker_compose(cx: &mut TestAppContext) { // State where service not defined in dev container From 8042408df4d6859b40aac307884a53530ce34c4d Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Thu, 28 May 2026 08:59:56 -0400 Subject: [PATCH 42/93] ep: Collect data for staff in Zed Industries repos (#57733) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A or Added/Fixed/Improved ... --- crates/edit_prediction/src/edit_prediction.rs | 33 +++++++++++++++---- crates/edit_prediction/src/zeta.rs | 13 ++------ 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index f6faa7d7123..b5449ff5390 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -2551,11 +2551,24 @@ impl EditPredictionStore { EditPredictionsMode::Subtle => PredictEditsMode::Subtle, }; - let is_open_source = snapshot - .file() - .map_or(false, |file| self.is_file_open_source(&project, file, cx)) - && events.iter().all(|event| event.in_open_source_repo()) - && related_files.iter().all(|file| file.in_open_source_repo); + let buffer_id = active_buffer.read(cx).remote_id(); + let repo_url = project + .read(cx) + .git_store() + .read(cx) + .repository_and_path_for_buffer_id(buffer_id, cx) + .and_then(|(repo, _)| repo.read(cx).default_remote_url()); + + let is_staff_zed_repo = cx.is_staff() + && repo_url + .as_ref() + .is_some_and(|url| is_zed_industries_repo(url)); + let is_open_source = is_staff_zed_repo + || (snapshot + .file() + .map_or(false, |file| self.is_file_open_source(&project, file, cx)) + && events.iter().all(|event| event.in_open_source_repo()) + && related_files.iter().all(|file| file.in_open_source_repo)); let can_collect_data = !cfg!(test) && is_open_source @@ -2594,7 +2607,7 @@ impl EditPredictionStore { ) }); - zeta::request_prediction_with_zeta(self, inputs, capture_events, cx) + zeta::request_prediction_with_zeta(self, inputs, capture_events, repo_url, cx) } EditPredictionModel::Fim { format } => fim::request_prediction(inputs, format, cx), EditPredictionModel::Mercury => { @@ -3286,3 +3299,11 @@ pub fn init(cx: &mut App) { }) .detach(); } + +fn is_zed_industries_repo(url: &str) -> bool { + url.strip_prefix("https://github.com/zed-industries/") + .or_else(|| url.strip_prefix("http://github.com/zed-industries/")) + .or_else(|| url.strip_prefix("git@github.com:zed-industries/")) + .or_else(|| url.strip_prefix("ssh://git@github.com/zed-industries/")) + .is_some_and(|repo| !repo.is_empty()) +} diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index f20e5786de1..28aa1229517 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -53,6 +53,7 @@ pub fn request_prediction_with_zeta( Vec, Task, Entity>>>, )>, + repo_url: Option, cx: &mut Context, ) -> Task>> { let settings = &all_language_settings(None, cx).edit_predictions; @@ -73,17 +74,7 @@ pub fn request_prediction_with_zeta( let excerpt_path = buffer_path_with_id_fallback(snapshot.file(), &snapshot.text, cx); - let repo_url = if can_collect_data { - let buffer_id = buffer.read(cx).remote_id(); - project - .read(cx) - .git_store() - .read(cx) - .repository_and_path_for_buffer_id(buffer_id, cx) - .and_then(|(repo, _)| repo.read(cx).default_remote_url()) - } else { - None - }; + let repo_url = repo_url.filter(|_| can_collect_data); let client = store.client.clone(); let llm_token = store.llm_token.clone(); let organization_id = store From b8c853a63d7a391475a1787532249576c0894f7f Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Thu, 28 May 2026 09:27:55 -0400 Subject: [PATCH 43/93] ep: Fix agent edits triggering edit predictions due to diagnostic refresh (#57832) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A or Added/Fixed/Improved ... --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- crates/acp_thread/src/acp_thread.rs | 6 +- crates/agent/src/tools/edit_session.rs | 4 +- crates/agent_ui/src/buffer_codegen.rs | 6 +- crates/edit_prediction/src/edit_prediction.rs | 30 +++++- .../src/edit_prediction_tests.rs | 101 +++++++++++++++++- crates/editor/src/editor.rs | 4 +- crates/language/src/buffer.rs | 54 +++++++--- crates/language/src/buffer_tests.rs | 24 +++-- crates/multi_buffer/src/multi_buffer.rs | 28 ++--- crates/multi_buffer/src/multi_buffer_tests.rs | 6 +- crates/multi_buffer/src/path_key.rs | 6 +- crates/multi_buffer/src/transaction.rs | 31 +++++- crates/project/src/project.rs | 14 +-- .../tests/integration/project_tests.rs | 24 +++-- 14 files changed, 274 insertions(+), 64 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 3270bac05f4..e27f09da557 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -16,7 +16,9 @@ use gpui::{ }; use itertools::Itertools; use language::language_settings::FormatOnSave; -use language::{Anchor, Buffer, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff}; +use language::{ + Anchor, Buffer, BufferEditSource, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff, +}; use markdown::{Markdown, MarkdownOptions}; pub use mention::*; use project::lsp_store::{FormatTrigger, LspFormatTarget}; @@ -2912,7 +2914,9 @@ impl AcpThread { }); let format_on_save = buffer.update(cx, |buffer, cx| { + buffer.start_transaction(); buffer.edit(edits, None, cx); + buffer.end_transaction_with_source(BufferEditSource::Agent, cx); let settings = language::language_settings::LanguageSettings::for_buffer(buffer, cx); diff --git a/crates/agent/src/tools/edit_session.rs b/crates/agent/src/tools/edit_session.rs index ed5112e908e..fcf3a98f678 100644 --- a/crates/agent/src/tools/edit_session.rs +++ b/crates/agent/src/tools/edit_session.rs @@ -12,7 +12,7 @@ use collections::HashSet; use futures::{FutureExt, channel::oneshot}; use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; use language::language_settings::{self, FormatOnSave}; -use language::{Buffer, BufferEvent, LanguageRegistry}; +use language::{Buffer, BufferEditSource, BufferEvent, LanguageRegistry}; use language_model::LanguageModelToolResultContent; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use project::{AgentLocation, Project, ProjectPath}; @@ -975,7 +975,9 @@ fn agent_edit_buffer( { cx.update(|cx| { buffer.update(cx, |buffer, cx| { + buffer.start_transaction(); buffer.edit(edits, None, cx); + buffer.end_transaction_with_source(BufferEditSource::Agent, cx); }); action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); }); diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 9848a78bc2d..dd43d1fbd17 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -12,7 +12,9 @@ use futures::{ stream::BoxStream, }; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task}; -use language::{Buffer, IndentKind, LanguageName, Point, TransactionId, line_diff}; +use language::{ + Buffer, BufferEditSource, IndentKind, LanguageName, Point, TransactionId, line_diff, +}; use language_model::{ CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, @@ -978,7 +980,7 @@ impl CodegenAlternative { buffer.finalize_last_transaction(cx); buffer.start_transaction(cx); buffer.edit(edits, None, cx); - buffer.end_transaction(cx) + buffer.end_transaction_with_source(BufferEditSource::Agent, cx) }); if let Some(transaction) = transaction { diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index b5449ff5390..f310e9865e3 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -38,9 +38,9 @@ use gpui::{ }; use heapless::Vec as ArrayVec; use language::{ - Anchor, Buffer, BufferSnapshot, EditPredictionPromptFormat, EditPredictionsMode, EditPreview, - File, OffsetRangeExt, Point, TextBufferSnapshot, ToOffset, ToPoint, - language_settings::all_language_settings, + Anchor, Buffer, BufferEditSource, BufferSnapshot, EditPredictionPromptFormat, + EditPredictionsMode, EditPreview, File, OffsetRangeExt, Point, TextBufferSnapshot, ToOffset, + ToPoint, language_settings::all_language_settings, }; use project::{DisableAiSettings, Project, ProjectPath, WorktreeId}; use release_channel::AppVersion; @@ -324,6 +324,7 @@ struct ProjectState { recent_paths: VecDeque, registered_buffers: HashMap, current_prediction: Option, + last_edit_source: Option, next_pending_prediction_id: usize, pending_predictions: ArrayVec, debug_tx: Option>, @@ -1212,6 +1213,7 @@ impl EditPredictionStore { debug_tx: None, registered_buffers: HashMap::default(), current_prediction: None, + last_edit_source: None, cancelled_predictions: HashSet::default(), pending_predictions: ArrayVec::new(), next_pending_prediction_id: 0, @@ -1315,6 +1317,9 @@ impl EditPredictionStore { } // TODO [zeta2] init with recent paths match event { + project::Event::BufferEdited { source } => { + self.get_or_init_project(&project, cx).last_edit_source = Some(*source); + } project::Event::ActiveEntryChanged(Some(active_entry_id)) => { let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { return; @@ -1332,6 +1337,15 @@ impl EditPredictionStore { } } project::Event::DiagnosticsUpdated { .. } => { + if self + .projects + .get(&project.entity_id()) + .and_then(|project_state| project_state.last_edit_source) + == Some(BufferEditSource::Agent) + { + return; + } + if cx.has_flag::() { self.refresh_prediction_from_diagnostics( project, @@ -1391,11 +1405,17 @@ impl EditPredictionStore { cx.subscribe(buffer, { let project = project.downgrade(); move |this, buffer, event, cx| { - if let language::BufferEvent::Edited { is_local } = event + if let language::BufferEvent::Edited { source } = event && let Some(project) = project.upgrade() { + let project_state = this.get_or_init_project(&project, cx); + project_state.last_edit_source = Some(*source); this.report_changes_for_buffer( - &buffer, &project, false, *is_local, cx, + &buffer, + &project, + false, + source.is_local(), + cx, ); } } diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 5eb4662af51..fb348616ba6 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -26,8 +26,8 @@ use gpui::{ }; use indoc::indoc; use language::{ - Anchor, Buffer, Capability, CursorShape, Diagnostic, DiagnosticEntry, DiagnosticSet, - DiagnosticSeverity, Operation, Point, Selection, SelectionGoal, + Anchor, Buffer, BufferEditSource, Capability, CursorShape, Diagnostic, DiagnosticEntry, + DiagnosticSet, DiagnosticSeverity, Operation, Point, Selection, SelectionGoal, }; use lsp::LanguageServerId; @@ -352,6 +352,70 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon }); } +#[gpui::test] +async fn test_diagnostics_refresh_suppressed_after_agent_edit(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + + cx.update(|cx| { + cx.update_flags( + false, + vec![EditPredictionJumpsFeatureFlag::NAME.to_string()], + ); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "1.txt": "Hello!\nHow\nBye\n", + "2.txt": "Hola!\nComo\nAdios\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/1.txt"), cx).unwrap(); + project.set_active_path(Some(path.clone()), cx); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_project(&project, cx); + ep_store.register_buffer(&buffer, &project, cx); + }); + + buffer.update(cx, |buffer, cx| { + buffer.start_transaction(); + buffer.edit([(Point::new(1, 3)..Point::new(1, 3), "!")], None, cx); + buffer.end_transaction_with_source(BufferEditSource::Agent, cx); + }); + cx.run_until_parked(); + + update_test_diagnostics(&project, path!("/root/2.txt"), "Sentence is incomplete", cx); + cx.run_until_parked(); + assert_no_predict_request_ready(&mut requests.predict); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(Point::new(1, 4)..Point::new(1, 4), "?")], None, cx); + }); + cx.run_until_parked(); + + update_test_diagnostics( + &project, + path!("/root/2.txt"), + "Sentence is still incomplete", + cx, + ); + + let (_request, respond_tx) = requests.predict.next().await.unwrap(); + respond_tx.send(empty_response()).unwrap(); + cx.run_until_parked(); +} + #[gpui::test] async fn test_simple_request(cx: &mut TestAppContext) { let (ep_store, mut requests) = init_test_with_fake_client(cx); @@ -2498,6 +2562,39 @@ fn assert_no_predict_request_ready( } } +fn update_test_diagnostics( + project: &Entity, + path: &str, + message: &str, + cx: &mut TestAppContext, +) { + let diagnostic = lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: message.to_string(), + ..Default::default() + }; + + project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path).unwrap(), + diagnostics: vec![diagnostic], + version: None, + }, + None, + language::DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + }); + }); +} + struct RequestChannels { predict: mpsc::UnboundedReceiver<( PredictEditsV3Request, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 4df67f8fdee..0ced11ed2e6 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -9231,7 +9231,7 @@ impl Editor { match event { multi_buffer::Event::Edited { edited_buffer, - is_local, + source, } => { self.scrollbar_marker_state.dirty = true; self.active_indent_guides_state.dirty = true; @@ -9242,7 +9242,7 @@ impl Editor { self.refresh_matching_bracket_highlights(&snapshot, cx); self.refresh_outline_symbols_at_cursor(cx); self.refresh_sticky_headers(&snapshot, cx); - if *is_local && self.has_active_edit_prediction() { + if source.is_local() && self.has_active_edit_prediction() { self.update_visible_edit_prediction(window, cx); } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index ec3b4327e1b..310788bc6b1 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -297,6 +297,19 @@ pub enum Operation { }, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BufferEditSource { + User, + Agent, + Remote, +} + +impl BufferEditSource { + pub fn is_local(self) -> bool { + !matches!(self, Self::Remote) + } +} + /// An event that occurs in a buffer. #[derive(Clone, Debug, PartialEq)] pub enum BufferEvent { @@ -307,7 +320,7 @@ pub enum BufferEvent { is_local: bool, }, /// The buffer was edited. - Edited { is_local: bool }, + Edited { source: BufferEditSource }, /// The buffer's `dirty` bit changed. DirtyChanged, /// The buffer was saved. @@ -2433,6 +2446,14 @@ impl Buffer { self.end_transaction_at(Instant::now(), cx) } + pub fn end_transaction_with_source( + &mut self, + source: BufferEditSource, + cx: &mut Context, + ) -> Option { + self.end_transaction_at_internal(Instant::now(), source, cx) + } + /// Terminates the current transaction, providing the current time. Subsequent transactions /// that occur within a short period of time will be grouped together. This /// is controlled by the buffer's undo grouping duration. @@ -2440,6 +2461,15 @@ impl Buffer { &mut self, now: Instant, cx: &mut Context, + ) -> Option { + self.end_transaction_at_internal(now, BufferEditSource::User, cx) + } + + fn end_transaction_at_internal( + &mut self, + now: Instant, + source: BufferEditSource, + cx: &mut Context, ) -> Option { assert!(self.transaction_depth > 0); self.transaction_depth -= 1; @@ -2449,7 +2479,7 @@ impl Buffer { false }; if let Some((transaction_id, start_version)) = self.text.end_transaction_at(now) { - self.did_edit(&start_version, was_dirty, true, cx); + self.did_edit(&start_version, was_dirty, source, cx); Some(transaction_id) } else { None @@ -2844,7 +2874,7 @@ impl Buffer { &mut self, old_version: &clock::Global, was_dirty: bool, - is_local: bool, + source: BufferEditSource, cx: &mut Context, ) { self.was_changed(); @@ -2854,7 +2884,7 @@ impl Buffer { } self.reparse(cx, true); - cx.emit(BufferEvent::Edited { is_local }); + cx.emit(BufferEvent::Edited { source }); let is_dirty = self.is_dirty(); if was_dirty != is_dirty { cx.emit(BufferEvent::DirtyChanged); @@ -2976,7 +3006,7 @@ impl Buffer { self.text.apply_ops(buffer_ops); self.deferred_ops.insert(deferred_ops); self.flush_deferred_ops(cx); - self.did_edit(&old_version, was_dirty, false, cx); + self.did_edit(&old_version, was_dirty, BufferEditSource::Remote, cx); // Notify independently of whether the buffer was edited as the operations could include a // selection update. cx.notify(); @@ -3131,7 +3161,7 @@ impl Buffer { if let Some((transaction_id, operation)) = self.text.undo() { self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, true, cx); + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx); self.restore_encoding_for_transaction(transaction_id, was_dirty); Some(transaction_id) } else { @@ -3149,7 +3179,7 @@ impl Buffer { let old_version = self.version.clone(); if let Some(operation) = self.text.undo_transaction(transaction_id) { self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, true, cx); + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx); true } else { false @@ -3171,7 +3201,7 @@ impl Buffer { self.send_operation(Operation::Buffer(operation), true, cx); } if undone { - self.did_edit(&old_version, was_dirty, true, cx) + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx) } undone } @@ -3181,7 +3211,7 @@ impl Buffer { let operation = self.text.undo_operations(counts); let old_version = self.version.clone(); self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, true, cx); + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx); } /// Manually redoes a specific transaction in the buffer's redo history. @@ -3191,7 +3221,7 @@ impl Buffer { if let Some((transaction_id, operation)) = self.text.redo() { self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, true, cx); + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx); self.restore_encoding_for_transaction(transaction_id, was_dirty); Some(transaction_id) } else { @@ -3232,7 +3262,7 @@ impl Buffer { self.send_operation(Operation::Buffer(operation), true, cx); } if redone { - self.did_edit(&old_version, was_dirty, true, cx) + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx) } redone } @@ -3342,7 +3372,7 @@ impl Buffer { if !ops.is_empty() { for op in ops { self.send_operation(Operation::Buffer(op), true, cx); - self.did_edit(&old_version, was_dirty, true, cx); + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx); } } } diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 493003c5b17..b46b3611a5d 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -460,16 +460,24 @@ fn test_edit_events(cx: &mut gpui::App) { assert_eq!( mem::take(&mut *buffer_1_events.lock()), vec![ - BufferEvent::Edited { is_local: true }, + BufferEvent::Edited { + source: BufferEditSource::User + }, BufferEvent::DirtyChanged, - BufferEvent::Edited { is_local: true }, - BufferEvent::Edited { is_local: true }, + BufferEvent::Edited { + source: BufferEditSource::User + }, + BufferEvent::Edited { + source: BufferEditSource::User + }, ] ); assert_eq!( mem::take(&mut *buffer_2_events.lock()), vec![ - BufferEvent::Edited { is_local: false }, + BufferEvent::Edited { + source: BufferEditSource::Remote + }, BufferEvent::DirtyChanged ] ); @@ -487,14 +495,18 @@ fn test_edit_events(cx: &mut gpui::App) { assert_eq!( mem::take(&mut *buffer_1_events.lock()), vec![ - BufferEvent::Edited { is_local: true }, + BufferEvent::Edited { + source: BufferEditSource::User + }, BufferEvent::DirtyChanged, ] ); assert_eq!( mem::take(&mut *buffer_2_events.lock()), vec![ - BufferEvent::Edited { is_local: false }, + BufferEvent::Edited { + source: BufferEditSource::Remote + }, BufferEvent::DirtyChanged ] ); diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 4b1231af45a..809f23bc394 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -20,11 +20,11 @@ use futures_lite::future::yield_now; use gpui::{App, Context, Entity, EventEmitter}; use itertools::Itertools; use language::{ - AutoindentMode, Buffer, BufferChunks, BufferRow, BufferSnapshot, Capability, CharClassifier, - CharKind, CharScopeContext, Chunk, CursorShape, DiagnosticEntryRef, File, IndentGuideSettings, - IndentSize, Language, LanguageAwareStyling, LanguageScope, OffsetRangeExt, OffsetUtf16, - Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, TextObject, ToOffset as _, - ToPoint as _, TransactionId, TreeSitterOptions, Unclipped, + AutoindentMode, Buffer, BufferChunks, BufferEditSource, BufferRow, BufferSnapshot, Capability, + CharClassifier, CharKind, CharScopeContext, Chunk, CursorShape, DiagnosticEntryRef, File, + IndentGuideSettings, IndentSize, Language, LanguageAwareStyling, LanguageScope, OffsetRangeExt, + OffsetUtf16, Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, TextObject, + ToOffset as _, ToPoint as _, TransactionId, TreeSitterOptions, Unclipped, language_settings::{AllLanguageSettings, LanguageSettings}, }; @@ -110,7 +110,7 @@ pub enum Event { DiffHunksToggled, Edited { edited_buffer: Option>, - is_local: bool, + source: BufferEditSource, }, TransactionUndone { transaction_id: TransactionId, @@ -1828,7 +1828,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); cx.emit(Event::BuffersRemoved { removed_buffer_ids }); cx.notify(); @@ -1952,9 +1952,9 @@ impl MultiBuffer { use language::BufferEvent; let buffer_id = buffer.read(cx).remote_id(); cx.emit(match event { - &BufferEvent::Edited { is_local } => Event::Edited { + &BufferEvent::Edited { source } => Event::Edited { edited_buffer: Some(buffer), - is_local, + source, }, BufferEvent::DirtyChanged => Event::DirtyChanged, BufferEvent::Saved => Event::Saved, @@ -2044,7 +2044,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); } @@ -2090,7 +2090,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); } @@ -2313,7 +2313,7 @@ impl MultiBuffer { cx.emit(Event::DiffHunksToggled); cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); } @@ -2449,7 +2449,7 @@ impl MultiBuffer { cx.emit(Event::DiffHunksToggled); cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); } @@ -3102,7 +3102,7 @@ impl MultiBuffer { cx.emit(Event::DiffHunksToggled); cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); } } diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index b90b3425616..3e71deb8f85 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -192,15 +192,15 @@ fn test_excerpt_boundaries_and_clipping(cx: &mut App) { &[ Event::Edited { edited_buffer: None, - is_local: true, + source: language::BufferEditSource::User, }, Event::Edited { edited_buffer: None, - is_local: true, + source: language::BufferEditSource::User, }, Event::Edited { edited_buffer: None, - is_local: true, + source: language::BufferEditSource::User, } ] ); diff --git a/crates/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs index a2fd1ae2646..8545827ef52 100644 --- a/crates/multi_buffer/src/path_key.rs +++ b/crates/multi_buffer/src/path_key.rs @@ -2,7 +2,7 @@ use std::{ops::Range, rc::Rc, sync::Arc}; use gpui::{App, AppContext, Context, Entity}; use itertools::Itertools; -use language::{Buffer, BufferSnapshot}; +use language::{Buffer, BufferEditSource, BufferSnapshot}; use rope::Point; use sum_tree::{Dimensions, SumTree}; use text::{Bias, BufferId, Edit, OffsetRangeExt, Patch}; @@ -603,7 +603,7 @@ impl MultiBuffer { cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); cx.emit(Event::BufferRangesUpdated { buffer, @@ -687,7 +687,7 @@ impl MultiBuffer { cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); cx.notify(); } diff --git a/crates/multi_buffer/src/transaction.rs b/crates/multi_buffer/src/transaction.rs index 8161df2b7e4..47ab39f9363 100644 --- a/crates/multi_buffer/src/transaction.rs +++ b/crates/multi_buffer/src/transaction.rs @@ -1,5 +1,5 @@ use gpui::{App, Context, Entity}; -use language::{self, Buffer, TransactionId}; +use language::{self, Buffer, BufferEditSource, TransactionId}; use std::{ collections::HashMap, ops::Range, @@ -288,6 +288,35 @@ impl MultiBuffer { self.end_transaction_at(Instant::now(), cx) } + pub fn end_transaction_with_source( + &mut self, + source: BufferEditSource, + cx: &mut Context, + ) -> Option { + let now = Instant::now(); + if let Some(buffer) = self.as_singleton() { + return buffer.update(cx, |buffer, cx| { + buffer.end_transaction_with_source(source, cx) + }); + } + + let mut buffer_transactions = HashMap::default(); + for BufferState { buffer, .. } in self.buffers.values() { + if let Some(transaction_id) = buffer.update(cx, |buffer, cx| { + buffer.end_transaction_with_source(source, cx) + }) { + buffer_transactions.insert(buffer.read(cx).remote_id(), transaction_id); + } + } + + if self.history.end_transaction(now, buffer_transactions) { + let transaction_id = self.history.group().unwrap(); + Some(transaction_id) + } else { + None + } + } + pub fn end_transaction_at( &mut self, now: Instant, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 12726349a8b..8544e0b833d 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -89,9 +89,9 @@ use gpui::{ Task, TaskExt, WeakEntity, Window, }; use language::{ - Buffer, BufferEvent, Capability, CodeLabel, CursorShape, DiskState, Language, LanguageName, - LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainMetadata, - ToolchainScope, Transaction, Unclipped, language_settings::InlayHintKind, + Buffer, BufferEditSource, BufferEvent, Capability, CodeLabel, CursorShape, DiskState, Language, + LanguageName, LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, + ToolchainMetadata, ToolchainScope, Transaction, Unclipped, language_settings::InlayHintKind, proto::split_operations, }; use lsp::{ @@ -410,7 +410,9 @@ pub enum Event { EntryRenamed(ProjectTransaction, ProjectPath, PathBuf), WorkspaceEditApplied(ProjectTransaction), AgentLocationChanged, - BufferEdited, + BufferEdited { + source: BufferEditSource, + }, } pub struct AgentLocationChanged; @@ -3810,8 +3812,8 @@ impl Project { self.request_buffer_diff_recalculation(&buffer, cx); } - if matches!(event, BufferEvent::Edited { .. }) { - cx.emit(Event::BufferEdited); + if let BufferEvent::Edited { source } = event { + cx.emit(Event::BufferEdited { source: *source }); } let buffer_id = buffer.read(cx).remote_id(); diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index e95e3b7e6c5..97f6b4b437b 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -6073,7 +6073,9 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited { is_local: true }, + language::BufferEvent::Edited { + source: language::BufferEditSource::User + }, language::BufferEvent::DirtyChanged ] ); @@ -6102,9 +6104,13 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited { is_local: true }, + language::BufferEvent::Edited { + source: language::BufferEditSource::User + }, language::BufferEvent::DirtyChanged, - language::BufferEvent::Edited { is_local: true }, + language::BufferEvent::Edited { + source: language::BufferEditSource::User + }, ], ); events.lock().clear(); @@ -6119,7 +6125,9 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited { is_local: true }, + language::BufferEvent::Edited { + source: language::BufferEditSource::User + }, language::BufferEvent::DirtyChanged ] ); @@ -6159,7 +6167,9 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( mem::take(&mut *events.lock()), &[ - language::BufferEvent::Edited { is_local: true }, + language::BufferEvent::Edited { + source: language::BufferEditSource::User + }, language::BufferEvent::DirtyChanged ] ); @@ -6174,7 +6184,9 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited { is_local: true }, + language::BufferEvent::Edited { + source: language::BufferEditSource::User + }, language::BufferEvent::DirtyChanged ] ); From 4d6a3c7e119698449ea22b708cd3d369be812678 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Thu, 28 May 2026 14:36:24 +0100 Subject: [PATCH 44/93] gpui: `Application::inaccessible()` (#57954) Provide a way to prevent GPUI from creating AccessKit adapters, and enable this in Zed. This will allow us to test AccessKit support in Zed without rolling it out more broadly, while we gain confidence in the implementation in GPUI. I've also added a log statement ## Motivation (i.e. a mini post-mortem about the #56065 panics) Merging #56065 caused some nasty panics in nightly. This was caused by a bug in the logic for selecting a focus node for a `TreeUpdate`. AccessKit panics when an invalid `TreeUpdate` is provided. My assumption was that, since Zed uses no a11y APIs, and also that essentially 0 zed users would have AT apps running, that merging this PR would have no effect on the behaviour of Zed itself. However, two issues combined to cause the panics: - It seems like many people (everyone?) on mac gets the activation callback called by accesskit_macos. A quick search suggests this might be due to password managers searching for password fields, but not sure how true that is. - The bug in question related to *forgetting to check* whether a node used a11y APIs, so we *were* pushing non-empty `TreeUpdate`s As a (probably temporary) defensive measure, I added a function to try to detect the bad cases and fix them. But it would be lovely if this could live in AccessKit itself, since it would mean we wouldn't have to do the check twice (once in GPUI, once in AccessKit). This would also help prevent drift when updating accesskit versions if new invariants are added. We also cannot protect against this with `catch_unwind`, since we use `panic=abort`. So our only option unfortunately is to temporarily disable AccessKit until we know our implementation is stable. Release Notes: - N/A or Added/Fixed/Improved ... --- crates/gpui/src/app.rs | 19 +++++++++++++++++++ crates/gpui/src/window.rs | 5 +++-- crates/gpui/src/window/a11y.rs | 25 ++++++++++++++++++++++--- crates/zed/src/main.rs | 14 +++++++++++--- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index a9255898daa..c8f1032fbd9 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -151,6 +151,21 @@ impl Application { )) } + /// Builds an app with accessibility (AccessKit) integration forcibly + /// disabled. + /// + /// In this mode, accessibility APIs (e.g. + /// [`div().role()`][crate::StatefulInteractiveElement::role]) silently + /// no-op. + /// + /// See the [accessibility guide](crate::_accessibility) for an overview of + /// the features this disables. + pub fn new_inaccessible(platform: Rc) -> Self { + let this = Self::with_platform(platform); + this.0.borrow_mut().accessibility_force_disabled = true; + this + } + /// Assigns the source of assets for the application. pub fn with_assets(self, asset_source: impl AssetSource) -> Self { let mut context_lock = self.0.borrow_mut(); @@ -666,6 +681,9 @@ pub struct App { pub(crate) window_update_stack: Vec, pub(crate) mode: GpuiMode, pub(crate) cursor_hide_mode: CursorHideMode, + /// Whether the app was created by [`Application::new_inaccessible`]. No + /// accesskit APIs will be called when this flag is set. + pub(crate) accessibility_force_disabled: bool, flushing_effects: bool, pending_updates: usize, quit_mode: QuitMode, @@ -755,6 +773,7 @@ impl App { quit_mode: QuitMode::default(), quitting: false, cursor_hide_mode: CursorHideMode::default(), + accessibility_force_disabled: false, #[cfg(any(test, feature = "test-support", debug_assertions))] name: None, diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 93106792312..f5292840299 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1330,10 +1330,11 @@ impl Window { WindowBounds::Windowed(_) => {} } + let accessibility_force_disabled = cx.accessibility_force_disabled; let a11y_active_flag = Arc::new(AtomicBool::new(false)); #[cfg(not(target_family = "wasm"))] - { + if !accessibility_force_disabled { let initial_tree = accesskit::TreeUpdate { nodes: vec![(ROOT_NODE_ID, accesskit::Node::new(accesskit::Role::Window))], tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), @@ -1717,7 +1718,7 @@ impl Window { captured_hitbox: None, #[cfg(any(feature = "inspector", debug_assertions))] inspector: None, - a11y: A11y::new(a11y_active_flag), + a11y: A11y::new(a11y_active_flag, accessibility_force_disabled), }) } diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs index 226a4e8d4cc..dfb818ead90 100644 --- a/crates/gpui/src/window/a11y.rs +++ b/crates/gpui/src/window/a11y.rs @@ -107,6 +107,10 @@ pub(crate) type A11yActionListener = /// Manages the AccessKit tree that is built each frame and the mappings /// needed to dispatch incoming action requests back to the right elements. pub(crate) struct A11y { + /// Whether accessibility has been [forcibly disabled] for this window. + /// + /// [forcibly disabled]: crate::Application::new_inaccessible + force_disabled: bool, /// Whether a11y features have been requested by the system. /// /// Updated by AccessKit using callbacks provided to the adapter. Can change @@ -131,8 +135,9 @@ pub(crate) struct A11y { } impl A11y { - pub(crate) fn new(active_flag: Arc) -> Self { + pub(crate) fn new(active_flag: Arc, force_disabled: bool) -> Self { Self { + force_disabled, active_flag, active_this_frame: false, nodes: A11yNodeBuilder::new(), @@ -147,7 +152,7 @@ impl A11y { /// See the docs for [`Self::active_flag`] and [`Self::active_this_frame`] /// for more commentary. pub(crate) fn sync_active_flag(&mut self) { - self.active_this_frame = self.active_flag.load(Ordering::SeqCst); + self.active_this_frame = !self.force_disabled && self.active_flag.load(Ordering::SeqCst); } pub(crate) fn is_active(&self) -> bool { @@ -164,7 +169,21 @@ impl A11y { /// Finalize the tree and produce a [`TreeUpdate`] for the platform adapter. pub(crate) fn end_frame(&mut self) -> TreeUpdate { - self.nodes.finalize() + let tree_update = self.nodes.finalize(); + + // Zed currently doesn't set any a11y APIs on *any* UI elements, so a + // tree with nodes other than the root indicates a bug in the + // `TreeUpdate`-producing logic. + // + // Remove this when adding aria attributes. + if tree_update.nodes.len() > 1 { + log::warn!( + "expected an empty a11y tree update (only the root node), but got {} nodes; Zed has no accessible UI elements yet", + tree_update.nodes.len() + ); + } + + tree_update } } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 3042510f2c6..b2a738f9314 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -85,6 +85,15 @@ use crate::zed::{CrashHandler, OpenRequestKind, eager_load_active_theme_and_icon #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; +fn build_application() -> Application { + let platform = gpui_platform::current_platform(false); + if std::env::var("ZED_EXPERIMENTAL_A11Y").as_deref() == Ok("1") { + Application::with_platform(platform) + } else { + Application::new_inaccessible(platform) + } +} + fn files_not_created_on_launch(errors: HashMap>) { let message = "Zed failed to launch"; let error_details = errors @@ -113,7 +122,7 @@ fn files_not_created_on_launch(errors: HashMap>) { .collect::>().join("\n\n"); eprintln!("{message}: {error_details}"); - Application::with_platform(gpui_platform::current_platform(false)) + build_application() .with_quit_mode(QuitMode::Explicit) .run(move |cx| { if let Ok(window) = cx.open_window(gpui::WindowOptions::default(), |_, cx| { @@ -338,8 +347,7 @@ fn main() { #[cfg(windows)] check_for_conpty_dll(); - let app = - Application::with_platform(gpui_platform::current_platform(false)).with_assets(Assets); + let app = build_application().with_assets(Assets); let app_db = db::AppDatabase::new(); let system_id = app.background_executor().spawn(system_id()); From 92b0efeee0b620272d1f47b30697cae0c434f9f4 Mon Sep 17 00:00:00 2001 From: KyleBarton Date: Thu, 28 May 2026 06:36:39 -0700 Subject: [PATCH 45/93] Update dev-containers.md (#57901) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes N/A Release Notes: - Improved docs for dev containers --- docs/src/dev-containers.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/src/dev-containers.md b/docs/src/dev-containers.md index 6d19e8e529d..3b0874812bf 100644 --- a/docs/src/dev-containers.md +++ b/docs/src/dev-containers.md @@ -41,12 +41,33 @@ If you modify `.devcontainer/devcontainer.json`, Zed does not currently rebuild Once connected, Zed operates inside the container environment for tasks, terminals, and language servers. Files are linked from your workspace into the container according to the dev container specification. +## Extensions + +You can specify extensions in `.devcontainer/devcontainer.json` under the "customizations" field like so: + +```json +{ + ... + "customizations": { + "zed": { + "extensions": ["vue", "ruby"], + }, + "vscode": { + ... + }, + "codespaces": { + ... + }, + } +} +``` + +Note that extensions load for the Zed session, so these extensions will exist on your local Zed instances as well. + ## Known Limitations > **Note:** This feature is still in development. -- **Extensions:** Zed does not yet manage extensions separately for container environments. The host's extensions are used as-is. -- **Port forwarding:** Only the `appPort` field is supported. `forwardPorts` and other advanced port-forwarding features are not implemented. - **Configuration changes:** Updates to `devcontainer.json` do not trigger automatic rebuilds or reloads; containers must be manually restarted. ## See also From f0ed342c1956b874b67e0f5cd2e42a9cd409ac32 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 28 May 2026 19:17:58 +0530 Subject: [PATCH 46/93] markdown: Add frontmatter metadata block rendering (#57845) Adds opt-in rendering for Markdown frontmatter metadata blocks in Markdown Preview and agent markdown. - Simple `key: value` metadata blocks now render as a two-column table, while more complex metadata falls back to a code-style block. - Metadata block content and key/value rows are parsed in the parser step, and the request layout simply takes over rendering. image Release Notes: - Added support for rendering Markdown frontmatter metadata blocks in Markdown Preview and Agent Panel. --- crates/acp_thread/src/acp_thread.rs | 1 + crates/markdown/src/markdown.rs | 188 +++++++++- crates/markdown/src/mermaid.rs | 6 +- crates/markdown/src/parser.rs | 354 +++++++++++++++++- .../src/markdown_preview_view.rs | 1 + 5 files changed, 523 insertions(+), 27 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index e27f09da557..c42694cd8c6 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -775,6 +775,7 @@ impl ContentBlock { None, MarkdownOptions { render_mermaid_diagrams: true, + render_metadata_blocks: true, ..Default::default() }, cx, diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 4cbcbe85678..7fcbf393fb4 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -40,7 +40,8 @@ use gpui::{ use language::{CharClassifier, Language, LanguageRegistry, Rope}; use parser::CodeBlockMetadata; use parser::{ - MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown_with_options, + MarkdownEvent, MarkdownTag, MarkdownTagEnd, ParsedMetadataBlock, parse_links_only, + parse_markdown_with_options, }; use pulldown_cmark::{Alignment, BlockQuoteKind}; use sum_tree::TreeMap; @@ -350,6 +351,7 @@ pub struct MarkdownOptions { pub parse_html: bool, pub render_mermaid_diagrams: bool, pub parse_heading_slugs: bool, + pub render_metadata_blocks: bool, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -847,6 +849,7 @@ impl Markdown { let should_parse_html = self.options.parse_html; let should_render_mermaid_diagrams = self.options.render_mermaid_diagrams; let should_parse_heading_slugs = self.options.parse_heading_slugs; + let should_parse_metadata_blocks = self.options.render_metadata_blocks; let language_registry = self.language_registry.clone(); let fallback = self.fallback_code_block_language.clone(); @@ -860,6 +863,7 @@ impl Markdown { languages_by_path: TreeMap::default(), root_block_starts: Arc::default(), html_blocks: BTreeMap::default(), + metadata_blocks: BTreeMap::default(), mermaid_diagrams: BTreeMap::default(), heading_slugs: HashMap::default(), footnote_definitions: HashMap::default(), @@ -868,13 +872,18 @@ impl Markdown { ); } - let parsed = - parse_markdown_with_options(&source, should_parse_html, should_parse_heading_slugs); + let parsed = parse_markdown_with_options( + &source, + should_parse_html, + should_parse_heading_slugs, + should_parse_metadata_blocks, + ); let events = parsed.events; let language_names = parsed.language_names; let paths = parsed.language_paths; let root_block_starts = parsed.root_block_starts; let html_blocks = parsed.html_blocks; + let metadata_blocks = parsed.metadata_blocks; let heading_slugs = parsed.heading_slugs; let footnote_definitions = parsed.footnote_definitions; let mermaid_diagrams = if should_render_mermaid_diagrams { @@ -942,6 +951,7 @@ impl Markdown { languages_by_path, root_block_starts: Arc::from(root_block_starts), html_blocks, + metadata_blocks, mermaid_diagrams, heading_slugs, footnote_definitions, @@ -1070,6 +1080,7 @@ pub struct ParsedMarkdown { pub languages_by_path: TreeMap, Arc>, pub root_block_starts: Arc<[usize]>, pub(crate) html_blocks: BTreeMap, + pub(crate) metadata_blocks: BTreeMap, pub(crate) mermaid_diagrams: BTreeMap, pub heading_slugs: HashMap, pub footnote_definitions: HashMap, @@ -1398,6 +1409,114 @@ impl MarkdownElement { builder.pop_text_style(); } + fn push_metadata_block( + &self, + builder: &mut MarkdownElementBuilder, + source: &str, + metadata_block: &ParsedMetadataBlock, + markdown_end: usize, + cx: &App, + ) { + let content_range = &metadata_block.content_range; + if let Some(rows) = metadata_block.rows.as_deref() { + builder.push_div( + div() + .grid() + .grid_cols(2) + .w_full() + .mb_2() + .border_1() + .border_color(cx.theme().colors().border) + .rounded_sm() + .overflow_hidden(), + content_range, + markdown_end, + ); + + for (row_index, row) in rows.iter().enumerate() { + self.push_metadata_cell( + builder, + source, + row.key.clone(), + content_range, + markdown_end, + MetadataCellStyle { + row_index, + is_key: true, + }, + cx, + ); + self.push_metadata_cell( + builder, + source, + row.value.clone(), + content_range, + markdown_end, + MetadataCellStyle { + row_index, + is_key: false, + }, + cx, + ); + } + + builder.pop_div(); + } else { + let mut metadata_block = div().w_full().rounded_md(); + metadata_block.style().refine(&self.style.code_block); + builder.push_text_style(self.style.code_block.text.to_owned()); + builder.push_code_block(None); + builder.push_div(metadata_block, content_range, markdown_end); + builder.push_text(&source[content_range.clone()], content_range.clone()); + builder.trim_trailing_newline(); + builder.pop_div(); + builder.pop_code_block(); + builder.pop_text_style(); + } + } + + fn push_metadata_cell( + &self, + builder: &mut MarkdownElementBuilder, + source: &str, + text_range: Range, + block_range: &Range, + markdown_end: usize, + cell_style: MetadataCellStyle, + cx: &App, + ) { + builder.push_div( + div() + .flex() + .flex_col() + .min_w_0() + .px_2() + .py_1() + .border_color(cx.theme().colors().border) + .when(cell_style.row_index > 0, |this| this.border_t_1()) + .when(!cell_style.is_key, |this| this.border_l_1()) + .when(cell_style.is_key, |this| { + this.bg(cx.theme().colors().panel_background) + }), + block_range, + markdown_end, + ); + + let text_style = if cell_style.is_key { + TextStyleRefinement { + color: Some(cx.theme().colors().text_muted), + font_weight: Some(FontWeight::SEMIBOLD), + ..Default::default() + } + } else { + TextStyleRefinement::default() + }; + builder.push_text_style(text_style); + builder.push_text(&source[text_range.clone()], text_range); + builder.pop_text_style(); + builder.pop_div(); + } + fn push_markdown_list_item( &self, builder: &mut MarkdownElementBuilder, @@ -1809,6 +1928,7 @@ impl Element for MarkdownElement { let mut current_img_block_range: Option> = None; let mut handled_html_block = false; let mut rendered_mermaid_block = false; + let mut rendered_metadata_block = false; for (index, (range, event)) in parsed_markdown.events.iter().enumerate() { // Skip alt text for images that rendered if let Some(current_img_block_range) = ¤t_img_block_range @@ -1832,6 +1952,13 @@ impl Element for MarkdownElement { continue; } + if rendered_metadata_block { + if matches!(event, MarkdownEvent::End(MarkdownTagEnd::MetadataBlock(_))) { + rendered_metadata_block = false; + } + continue; + } + match event { MarkdownEvent::RootStart => { if self.show_root_block_markers { @@ -2147,7 +2274,20 @@ impl Element for MarkdownElement { ); builder.push_div(div().flex_1().w_0(), range, markdown_end); } - MarkdownTag::MetadataBlock(_) => {} + MarkdownTag::MetadataBlock(_) => { + if let Some(metadata_block) = + parsed_markdown.metadata_blocks.get(&range.start) + { + self.push_metadata_block( + &mut builder, + &parsed_markdown.source, + metadata_block, + markdown_end, + cx, + ); + rendered_metadata_block = true; + } + } MarkdownTag::Table(alignments) => { builder.table.start(alignments.clone()); @@ -2359,6 +2499,7 @@ impl Element for MarkdownElement { builder.pop_div(); builder.pop_div(); } + MarkdownTagEnd::MetadataBlock(_) => {} _ => log::debug!("unsupported markdown tag end: {:?}", tag), }, MarkdownEvent::Text => { @@ -2752,6 +2893,11 @@ fn alignment_to_text_align(alignment: Alignment) -> Option { } } +struct MetadataCellStyle { + row_index: usize, + is_key: bool, +} + struct MarkdownElementBuilder { div_stack: Vec, rendered_lines: Vec, @@ -3586,6 +3732,34 @@ mod tests { render_markdown_with_language_registry(markdown, None, cx) } + #[gpui::test] + fn test_frontmatter_renders_without_delimiters(cx: &mut TestAppContext) { + let rendered = render_markdown_with_options( + "---\ntitle: Post\n---\nBody", + None, + MarkdownOptions { + render_metadata_blocks: true, + ..Default::default() + }, + cx, + ); + assert_eq!(rendered.text_for_range(0..24), "title\nPost\nBody"); + } + + #[gpui::test] + fn test_frontmatter_falls_back_to_code_block_for_nested_yaml(cx: &mut TestAppContext) { + let rendered = render_markdown_with_options( + "---\ntags:\n - zed\n---\nBody", + None, + MarkdownOptions { + render_metadata_blocks: true, + ..Default::default() + }, + cx, + ); + assert_eq!(rendered.text_for_range(0..26), "tags:\n - zed\nBody"); + } + fn render_markdown_with_code_span_link( markdown: &str, callback: impl Fn(&str, &App) -> Option + 'static, @@ -3873,7 +4047,7 @@ mod tests { #[test] fn test_table_checkbox_detection() { let md = "| Done |\n|------|\n| [x] |\n| [ ] |"; - let events = crate::parser::parse_markdown_with_options(md, false, false).events; + let events = crate::parser::parse_markdown_with_options(md, false, false, false).events; let mut in_table = false; let mut cell_texts: Vec = Vec::new(); @@ -3915,7 +4089,7 @@ mod tests { #[test] fn test_table_checkbox_marker_source_range() { let md = "| Done |\n|------|\n| [x] |\n| [ ] |"; - let events = crate::parser::parse_markdown_with_options(md, false, false).events; + let events = crate::parser::parse_markdown_with_options(md, false, false, false).events; let mut in_cell = false; let mut pending_text = String::new(); @@ -4192,7 +4366,7 @@ mod tests { } fn has_code_block(markdown: &str) -> bool { - let parsed_data = parse_markdown_with_options(markdown, false, false); + let parsed_data = parse_markdown_with_options(markdown, false, false, false); parsed_data .events .iter() diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs index 8730c318f0c..4acceb2577b 100644 --- a/crates/markdown/src/mermaid.rs +++ b/crates/markdown/src/mermaid.rs @@ -686,7 +686,8 @@ mod tests { #[test] fn test_extract_mermaid_diagrams_parses_scale() { let markdown = "```mermaid 150\ngraph TD;\n```\n\n```rust\nfn main() {}\n```"; - let events = crate::parser::parse_markdown_with_options(markdown, false, false).events; + let events = + crate::parser::parse_markdown_with_options(markdown, false, false, false).events; let diagrams = extract_mermaid_diagrams(markdown, &events); assert_eq!(diagrams.len(), 1); @@ -702,7 +703,8 @@ mod tests { "```mermaid\nblock-beta\n```\n\n", "```mermaid\nflowchart TD\n A --> B\n```", ); - let events = crate::parser::parse_markdown_with_options(markdown, false, false).events; + let events = + crate::parser::parse_markdown_with_options(markdown, false, false, false).events; let diagrams = extract_mermaid_diagrams(markdown, &events); assert_eq!( diagrams.len(), diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs index 4e9d6c29830..6301d759f69 100644 --- a/crates/markdown/src/parser.rs +++ b/crates/markdown/src/parser.rs @@ -37,10 +37,23 @@ pub(crate) struct ParsedMarkdownData { pub language_paths: HashSet>, pub root_block_starts: Vec, pub html_blocks: BTreeMap, + pub metadata_blocks: BTreeMap, pub heading_slugs: HashMap, pub footnote_definitions: HashMap, } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ParsedMetadataBlock { + pub content_range: Range, + pub rows: Option>, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct MetadataRow { + pub key: Range, + pub value: Range, +} + impl ParseState { fn push_event(&mut self, range: Range, event: MarkdownEvent) { match &event { @@ -149,27 +162,83 @@ fn build_heading_slugs( slugs } +fn parse_metadata_table_rows(source: &str, source_range: Range) -> Option> { + let mut rows = Vec::new(); + let mut line_start = source_range.start; + + for line in source[source_range].split_inclusive('\n') { + let line_end = line_start + line.len(); + let content_end = line_start + line.trim_end_matches(['\r', '\n']).len(); + let content_range = line_start..content_end; + let line_text = &source[content_range.clone()]; + + if line_text.is_empty() + || line_text + .chars() + .next() + .is_some_and(|character| character.is_whitespace()) + { + return None; + } + + let delimiter = line_text.find(':')?; + let key = trim_metadata_range(source, content_range.start..content_range.start + delimiter); + let value = trim_metadata_range( + source, + content_range.start + delimiter + 1..content_range.end, + ); + if key.is_empty() || value.is_empty() { + return None; + } + + rows.push(MetadataRow { key, value }); + line_start = line_end; + } + + if rows.is_empty() { None } else { Some(rows) } +} + +fn trim_metadata_range(source: &str, range: Range) -> Range { + let text = &source[range.clone()]; + let start_offset = text.len() - text.trim_start().len(); + let end_offset = text.trim_end().len(); + let start = range.start + start_offset; + let end = (range.start + end_offset).max(start); + start..end +} + pub(crate) fn parse_markdown_with_options( text: &str, parse_html: bool, parse_heading_slugs: bool, + parse_metadata_blocks: bool, ) -> ParsedMarkdownData { let mut state = ParseState::default(); let mut language_names = HashSet::default(); let mut language_paths = HashSet::default(); let mut html_blocks = BTreeMap::default(); + let mut metadata_blocks = BTreeMap::default(); let mut within_link = false; let mut within_code_block = false; let mut within_metadata = false; - let mut parser = Parser::new_ext(text, PARSE_OPTIONS) + let mut current_metadata_block_start = None; + let mut metadata_block_content_range: Option> = None; + let parse_options = if parse_metadata_blocks { + PARSE_OPTIONS.union(Options::ENABLE_YAML_STYLE_METADATA_BLOCKS) + } else { + PARSE_OPTIONS + }; + let mut parser = Parser::new_ext(text, parse_options) .into_offset_iter() .peekable(); while let Some((pulldown_event, range)) = parser.next() { - if within_metadata { - if let pulldown_cmark::Event::End(pulldown_cmark::TagEnd::MetadataBlock { .. }) = + if within_metadata && !parse_metadata_blocks { + if let pulldown_cmark::Event::End(pulldown_cmark::TagEnd::MetadataBlock(_)) = pulldown_event { within_metadata = false; + current_metadata_block_start = None; + metadata_block_content_range = None; } continue; } @@ -216,9 +285,14 @@ pub(crate) fn parse_markdown_with_options( id: SharedString::from(id.into_string()), } } - pulldown_cmark::Tag::MetadataBlock(_kind) => { + pulldown_cmark::Tag::MetadataBlock(kind) => { within_metadata = true; - continue; + current_metadata_block_start = Some(range.start); + metadata_block_content_range = None; + if !parse_metadata_blocks { + continue; + } + MarkdownTag::MetadataBlock(kind) } pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Indented) => { within_code_block = true; @@ -347,6 +421,25 @@ pub(crate) fn parse_markdown_with_options( within_link = false; } else if let pulldown_cmark::TagEnd::CodeBlock = tag { within_code_block = false; + } else if let pulldown_cmark::TagEnd::MetadataBlock(_) = tag { + within_metadata = false; + let block_start = current_metadata_block_start.take(); + let content_range = metadata_block_content_range.take(); + if parse_metadata_blocks + && let (Some(block_start), Some(content_range)) = + (block_start, content_range) + { + metadata_blocks.insert( + block_start, + ParsedMetadataBlock { + rows: parse_metadata_table_rows(text, content_range.clone()), + content_range, + }, + ); + } + if !parse_metadata_blocks { + continue; + } } state.push_event(range, MarkdownEvent::End(tag)); } @@ -363,6 +456,18 @@ pub(crate) fn parse_markdown_with_options( } } + if within_metadata { + match &mut metadata_block_content_range { + Some(content_range) => { + content_range.start = content_range.start.min(range.start); + content_range.end = content_range.end.max(range.end); + } + None => metadata_block_content_range = Some(range.clone()), + } + state.push_event(range, MarkdownEvent::Text); + continue; + } + if within_code_block { let (range, event) = event_for(text, range, &parsed); state.push_event(range, event); @@ -541,6 +646,7 @@ pub(crate) fn parse_markdown_with_options( language_paths, root_block_starts: state.root_block_starts, html_blocks, + metadata_blocks, heading_slugs, footnote_definitions, } @@ -798,8 +904,8 @@ mod tests { use super::MarkdownTag::*; use super::*; - const UNWANTED_OPTIONS: Options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS - .union(Options::ENABLE_MATH) + const CONDITIONAL_OPTIONS: Options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS; + const UNWANTED_OPTIONS: Options = Options::ENABLE_MATH .union(Options::ENABLE_DEFINITION_LIST) .union(Options::ENABLE_WIKILINKS); @@ -807,21 +913,174 @@ mod tests { fn all_options_considered() { // The purpose of this is to fail when new options are added to pulldown_cmark, so that they // can be evaluated for inclusion. - assert_eq!(PARSE_OPTIONS.union(UNWANTED_OPTIONS), Options::all()); + assert_eq!( + PARSE_OPTIONS + .union(CONDITIONAL_OPTIONS) + .union(UNWANTED_OPTIONS), + Options::all() + ); } #[test] fn wanted_and_unwanted_options_disjoint() { assert_eq!( - PARSE_OPTIONS.intersection(UNWANTED_OPTIONS), + PARSE_OPTIONS + .union(CONDITIONAL_OPTIONS) + .intersection(UNWANTED_OPTIONS), Options::empty() ); } + #[test] + fn test_yaml_style_metadata_block() { + assert_eq!( + parse_markdown_with_options("---\ntitle: Post\n---\n# Heading", false, false, true), + ParsedMarkdownData { + events: vec![ + (0..19, RootStart), + (0..19, Start(MetadataBlock(MetadataBlockKind::YamlStyle))), + (4..16, Text), + ( + 0..19, + End(MarkdownTagEnd::MetadataBlock(MetadataBlockKind::YamlStyle)) + ), + (0..19, RootEnd(0)), + (20..29, RootStart), + ( + 20..29, + Start(Heading { + level: HeadingLevel::H1, + id: None, + classes: Vec::new(), + attrs: Vec::new(), + }) + ), + (22..29, Text), + (20..29, End(MarkdownTagEnd::Heading(HeadingLevel::H1))), + (20..29, RootEnd(1)), + ], + root_block_starts: vec![0, 20], + metadata_blocks: BTreeMap::from_iter([( + 0, + ParsedMetadataBlock { + content_range: 4..16, + rows: Some(vec![MetadataRow { + key: 4..9, + value: 11..15, + }]), + }, + )]), + ..Default::default() + } + ) + } + + #[test] + fn test_metadata_block_text_is_verbatim() { + let parsed = + parse_markdown_with_options("---\nurl: https://zed.dev\n---\nBody", false, false, true); + assert!( + parsed + .events + .iter() + .all(|(_, event)| !matches!(event, Start(Link { .. }))) + ); + } + + #[test] + fn test_metadata_blocks_store_table_rows() { + let parsed = parse_markdown_with_options( + "---\ntitle: Post\nauthor: Zed\n---\nBody", + false, + false, + true, + ); + + assert_eq!( + parsed.metadata_blocks, + BTreeMap::from_iter([( + 0, + ParsedMetadataBlock { + content_range: 4..28, + rows: Some(vec![ + MetadataRow { + key: 4..9, + value: 11..15, + }, + MetadataRow { + key: 16..22, + value: 24..27, + }, + ]), + }, + )]) + ); + } + + #[test] + fn test_metadata_blocks_store_fallback_for_nested_yaml() { + let parsed = + parse_markdown_with_options("---\ntags:\n - zed\n---\nBody", false, false, true); + + assert_eq!( + parsed.metadata_blocks, + BTreeMap::from_iter([( + 0, + ParsedMetadataBlock { + content_range: 4..18, + rows: None, + }, + )]) + ); + } + + #[test] + fn test_metadata_table_rows_parse_simple_colon_pairs() { + let source = "title: Post\nauthor: Zed\n"; + let Some(rows) = parse_metadata_table_rows(source, 0..source.len()) else { + panic!("expected metadata rows"); + }; + let pairs = rows + .into_iter() + .map(|row| (&source[row.key], &source[row.value])) + .collect::>(); + + assert_eq!(pairs, vec![("title", "Post"), ("author", "Zed")]); + } + + #[test] + fn test_metadata_table_rows_reject_non_simple_colon_pairs() { + for source in [ + "tags:\n - zed\n", + "title = Post\n", + "title:\n", + "title: \n", + ": Post\n", + " title: Post\n", + "\n", + ] { + assert!(parse_metadata_table_rows(source, 0..source.len()).is_none()); + } + } + + #[test] + fn test_trim_metadata_range_returns_valid_empty_range() { + let source = "key: \n"; + let trimmed = trim_metadata_range(source, 4..7); + + assert_eq!(trimmed, 7..7); + assert!(source[trimmed].is_empty()); + } + #[test] fn test_html_comments() { assert_eq!( - parse_markdown_with_options(" \nReturns", false, false), + parse_markdown_with_options( + " \nReturns", + false, + false, + false + ), ParsedMarkdownData { events: vec![ (2..30, RootStart), @@ -851,6 +1110,7 @@ mod tests { "   https://some.url some \\`►\\` text", false, false, + false, ), ParsedMarkdownData { events: vec![ @@ -891,6 +1151,7 @@ mod tests { "You can use the [GitHub Search API](https://docs.github.com/en", false, false, + false, ) .events, vec![ @@ -925,6 +1186,7 @@ mod tests { "-- --- ... \"double quoted\" 'single quoted' ----------", false, false, + false, ), ParsedMarkdownData { events: vec![ @@ -957,7 +1219,12 @@ mod tests { #[test] fn test_code_block_metadata() { assert_eq!( - parse_markdown_with_options("```rust\nfn main() {\n let a = 1;\n}\n```", false, false), + parse_markdown_with_options( + "```rust\nfn main() {\n let a = 1;\n}\n```", + false, + false, + false + ), ParsedMarkdownData { events: vec![ (0..37, RootStart), @@ -986,7 +1253,7 @@ mod tests { } ); assert_eq!( - parse_markdown_with_options(" fn main() {}", false, false), + parse_markdown_with_options(" fn main() {}", false, false, false), ParsedMarkdownData { events: vec![ (4..16, RootStart), @@ -1012,7 +1279,7 @@ mod tests { } fn assert_code_block_does_not_emit_links(markdown: &str) { - let parsed = parse_markdown_with_options(markdown, false, false); + let parsed = parse_markdown_with_options(markdown, false, false, false); let mut code_block_depth = 0; let mut code_block_count = 0; let mut saw_text_inside_code_block = false; @@ -1064,9 +1331,54 @@ mod tests { } #[test] - fn test_metadata_blocks_do_not_affect_root_blocks() { + fn test_metadata_blocks_are_root_blocks() { assert_eq!( - parse_markdown_with_options("+++\ntitle = \"Example\"\n+++\n\nParagraph", false, false), + parse_markdown_with_options( + "+++\ntitle = \"Example\"\n+++\n\nParagraph", + false, + false, + true + ), + ParsedMarkdownData { + events: vec![ + (0..25, RootStart), + (0..25, Start(MetadataBlock(MetadataBlockKind::PlusesStyle))), + (4..22, Text), + ( + 0..25, + End(MarkdownTagEnd::MetadataBlock( + MetadataBlockKind::PlusesStyle + )) + ), + (0..25, RootEnd(0)), + (27..36, RootStart), + (27..36, Start(Paragraph)), + (27..36, Text), + (27..36, End(MarkdownTagEnd::Paragraph)), + (27..36, RootEnd(1)), + ], + root_block_starts: vec![0, 27], + metadata_blocks: BTreeMap::from_iter([( + 0, + ParsedMetadataBlock { + content_range: 4..22, + rows: None, + }, + )]), + ..Default::default() + } + ); + } + + #[test] + fn test_metadata_blocks_are_omitted_by_default() { + assert_eq!( + parse_markdown_with_options( + "+++\ntitle = \"Example\"\n+++\n\nParagraph", + false, + false, + false + ), ParsedMarkdownData { events: vec![ (27..36, RootStart), @@ -1088,7 +1400,7 @@ mod tests { |------|---------| | [x] | Fix bug | | [ ] | Add feature |"; - let parsed = parse_markdown_with_options(markdown, false, false); + let parsed = parse_markdown_with_options(markdown, false, false, false); let mut in_table = false; let mut saw_task_list_marker = false; @@ -1164,6 +1476,7 @@ mod tests { "Text with a footnote[^1] and some more text.\n\n[^1]: This is the footnote content.", false, false, + false, ); assert_eq!( parsed.events, @@ -1194,6 +1507,7 @@ mod tests { "Text[^a] and[^b].\n\n[^a]: First.\n\n[^b]: Second.", false, false, + false, ); assert_eq!(parsed.footnote_definitions.len(), 2); assert!(parsed.footnote_definitions.contains_key("a")); @@ -1211,6 +1525,7 @@ mod tests { "https:/\\/example.com is equivalent to https://example.com!", false, false, + false, ) .events, vec![ @@ -1253,6 +1568,7 @@ mod tests { "Visit https://example.com/cat\\/é‍☕ for coffee!", false, false, + false, ) .events, [ @@ -1286,6 +1602,7 @@ mod tests { "# Hello World\n\n## Code `block`\n\n### Third Level\n\n#### Fourth Level\n\n## Hello World", false, true, + false, ); assert_eq!(parsed.heading_slugs.len(), 5); assert!(parsed.heading_slugs.contains_key("hello-world")); @@ -1301,6 +1618,7 @@ mod tests { "# Duplicate\n\nText\n\n## Duplicate\n\nMore text", false, true, + false, ); let first = parsed.heading_slugs.get("duplicate").copied(); let second = parsed.heading_slugs.get("duplicate-1").copied(); @@ -1311,7 +1629,7 @@ mod tests { #[test] fn test_heading_slug_collision_with_dedup_suffix() { - let parsed = parse_markdown_with_options("# Foo\n\n## Foo\n\n## Foo 1", false, true); + let parsed = parse_markdown_with_options("# Foo\n\n## Foo\n\n## Foo 1", false, true, false); assert_eq!(parsed.heading_slugs.len(), 3); assert!(parsed.heading_slugs.contains_key("foo")); assert!(parsed.heading_slugs.contains_key("foo-1")); @@ -1323,7 +1641,7 @@ mod tests { use pulldown_cmark::BlockQuoteKind; let markdown = "\n> [!NOTE]\n> A note.\n\n> [!TIP]\n> A tip.\n\n> [!IMPORTANT]\n> Important.\n\n> [!WARNING]\n> A warning.\n\n> [!CAUTION]\n> A caution.\n\n> Plain quote.\n"; - let parsed = parse_markdown_with_options(markdown, false, false); + let parsed = parse_markdown_with_options(markdown, false, false, false); let block_quote_kinds: Vec<_> = parsed .events diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 38ce126badb..2db1e9b0a24 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -223,6 +223,7 @@ impl MarkdownPreviewView { parse_html: true, render_mermaid_diagrams: true, parse_heading_slugs: true, + render_metadata_blocks: true, ..Default::default() }, cx, From d74e47ea519a50fb7707c246c440aa87feec4bee Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 28 May 2026 16:15:40 +0200 Subject: [PATCH 47/93] git_ui: Fix creating worktree not possible if default branch unavailable (#57918) Follow up to #57704 This makes sure that we offer a worktree creation option in case resolving the default branch fails Release Notes: - git: Fixed an issue where worktree creation would not be possible if resolving default branch fails --- crates/git_ui/src/worktree_picker.rs | 55 ++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 70219da3cac..4e6eec71914 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -420,18 +420,18 @@ impl WorktreePickerDelegate { fn build_fixed_entries(&self) -> Vec { let mut entries = Vec::new(); - if !self.has_multiple_repositories { - if let Some(ref default_branch) = self.default_branch { - let is_different = self - .current_branch_name - .as_ref() - .is_none_or(|current| current != &default_branch.branch_name); - entries.push(WorktreeEntry::CreateFromDefaultBranch { - default_branch: default_branch.clone(), - }); - if is_different { - entries.push(WorktreeEntry::CreateFromCurrentBranch); - } + if self.has_multiple_repositories { + entries.push(WorktreeEntry::CreateFromCurrentBranch); + } else if let Some(ref default_branch) = self.default_branch { + let is_different = self + .current_branch_name + .as_ref() + .is_none_or(|current| current != &default_branch.branch_name); + entries.push(WorktreeEntry::CreateFromDefaultBranch { + default_branch: default_branch.clone(), + }); + if is_different { + entries.push(WorktreeEntry::CreateFromCurrentBranch); } } else { entries.push(WorktreeEntry::CreateFromCurrentBranch); @@ -1539,6 +1539,37 @@ mod tests { }); } + #[gpui::test] + async fn test_current_branch_create_target_is_shown_without_default_branch( + cx: &mut TestAppContext, + ) { + let (_fs, worktree_picker, _repository, _worktree_path, mut cx) = + init_worktree_picker_test(cx).await; + + worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| { + worktree_picker.picker.update(cx, |picker, cx| { + picker.delegate.default_branch = None; + picker.refresh(window, cx); + }); + }); + cx.run_until_parked(); + + worktree_picker.update(&mut cx, |worktree_picker, cx| { + worktree_picker.picker.update(cx, |picker, _| { + assert!(matches!( + picker.delegate.matches.first(), + Some(WorktreeEntry::CreateFromCurrentBranch) + )); + assert!( + !picker.delegate.matches.iter().any(|entry| matches!( + entry, + WorktreeEntry::CreateFromDefaultBranch { .. } + )) + ); + }); + }); + } + #[gpui::test] async fn test_delete_dirty_worktree_prompts_for_force_delete(cx: &mut TestAppContext) { let (fs, worktree_picker, repository, worktree_path, mut cx) = From bc64e1f9556228a95b8aa772680352688310387f Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Thu, 28 May 2026 11:00:31 -0400 Subject: [PATCH 48/93] copilot: Fix auth db fallback (#57764) This PR uses https://github.com/zed-industries/zed/pull/57758 as a base and adds tests, cleans up the comments, and checks changes the database query used in auth.db to include oauth key. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - Fixed GitHub Copilot Chat showing an empty model dropdown for users on newer Copilot SDK builds --------- Co-authored-by: Alexander Shlemov Co-authored-by: cameron --- Cargo.lock | 2 + crates/copilot/src/copilot.rs | 21 ++- crates/copilot_chat/Cargo.toml | 2 + crates/copilot_chat/src/copilot_chat.rs | 208 +++++++++++++++++++++--- 4 files changed, 210 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0411aa04340..0cc384eef22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3880,6 +3880,8 @@ dependencies = [ "serde", "serde_json", "settings", + "sqlez", + "tempfile", ] [[package]] diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index a48bf3c1a43..6936a5a416c 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -511,8 +511,14 @@ impl Copilot { }; } - if let Ok(oauth_token) = env::var(copilot_chat::COPILOT_OAUTH_ENV_VAR) { - env.insert(copilot_chat::COPILOT_OAUTH_ENV_VAR.to_string(), oauth_token); + for env_var in [ + copilot_chat::COPILOT_OAUTH_ENV_VAR, + copilot_chat::GITHUB_COPILOT_OAUTH_ENV_VAR, + ] { + if let Ok(oauth_token) = env::var(env_var) { + env.insert(env_var.to_string(), oauth_token); + break; + } } if env.is_empty() { None } else { Some(env) } @@ -1259,6 +1265,7 @@ impl Copilot { | request::SignInStatus::AlreadySignedIn { .. } => { server.sign_in_status = SignInStatus::Authorized; cx.emit(Event::CopilotAuthSignedIn); + notify_copilot_chat_auth_changed(cx); for buffer in self.buffers.iter().cloned().collect::>() { if let Some(buffer) = buffer.upgrade() { self.register_buffer(&buffer, cx); @@ -1278,6 +1285,7 @@ impl Copilot { }; } cx.emit(Event::CopilotAuthSignedOut); + notify_copilot_chat_auth_changed(cx); for buffer in self.buffers.iter().cloned().collect::>() { self.unregister_buffer(&buffer); } @@ -1381,6 +1389,15 @@ fn notify_did_change_config_to_server( Ok(()) } +/// Notify Copilot Chat after the Copilot LSP reports an auth state change. +/// This replaces watching the SDK's token files, which is unreliable for +/// SQLite backed auth because writes may go through WAL files. +fn notify_copilot_chat_auth_changed(cx: &mut Context) { + if let Some(copilot_chat) = copilot_chat::CopilotChat::global(cx) { + copilot_chat.update(cx, |chat, cx| chat.reload_auth(cx)); + } +} + async fn clear_copilot_dir() { remove_matching(paths::copilot_dir(), |_| true).await } diff --git a/crates/copilot_chat/Cargo.toml b/crates/copilot_chat/Cargo.toml index 79159d59cc0..c6e6253bf45 100644 --- a/crates/copilot_chat/Cargo.toml +++ b/crates/copilot_chat/Cargo.toml @@ -34,7 +34,9 @@ paths.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true +sqlez.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/copilot_chat/src/copilot_chat.rs b/crates/copilot_chat/src/copilot_chat.rs index ab5c08b6174..4d0e5e6c46e 100644 --- a/crates/copilot_chat/src/copilot_chat.rs +++ b/crates/copilot_chat/src/copilot_chat.rs @@ -1,6 +1,6 @@ pub mod responses; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::OnceLock; @@ -17,9 +17,10 @@ use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use paths::home_dir; use serde::{Deserialize, Serialize}; -use settings::watch_config_dir; - +// The Copilot language server unofficially supports both token env vars: +// https://github.com/github/copilot-language-server-release/issues/3#issuecomment-2699433055 pub const COPILOT_OAUTH_ENV_VAR: &str = "GH_COPILOT_TOKEN"; +pub const GITHUB_COPILOT_OAUTH_ENV_VAR: &str = "GITHUB_COPILOT_TOKEN"; const DEFAULT_COPILOT_API_ENDPOINT: &str = "https://api.githubcopilot.com"; #[derive(Default, Clone, Debug, PartialEq)] @@ -501,6 +502,7 @@ pub struct CopilotChat { configuration: CopilotChatConfiguration, models: Option>, client: Arc, + fs: Arc, } pub fn init( @@ -529,11 +531,19 @@ pub fn copilot_chat_config_dir() -> &'static PathBuf { }) } +/// Legacy JSON token-storage paths used by older Copilot SDK builds. +/// TODO(copilot): once Copilot SDK supports `auth.db`, remove these paths. fn copilot_chat_config_paths() -> [PathBuf; 2] { let base_dir = copilot_chat_config_dir(); [base_dir.join("hosts.json"), base_dir.join("apps.json")] } +fn oauth_token_from_env() -> Option { + std::env::var(COPILOT_OAUTH_ENV_VAR) + .ok() + .or_else(|| std::env::var(GITHUB_COPILOT_OAUTH_ENV_VAR).ok()) +} + impl CopilotChat { pub fn global(cx: &App) -> Option> { cx.try_global::() @@ -546,40 +556,42 @@ impl CopilotChat { configuration: CopilotChatConfiguration, cx: &mut Context, ) -> Self { - let config_paths: HashSet = copilot_chat_config_paths().into_iter().collect(); - let dir_path = copilot_chat_config_dir(); - - cx.spawn(async move |this, cx| { - let mut parent_watch_rx = watch_config_dir( - cx.background_executor(), - fs.clone(), - dir_path.clone(), - config_paths, - ); - while let Some(contents) = parent_watch_rx.next().await { + // Initial async scan of token sources. Live reload is driven by the + // Copilot LSP's auth status notifications instead of watching files, + // because SQLite WAL writes can make directory watchers racy. + cx.spawn({ + let fs = fs.clone(); + async move |this, cx| { let oauth_domain = this.read_with(cx, |this, _| this.configuration.oauth_domain())?; - let oauth_token = extract_oauth_token(contents, &oauth_domain); + let config_paths: HashSet = + copilot_chat_config_paths().into_iter().collect(); + let auth_db_path = copilot_chat_config_dir().join("auth.db"); - this.update(cx, |this, cx| { - this.oauth_token = oauth_token.clone(); - cx.notify(); - })?; + let oauth_token = + read_oauth_token(&fs, &config_paths, &oauth_domain, &auth_db_path, cx).await; if oauth_token.is_some() { + this.update(cx, |this, cx| { + this.oauth_token = oauth_token; + cx.notify(); + })?; Self::update_models(&this, cx).await?; } + anyhow::Ok(()) } - anyhow::Ok(()) }) .detach_and_log_err(cx); + // Initial state uses env var because it's cheap. The others do IO, so + // are on the background. let this = Self { - oauth_token: std::env::var(COPILOT_OAUTH_ENV_VAR).ok(), + oauth_token: oauth_token_from_env(), api_endpoint: None, models: None, configuration, client, + fs, }; if this.oauth_token.is_some() { @@ -764,6 +776,39 @@ impl CopilotChat { .detach(); } } + + pub fn reload_auth(&mut self, cx: &mut Context) { + let fs = self.fs.clone(); + let oauth_domain = self.configuration.oauth_domain(); + cx.spawn(async move |this, cx| { + let config_paths: HashSet = copilot_chat_config_paths().into_iter().collect(); + let auth_db_path = copilot_chat_config_dir().join("auth.db"); + + let new_token = + read_oauth_token(&fs, &config_paths, &oauth_domain, &auth_db_path, cx).await; + + let token_present = this.update(cx, |this, cx| { + let changed = this.oauth_token != new_token; + if changed { + this.oauth_token = new_token.clone(); + if new_token.is_none() { + // Sign-out: drop derived state so a future sign-in + // re-discovers the endpoint and re-fetches models. + this.api_endpoint = None; + this.models = None; + } + cx.notify(); + } + new_token.is_some() + })?; + + if token_present { + Self::update_models(&this, cx).await?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } } async fn get_models( @@ -917,6 +962,40 @@ async fn request_models( Ok(models) } +async fn read_oauth_token( + fs: &Arc, + config_paths: &HashSet, + oauth_domain: &str, + auth_db_path: &std::path::Path, + cx: &AsyncApp, +) -> Option { + if let Some(token) = oauth_token_from_env() { + return Some(token); + } + + let token_from_db = cx + .background_spawn({ + let auth_db_path = auth_db_path.to_path_buf(); + let oauth_domain = oauth_domain.to_string(); + async move { extract_oauth_token_from_db(&auth_db_path, &oauth_domain) } + }) + .await; + + if let Some(token) = token_from_db { + return Some(token); + } + + for file_path in config_paths { + if let Ok(contents) = fs.load(file_path).await { + if let Some(token) = extract_oauth_token(contents, oauth_domain) { + return Some(token); + } + } + } + + None +} + fn extract_oauth_token(contents: String, domain: &str) -> Option { serde_json::from_str::(&contents) .map(|v| { @@ -934,6 +1013,36 @@ fn extract_oauth_token(contents: String, domain: &str) -> Option { .flatten() } +fn extract_oauth_token_from_db(db_path: &Path, auth_authority: &str) -> Option { + if !db_path.exists() { + return None; + } + + let db = sqlez::connection::Connection::open_file(db_path.to_str()?); + + let token_bytes: Option> = db + .select_row_bound::<&str, Vec>( + "SELECT token_ciphertext FROM oauth_tokens WHERE auth_authority = ? ORDER BY last_used_at DESC, token_id DESC LIMIT 1", + ) + .ok() + .and_then(|mut select| select(auth_authority).ok().flatten()); + + let token = token_bytes.and_then(|bytes| String::from_utf8(bytes).ok())?; + + if token.starts_with("ghu_") + && token.len() >= 36 + && token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + { + log::debug!("Copilot OAuth token loaded from auth.db"); + Some(token) + } else { + log::warn!( + "Copilot auth.db: token does not match expected GitHub OAuth format (ghu_)" + ); + None + } +} + async fn stream_completion( client: Arc, oauth_token: String, @@ -1751,4 +1860,61 @@ mod tests { "\"none\"" ); } + + #[test] + fn test_extract_oauth_token_from_db_matches_auth_authority_and_recency() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("auth.db"); + let older_github_token = "ghu_oldergithubtokenvalue000000000000"; + let newer_github_token = "ghu_newergithubtokenvalue000000000000"; + let enterprise_token = "ghu_enterprisetokenvalue0000000000000"; + + let connection = sqlez::connection::Connection::open_file(db_path.to_str().unwrap()); + connection + .exec( + "CREATE TABLE oauth_tokens ( + token_id INTEGER PRIMARY KEY AUTOINCREMENT, + auth_authority TEXT NOT NULL, + token_ciphertext BLOB NOT NULL, + last_used_at INTEGER NOT NULL + );", + ) + .unwrap()() + .unwrap(); + + { + let mut insert_token = connection + .exec_bound::<(&str, Vec, i64)>( + "INSERT INTO oauth_tokens (auth_authority, token_ciphertext, last_used_at) VALUES (?, ?, ?);", + ) + .unwrap(); + insert_token(("github.com", older_github_token.as_bytes().to_vec(), 10)).unwrap(); + insert_token(( + "github.enterprise.test", + enterprise_token.as_bytes().to_vec(), + 30, + )) + .unwrap(); + insert_token(("github.com", newer_github_token.as_bytes().to_vec(), 20)).unwrap(); + } + drop(connection); + + assert_eq!( + extract_oauth_token_from_db(&db_path, "github.com").as_deref(), + Some(newer_github_token) + ); + assert_eq!( + extract_oauth_token_from_db(&db_path, "github.enterprise.test").as_deref(), + Some(enterprise_token) + ); + } + + #[test] + fn test_extract_oauth_token_from_db_missing_db_does_not_create_file() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("auth.db"); + + assert_eq!(extract_oauth_token_from_db(&db_path, "github.com"), None); + assert!(!db_path.exists()); + } } From 830414004a22c2294df77c49805b168108e4c06d Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Thu, 28 May 2026 23:25:54 +0800 Subject: [PATCH 49/93] settings: Apply VS Code minimap default when importing settings (#56483) When importing settings from VS Code, the minimap is now enabled by default (matching VS Code's behavior) even if the user hasn't explicitly set minimap-related options in their VS Code settings.json. Previously, if `editor.minimap.enabled` and `editor.minimap.autohide` were absent from the VS Code config, Zed would leave minimap at its own default ("never"). Since VS Code defaults to minimap on, this caused a mismatch for users expecting their VS Code experience to carry over. Closes #56297 Release Notes: - Fixed apply VS Code minimap default when importing settings Signed-off-by: Xiaobo Liu --- crates/settings/src/settings_store.rs | 48 +++++++++++++++++++++++---- crates/settings/src/vscode_import.rs | 11 +++--- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 5677b70b7c8..7e98e99f85d 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -2177,6 +2177,9 @@ mod tests { r#" { "editor.tabSize": 37 } "#.to_owned(), r#"{ "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, "tab_size": 37 } "# @@ -2195,6 +2198,9 @@ mod tests { r#"{ "editor.tabSize": 42 }"#.to_owned(), r#"{ "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, "tab_size": 42, "preferred_line_length": 99, } @@ -2215,6 +2221,9 @@ mod tests { r#"{}"#.to_owned(), r#"{ "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, "preferred_line_length": 99, "tab_size": 42 } @@ -2241,6 +2250,9 @@ mod tests { "base_keymap": "VSCode", "tabs": { "git_status": true + }, + "minimap": { + "show": "always" } } "# @@ -2265,7 +2277,10 @@ mod tests { "sort_mode": "mixed", "sort_order": "lower" }, - "base_keymap": "VSCode" + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } } "# .unindent(), @@ -2282,6 +2297,9 @@ mod tests { r#"{ "editor.fontFamily": "Cascadia Code, 'Consolas', Courier New" }"#.to_owned(), r#"{ "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, "buffer_font_fallbacks": [ "Consolas", "Courier New" @@ -2305,7 +2323,10 @@ mod tests { "terminal": { "bell": "system" }, - "base_keymap": "VSCode" + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } } "# .unindent(), @@ -2324,7 +2345,10 @@ mod tests { "terminal": { "bell": "off" }, - "base_keymap": "VSCode" + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } } "# .unindent(), @@ -2343,7 +2367,10 @@ mod tests { "terminal": { "bell": "system" }, - "base_keymap": "VSCode" + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } } "# .unindent(), @@ -2362,7 +2389,10 @@ mod tests { "terminal": { "bell": "off" }, - "base_keymap": "VSCode" + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } } "# .unindent(), @@ -2385,7 +2415,10 @@ mod tests { "terminal": { "bell": "off" }, - "base_keymap": "VSCode" + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } } "# .unindent(), @@ -2406,6 +2439,9 @@ mod tests { .to_owned(), r#"{ "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, "hover_popover_hiding_delay": 500, "hover_popover_sticky": false } diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 5954453769a..59dcdfbccf6 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -474,13 +474,12 @@ impl VsCodeSettings { } fn minimap_content(&self) -> Option { - let minimap_enabled = self.read_bool("editor.minimap.enabled"); - let autohide = self.read_bool("editor.minimap.autohide"); + let minimap_enabled = self.read_bool("editor.minimap.enabled").unwrap_or(true); + let autohide = self.read_bool("editor.minimap.autohide").unwrap_or(false); let show = match (minimap_enabled, autohide) { - (Some(true), Some(false)) => Some(ShowMinimap::Always), - (Some(true), _) => Some(ShowMinimap::Auto), - (Some(false), _) => Some(ShowMinimap::Never), - _ => None, + (true, false) => Some(ShowMinimap::Always), + (true, true) => Some(ShowMinimap::Auto), + (false, _) => Some(ShowMinimap::Never), }; skip_default(MinimapContent { From d139a871db1e9be2f0e56be220fa91e24c5f3416 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 28 May 2026 17:28:26 +0200 Subject: [PATCH 50/93] copilot_chat: Fix error when using GPT models (#57958) We were including reasoning blocks twice. This matches what the Copilot API does (that is only including the reasoning items in `output_item.done`). Before: ``` { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "Run ls" }] }, { "type": "reasoning", "id": "khgBW2vnLWOFgjxAybXdEtL47dY9XaqKW6yvy2JHSaWVeONUkhuQXcDTWwmnbpKKzlzYuaZsO2YUyvieidwC+RbAfD0e2ZjipDSLTdF7DZM6yLUpAwSgV+09d234G93pdg2VRW7dlgcllPCbVeor9V7bZQUOifDp/vU0VIrKVNFjDgigPYojwSZ476o/rTLvTBSlKt1IbkqyLLiix2gokE1lR+DpmG54UiynMFX2WtOVtZP+BriKGfbw3QQF+gz5HQDZuU/Pc7L2CzHTwQVsmTXljeJI9BTZjuiDBVaDZf4ILQiuQ33wVvh3jC5la47lC8jbqlYpmRirsxgBb/vcAFWnUKxcJh/Bgs3tnNk4TKXTeoYhZ+71YGSv2jFnK0JzJ9iZB8w4bkZvHE6136HnSg==", "summary": [], "encrypted_content": "NgKURnC7g/R/NdnmWu2i70W/hCirYyUWxv7e6Q8CsrTJ1KkUL+e17Frav+KvL1LBzvd0ZklRlmGvLgSUNIW2X+4RG9Ff4m+z660xT00wUqsD5GcAJCTs11k9RPHE0uUJ5gs6EMDNgbgBhj9CcArEbiQEJuzwuRMTINv4lM63aVUcvYfY3R5qQnYm+ixEJr1gQyNmK/WUMvQgZRThjYeMt2/bYtRCPDvKQSXBIrh+tFUMPj9p/ohHSOVRnuwJc7X4JvP0flR0rrqIdZoNXYJ0Jyw2Bm1yz0iy15ckxczFmagqI6r81herd6ZIWA+CrosvLbZujIVUQMYAe0rdESN/kqzgUhqJl6XLShevrWJ+RGxUM5HuICQAuFhMRF6PRBwO+BN4tYi4VEXSBwrR0Z1uhdkPzc1UXPOJFIky25nrhPu0T+k7b3eYWSEAVHvzfsKBC8fKUIFDO8hhSo7LcKrMQx0ADHT4zbbLehF7dP4cR2LgQV2WtPW/AFV160EvR3EySpBSkTgrZ7W3bySYq3PYTbUk7eOYWyauNPD1RBqFIAU51WLlhyagFcfskRjDk7TIEtJnODz4EZvlbwXM1cBt6CwnO9ehy2GWSTSkQbizvNZBr1L8aYI1b3u/nB9ezoM4lOQwPcd1f8g858WlTA1GuIMjDEBoBGNnMolHzn7f64VJZMjinAoyKWFBOLh3U+tQUvPGFpyq9mnDraWwMnkQtsi9NZTbf78kLg5+QRvAepbmCmcdvHuPKZYzFk8BebgQyhiob4ONa2n5q6EmOGKGqkqbAB0dUfjE1+tMsR+rYeF7VLYBjGapYGqJZeYW4wsg6rYrQv1VdleFejmgM7toWDlsd/COEDPAMEZ0mM2doPxfB6JEuxld4T5wJO1uzYA5dVI5ImgnHIXkljjH88/TVSZ0VgOlogsf7qeh8reGSJ4btIReVFZxuHQX7c13Isn+65F0RSx+NCJHh87vuJBE7nGLP0CLNzc7B9pqAQA3G9yVlrBrv3eT1NR0Ha4vah7/1/DNvqzDvBdIzUQB67IpOhQo4gBMUVisXIZHT2U38eusaGry4j7L3SSspBF0cnOsVzdZL34LYgqsl8LxMCgDGOyTyMqKS/PgKofTNtDrwMWKnNApTRdMdrjbDmqY1hNKM6CmQ033XWztToUu5tDW/Mgl5pfUmgdFjVCSHfUpFsNLEdx32LlL1oO/hPofLA8k1SNVBJx55wez73gcWYugkVIV9wPyTEu0tbcOEya1A4CrSDqLf8kbwZ/fhotKjqdKulkGnIhfs0lVathO8ENylCgH0in+jBUZ+b14MMLnBjIK5xV9IXo+rZ4fBsXzwLxYAeUa+Z0VWCZJnjzFhlMUpQZHQxWG83nHTpWcRxE1/JEVBaTi/gJA6VAMaetpEfwoxjdB8Ydh/E2WTlQzqFn1tNdibp46Km1PfwIyeVwwQX2DEkvlN7qDBAQEPdR10/qBEjKXTj1hHZaS+yJt4w5RuCr2/YdUzKz6rDf7oc885NUgTFZtYh+VDVrLl3qYIQyQuAp0h5Hv35cYOnNG2NnxOC+Z79vCK6xoSE5fIYUArzuSyqIrzXR7F4IDgmikH7KplkcLYBP6s9Coc+c2s+WMSUPq1hzZDweWfqnsR22BYzsTTwGAMul3AUurqevcljUmpKsZTOSmuyKt/of2hTzHOrFRiZy5ttFe8T+0CycaediLmx5tdJsPWvJEnIHICaawUxC8O0Mk0shHfKKnR9EYifkBn4ygrm6jPKEE1b7egZ668oPtWIDKxD/HHDCbh1qED7u4PD0i4abZTNwhSpzrB0SOWCNoSYDzEqp37LSlgYubAZJztk8sxmKYWeXwdeZBiuJmNqkclHWQxbPJLXymFlcz1C0LCitwKLLMyUWHWFy47kRXDkH1eYsXgxYANRbKfGHLLxPO+N5cJszqv1DFwOje5PkBxgOAfuYVjO3DtIDqIfdJluNeUPBdphGqm/X3FvUBn3pm6tOQgteaMGwJblN8ewuPY6of+AsubruQ0io0kpDLKCje++9negmo9qMxgVRugEduEs/RlZOJ8E3bAz1DT+nlrCytWXWr9UM8xB8igPlL9cHzeKBv1QFmW/CpHjfjzCqU5fldeDGJ0t2Bu5reLMoNC35hwcD+8K63ePjwb1clMMFFj9fdmGnXUOF+c+twj5xesSLxMB6O4abd+soVb8bHdn49z9w626Br5epVs3Qwf/HNiWDU8CVKgyb9ccj6s8/ETP24zDHUu5EQ517yqm18GPvy/nsyasiH5w0xoZxc1Ba8+KsSYvs+DkPujidl2Wgl3MUJh96R7c8BVTEchPFJoWe1YDDbaEp4aySDApVyTm/YVgG/nACmvPtfUlUgVN7BqR2PxMwZ1FmEYOxxFPEoKDtO9pNx76wsT8H+yKe8VCoguqe9HaXcHm6/pVKfM2Wgkmg5eRtNeFVMjE0Ejys/a4htzma5gPO4xqGc2bH9JxeP94qYkjIe4xp9I55+5vbztXAwsYF00aTQ4K9c6gLugHMbJLLQhoC1AK7ZHPh4XhXIdUBgc00oodMcKR5PaUzFqTZF0XzLCWjhpYiWE4VzmHKjatoPfwQHNsH0mwNWdtFS6UWthUEMvY6azjfZpfyJrry9Pj4RnHvJIaBchGciTo4QSWSh6GmTYspX7QCveF0sVzkC0o0jRtosQ/b6osH1PqOmsDqEw9IWFZCOET0CSyys0Vry2HGa+U1vvQAs8p2ek0va3BKFITeBPkACRhLaZlWHpWjZ5F/AqwUR+Eguo330j3Wk9lQ7F2VV+GAS+ZgDh8Az56ed4kTlA8rHs7NliqKr8nqFUBEWiWDWUlhjEgXtrt9G2/jpFIU/3UF0M1NFQ94F28mTmA==" }, { "type": "reasoning", "id": "Vj0QJZMbDF6p5SHwQfzk3FCBzFmXvd6ERARDR68BTo93WJpORDWhnaR2CPiPRIMhbvt9fI5VjqR5O5mUXtciw+V74B9petxJMcM1OtsoQqeQr0Go2QjQAYquGV/Q+1n/+x1HjWXRGTzHkwplhZ1KYL9juwFF8PZke8wnl6wQRyUUTjLFxdxNMfl6kTBYAU4RBjnJiz16dSrvzaRLbHc0ZgezT7IqJOIA8paZvwncwY6ftXEcdYTKYUDGChK7aUcVt+RyCGtSl81ayzmoCdv0SGCJBsnX5BuG1GmINkDHEpwTNHteYXmSDxLKgzNIwIBJxR1Z5WMBEZDI4OQWNzLTuKppCOYv0E7UxCclSwkGKEof8yDBVvgdFfwfoFIvFJKJiy/niUDuzKbqW14CtodaLg==", "summary": [], "encrypted_content": "X9oCaUyulbIo0tcHBAzE0sZ5BIO4OcIAgr/YrjmI24ANFOTxxb16GEwwXGjDQFi402ii2nCbSh0vcaZPmZjbwydelCo0rXC0rkWlKgxPJnIioeVZwKN4GBEW+bgbKkGu1TspCCoJE/Eppkk1qOSkZ1PcuLynj06aDUkTyPMK2ZGQQps3H/TvJ5LHES5bX3Op5pejamPKa4QB7wXIn710j1CIjUyaXgz2aal6OwYHLoZJJyv4TFvmgzP9Wt5eggzpBOFQnJ1tzw+a1/3D41GBogS1UdW0J77bdolkFgoARWAib4uyW9hAiL6+H0qrXH9/FHa9d5WU+XmWG9celRK1zSpV0IXqC1DUOoVeHm4RNRVD/Pwnt2NkFPwpdGOUG4kaDyIxf7rN4Lw571tW8x19DP/lLaqKzWY4BQpzUC3OgL9EX0ky2PhWmYP5b9Zu3U5fhbemvjf7jQ20gzSnWUlyGe5y7ckzBIzhYVxkLGLX1j1CkMnK7Po329Gt1GfPlymOODUOxkxd8e+ZwH66/S19fbgNFdfDFMCGsTtsN1A7YuHYC1KsKnaitpe07t3kWsKkSsaUUyPX1HDW1613QEe56bU45uLQ294p46iFg6xuzPQSfP/ynqExh7xf+dOU+9Iq5kEXh4R3UbTUaVqPlAHdzEYhJflkezVG4cDN3qkUJa1Sh2YtV1Cc2zjEqmfSkhEiOHpthrNHFTC9nHmaV9BR7jMxll7McuoRqjBm/aungGsheXUaTq0+xJUqm/FG5tuQyAga+M+g5/PC9JPcXOXeRpF+43PNnzqNUByA0evd/6qSM2Z8twL7wl5zsGzJuPS9mVVU8zUFUXxH7qJ3aSIu9azwRtNODPn25oIr6yuJlh4yKSYXz48r5/Ak+xYEJTXucm5KJZqffXl72YwjG0M4yk2A0m/mRvVY9G1GpBLWcNw17GBh/JsXv1NLz7FZ3SQLhRGE4yE53/780oMimLWBLcVSG7gyCCnz4+geFOf4mtO1hkQ4bP0n1w7qBZUM9KJUTMHvnXIlL9lR4m6Mgs9hMM/Bx24if7qdHVN/o0bD0cSUiiZsa9jiiibqsumnLte1d3rSjavpc6zkVy/K1H6uXV/YoNS1mn2xdxnLW6W8HD9XU6Luzbg0QjYoIb6BXtb1DTHRq3Op64xIBNmxYMX3OzS5ev+GRFrOBMqj0IJYmnM82gozAMhoHXapW8GNSccxx9/Br6NigwjUV5u/vlTBVqsEB3A7ondmw4S1ZqXuvkBJSg7CoYEKbUK8gf4kNeMD7YpXyFXh0SXVZV+tznKMnjw8aIdXGM6hwQXjym6F9WM4vPah5RrGR7XKyfJQGl4Ouj4PERZyIJcNWHPwBp0rpv+/Vu2el/arBIv5hoal15W2c9rhCg+vA5lrWfPlIRUi4dT/k8RytFSlRlcm0hnwDADaEr4wqwahwwtcSHOPP0eOZYjLEr6PGYdgSxaDnKgtPZHvoTI6zMGIlVMMCVS+46datGm15/3RbjxLXHGCkc0VQGkGfSDjJkgLGHk9LQ/ydzJELZuT392JmDMr1eKKCzVyg4EH4gAFkBKq/B4x7dK7hRJtrbMitdaa0+Llp/McrQADNuG1UKjmz5StXi6ocwDdOr37sQpq1JAYy5ahae85EuZ1WM41n6p5IRQKUG9r786boBYy0zFTIcSjbpNqTteXRWTJOdGQqiW+vxUhmfOTRre3tsszbPNnxhaX2J06EIPSdOXx6T/bBrRkG5b2dwtud/QPbomMXqD17xAsgBQO5+8bKL3Mcks76Y/6yVScSpFVi3tgWsB2+tvY8onE7xsrrhkSSZZhQYJ8wI0D5HJVegs/M7bYTPPMT0r+wAhnC3AMdlbtwrCrDKBwwSUNjc2IoAy7kRfdQWr2+E0Do9xcFKy4cHUXPi/wegiOHCGUimWc9MQNWNrGN5+EpmDyc0kgq4iVvyce/MG61nYXuOxuU4Vr2Q4zRp2xi3ZZAQf6zEMZ5kWGRUJxUO5pZkoQex4JmKdMkyF6xgG25aeqw4ppsiXB0O1puAHhlehCjJcyIDYDoKAToM3u6bfjGj1tWH+t32WTMKZ97JOV8+zi2xHB/qDccxDietNpObIfnEEXn/ZVy6KJ/M9IRLHBy98Ma1LgydXBK68YM/BLUx7NH10JjQStrVSpE4JITADZ7Bw/wi2QSFju7oC6+qtF2pz0FThWXBWHErfYEciAcu2FjNTLH44mjFGF7NC4ETZfo7kBeMq46q4WMzAY6YbkFNvudsYnHO360n/IxTl596944LuVTO2RdqS7DwE5RLVj8hWmlaVTuiDoBhWchHTtcFrk0DCpxRl76Tx8b3LLkotMq3GZfgC0HO1Gz9pJiwseBqg8xS5Kzc6qxKWJbVbqccVz5gGVGSXLiyqI40NO/gQEGNYpTC+0pydbOOgQ1mQEJtpE/6nzBkw7GjDmfmy/1uuU9wQiVt5NLor3dlNH2ClqLnoV5rdZoisDY5Z9vXhp+5qe19S4Qm2WY+h324UzrtGzQgaYdGFRAxS+iki5PSGJEpJnelDCYSzkFoIUGprOzzai0j0jPLG9ueAlKEiMmgz1NV7s5Dx49u2bNGEgfGaKP3o6ey05DrYM19iwfrcGHlzgfj4x1qmx3WzKTwAxL/RFbi788dK3ggn95vWtre1adMpJ8RB9vDnSXmp9gRrosnIgNfMhRhTKZ+3NcvF3sCPJXHe9SLmecUwvXVIE944TihHsLPvpVWRe7RjDFbn3K0TSneJ0Eyf6E5vecRt1S16qHl7zkacxFnl+tL6NOOHC6BvU4anxhX5i0AHOKRxFs4WUf+Nyrl+plURz1lxgoOQBFjrkNHuqSY712AcI8i0KS9SbjlGcRdik6J5gz3fniw==" }, { "type": "function_call", "call_id": "call_kYosdPp77arTCMT4vxKotORr", "name": "update_title", "arguments": "{\"title\":\"List project files\"}" }, { "type": "function_call_output", "call_id": "call_kYosdPp77arTCMT4vxKotORr", "output": "Session title updated" } ``` After: ``` { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "Run ls" }] }, { "type": "function_call", "call_id": "call_JAUQ6fRiJGBC2WxHjMuG3opI", "name": "update_title", "arguments": "{\"title\":\"List project files\"}" }, { "type": "function_call", "call_id": "call_lMZRlqGeJ8sSiITJ4xesgMwt", "name": "terminal", "arguments": "{\"command\":\"ls\",\"cd\":\"calculator\",\"timeout_ms\":5000,\"allow_network\":false,\"allow_fs_write\":false,\"unsandboxed\":false}" }, { "type": "reasoning", "summary": [], "encrypted_content": "4pRqoArHBOJL2K5USHgwuzGfxOyi4l1sFCcQwTsSxBs4nZJQr/4ELKHuqUK1xCJTO3e9vfPGvZDZtxTiV/ghrF5tNOjl0dxg7G77d2i1DiRm+Wn3dAgr5K1ssOgBMl07252Ukvs7B4zLJ89ovulGe0r/VM6+fg4JeNXYS9lE/whkZnib77lvqLvTsFDHUJkROCMsuUeNEGzavCf5xxG9qhSJQ8ieT2TcNiaTpHAdl4rkCi2JIyOPR6zCQjrptLy3RTJGBzSrChVViHi0v5tnMx0H6vcBR2d3SqYNqvaDfFkCNVOqHeYojSKLNjHoZizx/m9KLM/ZkVdgopsu5DaQpEWXLBzY+JbBkTnNSi+pUVMCCnHaj8UDGqsseCUv3JwsuM0udLFPaWYe+YnjjD340CtmtIG+jjQv80TMSzi9XtTpKS+cuC4OXHSLWMltDJEneoa4JfIKYQObO/xpZ7TwDsxXhR5BVo0KY5UGKcXOAogaLxIS2W5B8mqMfHvjeQtMzfnHjVuLHku/AL6M59SWd4MREFyjXROLLcFyPzwaCR4WrNeMrbIf+8WvzT7HlfD7ImV3McAEwEF/tYrj4pR8zfAMLiUfFCnRQptgoQFwZsm6HxfezPi1Nr4oMjPqlOll6px7lT++b+Tdb1qc6696uZ49MIkUuP4XOfpe6U0p0UqSZHQXQ6WFXm7G5DYHW2JW+sEYc62d1isRbdwoYDt7kVMslmKRCaYdFB0+TpuBOUn/Fpg20PtZe1Zsl6m5feafsLPmS+JypWDpbWj1/XAtCGRKQO0O0soW/QxmOZixvAMJjiym5+6TVeKdJCS1Objyk0faSDAFdlNQVP22B97VaW2aHTP2x1vsSI0Sqvd13iBu8v2oAaeLbfvuGeei9g8V/iEG4rLfK6zDvH8W7OJUoJBEveSSaCzMDzMChTkF5B+naP4PpH9b5rFRepxbWY8eU0Ab3N58aR7G53yXgdwJWBPisqcCj9xp+avO7e/WGro0T30htST0KHH++nVVdHLSb0ZxDec/h866EgMEkh2WrztcwRn5ovb4qwvpbJCSr0cKtyJAV6BT8/z+Pm513Lfe+B64OWAtbBmRasByYtjXSt+GmQ//DJed73UL+OUQm2v9vc9InG0AYWjVC7FRWfVMukTQDapEpo6LULbfbSEI7Z7Qa4eBYUSvoD+OiCHuGrrR+PyliiaGOIIa9/K4/hZQbhABnheVXlxN1igGniZoqtd546oghVGz89pEVO8Dv2zjw3U9+RAQwS2x90Sjqkx7F4Nr+heoXjySlghbSSoxB0ckeQUsn0JIElGH9pQxRgqIQ4swjlVaRtyrXrDK3zX3N6TCFBOlMdmlSZFSo/Gj0oaNM0Aot69DN5MnxkburXnaE7gsLH5xdBfaw8FABZUXxV2hJq/cpB/u9STqdLS6yCySFLwEWCmAn9V3mCClq5nxH3DqhTwo/vhQefQaT9n6+y9NfNV2svIOC+YRMKYbGsmcpxlZ/io/Y453C4Uy83Fl0UHbLieI+y5+Rm7Xs3VK3mcqwMSCniMvK7jiHdiizD9ONQ7KmyAhQyXbPjn/tZXgIHfMEf1RkWaHHxWn6QHSFdAmj+9IYY0owDm64WrmyvVZSuzz795Sicsek+VZZ85fGiemxuNRdkInxbQCC6rG8ZGyWIYTXgGXOrR+t0FvHO/04+RQUOvIJgYm/Bcauprw2oa2ipNQMLg/cdP5wi4pqUHlZiSF0HTixDiRaHS8w/XO0DYqDIKnBikZNmpT57DiiQwIPHK9BNpMi5M/2oQpQVdzCT7GrdPGNWJrl6p7oQCPtOFPQpuGyKvv8sVHMKkqj8l3olK09eimPl67SaLNhaY69+O8b9DXp3nJpWO8jgPfY6cKXPrH+8Sk6nwzc0uTlinZDMLsedJAdmoNObBgq+aqzRrUjuokBat3Ob498Dqajt4VWCpJ2+vO6obcTjML3s2jiU7G8yNrY8SGnMTWuR48GhjcCaub+rtk0zS2f2QVGElucJ1Ev6Fg4INqTjd8PdMLn0KcGF15JZSg3z/SZhxuyI+E6NAeAt/Xnribt4o7gTaqoYm6IMvi7zJ66TSWWp2bjEDJCwabifQRNtMw4UMtCiWNAv3pxG2M+jVFTsJrQ5xVEjoQMwYXWMCoTgtR+3/dK7J0xhkxdx5KweGCdb46pOM5tpSPcyxcr7NOgmpr5ovDKr+vbHK75deKGoQDGogOZehBKLW2ZHGmdAV6RJrtjGE4Ag1MjdO1pk3xAXY9w0tFSIy/YdX85l7pBV0xn719xH0RFG/9T95N9gxBtQAhuAfe9OVobctAZLVOMtQkMyaiPvkI32p6ehe/QD8+b29XhB0iO8HB0bvns2/gQwpIUQeZ3HB8Ef1QJxG3TUbKOP4Ud9XyDwYK4G1ExHXYmHB7bQcl0PrDaW+Au1We+3dmq6TUk/6GndJEVhcMS6rXt42T9cA7pE/8kTVmmea05rZwHyVSDoo8LbxMriAKoZfVi28Kg5rHuGdNOHvPxsa6+BJNY7XW41Q9ck2Pr0wt+aJYgMW9M4jRnQfqUY5QHiaxKYl/1/zASEg76QS1M8YDpjFytyb1S8a1bB5rTO+Ljplf/ubXO4i/tWtnJCecD8iBTnWG6dHBe0DFtDnBtRqFZFmz/stxk1Ti8OxR9klpPVUcuUoLYDwxvblDZnn53/knxbp5cbeIWDAVludm+JqwoycxXvJtbYUPzY2zpU7HcFoPFDHqy3LOvP1QgdWGcWCVplNwbSv2FQiWBfzf08e+o6IjOvk9B19hNbkI1lcAhjN16JlO0j/A2zKWsas3ixGAW216Wx0Z+hdQL6ISzCecQqolLhD/Nqb+MB5kigmH1gtmNQFQn2mrAF4TkoAZr3dS3gsHuq7vnOTGf/GN5g+gLGZvcK0IsG4kUpnja9miPif1bmhXkuHYZxANmr5B3O7vDGl9uirPK/vigLojwz8/bC0lhy4kpayLS1PaG3LBYhsDYKrDVot6R/ogrpfcB620pLN16KkutLIzW/ahDWgIVL/kNvFEIvvmSVuo" }, { "type": "function_call_output", "call_id": "call_JAUQ6fRiJGBC2WxHjMuG3opI", "output": "Session title updated" }, { "type": "function_call_output", "call_id": "call_lMZRlqGeJ8sSiITJ4xesgMwt", "output": "```\nCargo.lock\tCargo.toml\tCLAUDE.md\tsrc\t\ttarget\n```" } ``` Closes #57776 Release Notes: - copilot: Fixed an issue where using GPT models would return an error in `invalid_request_body` Co-authored-by: cameron --- .../src/provider/copilot_chat.rs | 78 +++++++++++++------ 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index bfac215b6c1..f42fad657c4 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -773,7 +773,6 @@ impl CopilotResponsesEventMapper { copilot_responses::StreamEvent::Completed { response } => { let mut events = Vec::new(); - events.extend(self.capture_reasoning_items_from_output(&response.output)); if let Some(usage) = response.usage { events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { input_tokens: usage.input_tokens.unwrap_or(0), @@ -805,7 +804,6 @@ impl CopilotResponsesEventMapper { }; let mut events = Vec::new(); - events.extend(self.capture_reasoning_items_from_output(&response.output)); if let Some(usage) = response.usage { events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { input_tokens: usage.input_tokens.unwrap_or(0), @@ -847,28 +845,6 @@ impl CopilotResponsesEventMapper { } } - fn capture_reasoning_items_from_output( - &mut self, - output: &[copilot_responses::ResponseOutputItem], - ) -> Vec> { - let mut events = Vec::new(); - for item in output { - if let copilot_responses::ResponseOutputItem::Reasoning { - id, - summary: _, - encrypted_content, - } = item - { - if let Some(reasoning_item) = - reasoning_input_item_from_output(&id, encrypted_content.clone()) - { - events.extend(self.capture_reasoning_item(reasoning_item)); - } - } - } - events - } - fn capture_reasoning_item( &mut self, reasoning_item: copilot_responses::ResponseReasoningInputItem, @@ -1597,6 +1573,60 @@ mod tests { } } + #[test] + fn responses_stream_ignores_reasoning_items_repeated_in_completed_output() { + let events = vec![ + responses::StreamEvent::OutputItemDone { + output_index: 0, + sequence_number: None, + item: responses::ResponseOutputItem::Reasoning { + id: "r1".into(), + summary: Some(Vec::new()), + encrypted_content: Some("ENC1".into()), + }, + }, + responses::StreamEvent::Completed { + response: responses::Response { + output: vec![ + responses::ResponseOutputItem::Reasoning { + id: "r1".into(), + summary: Some(Vec::new()), + encrypted_content: Some("ENC1".into()), + }, + responses::ResponseOutputItem::Reasoning { + id: "r2".into(), + summary: Some(Vec::new()), + encrypted_content: Some("ENC2".into()), + }, + ], + ..Default::default() + }, + }, + ]; + + let mapped = map_events(events); + let reasoning_details = mapped + .iter() + .filter_map(|event| match event { + LanguageModelCompletionEvent::ReasoningDetails(details) => Some(details), + _ => None, + }) + .collect::>(); + + assert_eq!( + reasoning_details, + vec![&json!({ + "reasoning_items": [ + { + "id": "r1", + "summary": [], + "encrypted_content": "ENC1" + } + ] + })] + ); + } + #[test] fn into_copilot_responses_replays_reasoning_details() { let model = test_responses_model(); From 5abe4bcbc6190f3ea8397eff6dd5ddea95968790 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Thu, 28 May 2026 11:56:02 -0400 Subject: [PATCH 51/93] Merge `community_champion_auto_labeler` into `pr_labeler` (#57898) Self-Review Checklist: - [X] I've reviewed my own diff for quality, security, and reliability - [n/a] Unsafe blocks (if any) have justifying comments - [n/a] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [n/a] Tests cover the new/changed behavior - [n/a] Performance impact has been considered and is acceptable Release Notes: - N/A --- .../community_champion_auto_labeler.yml | 113 -------- .github/workflows/pr_issue_labeler.yml | 247 ++++++++++++++++++ .github/workflows/pr_labeler.yml | 150 ----------- 3 files changed, 247 insertions(+), 263 deletions(-) delete mode 100644 .github/workflows/community_champion_auto_labeler.yml create mode 100644 .github/workflows/pr_issue_labeler.yml delete mode 100644 .github/workflows/pr_labeler.yml diff --git a/.github/workflows/community_champion_auto_labeler.yml b/.github/workflows/community_champion_auto_labeler.yml deleted file mode 100644 index 82a9e274d64..00000000000 --- a/.github/workflows/community_champion_auto_labeler.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: Community Champion Auto Labeler - -on: - issues: - types: [opened] - pull_request_target: - types: [opened] - -jobs: - label_community_champion: - if: github.repository_owner == 'zed-industries' - runs-on: namespace-profile-2x4-ubuntu-2404 - steps: - - name: Check if author is a community champion and apply label - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - env: - COMMUNITY_CHAMPIONS: | - 0x2CA - 5brian - 5herlocked - abdelq - afgomez - AidanV - akbxr - AlvaroParker - amtoaer - artemevsevev - bajrangCoder - bcomnes - Be-ing - blopker - bnjjj - bobbymannino - CharlesChen0823 - chbk - davewa - davidbarsky - ddoemonn - djsauble - errmayank - fantacell - fdncred - findrakecil - FloppyDisco - gko - huacnlee - imumesh18 - injust - jacobtread - jansol - jeffreyguenther - jenslys - jongretar - lemorage - lingyaochu - lnay - marcocondrache - marius851000 - mikebronner - ognevny - PKief - playdohface - RemcoSmitsDev - rgbkrk - romaninsh - rxptr - Simek - someone13574 - sourcefrog - suxiaoshao - Takk8IS - tartarughina - thedadams - tidely - timvermeulen - valentinegb - versecafe - vitallium - WhySoBad - ya7010 - Zertsov - with: - script: | - const communityChampions = process.env.COMMUNITY_CHAMPIONS - .split('\n') - .map(handle => handle.trim().toLowerCase()) - .filter(handle => handle.length > 0); - - let author; - if (context.eventName === 'issues') { - author = context.payload.issue.user.login; - } else if (context.eventName === 'pull_request_target') { - author = context.payload.pull_request.user.login; - } - - if (!author || !communityChampions.includes(author.toLowerCase())) { - return; - } - - const issueNumber = context.payload.issue?.number || context.payload.pull_request?.number; - - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: ['community champion'] - }); - - console.log(`Applied 'community champion' label to #${issueNumber} by ${author}`); - } catch (error) { - console.error(`Failed to apply label: ${error.message}`); - } diff --git a/.github/workflows/pr_issue_labeler.yml b/.github/workflows/pr_issue_labeler.yml new file mode 100644 index 00000000000..e27877b0a9f --- /dev/null +++ b/.github/workflows/pr_issue_labeler.yml @@ -0,0 +1,247 @@ +# Labels pull requests by author: +# - 'community champion' for community champions +# - 'bot' for bot accounts +# - 'staff' for staff team members +# - 'guild' for guild members +# - 'first contribution' for first-time external contributors +# Labels issues by author: +# - 'community champion' for community champions + +name: PR Issue Labeler + +on: + issues: + types: [opened] + pull_request_target: + types: [opened] + +permissions: + contents: read + +jobs: + check-authorship-and-label: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 5 + steps: + - id: get-app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + + - id: apply-authorship-label + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ steps.get-app-token.outputs.token }} + script: | + const BOT_LABEL = 'bot'; + const STAFF_LABEL = 'staff'; + const STAFF_TEAM_SLUG = 'staff'; + const FIRST_CONTRIBUTION_LABEL = 'first contribution'; + const GUILD_LABEL = 'guild'; + const GUILD_MEMBERS = [ + '11happy', + 'AidanV', + 'AmaanBilwar', + 'MostlyKIGuess', + 'OmChillure', + 'Palanikannan1437', + 'Shivansh-25', + 'SkandaBhat', + 'TwistingTwists', + 'YEDASAVG', + 'Ziqi-Yang', + 'alanpjohn', + 'arjunkomath', + 'austincummings', + 'ayushk-1801', + 'criticic', + 'dongdong867', + 'emamulandalib', + 'eureka928', + 'feitreim', + 'iam-liam', + 'iksuddle', + 'ishaksebsib', + 'lingyaochu', + 'loadingalias', + 'marcocondrache', + 'mchisolm0', + 'nairadithya', + 'nihalxkumar', + 'notJoon', + 'polyesterswing', + 'prayanshchh', + 'razeghi71', + 'sarmadgulzar', + 'seanstrom', + 'th0jensen', + 'tommyming', + 'transitoryangel', + 'virajbhartiya', + ]; + const COMMUNITY_CHAMPION_LABEL = 'community champion'; + const COMMUNITY_CHAMPIONS = [ + '0x2CA', + '5brian', + '5herlocked', + 'abdelq', + 'afgomez', + 'AidanV', + 'akbxr', + 'AlvaroParker', + 'amtoaer', + 'artemevsevev', + 'bajrangCoder', + 'bcomnes', + 'Be-ing', + 'blopker', + 'bnjjj', + 'bobbymannino', + 'CharlesChen0823', + 'chbk', + 'davewa', + 'davidbarsky', + 'ddoemonn', + 'djsauble', + 'errmayank', + 'fantacell', + 'fdncred', + 'findrakecil', + 'FloppyDisco', + 'gko', + 'huacnlee', + 'imumesh18', + 'injust', + 'jacobtread', + 'jansol', + 'jeffreyguenther', + 'jenslys', + 'jongretar', + 'lemorage', + 'lingyaochu', + 'lnay', + 'marcocondrache', + 'marius851000', + 'mikebronner', + 'ognevny', + 'PKief', + 'playdohface', + 'RemcoSmitsDev', + 'rgbkrk', + 'romaninsh', + 'rxptr', + 'Simek', + 'someone13574', + 'sourcefrog', + 'suxiaoshao', + 'Takk8IS', + 'tartarughina', + 'thedadams', + 'tidely', + 'timvermeulen', + 'valentinegb', + 'versecafe', + 'vitallium', + 'WhySoBad', + 'ya7010', + 'Zertsov', + ]; + + const pr = context.payload.pull_request; + const issue = context.payload.issue; + const target = pr || issue; + const author = target.user.login; + + const listIncludesAuthor = (members, author) => { + const authorLower = author.toLowerCase(); + return members.some((member) => member.toLowerCase() === authorLower); + }; + + const isStaffMember = async (author) => { + try { + const response = await github.rest.teams.getMembershipForUserInOrg({ + org: 'zed-industries', + team_slug: STAFF_TEAM_SLUG, + username: author + }); + return response.data.state === 'active'; + } catch (error) { + if (error.status !== 404) { + throw error; + } + return false; + } + }; + + const getIssueLabels = () => { + if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) { + return [COMMUNITY_CHAMPION_LABEL]; + } + + return []; + }; + + const getPullRequestLabels = async () => { + if (target.user.type === 'Bot') { + return [BOT_LABEL]; + } + + if (await isStaffMember(author)) { + return [STAFF_LABEL]; + } + + // External contributors + + const labelsToAdd = []; + + if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) { + labelsToAdd.push(COMMUNITY_CHAMPION_LABEL); + } + + if (listIncludesAuthor(GUILD_MEMBERS, author)) { + labelsToAdd.push(GUILD_LABEL); + } + + // We use inverted logic here due to a suspected GitHub bug where first-time contributors + // get 'NONE' instead of 'FIRST_TIME_CONTRIBUTOR' or 'FIRST_TIMER'. + // https://github.com/orgs/community/discussions/78038 + // This will break if GitHub ever adds new associations. + const association = pr.author_association; + const knownAssociations = ['CONTRIBUTOR', 'COLLABORATOR', 'MEMBER', 'OWNER', 'MANNEQUIN']; + + if (knownAssociations.includes(association)) { + console.log(`PR #${pr.number} by ${author}: not a first-time contributor (association: '${association}')`); + } else { + labelsToAdd.push(FIRST_CONTRIBUTION_LABEL); + } + + return labelsToAdd; + }; + + const labelsToAdd = pr ? await getPullRequestLabels() : getIssueLabels(); + + if (labelsToAdd.length === 0) { + return; + } + + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: target.number, + labels: labelsToAdd + }); + + const targetType = pr ? 'PR' : 'issue'; + const labels = labelsToAdd.map((label) => `'${label}'`).join(', '); + console.log(`${targetType} #${target.number} by ${author}: labeled ${labels}`); + } catch (error) { + if (pr) { + throw error; + } + + console.error(`Failed to label issue #${target.number}: ${error.message}`); + } diff --git a/.github/workflows/pr_labeler.yml b/.github/workflows/pr_labeler.yml deleted file mode 100644 index 9ea70385432..00000000000 --- a/.github/workflows/pr_labeler.yml +++ /dev/null @@ -1,150 +0,0 @@ -# Labels pull requests by author: 'bot' for bot accounts, 'staff' for -# staff team members, 'guild' for guild members, 'first contribution' for -# first-time external contributors. -name: PR Labeler - -on: - pull_request_target: - types: [opened] - -permissions: - contents: read - -jobs: - check-authorship-and-label: - if: github.repository == 'zed-industries/zed' - runs-on: namespace-profile-2x4-ubuntu-2404 - timeout-minutes: 5 - steps: - - id: get-app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 - with: - app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} - private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} - owner: zed-industries - - - id: apply-authorship-label - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - github-token: ${{ steps.get-app-token.outputs.token }} - script: | - const BOT_LABEL = 'bot'; - const STAFF_LABEL = 'staff'; - const GUILD_LABEL = 'guild'; - const FIRST_CONTRIBUTION_LABEL = 'first contribution'; - const STAFF_TEAM_SLUG = 'staff'; - const GUILD_MEMBERS = [ - '11happy', - 'AidanV', - 'AmaanBilwar', - 'MostlyKIGuess', - 'OmChillure', - 'Palanikannan1437', - 'Shivansh-25', - 'SkandaBhat', - 'TwistingTwists', - 'YEDASAVG', - 'Ziqi-Yang', - 'alanpjohn', - 'arjunkomath', - 'austincummings', - 'ayushk-1801', - 'criticic', - 'dongdong867', - 'emamulandalib', - 'eureka928', - 'feitreim', - 'iam-liam', - 'iksuddle', - 'ishaksebsib', - 'lingyaochu', - 'loadingalias', - 'marcocondrache', - 'mchisolm0', - 'nairadithya', - 'nihalxkumar', - 'notJoon', - 'polyesterswing', - 'prayanshchh', - 'razeghi71', - 'sarmadgulzar', - 'seanstrom', - 'th0jensen', - 'tommyming', - 'transitoryangel', - 'virajbhartiya', - ]; - - const pr = context.payload.pull_request; - const author = pr.user.login; - - if (pr.user.type === 'Bot') { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [BOT_LABEL] - }); - console.log(`PR #${pr.number} by ${author}: labeled '${BOT_LABEL}' (user type: '${pr.user.type}')`); - return; - } - - let isStaff = false; - try { - const response = await github.rest.teams.getMembershipForUserInOrg({ - org: 'zed-industries', - team_slug: STAFF_TEAM_SLUG, - username: author - }); - isStaff = response.data.state === 'active'; - } catch (error) { - if (error.status !== 404) { - throw error; - } - } - - if (isStaff) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [STAFF_LABEL] - }); - console.log(`PR #${pr.number} by ${author}: labeled '${STAFF_LABEL}' (staff team member)`); - return; - } - - const authorLower = author.toLowerCase(); - const isGuildMember = GUILD_MEMBERS.some( - (member) => member.toLowerCase() === authorLower - ); - if (isGuildMember) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [GUILD_LABEL] - }); - console.log(`PR #${pr.number} by ${author}: labeled '${GUILD_LABEL}' (guild member)`); - // No early return: guild members can also get 'first contribution' - } - - // We use inverted logic here due to a suspected GitHub bug where first-time contributors - // get 'NONE' instead of 'FIRST_TIME_CONTRIBUTOR' or 'FIRST_TIMER'. - // https://github.com/orgs/community/discussions/78038 - // This will break if GitHub ever adds new associations. - const association = pr.author_association; - const knownAssociations = ['CONTRIBUTOR', 'COLLABORATOR', 'MEMBER', 'OWNER', 'MANNEQUIN']; - - if (knownAssociations.includes(association)) { - console.log(`PR #${pr.number} by ${author}: not a first-time contributor (association: '${association}')`); - return; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [FIRST_CONTRIBUTION_LABEL] - }); - console.log(`PR #${pr.number} by ${author}: labeled '${FIRST_CONTRIBUTION_LABEL}' (association: '${association}')`); From ef5606bb61da57e922db36b2755176b95d2aa3d0 Mon Sep 17 00:00:00 2001 From: morgankrey Date: Thu, 28 May 2026 11:41:26 -0500 Subject: [PATCH 52/93] Improve ChatGPT subscription response resilience (#57891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This started from #57636, after we saw ChatGPT subscription/Codex requests stall over the past week. OpenCode v1.15.11 shipped related resilience fixes for the same class of Codex subscription endpoint issues, so this ports the relevant pieces into Zed's native ChatGPT subscription provider. When Zed asks ChatGPT/Codex for a response, sometimes the server connection can get stuck before it even sends the first response headers. Before this PR, Zed could wait indefinitely, which looks like OpenCode/Zed “stalling.” This PR makes Zed: - Wait up to 10 seconds for the server to start responding. - If nothing comes back in that window, treat it as a temporary network/API failure. - Let the existing retry logic try again instead of leaving the user stuck. - Send a stable session-id header so OpenAI’s Codex backend can associate requests with the same Zed agent thread. - Add tests to make sure: - stuck-before-response requests time out, - normal slow streaming responses are not cut off, - ChatGPT subscription requests send the right session header, - the agent retries this kind of failure. intended user-facing result is: fewer “the assistant is just sitting there forever” failures when using ChatGPT subscription models. ## Verification - cargo test -p open_ai responses - cargo test -p language_models openai_subscribed - cargo test -p agent test_send_retry_on_http_send_error - cargo check -p open_ai - cargo check -p language_models - cargo check -p agent Release Notes: - Fixed ChatGPT subscription requests stalling indefinitely before response headers arrive. --- crates/agent/src/tests/mod.rs | 57 ++++ .../src/provider/openai_subscribed.rs | 204 +++++++++++- .../src/provider/vercel_ai_gateway.rs | 6 + crates/open_ai/src/open_ai.rs | 8 +- crates/open_ai/src/responses.rs | 297 +++++++++++++++++- 5 files changed, 557 insertions(+), 15 deletions(-) diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 16faa56c786..72ce5227975 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -4069,6 +4069,63 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_send_retry_on_http_send_error(cx: &mut TestAppContext) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let mut events = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello!"], cx) + }) + .expect("thread send should start"); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_error(LanguageModelCompletionError::HttpSend { + provider: LanguageModelProviderName::new("OpenAI"), + error: anyhow::anyhow!("response headers timed out after 10s"), + }); + fake_model.end_last_completion_stream(); + + cx.executor().advance_clock(BASE_RETRY_DELAY); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Recovered!"); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let mut retry_events = Vec::new(); + while let Some(Ok(event)) = events.next().await { + match event { + ThreadEvent::Retry(retry_status) => { + retry_events.push(retry_status); + } + ThreadEvent::Stop(..) => break, + _ => {} + } + } + + assert_eq!(retry_events.len(), 1); + assert!(matches!( + retry_events[0], + acp_thread::RetryStatus { attempt: 1, .. } + )); + thread.read_with(cx, |thread, _cx| { + assert_eq!( + thread.to_markdown(), + indoc! {" + ## User + + Hello! + + ## Assistant + + Recovered! + "} + ) + }); +} + #[gpui::test] async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 66716ebdadb..8d44101c181 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -12,12 +12,15 @@ use language_model::{ LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, }; -use open_ai::{ReasoningEffort, responses::stream_response}; +use open_ai::{ + ReasoningEffort, + responses::{StreamResponseOptions, stream_response_with_options}, +}; use rand::RngCore as _; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use ui::{ConfiguredApiCard, prelude::*}; use url::form_urlencoded; use util::ResultExt as _; @@ -35,6 +38,31 @@ const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const CREDENTIALS_KEY: &str = "https://chatgpt.com/backend-api/codex"; const TOKEN_REFRESH_BUFFER_MS: u64 = 5 * 60 * 1000; +const CODEX_RESPONSE_HEADER_TIMEOUT: Duration = Duration::from_secs(10); + +fn codex_extra_headers( + account_id: Option<&str>, + session_id: Option<&str>, +) -> Vec<(String, String)> { + let mut extra_headers: Vec<(String, String)> = vec![ + ("originator".into(), "zed".into()), + ("OpenAI-Beta".into(), "responses=experimental".into()), + ]; + + if let Some(id) = account_id { + if !id.is_empty() { + extra_headers.push(("ChatGPT-Account-Id".into(), id.into())); + } + } + + if let Some(id) = session_id { + if !id.is_empty() { + extra_headers.push(("session-id".into(), id.into())); + } + } + + extra_headers +} #[derive(Serialize, Deserialize, Clone, Debug)] struct CodexCredentials { @@ -472,6 +500,7 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { // The Codex backend rejects `max_output_tokens` (`Unsupported parameter`), // unlike the public OpenAI Responses API. Pass `None` so the field is // omitted from the serialized request body entirely. + let session_id = request.thread_id.clone(); let mut responses_request = into_open_ai_response( request, self.model.id(), @@ -510,26 +539,24 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { let future = cx.spawn(async move |cx| { let creds = get_fresh_credentials(&state, &http_client, cx).await?; - let mut extra_headers: Vec<(String, String)> = vec![ - ("originator".into(), "zed".into()), - ("OpenAI-Beta".into(), "responses=experimental".into()), - ]; - if let Some(ref id) = creds.account_id { - if !id.is_empty() { - extra_headers.push(("ChatGPT-Account-Id".into(), id.clone())); - } - } + let extra_headers = + codex_extra_headers(creds.account_id.as_deref(), session_id.as_deref()); let access_token = creds.access_token.clone(); + let background_executor = cx.background_executor().clone(); request_limiter .stream(async move { - stream_response( + stream_response_with_options( http_client.as_ref(), PROVIDER_NAME.0.as_str(), CODEX_BASE_URL, &access_token, responses_request, extra_headers, + StreamResponseOptions::response_header_timeout( + CODEX_RESPONSE_HEADER_TIMEOUT, + background_executor.timer(CODEX_RESPONSE_HEADER_TIMEOUT), + ), ) .await .map_err(LanguageModelCompletionError::from) @@ -1108,6 +1135,7 @@ mod tests { use super::*; use gpui::TestAppContext; use http_client::FakeHttpClient; + use language_model::{LanguageModelRequestMessage, Role}; use parking_lot::Mutex; use std::future::Future; use std::pin::Pin; @@ -1157,6 +1185,30 @@ mod tests { } } + #[test] + fn test_codex_extra_headers_include_session_id() { + assert_eq!( + codex_extra_headers(Some("account-1"), Some("thread-1")), + vec![ + ("originator".into(), "zed".into()), + ("OpenAI-Beta".into(), "responses=experimental".into()), + ("ChatGPT-Account-Id".into(), "account-1".into()), + ("session-id".into(), "thread-1".into()), + ] + ); + } + + #[test] + fn test_codex_extra_headers_omit_empty_optional_ids() { + assert_eq!( + codex_extra_headers(Some(""), Some("")), + vec![ + ("originator".into(), "zed".into()), + ("OpenAI-Beta".into(), "responses=experimental".into()), + ] + ); + } + fn make_expired_credentials() -> CodexCredentials { CodexCredentials { access_token: "old_access".to_string(), @@ -1177,6 +1229,13 @@ mod tests { } } + fn make_fresh_credentials_with_account() -> CodexCredentials { + CodexCredentials { + account_id: Some("account-1".to_string()), + ..make_fresh_credentials() + } + } + fn fake_token_response() -> String { serde_json::json!({ "access_token": "fresh_access", @@ -1186,6 +1245,127 @@ mod tests { .to_string() } + #[gpui::test] + async fn test_stream_completion_sends_codex_session_header(cx: &mut TestAppContext) { + let captured_headers = Arc::new(Mutex::new(None::)); + let captured_headers_clone = captured_headers.clone(); + let http_client = FakeHttpClient::create(move |request| { + *captured_headers_clone.lock() = Some(request.headers().clone()); + async move { + let body = r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}"#; + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::from(format!("{body}\n\n")))?) + } + }); + + let state = cx.new(|_cx| State { + credentials: Some(make_fresh_credentials_with_account()), + sign_in_task: None, + refresh_task: None, + load_task: None, + credentials_provider: Arc::new(FakeCredentialsProvider::new()), + auth_generation: 0, + last_auth_error: None, + }); + + let model = OpenAiSubscribedLanguageModel { + id: LanguageModelId::from(ChatGptModel::Gpt55.id().to_string()), + model: ChatGptModel::Gpt55, + state, + http_client, + request_limiter: RateLimiter::new(4), + }; + let request = LanguageModelRequest { + thread_id: Some("thread-1".to_string()), + prompt_id: Some("prompt-1".to_string()), + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec!["Hello".into()], + cache: false, + reasoning_details: None, + }], + ..Default::default() + }; + + let mut stream = model + .stream_completion(request, &cx.to_async()) + .await + .expect("stream should start"); + stream + .next() + .await + .expect("stream should emit event") + .expect("event should parse"); + + let captured_headers = captured_headers + .lock() + .clone() + .expect("request headers should be captured"); + assert_eq!( + captured_headers + .get("session-id") + .and_then(|value| value.to_str().ok()), + Some("thread-1") + ); + assert_eq!( + captured_headers + .get("ChatGPT-Account-Id") + .and_then(|value| value.to_str().ok()), + Some("account-1") + ); + } + + #[gpui::test] + async fn test_stream_completion_times_out_before_codex_headers(cx: &mut TestAppContext) { + let http_client = FakeHttpClient::create(|_request| { + futures::future::pending::>>() + }); + + let state = cx.new(|_cx| State { + credentials: Some(make_fresh_credentials()), + sign_in_task: None, + refresh_task: None, + load_task: None, + credentials_provider: Arc::new(FakeCredentialsProvider::new()), + auth_generation: 0, + last_auth_error: None, + }); + + let model = OpenAiSubscribedLanguageModel { + id: LanguageModelId::from(ChatGptModel::Gpt55.id().to_string()), + model: ChatGptModel::Gpt55, + state, + http_client, + request_limiter: RateLimiter::new(4), + }; + let request = LanguageModelRequest { + thread_id: Some("thread-1".to_string()), + prompt_id: Some("prompt-1".to_string()), + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec!["Hello".into()], + cache: false, + reasoning_details: None, + }], + ..Default::default() + }; + + let stream_completion = model.stream_completion(request, &cx.to_async()); + cx.run_until_parked(); + cx.executor().advance_clock(CODEX_RESPONSE_HEADER_TIMEOUT); + + let error = match stream_completion.await { + Ok(_) => panic!("stream should time out before headers arrive"), + Err(error) => error, + }; + assert!(matches!( + error, + LanguageModelCompletionError::HttpSend { provider, .. } + if provider == PROVIDER_NAME + )); + } + #[gpui::test] async fn test_concurrent_refresh_deduplicates(cx: &mut TestAppContext) { let refresh_count = Arc::new(AtomicUsize::new(0)); diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index 312cdee5a66..ea6f9d3d4d7 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -316,6 +316,12 @@ fn map_open_ai_error(error: open_ai::RequestError) -> LanguageModelCompletionErr retry_after, ) } + open_ai::RequestError::ResponseHeaderTimeout { timeout, .. } => { + LanguageModelCompletionError::HttpSend { + provider: PROVIDER_NAME, + error: anyhow::anyhow!("response headers timed out after {timeout:?}"), + } + } open_ai::RequestError::Other(error) => LanguageModelCompletionError::Other(error), } } diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 0ff1308d52a..5b9e5958267 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -11,7 +11,7 @@ use http_client::{ pub use language_model_core::ReasoningEffort; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::{convert::TryFrom, future::Future}; +use std::{convert::TryFrom, future::Future, time::Duration}; use strum::EnumIter; use thiserror::Error; @@ -684,6 +684,8 @@ pub enum RequestError { body: String, headers: HeaderMap, }, + #[error("response headers from {provider}'s API timed out after {timeout:?}")] + ResponseHeaderTimeout { provider: String, timeout: Duration }, #[error(transparent)] Other(#[from] anyhow::Error), } @@ -903,6 +905,10 @@ impl From for language_model_core::LanguageModelCompletionError { Self::from_http_status(provider.into(), status_code, body, retry_after) } + RequestError::ResponseHeaderTimeout { provider, timeout } => Self::HttpSend { + provider: provider.into(), + error: anyhow!("response headers timed out after {timeout:?}"), + }, RequestError::Other(e) => Self::Other(e), } } diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 6cc05699254..c3ee619fe73 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -1,11 +1,266 @@ use anyhow::{Result, anyhow}; -use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; +use futures::{ + AsyncBufReadExt, AsyncReadExt, FutureExt, StreamExt, future::BoxFuture, io::BufReader, + stream::BoxStream, +}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::{future::Future, time::Duration}; use crate::{ReasoningEffort, RequestError, Role, ServiceTier, ToolChoice}; +#[derive(Default)] +pub struct StreamResponseOptions { + response_header_timeout: Option<(Duration, BoxFuture<'static, ()>)>, +} + +impl StreamResponseOptions { + pub fn response_header_timeout( + timeout: Duration, + timer: impl Future + Send + 'static, + ) -> Self { + Self { + response_header_timeout: Some((timeout, timer.boxed())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::{FutureExt, StreamExt, future}; + use http_client::{ + AsyncBody, HttpClient, Request as HttpRequest, Response as HttpResponse, Url, + }; + use std::{ + io::{Cursor, Read}, + pin::Pin, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + task::{Context, Poll, Waker}, + }; + + struct TestHttpClient { + handler: Arc< + dyn Fn( + HttpRequest, + ) -> BoxFuture<'static, anyhow::Result>> + + Send + + Sync, + >, + } + + impl TestHttpClient { + fn new(handler: F) -> Self + where + F: Fn( + HttpRequest, + ) -> BoxFuture<'static, anyhow::Result>> + + Send + + Sync + + 'static, + { + Self { + handler: Arc::new(handler), + } + } + } + + impl HttpClient for TestHttpClient { + fn user_agent(&self) -> Option<&http_client::http::HeaderValue> { + None + } + + fn proxy(&self) -> Option<&Url> { + None + } + + fn send( + &self, + request: HttpRequest, + ) -> BoxFuture<'static, anyhow::Result>> { + (self.handler)(request) + } + } + + struct DelayedBody { + state: Arc, + bytes: Cursor>, + } + + struct DelayedBodyState { + released: AtomicBool, + waker: Mutex>, + } + + struct DelayedBodyHandle { + state: Arc, + } + + impl DelayedBody { + fn new(bytes: Vec) -> (Self, DelayedBodyHandle) { + let state = Arc::new(DelayedBodyState { + released: AtomicBool::new(false), + waker: Mutex::new(None), + }); + + ( + Self { + state: state.clone(), + bytes: Cursor::new(bytes), + }, + DelayedBodyHandle { state }, + ) + } + } + + impl DelayedBodyHandle { + fn release(&self) { + self.state.released.store(true, Ordering::SeqCst); + if let Some(waker) = self.state.waker.lock().expect("lock poisoned").take() { + waker.wake(); + } + } + } + + impl futures::AsyncRead for DelayedBody { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &mut [u8], + ) -> Poll> { + if !self.state.released.load(Ordering::SeqCst) { + self.state + .waker + .lock() + .expect("lock poisoned") + .replace(cx.waker().clone()); + return Poll::Pending; + } + + Poll::Ready(self.bytes.read(buffer)) + } + } + + fn test_request() -> Request { + Request { + model: "gpt-test".into(), + instructions: None, + input: Vec::new(), + include: Vec::new(), + stream: true, + temperature: None, + top_p: None, + max_output_tokens: None, + parallel_tool_calls: None, + tool_choice: None, + tools: Vec::new(), + prompt_cache_key: None, + reasoning: None, + store: None, + service_tier: None, + } + } + + #[test] + fn stream_response_times_out_before_headers() { + futures::executor::block_on(async { + let client = TestHttpClient::new(|_| { + future::pending::>>().boxed() + }); + + let result = stream_response_with_options( + &client, + "Test Provider", + "https://api.test/v1", + "test-key", + test_request(), + Vec::new(), + StreamResponseOptions::response_header_timeout( + Duration::from_secs(10), + future::ready(()), + ), + ) + .await; + + assert!(matches!( + result, + Err(RequestError::ResponseHeaderTimeout { + provider, + timeout + }) if provider == "Test Provider" && timeout == Duration::from_secs(10) + )); + }); + } + + #[test] + fn stream_response_does_not_timeout_after_headers_arrive() { + futures::executor::block_on(async { + let body = r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}"#; + let (delayed_body, delayed_body_handle) = + DelayedBody::new(format!("{body}\n\n").into_bytes()); + let delayed_body = Mutex::new(Some(delayed_body)); + let client = TestHttpClient::new(move |_| { + let delayed_body = delayed_body + .lock() + .expect("lock poisoned") + .take() + .expect("test sends only one request"); + async { + Ok(HttpResponse::builder() + .status(200) + .body(AsyncBody::from_reader(delayed_body))?) + } + .boxed() + }); + let (timeout_tx, timeout_rx) = futures::channel::oneshot::channel::<()>(); + + let mut stream = stream_response_with_options( + &client, + "Test Provider", + "https://api.test/v1", + "test-key", + test_request(), + Vec::new(), + StreamResponseOptions::response_header_timeout( + Duration::from_secs(10), + async move { + assert!( + timeout_rx.await.is_ok(), + "timer should be dropped after headers arrive" + ); + }, + ), + ) + .await + .expect("headers should arrive before timeout"); + + assert!( + timeout_tx.send(()).is_err(), + "timeout future should be dropped after headers arrive" + ); + + assert!( + stream.next().now_or_never().is_none(), + "stream should wait for delayed body bytes" + ); + + delayed_body_handle.release(); + + let event = stream + .next() + .await + .expect("stream should produce an event") + .expect("event should parse"); + + assert!(matches!(event, StreamEvent::Completed { .. })); + }); + } +} + #[derive(Serialize, Debug)] pub struct Request { pub model: String, @@ -440,6 +695,27 @@ pub async fn stream_response( api_key: &str, request: Request, extra_headers: Vec<(String, String)>, +) -> Result>, RequestError> { + stream_response_with_options( + client, + provider_name, + api_url, + api_key, + request, + extra_headers, + StreamResponseOptions::default(), + ) + .await +} + +pub async fn stream_response_with_options( + client: &dyn HttpClient, + provider_name: &str, + api_url: &str, + api_key: &str, + request: Request, + extra_headers: Vec<(String, String)>, + options: StreamResponseOptions, ) -> Result>, RequestError> { let uri = format!("{api_url}/responses"); let mut request_builder = HttpRequest::builder() @@ -458,7 +734,24 @@ pub async fn stream_response( )) .map_err(|e| RequestError::Other(e.into()))?; - let mut response = client.send(request).await?; + let mut response = if let Some((timeout, timer)) = options.response_header_timeout { + let send_request = client.send(request).fuse(); + let timer = timer.fuse(); + futures::pin_mut!(send_request); + futures::pin_mut!(timer); + + futures::select! { + response = send_request => response?, + () = timer => { + return Err(RequestError::ResponseHeaderTimeout { + provider: provider_name.to_owned(), + timeout, + }); + } + } + } else { + client.send(request).await? + }; if response.status().is_success() { if is_streaming { let reader = BufReader::new(response.into_body()); From 24fd1015f0d1672c659f697285d74e9eca667b39 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Thu, 28 May 2026 12:55:35 -0400 Subject: [PATCH 53/93] Sort guild members case insensitively (#57977) Aligns list sorting with the community champions list. Self-Review Checklist: - [X] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A --- .github/workflows/pr_issue_labeler.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pr_issue_labeler.yml b/.github/workflows/pr_issue_labeler.yml index e27877b0a9f..f9927f32252 100644 --- a/.github/workflows/pr_issue_labeler.yml +++ b/.github/workflows/pr_issue_labeler.yml @@ -44,16 +44,8 @@ jobs: const GUILD_MEMBERS = [ '11happy', 'AidanV', - 'AmaanBilwar', - 'MostlyKIGuess', - 'OmChillure', - 'Palanikannan1437', - 'Shivansh-25', - 'SkandaBhat', - 'TwistingTwists', - 'YEDASAVG', - 'Ziqi-Yang', 'alanpjohn', + 'AmaanBilwar', 'arjunkomath', 'austincummings', 'ayushk-1801', @@ -69,18 +61,26 @@ jobs: 'loadingalias', 'marcocondrache', 'mchisolm0', + 'MostlyKIGuess', 'nairadithya', 'nihalxkumar', 'notJoon', + 'OmChillure', + 'Palanikannan1437', 'polyesterswing', 'prayanshchh', 'razeghi71', 'sarmadgulzar', 'seanstrom', + 'Shivansh-25', + 'SkandaBhat', 'th0jensen', 'tommyming', 'transitoryangel', + 'TwistingTwists', 'virajbhartiya', + 'YEDASAVG', + 'Ziqi-Yang', ]; const COMMUNITY_CHAMPION_LABEL = 'community champion'; const COMMUNITY_CHAMPIONS = [ From 3bb2f2a61d767f2bf50a1cf6d5f2898b4cece85b Mon Sep 17 00:00:00 2001 From: nullstalgia Date: Thu, 28 May 2026 10:30:36 -0700 Subject: [PATCH 54/93] diagnostics: Prefer activating diagnostic under cursor (#52957) Update the behavior of both `editor: go to diagnostic` and `editor: go to previous diagnostic` in order to ensure that, if there's a diagnostic under the user's cursor that isn't active, it is first activated, with a subsequent call jumping to the next or previous diagnostic, respectively. These changes also update how diagnostic activation handles the situation when the global diagnostic renderer is not registered, as we used to not update the active diagnostic group in that situation. However, we now rely on it to determine whether the user's cursor is already in the active diagnostic, with some tests now failing, so we now default to an empty set of blocks for the active diagnostic group when no global renderer is registered. Release Notes: - Update both `editor: go to diagnostic` and `editor: go to previous diagnostic` to prefer activating the diagnostic under the cursor before jumping to the next or previous diagnostic, respectively --------- Co-authored-by: dino --- crates/diagnostics/src/diagnostics_tests.rs | 2 + crates/diagnostics/src/items.rs | 20 +- crates/editor/src/actions.rs | 6 +- crates/editor/src/diagnostics.rs | 211 +++++++++++++------- crates/editor/src/editor_tests.rs | 124 ++++++++++++ docs/src/reference/all-settings.md | 6 + 6 files changed, 295 insertions(+), 74 deletions(-) diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index 6352532484c..613b485c946 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -1550,6 +1550,8 @@ async fn go_to_diagnostic_with_severity(cx: &mut TestAppContext) { // Default, should cycle through all diagnostics go!(GoToDiagnosticSeverityFilter::default()); + cx.assert_editor_state(indoc! {"error warning info ˇhint"}); + go!(GoToDiagnosticSeverityFilter::default()); cx.assert_editor_state(indoc! {"ˇerror warning info hint"}); go!(GoToDiagnosticSeverityFilter::default()); cx.assert_editor_state(indoc! {"error ˇwarning info hint"}); diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 7733dab8201..9f243c78102 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -64,13 +64,19 @@ impl Render for DiagnosticIndicator { .message .split_once('\n') .map_or(&*diagnostic.message, |(first, _)| first); + let diagnostics_already_active = self.any_active_diagnostics(cx); + let tooltip = if !diagnostics_already_active { + "Expand Diagnostics" + } else { + "Next Diagnostic" + }; Some( Button::new("diagnostic_message", SharedString::new(message)) .label_size(LabelSize::Small) .truncate(true) - .tooltip(|_window, cx| { + .tooltip(move |_window, cx| { Tooltip::for_action( - "Next Diagnostic", + tooltip, &editor::actions::GoToDiagnostic::default(), cx, ) @@ -154,10 +160,18 @@ impl DiagnosticIndicator { } } + fn any_active_diagnostics(&self, cx: &mut Context) -> bool { + if let Some(editor) = self.active_editor.as_ref().and_then(|e| e.upgrade()) { + editor.read(cx).any_active_diagnostics() + } else { + false + } + } + fn go_to_next_diagnostic(&mut self, window: &mut Window, cx: &mut Context) { if let Some(editor) = self.active_editor.as_ref().and_then(|e| e.upgrade()) { editor.update(cx, |editor, cx| { - editor.go_to_diagnostic_impl( + editor.go_to_diagnostic_at_cursor( editor::Direction::Next, GoToDiagnosticSeverityFilter::default(), window, diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 03557c029f1..827c9067e3e 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -323,7 +323,8 @@ pub struct SplitSelectionIntoLines { pub keep_selections: bool, } -/// Goes to the next diagnostic in the file. +/// Expands the diagnostic under the cursor, if any, in case diagnostics are not +/// yet active. Otherwise, goes to the next diagnostic in the file. #[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] @@ -332,7 +333,8 @@ pub struct GoToDiagnostic { pub severity: GoToDiagnosticSeverityFilter, } -/// Goes to the previous diagnostic in the file. +/// Expands the diagnostic under the cursor, if any, in case diagnostics are not +/// yet active. Otherwise, goes to the previous diagnostic in the file. #[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] diff --git a/crates/editor/src/diagnostics.rs b/crates/editor/src/diagnostics.rs index b13b4b699f5..cdbb341a41c 100644 --- a/crates/editor/src/diagnostics.rs +++ b/crates/editor/src/diagnostics.rs @@ -77,7 +77,8 @@ impl Editor { if !self.diagnostics_enabled() { return; } - self.go_to_diagnostic_impl(Direction::Next, action.severity, window, cx) + + self.go_to_diagnostic_at_cursor(Direction::Next, action.severity, window, cx); } pub fn go_to_prev_diagnostic( @@ -89,10 +90,43 @@ impl Editor { if !self.diagnostics_enabled() { return; } - self.go_to_diagnostic_impl(Direction::Prev, action.severity, window, cx) + + self.go_to_diagnostic_at_cursor(Direction::Prev, action.severity, window, cx); } - pub fn go_to_diagnostic_impl( + fn diagnostics_before_cursor<'a>( + buffer: &'a MultiBufferSnapshot, + cursor: MultiBufferOffset, + severity: GoToDiagnosticSeverityFilter, + ) -> impl Iterator> { + buffer + .diagnostics_in_range(MultiBufferOffset(0)..cursor) + .filter(move |entry| entry.range.start <= cursor) + .filter(move |entry| severity.matches(entry.diagnostic.severity)) + .filter(|entry| entry.range.start != entry.range.end) + .filter(|entry| !entry.diagnostic.is_unnecessary) + } + + fn diagnostics_after_cursor<'a>( + buffer: &'a MultiBufferSnapshot, + cursor: MultiBufferOffset, + severity: GoToDiagnosticSeverityFilter, + ) -> impl Iterator> { + buffer + .diagnostics_in_range(cursor..buffer.len()) + .filter(move |entry| entry.range.start >= cursor) + .filter(move |entry| severity.matches(entry.diagnostic.severity)) + .filter(|entry| entry.range.start != entry.range.end) + .filter(|entry| !entry.diagnostic.is_unnecessary) + } + + /// Attempts to expand the diagnostic at the current cursor position, + /// updating the cursor position to the diagnostic's start point. + /// + /// In case there's no diagnostic at the current cursor position, this will + /// fallback to finding the next or previous diagnostic instead, depending + /// on the provided `direction`. + pub fn go_to_diagnostic_at_cursor( &mut self, direction: Direction, severity: GoToDiagnosticSeverityFilter, @@ -104,6 +138,71 @@ impl Editor { .selections .newest::(&self.display_snapshot(cx)); + let before = Self::diagnostics_before_cursor(&buffer, selection.start, severity); + let after = Self::diagnostics_after_cursor(&buffer, selection.start, severity); + let active_group_id = match &self.active_diagnostics { + ActiveDiagnostic::Group(group) => Some(group.group_id), + _ => None, + }; + + let mut cursor_on_active = false; + let mut target = None; + + for diagnostic in after.chain(before) { + let contains_cursor = diagnostic.range.contains(&selection.start) + || diagnostic.range.end == selection.head(); + + if !contains_cursor { + continue; + } + + if active_group_id == Some(diagnostic.diagnostic.group_id) { + cursor_on_active = true; + } else if target.is_none() { + target = Some(diagnostic); + } + } + + match (target, cursor_on_active) { + (Some(diagnostic), false) => self.activate_diagnostic(&buffer, diagnostic, window, cx), + _ => self.go_to_diagnostic_in_direction( + &buffer, &selection, direction, severity, window, cx, + ), + } + } + + fn activate_diagnostic( + &mut self, + buffer: &MultiBufferSnapshot, + diagnostic: DiagnosticEntryRef, + window: &mut Window, + cx: &mut Context, + ) { + let diagnostic_start = buffer.anchor_after(diagnostic.range.start); + let Some((buffer_anchor, _)) = buffer.anchor_to_buffer_anchor(diagnostic_start) else { + return; + }; + let buffer_id = buffer_anchor.buffer_id; + let snapshot = self.snapshot(window, cx); + if snapshot.intersects_fold(diagnostic.range.start) { + self.unfold_ranges(std::slice::from_ref(&diagnostic.range), true, false, cx); + } + self.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(vec![diagnostic.range.start..diagnostic.range.start]) + }); + self.activate_diagnostics(buffer_id, diagnostic, window, cx); + self.refresh_edit_prediction(false, true, window, cx); + } + + pub fn go_to_diagnostic_in_direction( + &mut self, + buffer: &MultiBufferSnapshot, + selection: &Selection, + direction: Direction, + severity: GoToDiagnosticSeverityFilter, + window: &mut Window, + cx: &mut Context, + ) { let mut active_group_id = None; if let ActiveDiagnostic::Group(active_group) = &self.active_diagnostics && active_group.active_range.start.to_offset(&buffer) == selection.start @@ -111,28 +210,8 @@ impl Editor { active_group_id = Some(active_group.group_id); } - fn filtered<'a>( - severity: GoToDiagnosticSeverityFilter, - diagnostics: impl Iterator>, - ) -> impl Iterator> { - diagnostics - .filter(move |entry| severity.matches(entry.diagnostic.severity)) - .filter(|entry| entry.range.start != entry.range.end) - .filter(|entry| !entry.diagnostic.is_unnecessary) - } - - let before = filtered( - severity, - buffer - .diagnostics_in_range(MultiBufferOffset(0)..selection.start) - .filter(|entry| entry.range.start <= selection.start), - ); - let after = filtered( - severity, - buffer - .diagnostics_in_range(selection.start..buffer.len()) - .filter(|entry| entry.range.start >= selection.start), - ); + let before = Self::diagnostics_before_cursor(&buffer, selection.start, severity); + let after = Self::diagnostics_after_cursor(&buffer, selection.start, severity); let mut found: Option> = None; if direction == Direction::Prev { @@ -158,31 +237,12 @@ impl Editor { } } } + let Some(next_diagnostic) = found else { return; }; - let next_diagnostic_start = buffer.anchor_after(next_diagnostic.range.start); - let Some((buffer_anchor, _)) = buffer.anchor_to_buffer_anchor(next_diagnostic_start) else { - return; - }; - let buffer_id = buffer_anchor.buffer_id; - let snapshot = self.snapshot(window, cx); - if snapshot.intersects_fold(next_diagnostic.range.start) { - self.unfold_ranges( - std::slice::from_ref(&next_diagnostic.range), - true, - false, - cx, - ); - } - self.change_selections(Default::default(), window, cx, |s| { - s.select_ranges(vec![ - next_diagnostic.range.start..next_diagnostic.range.start, - ]) - }); - self.activate_diagnostics(buffer_id, next_diagnostic, window, cx); - self.refresh_edit_prediction(false, true, window, cx); + self.activate_diagnostic(&buffer, next_diagnostic, window, cx); } #[cfg(any(test, feature = "test-support"))] @@ -324,32 +384,37 @@ impl Editor { return; } self.dismiss_diagnostics(cx); - let snapshot = self.snapshot(window, cx); let buffer = self.buffer.read(cx).snapshot(cx); - let Some(renderer) = GlobalDiagnosticRenderer::global(cx) else { - return; + + let blocks = if let Some(renderer) = GlobalDiagnosticRenderer::global(cx) { + let snapshot = self.snapshot(window, cx); + let diagnostic_group = buffer + .diagnostic_group(buffer_id, diagnostic.diagnostic.group_id) + .collect::>(); + + let language_registry = self + .project() + .map(|project| project.read(cx).languages().clone()); + + let blocks = renderer.render_group( + diagnostic_group, + buffer_id, + snapshot, + cx.weak_entity(), + language_registry, + cx, + ); + + self.display_map.update(cx, |display_map, cx| { + display_map.insert_blocks(blocks, cx).into_iter().collect() + }) + } else { + // Ensure that, even if there's no global renderer set, we still use + // an empty set of blocks, such that we can record the active group + // below instead of bailing out. + HashSet::default() }; - let diagnostic_group = buffer - .diagnostic_group(buffer_id, diagnostic.diagnostic.group_id) - .collect::>(); - - let language_registry = self - .project() - .map(|project| project.read(cx).languages().clone()); - - let blocks = renderer.render_group( - diagnostic_group, - buffer_id, - snapshot, - cx.weak_entity(), - language_registry, - cx, - ); - - let blocks = self.display_map.update(cx, |display_map, cx| { - display_map.insert_blocks(blocks, cx).into_iter().collect() - }); self.active_diagnostics = ActiveDiagnostic::Group(ActiveDiagnosticGroup { active_range: buffer.anchor_before(diagnostic.range.start) ..buffer.anchor_after(diagnostic.range.end), @@ -516,4 +581,12 @@ impl Editor { self.scrollbar_marker_state.dirty = true; cx.notify(); } + + pub fn any_active_diagnostics(&self) -> bool { + match &self.active_diagnostics { + ActiveDiagnostic::None => false, + ActiveDiagnostic::All => true, + ActiveDiagnostic::Group(_) => true, + } + } } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 3b82a9580cb..3eca954d6de 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -20506,6 +20506,130 @@ async fn go_to_prev_overlapping_diagnostic(executor: BackgroundExecutor, cx: &mu "}); } +#[gpui::test] +async fn go_to_diagnostic(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let lsp_store = + cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store()); + + // Place the cursor inside the `def` diagnostic (`[12, 15)`) before any + // diagnostic is active so we can later confirm that running `editor: go to + // diagnostic` will activate this diagnostic instead of advancing to the + // next one. + cx.set_state(indoc! {" + fn func(abc dˇef: i32) -> u32 { + } + "}); + + // Set up the diagnostics: + // + // * `[11, 12)` (the space before `def`), + // * `[12, 15)` (`def`), + // * `[25, 28)` (`u32`). + cx.update(|_, cx| { + lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(), + version: None, + diagnostics: vec![ + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 11), + lsp::Position::new(0, 12), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 12), + lsp::Position::new(0, 15), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 25), + lsp::Position::new(0, 28), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + ..Default::default() + }, + ], + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap() + }); + }); + + executor.run_until_parked(); + + // When the cursor is at an inactive diagnostic, cursor should be moved to + // the start of that same diagnostic and activate it. + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); + }); + cx.assert_editor_state(indoc! {" + fn func(abc ˇdef: i32) -> u32 { + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); + }); + cx.assert_editor_state(indoc! {" + fn func(abc def: i32) -> ˇu32 { + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); + }); + cx.assert_editor_state(indoc! {" + fn func(abcˇ def: i32) -> u32 { + } + "}); + + // Manually move the cursor to a different, not yet active diagnostic to + // confirm that using `editor: go to diagnostic` will now activate this one. + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([Point::new(0, 26)..Point::new(0, 26)]) + }); + }); + + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); + }); + cx.assert_editor_state(indoc! {" + fn func(abc def: i32) -> ˇu32 { + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([Point::new(0, 0)..Point::new(0, 0)]) + }); + }); + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); + }); + cx.assert_editor_state(indoc! {" + fn func(abcˇ def: i32) -> u32 { + } + "}); +} + #[gpui::test] async fn test_go_to_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index e5b7d7d2dee..b103b8f61fc 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -2098,6 +2098,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files ```json [settings] { "diagnostics": { + "button": true, "include_warnings": true, "inline": { "enabled": false @@ -2106,6 +2107,11 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files } ``` +**Options** + +- `button`: Whether to show the project diagnostics button in the status bar +- `include_warnings`: Whether to show warnings or not by default + ### Inline Diagnostics - Description: Whether or not to show diagnostics information inline. From a6b0ee9f369785bcd0146410aa773df5d2a72aea Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 28 May 2026 13:45:54 -0400 Subject: [PATCH 55/93] Add Claude Opus 4.8 support (#57984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2026-05-28 at 1 20 22 PM Adds Claude Opus 4.8 for BYOK providers, including Anthropic fast-mode handling and Bedrock/OpenCode model definitions. Closes AI-336 Release Notes: - Added Claude Opus 4.8 BYOK support --- crates/anthropic/src/anthropic.rs | 19 +++++-- crates/anthropic/src/completion.rs | 39 +++++++++++++++ crates/bedrock/src/models.rs | 50 +++++++++++++++++-- .../language_models/src/provider/bedrock.rs | 11 ++++ .../language_models/src/provider/opencode.rs | 2 +- crates/opencode/src/opencode.rs | 21 ++++++-- docs/src/ai/models.md | 7 ++- 7 files changed, 136 insertions(+), 13 deletions(-) diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 6df9d5da241..6ea08a5fd50 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -122,9 +122,6 @@ impl Model { let mut supported_effort_levels = Vec::new(); if let Some(effort) = entry.capabilities.as_ref().and_then(|e| e.effort.as_ref()) { - // The `xhigh` effort level reported by the API has no - // corresponding `Effort` variant in the request enum, so it is - // intentionally dropped here. for (level, supported) in [ (Effort::Low, effort.low.as_ref()), (Effort::Medium, effort.medium.as_ref()), @@ -148,7 +145,10 @@ impl Model { AnthropicModelMode::Default }; - let supports_speed = matches!(entry.id.as_str(), "claude-opus-4-6" | "claude-opus-4-7"); + let supports_speed = matches!( + entry.id.as_str(), + "claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8" + ); let mut extra_beta_headers = Vec::new(); if supports_speed { @@ -1056,6 +1056,17 @@ mod tests { assert_eq!(model.mode, AnthropicModelMode::Default); } + #[test] + fn from_listed_enables_fast_mode_for_opus_4_8() { + let model = Model::from_listed(listed_entry( + "claude-opus-4-8", + ModelCapabilities::default(), + )); + + assert!(model.supports_speed); + assert_eq!(model.beta_headers().as_deref(), Some(FAST_MODE_BETA_HEADER)); + } + #[test] fn from_listed_collects_supported_effort_levels() { let entry = listed_entry( diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index 5cb864a088e..2b42bcae88b 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -300,6 +300,7 @@ pub fn into_anthropic( "low" => Some(crate::Effort::Low), "medium" => Some(crate::Effort::Medium), "high" => Some(crate::Effort::High), + "xhigh" => Some(crate::Effort::XHigh), "max" => Some(crate::Effort::Max), _ => None, }; @@ -705,6 +706,44 @@ mod tests { )); } + #[test] + fn test_xhigh_effort_is_serialized_for_adaptive_thinking() { + let request = LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hi".to_string())], + cache: false, + reasoning_details: None, + }], + thread_id: None, + prompt_id: None, + intent: None, + stop: vec![], + temperature: None, + tools: vec![], + tool_choice: None, + thinking_allowed: true, + thinking_effort: Some("xhigh".into()), + speed: None, + }; + + let anthropic_request = into_anthropic( + request, + "claude-opus-4-8".to_string(), + 1.0, + 128_000, + AnthropicModelMode::AdaptiveThinking, + AnthropicPromptCacheMode::Automatic, + ); + + assert_eq!( + anthropic_request + .output_config + .and_then(|config| config.effort), + Some(crate::Effort::XHigh) + ); + } + #[test] fn test_no_cache_control_when_caching_disabled() { let request = LanguageModelRequest { diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index ee5d57ee808..a2f299a3c9e 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -8,6 +8,7 @@ pub enum BedrockAdaptiveThinkingEffort { Medium, #[default] High, + XHigh, Max, } @@ -17,6 +18,7 @@ impl BedrockAdaptiveThinkingEffort { Self::Low => "low", Self::Medium => "medium", Self::High => "high", + Self::XHigh => "xhigh", Self::Max => "max", } } @@ -91,6 +93,13 @@ pub enum Model { alias = "claude-opus-4-7-thinking-latest" )] ClaudeOpus4_7, + #[serde( + rename = "claude-opus-4-8", + alias = "claude-opus-4-8-latest", + alias = "claude-opus-4-8-thinking", + alias = "claude-opus-4-8-thinking-latest" + )] + ClaudeOpus4_8, #[serde( rename = "claude-sonnet-4-6", alias = "claude-sonnet-4-6-latest", @@ -210,7 +219,9 @@ impl Model { } pub fn from_id(id: &str) -> anyhow::Result { - if id.starts_with("claude-opus-4-7") { + if id.starts_with("claude-opus-4-8") { + Ok(Self::ClaudeOpus4_8) + } else if id.starts_with("claude-opus-4-7") { Ok(Self::ClaudeOpus4_7) } else if id.starts_with("claude-opus-4-6") { Ok(Self::ClaudeOpus4_6) @@ -240,6 +251,7 @@ impl Model { Self::ClaudeOpus4_5 => "claude-opus-4-5", Self::ClaudeOpus4_6 => "claude-opus-4-6", Self::ClaudeOpus4_7 => "claude-opus-4-7", + Self::ClaudeOpus4_8 => "claude-opus-4-8", Self::ClaudeSonnet4_6 => "claude-sonnet-4-6", Self::Llama4Scout17B => "llama-4-scout-17b", Self::Llama4Maverick17B => "llama-4-maverick-17b", @@ -290,6 +302,7 @@ impl Model { Self::ClaudeOpus4_5 => "anthropic.claude-opus-4-5-20251101-v1:0", Self::ClaudeOpus4_6 => "anthropic.claude-opus-4-6-v1", Self::ClaudeOpus4_7 => "anthropic.claude-opus-4-7", + Self::ClaudeOpus4_8 => "anthropic.claude-opus-4-8", Self::ClaudeSonnet4_6 => "anthropic.claude-sonnet-4-6", Self::Llama4Scout17B => "meta.llama4-scout-17b-instruct-v1:0", Self::Llama4Maverick17B => "meta.llama4-maverick-17b-instruct-v1:0", @@ -340,6 +353,7 @@ impl Model { Self::ClaudeOpus4_5 => "Claude Opus 4.5", Self::ClaudeOpus4_6 => "Claude Opus 4.6", Self::ClaudeOpus4_7 => "Claude Opus 4.7", + Self::ClaudeOpus4_8 => "Claude Opus 4.8", Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6", Self::Llama4Scout17B => "Llama 4 Scout 17B", Self::Llama4Maverick17B => "Llama 4 Maverick 17B", @@ -391,6 +405,7 @@ impl Model { | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 => 1_000_000, Self::ClaudeOpus4_1 => 200_000, Self::Llama4Scout17B | Self::Llama4Maverick17B => 128_000, @@ -425,7 +440,7 @@ impl Model { | Self::ClaudeOpus4_5 | Self::ClaudeSonnet4_6 => 64_000, Self::ClaudeOpus4_1 => 32_000, - Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 => 128_000, + Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 => 128_000, Self::Llama4Scout17B | Self::Llama4Maverick17B | Self::Gemma3_4B @@ -464,6 +479,7 @@ impl Model { | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 => 1.0, Self::Custom { default_temperature, @@ -482,6 +498,7 @@ impl Model { | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 => true, Self::NovaLite | Self::NovaPro | Self::NovaPremier | Self::Nova2Lite => true, Self::MistralLarge3 | Self::PixtralLarge | Self::MagistralSmall => true, @@ -513,6 +530,7 @@ impl Model { | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 => true, Self::NovaLite | Self::NovaPro => true, Self::PixtralLarge => true, @@ -531,6 +549,7 @@ impl Model { | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 => true, Self::Custom { cache_configuration, @@ -550,6 +569,7 @@ impl Model { | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 ) } @@ -557,10 +577,14 @@ impl Model { pub fn supports_adaptive_thinking(&self) -> bool { matches!( self, - Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeSonnet4_6 + Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 ) } + pub fn supports_xhigh_adaptive_thinking(&self) -> bool { + matches!(self, Self::ClaudeOpus4_8) + } + pub fn thinking_mode(&self) -> BedrockModelMode { if self.supports_adaptive_thinking() { BedrockModelMode::AdaptiveThinking { @@ -590,6 +614,7 @@ impl Model { | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 | Self::Nova2Lite ); @@ -650,6 +675,7 @@ impl Model { | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 | Self::Nova2Lite, "global", @@ -667,6 +693,7 @@ impl Model { | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 | Self::Llama4Scout17B | Self::Llama4Maverick17B @@ -689,6 +716,7 @@ impl Model { | Self::ClaudeSonnet4_5 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 | Self::NovaLite | Self::NovaPro @@ -702,6 +730,7 @@ impl Model { | Self::ClaudeSonnet4_5 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6, "au", ) => Ok(format!("{}.{}", region_group, model_id)), @@ -779,6 +808,10 @@ mod tests { Model::ClaudeOpus4_7.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-4-7" ); + assert_eq!( + Model::ClaudeOpus4_8.cross_region_inference_id("eu-west-1", false)?, + "eu.anthropic.claude-opus-4-8" + ); Ok(()) } @@ -813,6 +846,10 @@ mod tests { Model::ClaudeOpus4_7.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-opus-4-7" ); + assert_eq!( + Model::ClaudeOpus4_8.cross_region_inference_id("ap-southeast-2", false)?, + "au.anthropic.claude-opus-4-8" + ); Ok(()) } @@ -877,6 +914,10 @@ mod tests { Model::ClaudeOpus4_7.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-opus-4-7" ); + assert_eq!( + Model::ClaudeOpus4_8.cross_region_inference_id("us-east-1", true)?, + "global.anthropic.claude-opus-4-8" + ); assert_eq!( Model::Nova2Lite.cross_region_inference_id("us-east-1", true)?, "global.amazon.nova-2-lite-v1:0" @@ -978,6 +1019,9 @@ mod tests { assert!(!Model::ClaudeSonnet4.supports_adaptive_thinking()); assert!(Model::ClaudeOpus4_6.supports_adaptive_thinking()); assert!(Model::ClaudeSonnet4_6.supports_adaptive_thinking()); + assert!(!Model::ClaudeOpus4_7.supports_xhigh_adaptive_thinking()); + assert!(Model::ClaudeOpus4_8.supports_xhigh_adaptive_thinking()); + assert_eq!(BedrockAdaptiveThinkingEffort::XHigh.as_str(), "xhigh"); assert_eq!( Model::ClaudeSonnet4.thinking_mode(), diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 23e5e4939a8..9d595b516ad 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -670,12 +670,22 @@ impl LanguageModel for BedrockModel { value: "high".into(), is_default: true, }, + language_model::LanguageModelEffortLevel { + name: "XHigh".into(), + value: "xhigh".into(), + is_default: false, + }, language_model::LanguageModelEffortLevel { name: "Max".into(), value: "max".into(), is_default: false, }, ] + .into_iter() + .filter(|effort_level| { + effort_level.value != "xhigh" || self.model.supports_xhigh_adaptive_thinking() + }) + .collect() } else { Vec::new() } @@ -1128,6 +1138,7 @@ pub fn into_bedrock( "low" => Some(bedrock::BedrockAdaptiveThinkingEffort::Low), "medium" => Some(bedrock::BedrockAdaptiveThinkingEffort::Medium), "high" => Some(bedrock::BedrockAdaptiveThinkingEffort::High), + "xhigh" => Some(bedrock::BedrockAdaptiveThinkingEffort::XHigh), "max" => Some(bedrock::BedrockAdaptiveThinkingEffort::Max), _ => None, }) diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 8179713a6a7..a5c98151feb 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -49,7 +49,7 @@ fn reasoning_effort_display(effort: ReasoningEffort) -> (&'static str, &'static ReasoningEffort::Low => ("Low", "low"), ReasoningEffort::Medium => ("Medium", "medium"), ReasoningEffort::High => ("High", "high"), - ReasoningEffort::XHigh => ("Max", "max"), + ReasoningEffort::XHigh => ("XHigh", "xhigh"), } } diff --git a/crates/opencode/src/opencode.rs b/crates/opencode/src/opencode.rs index c34644eb683..4dfc30c62b9 100644 --- a/crates/opencode/src/opencode.rs +++ b/crates/opencode/src/opencode.rs @@ -56,6 +56,8 @@ impl OpenCodeSubscription { #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] pub enum Model { // -- Anthropic protocol models -- + #[serde(rename = "claude-opus-4-8")] + ClaudeOpus4_8, #[serde(rename = "claude-opus-4-7")] ClaudeOpus4_7, #[serde(rename = "claude-opus-4-6")] @@ -219,6 +221,7 @@ impl Model { pub fn id(&self) -> &str { match self { + Self::ClaudeOpus4_8 => "claude-opus-4-8", Self::ClaudeOpus4_7 => "claude-opus-4-7", Self::ClaudeOpus4_6 => "claude-opus-4-6", Self::ClaudeOpus4_5 => "claude-opus-4-5", @@ -273,6 +276,7 @@ impl Model { pub fn display_name(&self) -> &str { match self { + Self::ClaudeOpus4_8 => "Claude Opus 4.8", Self::ClaudeOpus4_7 => "Claude Opus 4.7", Self::ClaudeOpus4_6 => "Claude Opus 4.6", Self::ClaudeOpus4_5 => "Claude Opus 4.5", @@ -339,7 +343,8 @@ impl Model { } } - Self::ClaudeOpus4_7 + Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_1 @@ -413,7 +418,7 @@ impl Model { pub fn max_token_count(&self, subscription: OpenCodeSubscription) -> u64 { match self { // Anthropic models - Self::ClaudeOpus4_7 => 1_000_000, + Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 => 1_000_000, Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 => 1_000_000, Self::ClaudeSonnet4_5 => 1_000_000, Self::ClaudeOpus4_5 | Self::ClaudeHaiku4_5 => 200_000, @@ -464,7 +469,7 @@ impl Model { pub fn max_output_tokens(&self, subscription: OpenCodeSubscription) -> Option { match self { // Anthropic models - Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 => Some(128_000), + Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 => Some(128_000), Self::ClaudeOpus4_5 | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 @@ -531,7 +536,8 @@ impl Model { pub fn supports_images(&self) -> bool { match self { // Anthropic models support images - Self::ClaudeOpus4_7 + Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_1 @@ -596,6 +602,13 @@ impl Model { pub fn supported_reasoning_effort_levels(&self) -> Option> { match self { + Self::ClaudeOpus4_8 => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]), + Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![ ReasoningEffort::Low, ReasoningEffort::Medium, diff --git a/docs/src/ai/models.md b/docs/src/ai/models.md index f582f25af01..72ef43239f5 100644 --- a/docs/src/ai/models.md +++ b/docs/src/ai/models.md @@ -23,6 +23,10 @@ Zed's plans offer hosted versions of major LLMs with higher rate limits than dir | | Anthropic | Output | $25.00 | $27.50 | | | Anthropic | Input - Cache Write | $6.25 | $6.875 | | | Anthropic | Input - Cache Read | $0.50 | $0.55 | +| Claude Opus 4.8 | Anthropic | Input | $5.00 | $5.50 | +| | Anthropic | Output | $25.00 | $27.50 | +| | Anthropic | Input - Cache Write | $6.25 | $6.875 | +| | Anthropic | Input - Cache Read | $0.50 | $0.55 | | Claude Sonnet 4.5 | Anthropic | Input | $3.00 | $3.30 | | | Anthropic | Output | $15.00 | $16.50 | | | Anthropic | Input - Cache Write | $3.75 | $4.125 | @@ -81,7 +85,7 @@ Zed's plans offer hosted versions of major LLMs with higher rate limits than dir As of February 19, 2026, Zed Pro serves newer model versions in place of the retired models below: -- Claude Opus 4.1 → Claude Opus 4.5, Claude Opus 4.6, or Claude Opus 4.7 +- Claude Opus 4.1 → Claude Opus 4.5, Claude Opus 4.6, Claude Opus 4.7, or Claude Opus 4.8 - Claude Sonnet 4 → Claude Sonnet 4.5 or Claude Sonnet 4.6 - Claude Sonnet 3.7 (retired Feb 19) → Claude Sonnet 4.5 or Claude Sonnet 4.6 - GPT-5.1 and GPT-5 → GPT-5.2 or GPT-5.2-Codex @@ -106,6 +110,7 @@ A context window is the maximum span of text and code an LLM can consider at onc | Claude Opus 4.5 | Anthropic | 200k | | Claude Opus 4.6 | Anthropic | 1M | | Claude Opus 4.7 | Anthropic | 1M | +| Claude Opus 4.8 | Anthropic | 1M | | Claude Sonnet 4.5 | Anthropic | 200k | | Claude Sonnet 4.6 | Anthropic | 1M | | Claude Haiku 4.5 | Anthropic | 200k | From b3d93d4474f77a578d617abaa57aa20a24a7ff7a Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 28 May 2026 14:20:11 -0400 Subject: [PATCH 56/93] anthropic: Fix serialized representation of `Effort::XHigh` (#57985) This PR fixes the serialized representation of the `Effort::XHigh` variant, as it should be `xhigh`, not `x_high`. https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking#adaptive-thinking-with-the-effort-parameter Release Notes: - Fixed using `xhigh` thinking effort with Anthropic models. --- crates/anthropic/src/anthropic.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 6ea08a5fd50..e18c7b9e961 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -676,6 +676,8 @@ pub enum Effort { Low, Medium, High, + #[serde(rename = "xhigh")] + #[strum(serialize = "xhigh")] XHigh, Max, } From ec64ba3e6904459ef824e9a02659776279d6cf6d Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 29 May 2026 04:43:30 +0900 Subject: [PATCH 57/93] time_format: Show compound "N years, M months ago" for blame entries (#57973) The relative date format introduced in #47687 floors to whole years, so a commit from 22 months ago is shown as "1 year ago". It would be better to align with `git blame`'s own behavior so it can display timestamps like "1 year, 10 months ago". reference: https://github.com/git/git/blob/c69baaf57ba26cf117c2b6793802877f19738b0d/date.c#L189-L205 Self-Review Checklist: - [X] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [X] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Closes #57907 Release Notes: - Fixed inaccurate humanized date in git blame, e.g. a commit from 22 months ago no longer shows as "1 year ago" --- crates/time_format/src/time_format.rs | 70 ++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/crates/time_format/src/time_format.rs b/crates/time_format/src/time_format.rs index bbf214623eb..85c79e9be4b 100644 --- a/crates/time_format/src/time_format.rs +++ b/crates/time_format/src/time_format.rs @@ -293,12 +293,14 @@ fn format_relative_date(timestamp: OffsetDateTime, reference: OffsetDateTime) -> match month_diff { 0..=1 => "1 month ago".to_string(), 2..=11 => format!("{} months ago", month_diff), + // Match git's `show_date_relative` behavior: for dates under 5 years old, + // include both years and months so, for example, 22 months is shown as + // "1 year, 10 months ago" instead of being collapsed to "1 year ago". + 12..60 => format_compound_year_month(month_diff), + // Beyond 5 years, round to the nearest year. months => { - let years = months / 12; - match years { - 1 => "1 year ago".to_string(), - _ => format!("{years} years ago"), - } + let years = (months + 6) / 12; + format!("{years} years ago") } } } @@ -307,6 +309,18 @@ fn format_relative_date(timestamp: OffsetDateTime, reference: OffsetDateTime) -> } } +fn format_compound_year_month(month_diff: usize) -> String { + let years = month_diff / 12; + let months = month_diff % 12; + let year_unit = if years == 1 { "year" } else { "years" }; + if months == 0 { + format!("{years} {year_unit} ago") + } else { + let month_unit = if months == 1 { "month" } else { "months" }; + format!("{years} {year_unit}, {months} {month_unit} ago") + } +} + /// Calculates the difference in months between two timestamps. /// The reference timestamp should always be greater than the timestamp. fn calculate_month_difference(timestamp: OffsetDateTime, reference: OffsetDateTime) -> usize { @@ -999,7 +1013,7 @@ mod tests { fn test_relative_format_years() { let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0); - // 12 months + // 12 months (exactly 1 year, no remainder) assert_eq!( format_relative_date(create_offset_datetime(1989, 4, 12, 23, 0, 0), reference), "1 year ago" @@ -1008,16 +1022,22 @@ mod tests { // 13 months assert_eq!( format_relative_date(create_offset_datetime(1989, 3, 12, 23, 0, 0), reference), - "1 year ago" + "1 year, 1 month ago" + ); + + // 22 months (regression test for issue #57907) + assert_eq!( + format_relative_date(create_offset_datetime(1988, 6, 12, 23, 0, 0), reference), + "1 year, 10 months ago" ); // 23 months assert_eq!( format_relative_date(create_offset_datetime(1988, 5, 12, 23, 0, 0), reference), - "1 year ago" + "1 year, 11 months ago" ); - // 24 months + // 24 months (exactly 2 years, no remainder) assert_eq!( format_relative_date(create_offset_datetime(1988, 4, 12, 23, 0, 0), reference), "2 years ago" @@ -1026,16 +1046,16 @@ mod tests { // 25 months assert_eq!( format_relative_date(create_offset_datetime(1988, 3, 12, 23, 0, 0), reference), - "2 years ago" + "2 years, 1 month ago" ); // 35 months assert_eq!( format_relative_date(create_offset_datetime(1987, 5, 12, 23, 0, 0), reference), - "2 years ago" + "2 years, 11 months ago" ); - // 36 months + // 36 months (exactly 3 years, no remainder) assert_eq!( format_relative_date(create_offset_datetime(1987, 4, 12, 23, 0, 0), reference), "3 years ago" @@ -1044,7 +1064,31 @@ mod tests { // 37 months assert_eq!( format_relative_date(create_offset_datetime(1987, 3, 12, 23, 0, 0), reference), - "3 years ago" + "3 years, 1 month ago" + ); + + // 59 months (just under 5-year compound cutoff) + assert_eq!( + format_relative_date(create_offset_datetime(1985, 5, 12, 23, 0, 0), reference), + "4 years, 11 months ago" + ); + + // 60 months (5 years exactly; switches to year-only) + assert_eq!( + format_relative_date(create_offset_datetime(1985, 4, 12, 23, 0, 0), reference), + "5 years ago" + ); + + // 65 months (5 years + 5 months → rounds down to 5 years) + assert_eq!( + format_relative_date(create_offset_datetime(1984, 11, 12, 23, 0, 0), reference), + "5 years ago" + ); + + // 66 months (5 years + 6 months → rounds up to 6 years) + assert_eq!( + format_relative_date(create_offset_datetime(1984, 10, 12, 23, 0, 0), reference), + "6 years ago" ); // 120 months From 2bba4e2220b105a77ed19e941b408dff977ae01d Mon Sep 17 00:00:00 2001 From: JannikRosendahl <38326857+JannikRosendahl@users.noreply.github.com> Date: Thu, 28 May 2026 22:14:34 +0200 Subject: [PATCH 58/93] Make notebook cells follow global font and markdown styling (#57567) Notebook cells are currently not responding to changes in font-family (`zed://settings/buffer_font_family`) and font-size (`zed://settings/buffer_font_size`). Currently, `MarkdownCell` and `CodeCell` create and set a `TextStyleRefinement` on their `Editor`, creating copies of font-family and font-size in the process. As a result, these do not get updated when the global font-family or font-size change. By not setting the refinement manually and letting the editor handle these value instead, these values get updated when the global settings change. This behaviour is consistent with how the inline repl already behaves and in my opinion is according to the users expectations. After Review: this PR changes the rendered preview of MarkdownCells to use the themed MarkdownStyle instead of an empty Markdown Style Before: https://github.com/user-attachments/assets/e70b9346-8fa1-4d66-aa85-07e987c56ff2 After: https://github.com/user-attachments/assets/4957e20e-9b5b-4cb9-a9df-3b33538bc686 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - not sure if this needs test or how they should look like... - [x] Performance impact has been considered and is acceptable ~~Closes #ISSUE~~ Release Notes: - Fixed notebook cells not responding to appearance settings changes --- crates/repl/src/notebook/cell.rs | 30 +++--------------------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/crates/repl/src/notebook/cell.rs b/crates/repl/src/notebook/cell.rs index ac078b3338c..cf602a46def 100644 --- a/crates/repl/src/notebook/cell.rs +++ b/crates/repl/src/notebook/cell.rs @@ -5,14 +5,13 @@ use editor::{Editor, EditorMode, MultiBuffer, SizingBehavior}; use futures::future::Shared; use gpui::{ App, Entity, EventEmitter, Focusable, Hsla, InteractiveElement, RetainAllImageCache, - StatefulInteractiveElement, Task, TextStyleRefinement, prelude::*, + StatefulInteractiveElement, Task, prelude::*, }; use language::{Buffer, Language, LanguageRegistry}; -use markdown::{Markdown, MarkdownElement, MarkdownStyle}; +use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle}; use nbformat::v4::{CellId, CellMetadata, CellType}; use runtimelib::{JupyterMessage, JupyterMessageContent}; use settings::Settings as _; -use theme_settings::ThemeSettings; use ui::{CommonAnimationExt, IconButtonShape, prelude::*}; use util::ResultExt; @@ -419,17 +418,7 @@ impl MarkdownCell { cx, ); - let theme = ThemeSettings::get_global(cx); - let refinement = TextStyleRefinement { - font_family: Some(theme.buffer_font.family.clone()), - font_size: Some(theme.buffer_font_size(cx).into()), - color: Some(cx.theme().colors().editor_foreground), - background_color: Some(gpui::transparent_black()), - ..Default::default() - }; - editor.set_show_gutter(false, cx); - editor.set_text_style_refinement(refinement); editor.set_use_modal_editing(true); editor.disable_mouse_wheel_zoom(); editor.disable_scrollbars_and_minimap(window, cx); @@ -606,10 +595,7 @@ impl Render for MarkdownCell { // Preview mode - show rendered markdown - let style = MarkdownStyle { - base_text_style: window.text_style(), - ..Default::default() - }; + let style = MarkdownStyle::themed(MarkdownFont::Preview, window, cx); v_flex() .size_full() @@ -710,20 +696,10 @@ impl CodeCell { cx, ); - let theme = ThemeSettings::get_global(cx); - let refinement = TextStyleRefinement { - font_family: Some(theme.buffer_font.family.clone()), - font_size: Some(theme.buffer_font_size(cx).into()), - color: Some(cx.theme().colors().editor_foreground), - background_color: Some(gpui::transparent_black()), - ..Default::default() - }; - editor.disable_mouse_wheel_zoom(); editor.disable_scrollbars_and_minimap(window, cx); editor.set_text(source.clone(), window, cx); editor.set_show_gutter(false, cx); - editor.set_text_style_refinement(refinement); editor.set_use_modal_editing(true); editor }); From 27c566c21207e13ceb8e9ec45fa2bc2bb856be38 Mon Sep 17 00:00:00 2001 From: morgankrey Date: Thu, 28 May 2026 15:19:17 -0500 Subject: [PATCH 59/93] Relicense Zed source code under GPL (#57948) ## Summary This moves the remaining first-party AGPL surface to GPL, a less restrictive license for these components. Apache-2.0 components are unchanged. Changes: - Updates the `collab` crate from `AGPL-3.0-or-later` to `GPL-3.0-or-later` - Removes the root AGPL license file and first-party crate AGPL symlinks - Updates web, documentation, Flatpak, README, and terms references to reflect the GPL/Apache licensing split - Updates the open-source component example list in the terms and regenerates the RTF copy; no other terms changes are intended - Adds guardrails so first-party crates cannot declare AGPL licensing or carry `LICENSE-AGPL` files Release timing: preview during the week of June 1, 2026; stable during the week of June 8, 2026. ## Residual AGPL/Affero references - `LICENSE-GPL`: GPLv3's own compatibility clause; unchanged official license text. - `crates/json_schema_store/src/schemas/package.json`: generic npm package-license schema value, not Zed licensing. - `script/check-licenses`, `script/new-crate`, `script/licenses/zed-licenses.toml`: guardrails that reject or warn against reintroducing AGPL. ## Verification - `script/check-licenses` - `script/generate-licenses` - `script/generate-terms-rtf` - `script/new-crate license_probe_for_gpl`, then discarded generated crate - `script/new-crate license_probe_for_agpl agpl` fails as expected - `mdbook build docs` - `./script/clippy` - `git grep -n -I -E "AGPL|Affero"` - `git diff --check` Release Notes: - The `collab` crate, used to implement Zed's collaboration backend, is now licensed under the GPL instead of the AGPL. The AGPL license is no longer used in the zed repository. --- LICENSE-AGPL | 788 ------------------ README.md | 2 + crates/collab/Cargo.toml | 2 +- crates/collab/LICENSE-AGPL | 1 - crates/collab/LICENSE-GPL | 1 + .../zed/resources/flatpak/zed.metainfo.xml.in | 2 +- crates/ztracing/LICENSE-AGPL | 1 - crates/ztracing_macro/LICENSE-AGPL | 1 - docs/src/migrate/rustrover.md | 2 +- legal/terms.md | 2 +- script/check-licenses | 72 +- script/licenses/zed-licenses.toml | 11 +- script/new-crate | 6 +- script/terms/terms.rtf | 2 +- tooling/xtask/src/tasks/licenses.rs | 2 +- 15 files changed, 49 insertions(+), 846 deletions(-) delete mode 100644 LICENSE-AGPL delete mode 120000 crates/collab/LICENSE-AGPL create mode 120000 crates/collab/LICENSE-GPL delete mode 120000 crates/ztracing/LICENSE-AGPL delete mode 120000 crates/ztracing_macro/LICENSE-AGPL diff --git a/LICENSE-AGPL b/LICENSE-AGPL deleted file mode 100644 index 87a0dea90eb..00000000000 --- a/LICENSE-AGPL +++ /dev/null @@ -1,788 +0,0 @@ -Copyright 2022 - 2025 Zed Industries, Inc. - - - - -This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. -This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. -You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - - - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - - Preamble - - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - - The precise terms and conditions for copying, distribution and -modification follow. - - - TERMS AND CONDITIONS - - - 0. Definitions. - - - "This License" refers to version 3 of the GNU Affero General Public License. - - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - - A "covered work" means either the unmodified Program or a work based -on the Program. - - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - - 1. Source Code. - - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - - The Corresponding Source for a work in source code form is that -same work. - - - 2. Basic Permissions. - - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - - 4. Conveying Verbatim Copies. - - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - - 5. Conveying Modified Source Versions. - - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - - 6. Conveying Non-Source Forms. - - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - - 7. Additional Terms. - - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - - 8. Termination. - - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - - 9. Acceptance Not Required for Having Copies. - - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - - 10. Automatic Licensing of Downstream Recipients. - - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - - 11. Patents. - - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - - 12. No Surrender of Others' Freedom. - - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - - 13. Remote Network Interaction; Use with the GNU General Public License. - - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - - 14. Revised Versions of this License. - - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - - 15. Disclaimer of Warranty. - - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - - 16. Limitation of Liability. - - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - - 17. Interpretation of Sections 15 and 16. - - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - - END OF TERMS AND CONDITIONS - - - How to Apply These Terms to Your New Programs - - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - - Copyright (C) - - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - - -Also add information on how to contact you by electronic and paper mail. - - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/README.md b/README.md index 414eb33fd25..9f641fb3841 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Also... we're hiring! Check out our [jobs](https://zed.dev/jobs) page for open r ### Licensing +Zed source code is licensed primarily under GPL-3.0-or-later, with Apache-2.0 components where marked. + License information for third party dependencies must be correctly provided for CI to pass. We use [`cargo-about`](https://github.com/EmbarkStudios/cargo-about) to automatically comply with open source licenses. If CI is failing, check the following: diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 6f9ac772028..67403f7b594 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true name = "collab" version = "0.44.0" publish.workspace = true -license = "AGPL-3.0-or-later" +license = "GPL-3.0-or-later" [lints] workspace = true diff --git a/crates/collab/LICENSE-AGPL b/crates/collab/LICENSE-AGPL deleted file mode 120000 index 5f5cf25dc45..00000000000 --- a/crates/collab/LICENSE-AGPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-AGPL \ No newline at end of file diff --git a/crates/collab/LICENSE-GPL b/crates/collab/LICENSE-GPL new file mode 120000 index 00000000000..89e542f750c --- /dev/null +++ b/crates/collab/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/zed/resources/flatpak/zed.metainfo.xml.in b/crates/zed/resources/flatpak/zed.metainfo.xml.in index b8a88d92213..ec31f4c285f 100644 --- a/crates/zed/resources/flatpak/zed.metainfo.xml.in +++ b/crates/zed/resources/flatpak/zed.metainfo.xml.in @@ -2,7 +2,7 @@ $APP_ID MIT - AGPL-3.0-or-later and Apache-2.0 and GPL-3.0-or-later + Apache-2.0 and GPL-3.0-or-later $APP_NAME High-performance, multiplayer code editor diff --git a/crates/ztracing/LICENSE-AGPL b/crates/ztracing/LICENSE-AGPL deleted file mode 120000 index 5f5cf25dc45..00000000000 --- a/crates/ztracing/LICENSE-AGPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-AGPL \ No newline at end of file diff --git a/crates/ztracing_macro/LICENSE-AGPL b/crates/ztracing_macro/LICENSE-AGPL deleted file mode 120000 index 5f5cf25dc45..00000000000 --- a/crates/ztracing_macro/LICENSE-AGPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-AGPL \ No newline at end of file diff --git a/docs/src/migrate/rustrover.md b/docs/src/migrate/rustrover.md index f4a8bccd6e3..883b3915d26 100644 --- a/docs/src/migrate/rustrover.md +++ b/docs/src/migrate/rustrover.md @@ -339,7 +339,7 @@ Here's what RustRover offers that Zed doesn't have: On licensing and telemetry: -- **Zed is open source** (MIT licensed for the editor, AGPL for collaboration services) +- **Zed is open source** (primarily GPL-licensed, with Apache-licensed components) - **Telemetry is optional** and can be disabled during onboarding or in settings ## Collaboration in Zed vs. RustRover diff --git a/legal/terms.md b/legal/terms.md index ed90fd36c83..5b4b87bd5ad 100644 --- a/legal/terms.md +++ b/legal/terms.md @@ -37,7 +37,7 @@ The Service uses technology provided by multiple third party AI subprocessors (t ### 2.4. Restrictions -Customer will not (and will not permit anyone else to), directly or indirectly, do any of the following: (a) provide access to, distribute, sell, or sublicense the Service to a third party; (b) seek to access non-public APIs associated with the Service; (c) copy any element of the Service; (d) interfere with the operation of the Service, circumvent any access restrictions, or conduct any security or vulnerability test of the Service; (e) transmit any viruses or other harmful materials to the Service or others; (f) take any action that risks harm to others or to the security, availability, or integrity of the Service except for the purposes of legitimate security or malware research; or (g) access or use the Service or Output in a manner that violates any applicable relevant local, state, federal or international laws, regulations, or conventions, including those related to data privacy or data transfer, international communications, or export of data (collectively, “**Laws**”), or the Terms. The Service incorporates functionality provided by third-party services, the use of which is subject to additional terms. Customer agrees that if Customer accesses or uses services, features or functionality in the Software or Service that are provided by a third party, Customer will comply with any applicable terms promulgated by that third party, including as set forth at [https://zed.dev/acceptable-use-policies](/acceptable-use-policies) (as may be updated from time to time). Customer further acknowledges that certain components of the Software or Service may be covered by open source licenses ("**Open Source Component**"), including but not limited to Apache License, Version 2.0, GNU General Public License v3.0, and the GNU Affero General Public License v3.0. To the extent required by such open source license for the applicable Open Source Component, the terms of such license will apply to such Open Source Component in lieu of the relevant provisions of these Terms. If such open source license prohibits any of the restrictions in these Terms, such restrictions will not apply to such Open Source Component. Zed shall provide Customer with a list of Open Source Components upon Customer's request. +Customer will not (and will not permit anyone else to), directly or indirectly, do any of the following: (a) provide access to, distribute, sell, or sublicense the Service to a third party; (b) seek to access non-public APIs associated with the Service; (c) copy any element of the Service; (d) interfere with the operation of the Service, circumvent any access restrictions, or conduct any security or vulnerability test of the Service; (e) transmit any viruses or other harmful materials to the Service or others; (f) take any action that risks harm to others or to the security, availability, or integrity of the Service except for the purposes of legitimate security or malware research; or (g) access or use the Service or Output in a manner that violates any applicable relevant local, state, federal or international laws, regulations, or conventions, including those related to data privacy or data transfer, international communications, or export of data (collectively, “**Laws**”), or the Terms. The Service incorporates functionality provided by third-party services, the use of which is subject to additional terms. Customer agrees that if Customer accesses or uses services, features or functionality in the Software or Service that are provided by a third party, Customer will comply with any applicable terms promulgated by that third party, including as set forth at [https://zed.dev/acceptable-use-policies](/acceptable-use-policies) (as may be updated from time to time). Customer further acknowledges that certain components of the Software or Service may be covered by open source licenses ("**Open Source Component**"), including but not limited to Apache License, Version 2.0 and GNU General Public License v3.0. To the extent required by such open source license for the applicable Open Source Component, the terms of such license will apply to such Open Source Component in lieu of the relevant provisions of these Terms. If such open source license prohibits any of the restrictions in these Terms, such restrictions will not apply to such Open Source Component. Zed shall provide Customer with a list of Open Source Components upon Customer's request. ## 3. General Payment Terms diff --git a/script/check-licenses b/script/check-licenses index 61db9c15d52..02f893709fd 100755 --- a/script/check-licenses +++ b/script/check-licenses @@ -2,8 +2,33 @@ set -euo pipefail -AGPL_CRATES=("collab") -RELEASE_CRATES=("cli" "remote_server" "zed") +check_manifest_for_agpl () { + local cargo_toml="$1" + + if grep -Eiq '(agpl|affero)' "$cargo_toml"; then + echo "Error: $cargo_toml references an AGPL license. First-party crates must use LICENSE-GPL or LICENSE-APACHE." + exit 1 + fi + + if grep -Eiq '^[[:space:]]*license-file[[:space:]]*=' "$cargo_toml"; then + echo "Error: $cargo_toml uses license-file. First-party crates must declare LICENSE-GPL or LICENSE-APACHE via the license field and symlink." + exit 1 + fi +} + +check_no_agpl_license_file () { + if [[ -e "LICENSE-AGPL" || -L "LICENSE-AGPL" ]]; then + echo "Error: LICENSE-AGPL exists. First-party code must use LICENSE-GPL or LICENSE-APACHE." + exit 1 + fi + + while IFS= read -r -d '' license_file; do + if [[ -e "$license_file" || -L "$license_file" ]]; then + echo "Error: $license_file exists. First-party crates must use LICENSE-GPL or LICENSE-APACHE." + exit 1 + fi + done < <(git ls-files -z -- "*/LICENSE-AGPL") +} check_symlink_target () { local symlink_path="$1" @@ -32,21 +57,7 @@ check_symlink_target () { check_license () { local dir="$1" - local allowed_licenses=() - - local is_agpl=false - for agpl_crate in "${AGPL_CRATES[@]}"; do - if [[ "$dir" == "crates/$agpl_crate" ]]; then - is_agpl=true - break - fi - done - - if [[ "$is_agpl" == true ]]; then - allowed_licenses=("LICENSE-AGPL") - else - allowed_licenses=("LICENSE-GPL" "LICENSE-APACHE") - fi + local allowed_licenses=("LICENSE-GPL" "LICENSE-APACHE") for license in "${allowed_licenses[@]}"; do if [[ -L "$dir/$license" ]]; then @@ -58,31 +69,18 @@ check_license () { fi done - if [[ "$is_agpl" == true ]]; then - echo "Error: $dir does not contain a LICENSE-AGPL symlink" - else - echo "Error: $dir does not contain a LICENSE-GPL or LICENSE-APACHE symlink" - fi + echo "Error: $dir does not contain a LICENSE-GPL or LICENSE-APACHE symlink" exit 1 } -git ls-files "**/*/Cargo.toml" | while read -r cargo_toml; do - check_license "$(dirname "$cargo_toml")" -done +check_no_agpl_license_file +git ls-files -z -- "Cargo.toml" "**/Cargo.toml" | while IFS= read -r -d '' cargo_toml; do + check_manifest_for_agpl "$cargo_toml" -# Make sure the AGPL server crates are included in the release tarball. -for release_crate in "${RELEASE_CRATES[@]}"; do - tree_output=$(cargo tree --package "$release_crate") - for agpl_crate in "${AGPL_CRATES[@]}"; do - # Look for lines that contain the crate name followed by " v" (version) - # This matches patterns like "├── collab v0.44.0" - if echo "$tree_output" | grep -E "(^|[^a-zA-Z_])${agpl_crate} v" > /dev/null; then - echo "Error: crate '${agpl_crate}' is AGPL and is a dependency of crate '${release_crate}'." >&2 - echo "AGPL licensed code should not be used in the release distribution, only in servers." >&2 - exit 1 - fi - done + if [[ "$cargo_toml" == */Cargo.toml ]]; then + check_license "$(dirname "$cargo_toml")" + fi done echo "check-licenses succeeded" diff --git a/script/licenses/zed-licenses.toml b/script/licenses/zed-licenses.toml index db14a280f2f..17ebc15954a 100644 --- a/script/licenses/zed-licenses.toml +++ b/script/licenses/zed-licenses.toml @@ -1,16 +1,9 @@ no-clearly-defined = true private = { ignore = true } -# Licenses allowed in Zed's dependencies. AGPL should not be added to -# this list as use of AGPL software is sometimes disallowed. When -# adding to this list, please check the following open source license -# policies: +# Licenses allowed in Zed's dependencies. AGPL should not be added to this list. +# When adding to this list, please check the following open source license policies: # # * https://opensource.google/documentation/reference/thirdparty/licenses -# -# The Zed project does have AGPL crates, but these are only involved -# in servers and are not built into the binaries in the release -# tarball. `script/check-licenses` checks that AGPL crates are not -# involved in release binaries. accepted = [ "Apache-2.0", "MIT", diff --git a/script/new-crate b/script/new-crate index 52ee900b308..4f70a5fdc32 100755 --- a/script/new-crate +++ b/script/new-crate @@ -18,13 +18,13 @@ fi CRATE_NAME="$1" -LICENSE_FLAG=$(echo "${2}" | tr '[:upper:]' '[:lower:]') +LICENSE_FLAG=$(echo "${2:-}" | tr '[:upper:]' '[:lower:]') if [[ "$LICENSE_FLAG" == *"apache"* ]]; then LICENSE_MODE="Apache-2.0" LICENSE_FILE="LICENSE-APACHE" elif [[ "$LICENSE_FLAG" == *"agpl"* ]]; then - LICENSE_MODE="AGPL-3.0-or-later" - LICENSE_FILE="LICENSE-AGPL" + echo "Error: New first-party crates cannot use AGPL. Use GPL or Apache." + exit 1 else LICENSE_MODE="GPL-3.0-or-later" LICENSE_FILE="LICENSE-GPL" diff --git a/script/terms/terms.rtf b/script/terms/terms.rtf index cd01004c11e..441b52cae4d 100644 --- a/script/terms/terms.rtf +++ b/script/terms/terms.rtf @@ -54,7 +54,7 @@ Community Guidelines {\pard \ql \f0 \sa180 \li0 \fi0 Customer will not (and will not permit anyone else to), directly or indirectly, do any of the following: (a) provide access to, distribute, sell, or sublicense the Service to a third party; (b)\u160 ?seek to access non-public APIs associated with the Service; (c) copy any element of the Service; (d) interfere with the operation of the Service, circumvent any access restrictions, or conduct any security or vulnerability test of the Service; (e) transmit any viruses or other harmful materials to the Service or others;\u160 ?(f) take any action that risks harm to others or to the security, availability, or integrity of the Service except for the purposes of legitimate security or malware research; or (g) access or use the Service or Output in a manner that violates any applicable relevant local, state, federal or international laws, regulations, or conventions, including those related to data privacy or data transfer, international communications, or export of data (collectively, \u8220"{\b Laws}\u8221"), or the Terms.\u160 ?The Service incorporates functionality provided by third-party services, the use of which is subject to additional terms. Customer agrees that if Customer accesses or uses services, features or functionality in the Software or Service that are provided by a third party, Customer will comply with any applicable terms promulgated by that third party, including as set forth at {\field{\*\fldinst{HYPERLINK "/acceptable-use-policies"}}{\fldrslt{\ul https://zed.dev/acceptable-use-policies }}} -\u160 ?(as may be updated from time to time). Customer further acknowledges that certain components of the Software or Service may be covered by open source licenses ("{\b Open Source Component}"), including but not limited to Apache License, Version 2.0, GNU General Public License v3.0, and the GNU Affero General Public License v3.0.\u160 ?To the extent required by such open source license for the applicable Open Source Component, the terms of such license will apply to such Open Source Component in lieu of the relevant provisions of these Terms. If such open\u160 ?source license prohibits any of the restrictions in these Terms, such restrictions will not apply to such Open Source Component. Zed shall provide Customer with a list of Open Source Components upon Customer's request.\par} +\u160 ?(as may be updated from time to time). Customer further acknowledges that certain components of the Software or Service may be covered by open source licenses ("{\b Open Source Component}"), including but not limited to Apache License, Version 2.0 and GNU General Public License v3.0.\u160 ?To the extent required by such open source license for the applicable Open Source Component, the terms of such license will apply to such Open Source Component in lieu of the relevant provisions of these Terms. If such open\u160 ?source license prohibits any of the restrictions in these Terms, such restrictions will not apply to such Open Source Component. Zed shall provide Customer with a list of Open Source Components upon Customer's request.\par} {\pard \ql \f0 \sa180 \li0 \fi0 \outlinelevel1 \b \fs32 3. General Payment Terms\par} {\pard \ql \f0 \sa180 \li0 \fi0 Accessing certain features and tiers of the Service requires Customer\u160 ?to pay fees. Before Customer pays any fees, Customer will have an opportunity to review and accept the fees that Customer will be charged. Unless otherwise specifically provided for in these Terms, all fees are in U.S. Dollars and are non-refundable, except as required by law.\par} {\pard \ql \f0 \sa180 \li0 \fi0 \outlinelevel2 \b \fs28 3.1. Price\par} diff --git a/tooling/xtask/src/tasks/licenses.rs b/tooling/xtask/src/tasks/licenses.rs index 449c774d458..e187299c375 100644 --- a/tooling/xtask/src/tasks/licenses.rs +++ b/tooling/xtask/src/tasks/licenses.rs @@ -9,7 +9,7 @@ use crate::workspace::load_workspace; pub struct LicensesArgs {} pub fn run_licenses(_args: LicensesArgs) -> Result<()> { - const LICENSE_FILES: &[&str] = &["LICENSE-APACHE", "LICENSE-GPL", "LICENSE-AGPL"]; + const LICENSE_FILES: &[&str] = &["LICENSE-APACHE", "LICENSE-GPL"]; let workspace = load_workspace()?; From 59d8766f35afdbe74678d4ac3bca86ed582c864e Mon Sep 17 00:00:00 2001 From: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com> Date: Thu, 28 May 2026 14:52:53 -0700 Subject: [PATCH 60/93] Show progress when deleting worktrees (#57751) Adds a progress indicator to the worktree picker so users get visual feedback while a worktree deletion is in progress, which can take several seconds. Closes AI-239 Release Notes: - Added progress feedback in the worktree picker while deleting a worktree --- crates/git_ui/src/worktree_picker.rs | 316 ++++++++++++++++++++++++--- 1 file changed, 289 insertions(+), 27 deletions(-) diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 4e6eec71914..61b60b41252 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -14,8 +14,8 @@ use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::Project; use project::git_store::RepositoryEvent; use ui::{ - Button, Divider, HighlightedLabel, IconButton, KeyBinding, ListItem, ListItemSpacing, Tooltip, - prelude::*, + Button, CommonAnimationExt as _, Divider, HighlightedLabel, IconButton, KeyBinding, ListItem, + ListItemSpacing, Tooltip, prelude::*, }; use util::ResultExt as _; use util::paths::PathExt; @@ -116,6 +116,7 @@ impl WorktreePicker { show_footer, modifiers: Modifiers::default(), hovered_delete_index: None, + deleting_worktree_paths: HashSet::default(), }; let picker = cx.new(|cx| { @@ -313,6 +314,7 @@ struct WorktreePickerDelegate { show_footer: bool, modifiers: Modifiers, hovered_delete_index: Option, + deleting_worktree_paths: HashSet, } fn remove_worktree_command(path: &Path, force: bool) -> String { @@ -464,7 +466,7 @@ impl WorktreePickerDelegate { } fn delete_worktree( - &self, + &mut self, ix: usize, force: bool, window: &mut Window, @@ -476,7 +478,9 @@ impl WorktreePickerDelegate { let WorktreeEntry::Worktree { worktree, .. } = entry else { return; }; - if !self.can_delete_worktree(worktree) { + if !self.can_delete_worktree(worktree) + || self.deleting_worktree_paths.contains(&worktree.path) + { return; } @@ -493,10 +497,27 @@ impl WorktreePickerDelegate { ); let workspace = self.workspace.clone(); + self.deleting_worktree_paths.insert(path.clone()); + if self.hovered_delete_index == Some(ix) { + self.hovered_delete_index = None; + } + cx.notify(); + cx.spawn_in(window, async move |picker, cx| { - let initial_result = repo + let initial_result = match repo .update(cx, |repo, _| repo.remove_worktree(path.clone(), force)) - .await?; + .await + { + Ok(result) => result, + Err(error) => { + picker.update_in(cx, |picker, _window, cx| { + if picker.delegate.deleting_worktree_paths.remove(&path) { + cx.notify(); + } + })?; + return Err(error.into()); + } + }; let (result, attempted_force) = match initial_result { Ok(()) => (Ok(()), force), @@ -510,6 +531,12 @@ impl WorktreePickerDelegate { .flatten(); if let Some(prompt_message) = force_delete_prompt { + picker.update_in(cx, |picker, _window, cx| { + if picker.delegate.deleting_worktree_paths.remove(&path) { + cx.notify(); + } + })?; + let answer = cx.update(|window, cx| { window.prompt( PromptLevel::Warning, @@ -524,9 +551,39 @@ impl WorktreePickerDelegate { return Ok(()); } - let retry = repo + let should_retry = picker.update_in(cx, |picker, _window, cx| { + let worktree_still_exists = picker + .delegate + .all_worktrees + .iter() + .any(|worktree| worktree.path == path); + if !worktree_still_exists + || !picker.delegate.deleting_worktree_paths.insert(path.clone()) + { + return false; + } + cx.notify(); + true + })?; + + if !should_retry { + return Ok(()); + } + + let retry = match repo .update(cx, |repo, _| repo.remove_worktree(path.clone(), true)) - .await?; + .await + { + Ok(result) => result, + Err(error) => { + picker.update_in(cx, |picker, _window, cx| { + if picker.delegate.deleting_worktree_paths.remove(&path) { + cx.notify(); + } + })?; + return Err(error.into()); + } + }; if let Err(error) = &retry { log::error!("Failed to force remove worktree: {error}"); @@ -540,6 +597,12 @@ impl WorktreePickerDelegate { }; if let Err(error) = result { + picker.update_in(cx, |picker, _window, cx| { + if picker.delegate.deleting_worktree_paths.remove(&path) { + cx.notify(); + } + })?; + if let Some(workspace) = workspace.upgrade() { cx.update(|_window, cx| { show_error_toast( @@ -555,6 +618,7 @@ impl WorktreePickerDelegate { } picker.update_in(cx, |picker, _window, cx| { + picker.delegate.deleting_worktree_paths.remove(&path); picker.delegate.matches.retain(|e| { !matches!(e, WorktreeEntry::Worktree { worktree, .. } if worktree.path == path) }); @@ -814,6 +878,10 @@ impl PickerDelegate for WorktreePickerDelegate { } } WorktreeEntry::Worktree { worktree, .. } => { + if self.deleting_worktree_paths.contains(&worktree.path) { + return; + } + let is_current = self.project_worktree_paths.contains(&worktree.path); if !is_current { @@ -956,6 +1024,7 @@ impl PickerDelegate for WorktreePickerDelegate { let sha = worktree.sha.chars().take(7).collect::(); let is_current = self.project_worktree_paths.contains(&worktree.path); + let is_deleting = self.deleting_worktree_paths.contains(&worktree.path); let can_delete = self.can_delete_worktree(worktree); let entry_icon = if is_current { @@ -1035,7 +1104,24 @@ impl PickerDelegate for WorktreePickerDelegate { ), ), ) - .when(!is_current, |this| { + .when(is_deleting, |this| { + this.end_slot( + h_flex() + .gap_1() + .child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + .child( + Label::new("Deleting…") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }) + .when(!is_deleting && !is_current, |this| { let open_in_new_window_button = IconButton::new(("open-new-window", ix), IconName::ArrowUpRight) .icon_size(IconSize::Small) @@ -1045,6 +1131,13 @@ impl PickerDelegate for WorktreePickerDelegate { return; }; if let WorktreeEntry::Worktree { worktree, .. } = entry { + if picker + .delegate + .deleting_worktree_paths + .contains(&worktree.path) + { + return; + } window.dispatch_action( Box::new(OpenWorktreeInNewWindow { path: worktree.path.clone(), @@ -1083,12 +1176,8 @@ impl PickerDelegate for WorktreePickerDelegate { .into() }) .on_click(cx.listener(move |picker, _, window, cx| { - picker.delegate.delete_worktree( - ix, - picker.delegate.modifiers.alt, - window, - cx, - ); + let force = picker.delegate.modifiers.alt; + picker.delegate.delete_worktree(ix, force, window, cx); })), ); @@ -1162,6 +1251,10 @@ impl PickerDelegate for WorktreePickerDelegate { matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.project_worktree_paths.contains(&worktree.path)) }); + let is_deleting = selected_entry.is_some_and(|e| { + matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.deleting_worktree_paths.contains(&worktree.path)) + }); + let footer = h_flex() .w_full() .p_1p5() @@ -1188,7 +1281,14 @@ impl PickerDelegate for WorktreePickerDelegate { } else if is_existing_worktree { Some( footer - .when(can_delete, |this| { + .when(is_deleting, |this| { + this.child( + Button::new("delete-worktree", "Deleting…") + .loading(true) + .disabled(true), + ) + }) + .when(!is_deleting && can_delete, |this| { let focus_handle = focus_handle.clone(); this.child( Button::new("delete-worktree", "Delete") @@ -1201,7 +1301,7 @@ impl PickerDelegate for WorktreePickerDelegate { }), ) }) - .when(!is_current, |this| { + .when(!is_deleting && !is_current, |this| { let focus_handle = focus_handle.clone(); this.child( Button::new("open-in-new-window", "Open in New Window") @@ -1218,16 +1318,18 @@ impl PickerDelegate for WorktreePickerDelegate { }), ) }) - .child( - Button::new("open-worktree", "Open") - .key_binding( - KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(menu::Confirm.boxed_clone(), cx) - }), - ) + .when(!is_deleting, |this| { + this.child( + Button::new("open-worktree", "Open") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), + ) + }) .into_any(), ) } else { @@ -1482,6 +1584,33 @@ mod tests { }) } + fn picker_contains_worktree( + worktree_picker: &Entity, + worktree_path: &Path, + cx: &mut VisualTestContext, + ) -> bool { + worktree_picker.update(cx, |worktree_picker, cx| { + worktree_picker.picker.update(cx, |picker, _| { + picker.delegate.all_worktrees.iter().any(|worktree| { + worktree.path == *worktree_path + }) && picker.delegate.matches.iter().any(|entry| { + matches!(entry, WorktreeEntry::Worktree { worktree, .. } if worktree.path == *worktree_path) + }) + }) + }) + } + + fn deleting_worktree_paths( + worktree_picker: &Entity, + cx: &mut VisualTestContext, + ) -> HashSet { + worktree_picker.update(cx, |worktree_picker, cx| { + worktree_picker.picker.update(cx, |picker, _| { + picker.delegate.deleting_worktree_paths.clone() + }) + }) + } + async fn repo_contains_worktree( repository: &Entity, worktree_path: &Path, @@ -1497,6 +1626,54 @@ mod tests { .any(|worktree| worktree.path == *worktree_path) } + #[gpui::test] + async fn test_delete_worktree_marks_row_pending_immediately(cx: &mut TestAppContext) { + let (_, worktree_picker, _repository, worktree_path, mut cx) = + init_worktree_picker_test(cx).await; + + let index = worktree_index(&worktree_picker, &worktree_path, &mut cx); + worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| { + worktree_picker.picker.update(cx, |picker, cx| { + picker.delegate.delete_worktree(index, false, window, cx); + }) + }); + + let pending_paths = deleting_worktree_paths(&worktree_picker, &mut cx); + assert_eq!(pending_paths.len(), 1); + assert!(pending_paths.contains(&worktree_path)); + + cx.run_until_parked(); + } + + #[gpui::test] + async fn test_delete_worktree_clears_pending_and_removes_row_on_success( + cx: &mut TestAppContext, + ) { + let (_, worktree_picker, repository, worktree_path, mut cx) = + init_worktree_picker_test(cx).await; + + let index = worktree_index(&worktree_picker, &worktree_path, &mut cx); + worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| { + worktree_picker.picker.update(cx, |picker, cx| { + picker.delegate.delete_worktree(index, false, window, cx); + }) + }); + assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path)); + + cx.run_until_parked(); + + assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty()); + assert!(!picker_contains_worktree( + &worktree_picker, + &worktree_path, + &mut cx + )); + assert!( + !repo_contains_worktree(&repository, &worktree_path, &mut cx).await, + "worktree should be removed after successful delete" + ); + } + #[gpui::test] async fn test_remote_default_branch_is_preferred_create_target(cx: &mut TestAppContext) { let (_fs, worktree_picker, _repository, _worktree_path, mut cx) = @@ -1588,19 +1765,96 @@ mod tests { picker.delegate.delete_worktree(index, false, window, cx); }) }); + assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path)); + cx.run_until_parked(); assert!(cx.has_pending_prompt()); + assert!( + !deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path), + "pending delete state should clear while waiting for force-delete confirmation" + ); cx.simulate_prompt_answer("Force Delete"); cx.run_until_parked(); assert!(!cx.has_pending_prompt()); + assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty()); + assert!(!picker_contains_worktree( + &worktree_picker, + &worktree_path, + &mut cx + )); assert!( !repo_contains_worktree(&repository, &worktree_path, &mut cx).await, "worktree should be removed after confirming force delete" ); } + #[gpui::test] + async fn test_duplicate_delete_worktree_is_ignored_while_pending(cx: &mut TestAppContext) { + let (fs, worktree_picker, _repository, worktree_path, mut cx) = + init_worktree_picker_test(cx).await; + + fs.with_git_state(path!("/root/project/.git").as_ref(), true, |state| { + state + .worktrees_requiring_force_delete + .insert(worktree_path.clone()); + }) + .expect("failed to mark test worktree as requiring force delete"); + + let index = worktree_index(&worktree_picker, &worktree_path, &mut cx); + worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| { + worktree_picker.picker.update(cx, |picker, cx| { + picker.delegate.delete_worktree(index, false, window, cx); + picker.delegate.delete_worktree(index, false, window, cx); + }) + }); + + let pending_paths = deleting_worktree_paths(&worktree_picker, &mut cx); + assert_eq!(pending_paths.len(), 1); + assert!(pending_paths.contains(&worktree_path)); + + cx.run_until_parked(); + assert!(cx.has_pending_prompt()); + assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty()); + + cx.simulate_prompt_answer("Cancel"); + cx.run_until_parked(); + + assert!(!cx.has_pending_prompt()); + assert!(picker_contains_worktree( + &worktree_picker, + &worktree_path, + &mut cx + )); + } + + #[gpui::test] + async fn test_selected_deleting_worktree_cannot_be_opened(cx: &mut TestAppContext) { + let (_, worktree_picker, _repository, worktree_path, mut cx) = + init_worktree_picker_test(cx).await; + + let subscription = cx.update(|_, cx| { + cx.subscribe(&worktree_picker, |_, _: &DismissEvent, _| { + panic!("DismissEvent should not be emitted for a deleting worktree"); + }) + }); + + let index = worktree_index(&worktree_picker, &worktree_path, &mut cx); + worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| { + worktree_picker.picker.update(cx, |picker, cx| { + picker.delegate.selected_index = index; + picker.delegate.delete_worktree(index, false, window, cx); + picker.delegate.confirm(false, window, cx); + }) + }); + + assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path)); + + drop(subscription); + cx.run_until_parked(); + } + #[gpui::test] async fn test_force_delete_worktree_deletes_without_prompt(cx: &mut TestAppContext) { let (fs, worktree_picker, repository, worktree_path, mut cx) = @@ -1620,9 +1874,17 @@ mod tests { picker.delegate.delete_worktree(index, true, window, cx); }) }); + assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path)); + cx.run_until_parked(); assert!(!cx.has_pending_prompt()); + assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty()); + assert!(!picker_contains_worktree( + &worktree_picker, + &worktree_path, + &mut cx + )); assert!( !repo_contains_worktree(&repository, &worktree_path, &mut cx).await, "worktree should be removed by explicit force delete" From b32570d931a3073cddf7e1a44740dc21d2e20f9f Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Thu, 28 May 2026 18:24:22 -0400 Subject: [PATCH 61/93] terminal: Move PTY spawning to the background thread and retain exit status (#58004) This PR does two things 1. Move PTY child spawning to the background executor. Forking to spawn the terminal child was taking between 10-70ms on profiles I was looking at, which caused frames to be dropped because it was on the foreground thread. This should fix issues such as #57574 where the miniprof from this comment showed terminal creation blocking the foreground thread as well. https://github.com/zed-industries/zed/issues/57574#issuecomment-4546858817 2. Stopped overwriting alacrities exit status with `9` on `AlacTermEvent::Exit` event handling. Before we get the exit event, alacritty emits `ChildExit(status)` with the real status, which we used to overwrite with exit status 9 when handling the AlacTermEvent::Exit. This was an easy fix so I added it in this PR when I noticed the bug. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - fixed dropped frames caused by agents or users creating new terminals --- Cargo.lock | 9 +++---- Cargo.toml | 2 +- crates/terminal/src/terminal.rs | 43 +++++++++++++++++---------------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0cc384eef22..5f11b1956c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -597,15 +597,14 @@ dependencies = [ [[package]] name = "alacritty_terminal" -version = "0.25.1" -source = "git+https://github.com/zed-industries/alacritty?rev=9d9640d4#9d9640d4e56d67a09d049f9c0a300aae08d4f61e" +version = "0.26.1-dev" +source = "git+https://github.com/zed-industries/alacritty?rev=fcf32feacb367b75ec84dd40f041e4fd411d3cc1#fcf32feacb367b75ec84dd40f041e4fd411d3cc1" dependencies = [ "base64 0.22.1", "bitflags 2.10.0", "home", "libc", "log", - "mach2 0.5.0", "miow", "parking_lot", "piper", @@ -17341,9 +17340,9 @@ dependencies = [ [[package]] name = "signal-hook" -version = "0.3.18" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" dependencies = [ "libc", "signal-hook-registry", diff --git a/Cargo.toml b/Cargo.toml index 521128c90df..a3bd7b2e32f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -511,7 +511,7 @@ accesskit_unix = "0.21.0" accesskit_windows = "0.32.1" agent-client-protocol = { version = "=0.12.1", features = ["unstable"] } aho-corasick = "1.1" -alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } +alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" } any_vec = "0.14" anyhow = "1.0.86" ashpd = { version = "0.13", default-features = false, features = [ diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 271e2df0e9a..7d22a86d7db 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -453,6 +453,13 @@ impl TerminalBuilder { ) -> Task> { let version = release_channel::AppVersion::global(cx); let background_executor = cx.background_executor().clone(); + #[cfg(not(windows))] + let child_signal_mask = match tty::SignalMask::current() + .context("failed to capture terminal child signal mask") + { + Ok(signal_mask) => Some(signal_mask), + Err(error) => return Task::ready(Err(error)), + }; let fut = async move { // Remove SHLVL so the spawned shell initializes it to 1, matching // the behavior of standalone terminal emulators like iTerm2/Kitty/Alacritty. @@ -542,6 +549,12 @@ impl TerminalBuilder { working_directory: working_directory.clone(), drain_on_exit: true, env: env.clone().into_iter().collect(), + // We pass in the foreground thread's signal mask to the child process via pty_options, + // so terminal construction can run on a background thread without breaking Ctrl-C and other signals + // otherwise the terminal would inherit the background executor's signal mask which blocks + // some terminal signals + #[cfg(not(windows))] + child_signal_mask, #[cfg(windows)] escape_args: shell_kind.tty_escape_args(), } @@ -688,12 +701,7 @@ impl TerminalBuilder { events_rx, }) }; - // the thread we spawn things on has an effect on signal handling - if !cfg!(target_os = "windows") { - cx.spawn(async move |_| fut.await) - } else { - cx.background_spawn(fut) - } + cx.background_spawn(fut) } pub fn subscribe(mut self, cx: &Context) -> Terminal { @@ -985,7 +993,7 @@ impl Terminal { AlacTermEvent::Bell => { cx.emit(Event::Bell); } - AlacTermEvent::Exit => self.register_task_finished(Some(9), cx), + AlacTermEvent::Exit => self.register_task_finished(None, cx), AlacTermEvent::MouseCursorDirty => { //NOOP, Handled in render } @@ -1009,8 +1017,8 @@ impl Terminal { .unwrap_or_else(|| to_alac_rgb(get_color_at_index(index, cx.theme().as_ref()))); self.write_to_pty(format(color).into_bytes()); } - AlacTermEvent::ChildExit(raw_status) => { - self.register_task_finished(Some(raw_status), cx); + AlacTermEvent::ChildExit(exit_status) => { + self.register_task_finished(Some(exit_status), cx); } } } @@ -2262,18 +2270,11 @@ impl Terminal { Task::ready(None) } - fn register_task_finished(&mut self, raw_status: Option, cx: &mut Context) { - let exit_status: Option = raw_status.map(|value| { - #[cfg(unix)] - { - std::os::unix::process::ExitStatusExt::from_raw(value) - } - #[cfg(windows)] - { - std::os::windows::process::ExitStatusExt::from_raw(value as u32) - } - }); - + fn register_task_finished( + &mut self, + exit_status: Option, + cx: &mut Context, + ) { if let Some(tx) = &self.completion_tx { tx.try_send(exit_status).ok(); } From 12aacf3ceae3184d13917436a5ba7b06bea1bcae Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 28 May 2026 16:04:44 -0700 Subject: [PATCH 62/93] Add skill share linking (#58009) Added because I'd like to get this skill Danilo made without having to upload a gist Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- Cargo.lock | 3 + crates/agent_skills/Cargo.toml | 2 + crates/agent_skills/agent_skills.rs | 74 +++++++++++++ crates/agent_ui/src/agent_panel.rs | 7 ++ crates/settings_ui/src/pages/skills_setup.rs | 65 ++++++++++- crates/settings_ui/src/settings_ui.rs | 10 ++ crates/skill_creator/src/skill_creator.rs | 111 +++++++++++++------ crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 22 ++++ crates/zed/src/zed/open_listener.rs | 63 +++++++++++ docs/src/ai/skills.md | 7 ++ 11 files changed, 328 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5f11b1956c8..97bafe5737e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,6 +426,7 @@ name = "agent_skills" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", "const_format", "fs", "futures 0.3.32", @@ -434,6 +435,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "url", "util", ] @@ -23513,6 +23515,7 @@ dependencies = [ "agent-client-protocol", "agent_servers", "agent_settings", + "agent_skills", "agent_ui", "anyhow", "ashpd", diff --git a/crates/agent_skills/Cargo.toml b/crates/agent_skills/Cargo.toml index db3fb7e2947..31864f7a4f0 100644 --- a/crates/agent_skills/Cargo.toml +++ b/crates/agent_skills/Cargo.toml @@ -13,6 +13,7 @@ path = "agent_skills.rs" [dependencies] anyhow.workspace = true +base64.workspace = true const_format.workspace = true fs.workspace = true futures.workspace = true @@ -20,6 +21,7 @@ gpui.workspace = true paths.workspace = true serde.workspace = true serde_yaml_ng.workspace = true +url.workspace = true util.workspace = true [dev-dependencies] diff --git a/crates/agent_skills/agent_skills.rs b/crates/agent_skills/agent_skills.rs index 7f0d863fc91..e2ffc550be3 100644 --- a/crates/agent_skills/agent_skills.rs +++ b/crates/agent_skills/agent_skills.rs @@ -6,6 +6,7 @@ use gpui::{Global, SharedString}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use url::Url; use util::paths::component_matches_ignore_ascii_case; /// First segment of the skills directory path: `.agents`. @@ -731,6 +732,58 @@ pub fn is_agents_skills_path(path: &Path) -> bool { false } +/// The `zed://` scheme used by share links. +const SKILL_SHARE_LINK_SCHEME: &str = "zed"; +/// The host (the part after `zed://`) that identifies a skill share link. +const SKILL_SHARE_LINK_HOST: &str = "skill"; +/// The query parameter that carries the embedded `SKILL.md` payload. +const SKILL_SHARE_LINK_DATA_PARAM: &str = "data"; + +/// The `zed://` deep-link prefix for a shared skill. Opening a link with this +/// prefix prompts the recipient to review and install the embedded skill. +pub const SKILL_SHARE_LINK_PREFIX: &str = + concatcp!(SKILL_SHARE_LINK_SCHEME, "://", SKILL_SHARE_LINK_HOST); + +/// Build a shareable `zed://skill?data=…` link that fully embeds the given +/// `SKILL.md` file contents. +/// +/// The contents are base64url-encoded (no padding) so the link is +/// self-contained and URL-safe: the recipient doesn't need the skill to be +/// hosted anywhere. Recover the contents with [`decode_skill_share_link`]. +pub fn encode_skill_share_link(skill_file_content: &str) -> String { + use base64::Engine as _; + let data = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(skill_file_content.as_bytes()); + let mut url = Url::parse(SKILL_SHARE_LINK_PREFIX).expect("skill share link prefix is valid"); + url.query_pairs_mut() + .append_pair(SKILL_SHARE_LINK_DATA_PARAM, &data); + url.into() +} + +/// Recover the `SKILL.md` contents embedded in a `zed://skill?data=…` link +/// produced by [`encode_skill_share_link`]. +pub fn decode_skill_share_link(link: &str) -> Result { + use base64::Engine as _; + let url = Url::parse(link).context("skill share link is not a valid URL")?; + anyhow::ensure!( + url.scheme() == SKILL_SHARE_LINK_SCHEME && url.host_str() == Some(SKILL_SHARE_LINK_HOST), + "not a skill share link" + ); + let data = url + .query_pairs() + .find_map(|(key, value)| (key == SKILL_SHARE_LINK_DATA_PARAM).then_some(value)) + .context("skill share link is missing the `data` parameter")?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(data.as_bytes()) + .context("skill share link `data` is not valid base64")?; + anyhow::ensure!( + bytes.len() <= MAX_SKILL_FILE_SIZE, + "shared skill exceeds the maximum size of {MAX_SKILL_FILE_SIZE} bytes" + ); + let content = String::from_utf8(bytes).context("skill share link `data` is not valid UTF-8")?; + Ok(content) +} + #[cfg(test)] mod tests { use super::*; @@ -1959,4 +2012,25 @@ description: A skill with no body content } } } + + #[test] + fn skill_share_link_round_trips() { + let content = + "---\nname: my-skill\ndescription: Does a thing.\n---\n\n## Steps\n\nDo the thing.\n"; + let link = encode_skill_share_link(content); + let data = link + .strip_prefix("zed://skill?data=") + .expect("link should start with the skill share prefix"); + // base64url (no-pad) output must not require percent-encoding. + assert!(!data.contains('+') && !data.contains('/') && !data.contains('=')); + assert_eq!(decode_skill_share_link(&link).unwrap(), content); + } + + #[test] + fn decode_skill_share_link_rejects_non_skill_links() { + assert!(decode_skill_share_link("zed://settings/agent.skills").is_err()); + assert!(decode_skill_share_link("zed://skill").is_err()); + assert!(decode_skill_share_link("zed://skill?other=1").is_err()); + assert!(decode_skill_share_link("zed://skill?data=!!!notbase64").is_err()); + } } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 540abc040f2..72ba53045e5 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -3251,6 +3251,13 @@ impl AgentPanel { self.open_skill_creator(SkillCreatorOpenMode::Url { initial_url }, cx); } + /// Open the skill creator pre-filled with a skill received from a + /// `zed://skill` share link, so the user can review it and choose a scope + /// before installing. + pub fn install_shared_skill(&mut self, content: String, cx: &mut Context) { + self.open_skill_creator(SkillCreatorOpenMode::Install { content }, cx); + } + fn open_skill_creator(&mut self, open_mode: SkillCreatorOpenMode, cx: &mut Context) { let this = cx.weak_entity(); let on_saved: Rc = Rc::new(move |cx: &mut App| { diff --git a/crates/settings_ui/src/pages/skills_setup.rs b/crates/settings_ui/src/pages/skills_setup.rs index 0208f7db47f..8f9c7b16f30 100644 --- a/crates/settings_ui/src/pages/skills_setup.rs +++ b/crates/settings_ui/src/pages/skills_setup.rs @@ -1,6 +1,6 @@ -use agent_skills::{Skill, SkillIndex}; +use agent_skills::{Skill, SkillIndex, encode_skill_share_link}; use fs::RemoveOptions; -use gpui::{Action as _, ScrollHandle, SharedString, prelude::*}; +use gpui::{Action as _, ClipboardItem, ScrollHandle, SharedString, prelude::*}; use ui::{Divider, Tooltip, prelude::*}; use util::ResultExt as _; @@ -93,7 +93,8 @@ pub(crate) fn render_skills_setup_page( this.track_scroll(scroll_handle) .overflow_y_scroll() .children(skills.iter().enumerate().flat_map(|(i, skill)| { - let mut elements: Vec = vec![render_skill_row(skill, cx)]; + let mut elements: Vec = + vec![render_skill_row(skill, settings_window, cx)]; if i + 1 < skills.len() { elements.push(Divider::horizontal().into_any_element()); } @@ -104,10 +105,22 @@ pub(crate) fn render_skills_setup_page( .into_any_element() } -fn render_skill_row(skill: &Skill, cx: &mut Context) -> AnyElement { +fn render_skill_row( + skill: &Skill, + settings_window: &SettingsWindow, + cx: &mut Context, +) -> AnyElement { let skill_file_path = skill.skill_file_path.clone(); let directory_path = skill.directory_path.clone(); + let share_copied = settings_window.last_copied_skill_directory_path.as_deref() + == Some(skill.directory_path.as_path()); + let (share_icon, share_icon_color) = if share_copied { + (IconName::Check, Color::Success) + } else { + (IconName::Link, Color::Muted) + }; + h_flex() .w_full() .justify_between() @@ -128,6 +141,50 @@ fn render_skill_row(skill: &Skill, cx: &mut Context) -> AnyEleme .child( h_flex() .gap_2() + .child({ + let share_skill_file_path = skill.skill_file_path.clone(); + let share_directory_path = skill.directory_path.clone(); + IconButton::new( + SharedString::from(format!("share-{}", skill.name)), + share_icon, + ) + .tab_index(0_isize) + .icon_size(IconSize::Small) + .icon_color(share_icon_color) + .tooltip(Tooltip::text("Copy Share Link")) + .on_click(cx.listener( + move |_settings_window, _event, _window, cx| { + let skill_file_path = share_skill_file_path.clone(); + let directory_path = share_directory_path.clone(); + let app_state = workspace::AppState::global(cx); + let fs = app_state.fs.clone(); + cx.spawn(async move |settings_window, cx| { + match fs.load(&skill_file_path).await { + Ok(content) => { + let link = encode_skill_share_link(&content); + settings_window + .update(cx, |settings_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + link, + )); + settings_window.last_copied_skill_directory_path = + Some(directory_path.clone()); + cx.notify(); + }) + .ok(); + } + Err(error) => { + log::error!( + "failed to read skill file {} for sharing: {error:#}", + skill_file_path.display() + ); + } + } + }) + .detach(); + }, + )) + }) .child( IconButton::new( SharedString::from(format!("delete-{}", skill.name)), diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 2a49a95af2a..eae0e60166e 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -772,6 +772,9 @@ pub struct SettingsWindow { pub(crate) hidden_deleted_skill_directory_paths: HashSet, pub(crate) regex_validation_error: Option, last_copied_link_path: Option<&'static str>, + /// Directory path of the skill whose share link was most recently copied, + /// used to show a transient "copied" checkmark on its share button. + pub(crate) last_copied_skill_directory_path: Option, } struct SearchDocument { @@ -1718,6 +1721,7 @@ impl SettingsWindow { regex_validation_error: None, list_state, last_copied_link_path: None, + last_copied_skill_directory_path: None, }; this.fetch_files(window, cx); @@ -2323,6 +2327,10 @@ impl SettingsWindow { } fn open_navbar_entry_page(&mut self, navbar_entry: usize) { + // Navigating to another page dismisses the transient "copied share + // link" checkmark shown on a Skills page row. + self.last_copied_skill_directory_path = None; + if !self.is_nav_entry_visible(navbar_entry) { self.open_first_nav_page(); } @@ -4577,6 +4585,7 @@ pub mod test { hidden_deleted_skill_directory_paths: HashSet::default(), regex_validation_error: None, last_copied_link_path: None, + last_copied_skill_directory_path: None, } } } @@ -4704,6 +4713,7 @@ pub mod test { hidden_deleted_skill_directory_paths: HashSet::default(), regex_validation_error: None, last_copied_link_path: None, + last_copied_skill_directory_path: None, }; settings_window.build_filter_table(); diff --git a/crates/skill_creator/src/skill_creator.rs b/crates/skill_creator/src/skill_creator.rs index 3ed3ee49b33..ffbe28801ad 100644 --- a/crates/skill_creator/src/skill_creator.rs +++ b/crates/skill_creator/src/skill_creator.rs @@ -56,6 +56,13 @@ pub enum SkillCreatorOpenMode { Url { initial_url: Option, }, + /// Review and install a skill whose full `SKILL.md` contents are + /// supplied inline, e.g. from a `zed://skill` share link. The form is + /// pre-filled with the parsed skill so the recipient can review it and + /// pick a scope before saving. + Install { + content: String, + }, } #[derive(Clone, Debug)] @@ -557,6 +564,33 @@ impl SkillCreator { SkillCreatorOpenMode::Url { initial_url } => { self.open_url_import(initial_url, window, cx); } + SkillCreatorOpenMode::Install { content } => { + self.open_install_review(content, window, cx); + } + } + } + + /// Pre-fill the form with a skill supplied inline (from a share link) so + /// the recipient can review it before saving. Unlike URL import, this + /// doesn't touch the URL editor or perform any network request. + fn open_install_review( + &mut self, + content: String, + window: &mut Window, + cx: &mut Context, + ) { + self.url_import_debounce_task = None; + self.url_import_task = None; + self.url_import_status = UrlImportStatus::Idle; + + match parse_imported_skill(&content, "") { + Ok(imported) => self.apply_imported_skill(imported, window, cx), + Err(err) => { + self.save_error = Some(SharedString::from(format!( + "Couldn't read shared skill: {err}" + ))); + cx.notify(); + } } } @@ -651,44 +685,12 @@ impl SkillCreator { let fetch_task = cx.background_spawn(fetch_imported_skill_from_url(http_client, url)); let task = cx.spawn_in(window, async move |this, cx| { let result = fetch_task.await; - let skill_creator = this.clone(); this.update_in(cx, |this, window, cx| { this.url_import_debounce_task = None; this.url_import_task = None; match result { Ok(imported) => { - this.url_import_status = UrlImportStatus::Idle; - this.save_error = None; - - let name_editor = this.name_editor.clone(); - let description_editor = this.description_editor.clone(); - let body_editor = this.body_editor.clone(); - window.defer(cx, move |window, cx| { - name_editor.update(cx, |input, cx| { - input.set_text(&imported.name, window, cx); - }); - description_editor.update(cx, |input, cx| { - input.set_text(&imported.description, window, cx); - }); - body_editor.update(cx, |editor, cx| { - editor.set_text(imported.body.clone(), window, cx); - }); - skill_creator - .update(cx, |this, cx| { - this.disable_model_invocation = - imported.disable_model_invocation; - this.url_import_status = UrlImportStatus::Idle; - this.url_import_debounce_task = None; - this.url_import_task = None; - this.save_error = None; - this.recompute_name_error(cx); - this.recompute_description_error(cx); - this.recompute_body_error(cx); - cx.notify(); - }) - .log_err(); - window.focus(&name_editor.focus_handle(cx), cx); - }); + this.apply_imported_skill(imported, window, cx); } Err(err) => { this.url_import_status = @@ -703,6 +705,49 @@ impl SkillCreator { cx.notify(); } + /// Populate the form fields from a parsed skill (shared by URL import and + /// share-link install). Deferred so the programmatic `set_text` calls run + /// before focus moves to the name field. + fn apply_imported_skill( + &mut self, + imported: ImportedSkill, + window: &mut Window, + cx: &mut Context, + ) { + self.url_import_status = UrlImportStatus::Idle; + self.save_error = None; + + let name_editor = self.name_editor.clone(); + let description_editor = self.description_editor.clone(); + let body_editor = self.body_editor.clone(); + let skill_creator = cx.weak_entity(); + window.defer(cx, move |window, cx| { + name_editor.update(cx, |input, cx| { + input.set_text(&imported.name, window, cx); + }); + description_editor.update(cx, |input, cx| { + input.set_text(&imported.description, window, cx); + }); + body_editor.update(cx, |editor, cx| { + editor.set_text(imported.body.clone(), window, cx); + }); + skill_creator + .update(cx, |this, cx| { + this.disable_model_invocation = imported.disable_model_invocation; + this.url_import_status = UrlImportStatus::Idle; + this.url_import_debounce_task = None; + this.url_import_task = None; + this.save_error = None; + this.recompute_name_error(cx); + this.recompute_description_error(cx); + this.recompute_body_error(cx); + cx.notify(); + }) + .log_err(); + window.focus(&name_editor.focus_handle(cx), cx); + }); + } + fn save_skill(&mut self, _: &SaveSkill, window: &mut Window, cx: &mut Context) { // Surface any field-level errors before attempting to save. self.recompute_name_error(cx); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 7d0e9b46364..691f06719bb 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -68,6 +68,7 @@ activity_indicator.workspace = true agent.workspace = true agent-client-protocol.workspace = true agent_settings.workspace = true +agent_skills.workspace = true agent_ui = { workspace = true, features = ["audio"] } anyhow.workspace = true askpass.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index b2a738f9314..6a832da767c 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1157,6 +1157,28 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut }) .detach_and_log_err(cx); } + OpenRequestKind::InstallSkill { content } => { + cx.spawn(async move |cx| { + let multi_workspace = + workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; + + multi_workspace.update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + if let Some(panel) = workspace.focus_panel::(window, cx) { + panel.update(cx, |panel, cx| { + panel.install_shared_skill(content, cx); + }); + } else { + log::warn!( + "zed://skill received but the AgentPanel is not registered \ + (is `disable_ai` enabled?)" + ); + } + }); + }) + }) + .detach_and_log_err(cx); + } OpenRequestKind::DockMenuAction { index } => { cx.perform_dock_menu_action(index); } diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 4a4f5fca518..a68d827373e 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -62,6 +62,10 @@ pub enum OpenRequestKind { SharedAgentThread { session_id: String, }, + InstallSkill { + /// Full `SKILL.md` contents embedded in a `zed://skill` share link. + content: String, + }, DockMenuAction { index: usize, }, @@ -99,6 +103,10 @@ impl std::fmt::Debug for OpenRequestKind { .debug_struct("SharedAgentThread") .field("session_id", session_id) .finish(), + Self::InstallSkill { content } => f + .debug_struct("InstallSkill") + .field("content_len", &content.len()) + .finish(), Self::DockMenuAction { index } => f .debug_struct("DockMenuAction") .field("index", index) @@ -178,6 +186,8 @@ impl OpenRequest { } else { log::error!("Invalid session ID in URL: {}", session_id_str); } + } else if url.starts_with(agent_skills::SKILL_SHARE_LINK_PREFIX) { + this.parse_skill_install_url(&url)? } else if let Some(agent_path) = url.strip_prefix("zed://agent") { this.parse_agent_url(agent_path) } else if url == "zed://" || url == "zed://open" || url == "zed://open/" { @@ -237,6 +247,13 @@ impl OpenRequest { }); } + fn parse_skill_install_url(&mut self, url: &str) -> Result<()> { + // Format: zed://skill?data= + let content = agent_skills::decode_skill_share_link(url)?; + self.kind = Some(OpenRequestKind::InstallSkill { content }); + Ok(()) + } + fn parse_git_clone_url(&mut self, clone_path: &str) -> Result<()> { // Format: /?repo= or ?repo= let clone_path = clone_path.strip_prefix('/').unwrap_or(clone_path); @@ -1268,6 +1285,52 @@ mod tests { } } + #[gpui::test] + fn test_parse_skill_install_url(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let content = + "---\nname: my-skill\ndescription: Does a thing.\n---\n\nDo the thing.\n".to_string(); + let link = agent_skills::encode_skill_share_link(&content); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec![link], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind { + Some(OpenRequestKind::InstallSkill { + content: parsed_content, + }) => { + assert_eq!(parsed_content, content); + } + _ => panic!("Expected InstallSkill kind"), + } + } + + #[gpui::test] + fn test_parse_malformed_skill_install_url_errors(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let result = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://skill?data=!!!notbase64".into()], + ..Default::default() + }, + cx, + ) + }); + + assert!(result.is_err()); + } + fn agent_url_with_prompt(prompt: &str) -> String { let mut serializer = url::form_urlencoded::Serializer::new("zed://agent?".to_string()); serializer.append_pair("prompt", prompt); diff --git a/docs/src/ai/skills.md b/docs/src/ai/skills.md index 997e4e56f9f..39ee6844d24 100644 --- a/docs/src/ai/skills.md +++ b/docs/src/ai/skills.md @@ -39,11 +39,18 @@ The **User** tab shows your global skills. The **Project** tab shows skills for For each skill you can: +- **Copy Share Link** — copies a `zed://skill` link that embeds the skill, ready to send to someone else (see [Sharing Skills](#sharing-skills)) - **Open** — opens the skill's `SKILL.md` file in the editor - **Delete** — removes the skill folder from disk If no skills are installed, the page shows a **Create a Skill** button that opens the Skill Creator. +## Sharing Skills {#sharing-skills} + +You can hand a skill to a teammate without hosting it anywhere. In the Skills settings page, click the **link** icon on a skill row to copy a `zed://skill?data=…` link to your clipboard. The link is self-contained: it embeds the full `SKILL.md` contents (base64url-encoded), so the recipient doesn't need access to your project or any registry. + +When someone opens that link (for example by pasting it into their browser or clicking it in a chat), Zed launches the Skill Creator pre-filled with the shared skill. The recipient can review the name, description, and full body, choose a scope (global or project-local), and click **Save** to install it. Nothing is written to disk until they explicitly save, so a shared link can never silently install instructions into someone's agent. + ## Managing Skills {#managing-skills} Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) and navigate to **AI > Skills**, or go directly to [agent.skills](zed://settings/agent.skills). From e5f5767d2c44f6342357c5b73ef7cfe710ec9566 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 28 May 2026 23:47:05 -0400 Subject: [PATCH 63/93] Preserve terminal spinners in thread titles (#57983) Summary: - Preserve spinner/logo prefixes from live terminal titles when terminal threads have custom titles. - Store raw terminal titles and custom user titles separately, recomposing display titles on demand. - Keep spinner prefixes out of the title editor while preserving sidebar/search display behavior. Tests: - cargo test -p agent_ui test_terminal_custom_title_recomposes_with_live_spinner -- --nocapture - cargo test -p agent_ui test_terminal_title_editor_excludes_spinner_prefix -- --nocapture - cargo test -p sidebar test_agent_panel_terminals_appear_in_sidebar_and_search -- --nocapture Closes AI-304 Release Notes: - Fixed terminal thread titles to preserve animated spinner and logo prefixes after renaming. --- crates/agent_ui/src/agent_panel.rs | 277 +++++++++++++++--- .../src/terminal_thread_metadata_store.rs | 96 ++++++ crates/sidebar/src/sidebar.rs | 5 +- crates/sidebar/src/sidebar_tests.rs | 11 +- crates/sidebar/src/thread_switcher.rs | 2 +- 5 files changed, 348 insertions(+), 43 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 72ba53045e5..91b17a91a79 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -38,7 +38,10 @@ use crate::ExpandMessageEditor; use crate::ManageProfiles; use crate::agent_connection_store::AgentConnectionStore; use crate::completion_provider::AgentContextSource; -use crate::terminal_thread_metadata_store::{TerminalThreadMetadata, TerminalThreadMetadataStore}; +use crate::terminal_thread_metadata_store::{ + TerminalThreadMetadata, TerminalThreadMetadataStore, compose_terminal_thread_title, + terminal_title_without_prefix, +}; use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore, ThreadMetadataStoreEvent}; use crate::{ AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, @@ -870,6 +873,7 @@ struct AgentTerminal { title_editor_initial_title: Option, title_editor_subscription: Option, last_known_title: String, + last_known_terminal_title: String, last_observed_program: Option, working_directory: Option, created_at: DateTime, @@ -880,32 +884,58 @@ struct AgentTerminal { } impl AgentTerminal { - fn title(&self, cx: &App) -> SharedString { - let view = self.view.read(cx); - let title = if let Some(custom_title) = view.custom_title() { - SharedString::from(custom_title) - } else { - let terminal = view.terminal().read(cx); - if terminal.breadcrumb_text.is_empty() { - let title = terminal.title(true); - if title == "Terminal" { - SharedString::from("") - } else { - title.into() - } + fn terminal_title_for_view(view: &TerminalView, cx: &App) -> SharedString { + let terminal = view.terminal().read(cx); + if terminal.breadcrumb_text.is_empty() { + let title = terminal.title(true); + if title == "Terminal" { + SharedString::from("") } else { - terminal.breadcrumb_text.clone().into() + title.into() } - }; + } else { + terminal.breadcrumb_text.clone().into() + } + } - if title.is_empty() && !self.last_known_title.is_empty() { - SharedString::from(self.last_known_title.clone()) + fn current_terminal_title(&self, cx: &App) -> SharedString { + let view = self.view.read(cx); + Self::terminal_title_for_view(view, cx) + } + + fn terminal_title(&self, cx: &App) -> SharedString { + let title = self.current_terminal_title(cx); + if title.is_empty() && !self.last_known_terminal_title.is_empty() { + SharedString::from(self.last_known_terminal_title.clone()) } else { title } } + fn title(&self, cx: &App) -> SharedString { + let terminal_title = self.terminal_title(cx); + let custom_title = self.custom_title(cx); + compose_terminal_thread_title( + terminal_title.as_ref(), + custom_title.as_ref().map(|title| title.as_ref()), + ) + } + + fn editable_title(&self, cx: &App) -> SharedString { + if let Some(custom_title) = self.custom_title(cx) { + custom_title + } else { + let terminal_title = self.terminal_title(cx); + SharedString::from(terminal_title_without_prefix(terminal_title.as_ref()).to_string()) + } + } + fn refresh_title(&mut self, cx: &mut App) -> bool { + let terminal_title = self.current_terminal_title(cx); + if !terminal_title.is_empty() { + self.last_known_terminal_title = terminal_title.to_string(); + } + let title = self.title(cx); let changed = self.last_known_title != title.as_ref(); if changed { @@ -1981,14 +2011,16 @@ impl AgentPanel { }, ); + let last_known_terminal_title = initial_title + .map(|title| title.to_string()) + .unwrap_or_default(); let mut terminal = AgentTerminal { view: terminal_view, title_editor: None, title_editor_initial_title: None, title_editor_subscription: None, - last_known_title: initial_title - .map(|title| title.to_string()) - .unwrap_or_default(), + last_known_title: last_known_terminal_title.clone(), + last_known_terminal_title, last_observed_program: None, working_directory, created_at: created_at.unwrap_or_else(Utc::now), @@ -2164,7 +2196,7 @@ impl AgentPanel { let project = self.project.read(cx); Some(TerminalThreadMetadata { terminal_id, - title: terminal.title(cx), + title: terminal.terminal_title(cx), custom_title: terminal.custom_title(cx), created_at: terminal.created_at, worktree_paths: project.worktree_paths(cx), @@ -2242,10 +2274,7 @@ impl AgentPanel { } fn terminal_restore_initial_title(metadata: &TerminalThreadMetadata) -> Option { - metadata - .custom_title - .clone() - .or_else(|| (!metadata.title.is_empty()).then(|| metadata.title.clone())) + (!metadata.title.is_empty()).then(|| metadata.title.clone()) } fn edit_terminal_title( @@ -2263,7 +2292,7 @@ impl AgentPanel { return; } - let title = terminal.title(cx).to_string(); + let title = terminal.editable_title(cx).to_string(); let title_editor_initial_title = title.clone(); let title_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); @@ -2331,7 +2360,7 @@ impl AgentPanel { if !title_editor.read(cx).is_focused(window) { return; } - let Some((terminal_view, initial_title)) = + let Some((terminal_view, initial_title, terminal_title)) = self.terminals.get(&terminal_id).and_then(|terminal| { terminal .title_editor @@ -2341,25 +2370,23 @@ impl AgentPanel { ( terminal.view.clone(), terminal.title_editor_initial_title.clone(), + terminal.terminal_title(cx), ) }) }) else { return; }; - let new_title = title_editor.read(cx).text(cx).trim().to_string(); - if initial_title.as_deref().map(str::trim) == Some(new_title.as_str()) { + let new_title = title_editor.read(cx).text(cx); + if initial_title.as_deref() == Some(new_title.as_str()) { return; } - let label = if new_title.is_empty() { + let label = if new_title.trim().is_empty() + || new_title == terminal_title_without_prefix(terminal_title.as_ref()) + { None } else { - let terminal_title = terminal_view.read(cx).terminal().read(cx).title(true); - if new_title == terminal_title { - None - } else { - Some(new_title) - } + Some(new_title) }; cx.defer(move |cx| { @@ -9008,6 +9035,182 @@ mod tests { }); } + #[gpui::test] + async fn test_terminal_custom_title_recomposes_with_live_spinner(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Fix bug", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + let terminal_entity = panel.read_with(&cx, |panel, _cx| { + panel + .terminals + .get(&terminal_id) + .expect("terminal should remain in the panel") + .view + .clone() + }); + let terminal_entity = + terminal_entity.read_with(&cx, |terminal_view, _cx| terminal_view.terminal().clone()); + + terminal_entity.update(&mut cx, |terminal, cx| { + terminal.breadcrumb_text = "⠋ Thinking".to_string(); + cx.emit(TerminalEvent::BreadcrumbsChanged); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let terminals = panel.terminals(cx); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].title.as_ref(), "⠋ Fix bug"); + let metadata = panel + .terminal_metadata(terminal_id, cx) + .expect("terminal metadata should be available"); + assert_eq!(metadata.title.as_ref(), "⠋ Thinking"); + assert_eq!( + metadata.custom_title.as_ref().map(|title| title.as_ref()), + Some("Fix bug") + ); + assert_eq!(metadata.display_title().as_ref(), "⠋ Fix bug"); + }); + + terminal_entity.update(&mut cx, |terminal, cx| { + terminal.breadcrumb_text = "⠙ Thinking".to_string(); + cx.emit(TerminalEvent::BreadcrumbsChanged); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let terminals = panel.terminals(cx); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].title.as_ref(), "⠙ Fix bug"); + let metadata = panel + .terminal_metadata(terminal_id, cx) + .expect("terminal metadata should be available"); + assert_eq!(metadata.title.as_ref(), "⠙ Thinking"); + assert_eq!(metadata.display_title().as_ref(), "⠙ Fix bug"); + }); + + terminal_entity.update(&mut cx, |terminal, cx| { + terminal.breadcrumb_text = "Thinking".to_string(); + cx.emit(TerminalEvent::BreadcrumbsChanged); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let terminals = panel.terminals(cx); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].title.as_ref(), "Fix bug"); + let metadata = panel + .terminal_metadata(terminal_id, cx) + .expect("terminal metadata should be available"); + assert_eq!(metadata.title.as_ref(), "Thinking"); + assert_eq!(metadata.display_title().as_ref(), "Fix bug"); + }); + } + + #[gpui::test] + async fn test_terminal_title_editor_excludes_spinner_prefix(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Initial Custom Title", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + let terminal_view = panel.read_with(&cx, |panel, _cx| { + panel + .terminals + .get(&terminal_id) + .expect("terminal should remain in the panel") + .view + .clone() + }); + terminal_view.update(&mut cx, |terminal_view, cx| { + terminal_view.set_custom_title(None, cx); + }); + let terminal_entity = + terminal_view.read_with(&cx, |terminal_view, _cx| terminal_view.terminal().clone()); + terminal_entity.update(&mut cx, |terminal, cx| { + terminal.breadcrumb_text = "⠋ Thinking".to_string(); + cx.emit(TerminalEvent::BreadcrumbsChanged); + }); + cx.run_until_parked(); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.edit_terminal_title(terminal_id, window, cx); + }); + cx.run_until_parked(); + + let title_editor = panel.read_with(&cx, |panel, cx| { + let terminal = panel + .terminals + .get(&terminal_id) + .expect("terminal should remain in the panel"); + let title_editor = terminal + .title_editor + .as_ref() + .expect("terminal title editor should be active while editing") + .clone(); + assert_eq!(title_editor.read(cx).text(cx), "Thinking"); + title_editor + }); + + title_editor.update_in(&mut cx, |editor, window, cx| { + editor.set_text("Fix bug", window, cx); + editor.focus_handle(cx).focus(window, cx); + }); + panel.update_in(&mut cx, |panel, window, cx| { + panel.handle_terminal_title_editor_event( + terminal_id, + &title_editor, + &editor::EditorEvent::BufferEdited, + window, + cx, + ); + }); + cx.run_until_parked(); + + terminal_view.read_with(&cx, |terminal_view, _cx| { + assert_eq!(terminal_view.custom_title(), Some("Fix bug")); + }); + panel.read_with(&cx, |panel, cx| { + let terminals = panel.terminals(cx); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].title.as_ref(), "⠋ Fix bug"); + let metadata = panel + .terminal_metadata(terminal_id, cx) + .expect("terminal metadata should be available"); + assert_eq!(metadata.title.as_ref(), "⠋ Thinking"); + assert_eq!( + metadata.custom_title.as_ref().map(|title| title.as_ref()), + Some("Fix bug") + ); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.stop_editing_terminal_title(terminal_id, false, window, cx); + panel.edit_terminal_title(terminal_id, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let terminal = panel + .terminals + .get(&terminal_id) + .expect("terminal should remain in the panel"); + let title_editor = terminal + .title_editor + .as_ref() + .expect("terminal title editor should be active while editing"); + assert_eq!(title_editor.read(cx).text(cx), "Fix bug"); + }); + } + #[gpui::test] async fn test_terminal_bell_marks_and_activation_clears_notification(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; diff --git a/crates/agent_ui/src/terminal_thread_metadata_store.rs b/crates/agent_ui/src/terminal_thread_metadata_store.rs index c5e2dbbdfba..6baedca4289 100644 --- a/crates/agent_ui/src/terminal_thread_metadata_store.rs +++ b/crates/agent_ui/src/terminal_thread_metadata_store.rs @@ -63,6 +63,76 @@ impl TerminalThreadMetadata { pub fn main_worktree_paths(&self) -> &PathList { self.worktree_paths.main_worktree_path_list() } + + pub fn display_title(&self) -> SharedString { + compose_terminal_thread_title( + self.title.as_ref(), + self.custom_title.as_ref().map(|title| title.as_ref()), + ) + } +} + +pub(crate) fn compose_terminal_thread_title( + terminal_title: &str, + custom_title: Option<&str>, +) -> SharedString { + let Some(custom_title) = custom_title.filter(|title| !title.trim().is_empty()) else { + return SharedString::from(terminal_title.to_string()); + }; + + if let Some(prefix) = terminal_title_prefix(terminal_title) { + SharedString::from(format!("{prefix}{custom_title}")) + } else { + SharedString::from(custom_title.to_string()) + } +} + +pub(crate) fn terminal_title_without_prefix(title: &str) -> &str { + terminal_title_prefix(title) + .map(|prefix| &title[prefix.len()..]) + .unwrap_or(title) +} + +fn terminal_title_prefix(title: &str) -> Option<&str> { + let mut prefix_byte_len = 0; + let mut saw_prefix_character = false; + let mut saw_whitespace_after_prefix = false; + + let mut chars = title.chars().peekable(); + while let Some(character) = chars.next() { + if character.is_alphanumeric() { + return None; + } + + if character.is_whitespace() { + if !saw_prefix_character { + return None; + } + + prefix_byte_len += character.len_utf8(); + saw_whitespace_after_prefix = true; + + while let Some(character) = chars.peek() { + if !character.is_whitespace() { + break; + } + + prefix_byte_len += character.len_utf8(); + chars.next(); + } + + break; + } + + saw_prefix_character = true; + prefix_byte_len += character.len_utf8(); + } + + if saw_whitespace_after_prefix { + Some(&title[..prefix_byte_len]) + } else { + None + } } pub struct TerminalThreadMetadataStore { @@ -563,6 +633,32 @@ mod tests { } } + #[test] + fn test_terminal_title_prefix_preserves_non_alphanumeric_prefixes() { + assert_eq!(terminal_title_prefix("✳ Thinking"), Some("✳ ")); + assert_eq!(terminal_title_prefix(">>> Thinking"), Some(">>> ")); + assert_eq!(terminal_title_prefix("⠋ Running"), Some("⠋ ")); + assert_eq!(terminal_title_prefix("* Claude"), Some("* ")); + assert_eq!(terminal_title_prefix("✳Thinking"), None); + assert_eq!(terminal_title_prefix("Thinking"), None); + assert_eq!(terminal_title_prefix(" Thinking"), None); + assert_eq!(terminal_title_prefix("✳"), None); + assert_eq!(terminal_title_prefix("v1 Running"), None); + } + + #[test] + fn test_terminal_thread_display_title_combines_raw_and_custom_titles() { + let mut metadata = metadata( + "⠋ Thinking", + WorktreePaths::from_folder_paths(&PathList::default()), + ); + metadata.custom_title = Some("Fix bug".into()); + assert_eq!(metadata.display_title().as_ref(), "⠋ Fix bug"); + + metadata.title = "Thinking".into(); + assert_eq!(metadata.display_title().as_ref(), "Fix bug"); + } + #[gpui::test] async fn test_change_worktree_paths_reindexes_terminal_metadata(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 81d65e63366..0a83af342ff 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -1735,7 +1735,8 @@ impl Sidebar { let mut matched_terminals: Vec = Vec::new(); for mut terminal in terminals { let mut terminal_matched = false; - if let Some(positions) = fuzzy_match_positions(&query, &terminal.metadata.title) + let terminal_title = terminal.metadata.display_title(); + if let Some(positions) = fuzzy_match_positions(&query, terminal_title.as_ref()) { terminal.highlight_positions = positions; terminal_matched = true; @@ -5858,7 +5859,7 @@ impl Sidebar { ); let is_remote = terminal.workspace.is_remote(cx); - ThreadItem::new(id, terminal.metadata.title.clone()) + ThreadItem::new(id, terminal.metadata.display_title()) .base_bg(sidebar_bg) .icon(IconName::Terminal) .is_remote(is_remote) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 40cfc62fa39..c1a06095333 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -595,7 +595,7 @@ fn visible_entries_as_strings( } } ListEntry::Terminal(terminal) => { - let title = &terminal.metadata.title; + let title = terminal.metadata.display_title(); let worktree = format_linked_worktree_chips(&terminal.worktrees); format!(" {title}{worktree}{selected}") } @@ -1712,7 +1712,7 @@ async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAp ); assert!( sidebar.contents.entries.iter().any(|entry| { - matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id && terminal.metadata.title.as_ref() == "Dev Server") + matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id && terminal.metadata.display_title().as_ref() == "Dev Server") }), "expected the inserted terminal to appear in sidebar contents", ); @@ -1722,7 +1722,12 @@ async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAp let metadata = store .entry(terminal_id) .expect("terminal metadata should be persisted"); - assert_eq!(metadata.title.as_ref(), "Dev Server"); + assert_eq!(metadata.title.as_ref(), ""); + assert_eq!( + metadata.custom_title.as_ref().map(|title| title.as_ref()), + Some("Dev Server") + ); + assert_eq!(metadata.display_title().as_ref(), "Dev Server"); assert!( metadata .folder_paths() diff --git a/crates/sidebar/src/thread_switcher.rs b/crates/sidebar/src/thread_switcher.rs index e2f9cddd747..aa947416082 100644 --- a/crates/sidebar/src/thread_switcher.rs +++ b/crates/sidebar/src/thread_switcher.rs @@ -89,7 +89,7 @@ impl ThreadSwitcherEntry { fn title(&self) -> SharedString { match self { Self::Thread(entry) => entry.title.clone(), - Self::Terminal(entry) => entry.metadata.title.clone(), + Self::Terminal(entry) => entry.metadata.display_title(), } } From d3070bbc0d3e48bb27595f08b9051c80e6e63d15 Mon Sep 17 00:00:00 2001 From: Yauhen Date: Fri, 29 May 2026 07:12:28 +0200 Subject: [PATCH 64/93] dev_container: Respect runServices for Docker Compose (#56293) This PR fixes Docker Compose dev containers starting every service in the compose project, even when `devcontainer.json` specifies `runServices`. Previously, Zed deserialized `runServices` but did not use it when invoking Docker Compose. The startup command was: ```sh docker compose ... up -d ``` With no service operands, Compose starts every enabled service in the project. This means unrelated services are started even when the devcontainer config asks to run only the primary service and its dependencies. The fix propagates `runServices` into the Docker Compose build/start path so Zed invokes Compose with the requested services: ```sh docker compose ... up -d devcontainer ``` Compose will still start services required by `depends_on`, but unrelated services are left untouched. **Reproduction** `.devcontainer/devcontainer.json`: ```json { "name": "Run Services", "dockerComposeFile": "../compose.yml", "service": "devcontainer", "runServices": ["devcontainer"], "workspaceFolder": "/workspace" } ``` `compose.yml`: ```yaml services: devcontainer: image: ubuntu:24.04 command: sleep infinity volumes: - .:/workspace depends_on: - database database: image: postgres:16-alpine environment: POSTGRES_PASSWORD: postgres unrelated: image: nginx:alpine ``` **Expected**: Zed starts `devcontainer` and `database`. **Before this fix**: Zed also starts `unrelated`. **After this fix**: `unrelated` remains stopped. Closes: https://github.com/zed-industries/zed/issues/57279 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed Docker Compose dev containers starting services not listed in `runServices`. --- crates/dev_container/src/devcontainer_json.rs | 2 +- .../src/devcontainer_manifest.rs | 134 +++++++++++++++++- crates/dev_container/src/docker.rs | 5 + 3 files changed, 138 insertions(+), 3 deletions(-) diff --git a/crates/dev_container/src/devcontainer_json.rs b/crates/dev_container/src/devcontainer_json.rs index 6033ca29f0b..c8573b6b9c8 100644 --- a/crates/dev_container/src/devcontainer_json.rs +++ b/crates/dev_container/src/devcontainer_json.rs @@ -232,7 +232,7 @@ pub(crate) struct DevContainer { #[serde(default, deserialize_with = "deserialize_string_or_array")] pub(crate) docker_compose_file: Option>, pub(crate) service: Option, - run_services: Option>, + pub(crate) run_services: Option>, pub(crate) initialize_command: Option, pub(crate) on_create_command: Option, pub(crate) update_content_command: Option, diff --git a/crates/dev_container/src/devcontainer_manifest.rs b/crates/dev_container/src/devcontainer_manifest.rs index cc8fbedb14d..0c38538657d 100644 --- a/crates/dev_container/src/devcontainer_manifest.rs +++ b/crates/dev_container/src/devcontainer_manifest.rs @@ -1058,7 +1058,11 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true let project_name = self.project_name().await?; self.docker_client - .docker_compose_build(&docker_compose_resources.files, &project_name) + .docker_compose_build( + &docker_compose_resources.files, + &project_name, + dev_container.run_services.as_ref(), + ) .await?; ( self.docker_client @@ -1151,7 +1155,11 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true let project_name = self.project_name().await?; self.docker_client - .docker_compose_build(&docker_compose_resources.files, &project_name) + .docker_compose_build( + &docker_compose_resources.files, + &project_name, + dev_container.run_services.as_ref(), + ) .await?; ( @@ -1785,6 +1793,9 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true command.args(&["-f", &docker_compose_file.display().to_string()]); } command.args(&["up", "-d"]); + if let Some(run_services) = self.dev_container().run_services.as_ref() { + command.args(run_services); + } let output = self .command_runner @@ -4783,6 +4794,111 @@ ENV DOCKER_BUILDKIT=1 ); } + #[gpui::test] + async fn test_spawns_only_requested_compose_services(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + env_logger::try_init().ok(); + let given_devcontainer_contents = r#" + { + "name": "Devcontainer and PostgreSQL", + "dockerComposeFile": "docker-compose.yml", + "service": "devcontainer", + "runServices": ["devcontainer", "db"], + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "updateRemoteUserUID": false + } + "#; + let (test_dependencies, mut devcontainer_manifest) = + init_default_devcontainer_manifest(cx, given_devcontainer_contents) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"), + r#" +version: '3.8' + +x-base: &base + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + +volumes: + postgres-data: + +services: + app: + <<: *base + ports: + - "3000:3000" + + devcontainer: + <<: *base + ports: + - "3000:3000" + volumes: + - ../..:/workspaces:cached + + db: + image: postgres:14.1 + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + env_file: + - .env + "# + .trim() + .to_string(), + ) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"), + r#" +FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm + +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +&& apt-get -y install clang lld \ +&& apt-get autoremove -y && apt-get clean -y + "# + .trim() + .to_string(), + ) + .await + .unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap(); + + let docker_commands = test_dependencies + .command_runner + .commands_by_program("docker"); + let compose_up = docker_commands + .iter() + .find(|c| { + c.args.first().map(String::as_str) == Some("compose") + && c.args.iter().any(|a| a == "up") + }) + .expect("docker compose up command recorded"); + assert!( + compose_up.args.ends_with(&[ + "up".to_string(), + "-d".to_string(), + "devcontainer".to_string(), + "db".to_string(), + ]), + "compose up should target only the requested service, got: {:?}", + compose_up.args + ); + } + #[cfg(not(target_os = "windows"))] #[gpui::test] async fn test_spawns_devcontainer_with_docker_compose_and_podman(cx: &mut TestAppContext) { @@ -6067,6 +6183,19 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de return Ok(Some(DockerComposeConfig { name: None, services: HashMap::from([ + ( + "devcontainer".to_string(), + DockerComposeService { + image: Some("test_image:latest".to_string()), + volumes: vec![MountDefinition { + source: Some("../..".to_string()), + target: "/workspaces".to_string(), + mount_type: Some("bind".to_string()), + }], + command: vec!["sleep".to_string(), "infinity".to_string()], + ..Default::default() + }, + ), ( "app".to_string(), DockerComposeService { @@ -6193,6 +6322,7 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de &self, _config_files: &Vec, _project_name: &str, + _services: Option<&Vec>, ) -> Result<(), DevContainerError> { Ok(()) } diff --git a/crates/dev_container/src/docker.rs b/crates/dev_container/src/docker.rs index cccbb4373ed..6a583681ac7 100644 --- a/crates/dev_container/src/docker.rs +++ b/crates/dev_container/src/docker.rs @@ -291,6 +291,7 @@ impl DockerClient for Docker { &self, config_files: &Vec, project_name: &str, + services: Option<&Vec>, ) -> Result<(), DevContainerError> { let mut command = Command::new(&self.docker_cli); if !self.is_podman() { @@ -301,6 +302,9 @@ impl DockerClient for Docker { command.args(&["-f", &docker_compose_file.display().to_string()]); } command.arg("build"); + if let Some(services) = services { + command.args(services); + } let output = command.output().await.map_err(|e| { log::error!("Error running docker compose up: {e}"); @@ -457,6 +461,7 @@ pub(crate) trait DockerClient { &self, config_files: &Vec, project_name: &str, + services: Option<&Vec>, ) -> Result<(), DevContainerError>; async fn run_docker_exec( &self, From 486e5f7cddac4e8a2137bc3283a8a37f8a0cff62 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 29 May 2026 07:56:42 +0200 Subject: [PATCH 65/93] ci: Remove garnix substitutor (#58033) Release Notes: - N/A --- flake.nix | 2 -- 1 file changed, 2 deletions(-) diff --git a/flake.nix b/flake.nix index 3a9744fe1ee..1902a3ee434 100644 --- a/flake.nix +++ b/flake.nix @@ -31,11 +31,9 @@ nixConfig = { extra-substituters = [ "https://zed.cachix.org" - "https://cache.garnix.io" ]; extra-trusted-public-keys = [ "zed.cachix.org-1:/pHQ6dpMsAZk2DiP4WCL0p9YDNKWj2Q5FL20bNmw1cU=" - "cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g=" ]; }; } From aeb5d6d7ff97547b3da0e52edef241c4f3b09293 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 29 May 2026 08:01:22 +0200 Subject: [PATCH 66/93] ci: Reinstate run-nix label in addition to run-bundling (#58034) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A --- .github/workflows/nix_build.yml | 97 +++++++++++++++++++ .github/workflows/run_bundling.yml | 79 --------------- tooling/xtask/src/tasks/workflows.rs | 1 + .../xtask/src/tasks/workflows/nix_build.rs | 60 +++++++++++- .../xtask/src/tasks/workflows/run_bundling.rs | 27 +----- 5 files changed, 158 insertions(+), 106 deletions(-) create mode 100644 .github/workflows/nix_build.yml diff --git a/.github/workflows/nix_build.yml b/.github/workflows/nix_build.yml new file mode 100644 index 00000000000..f658634c06c --- /dev/null +++ b/.github/workflows/nix_build.yml @@ -0,0 +1,97 @@ +# Generated from xtask::workflows::nix_build +# Rebuild with `cargo xtask workflows`. +name: nix_build +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: '1' +on: + pull_request: + types: + - labeled + - synchronize +jobs: + build_nix_linux_x86_64: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && (github.event.label.name == 'run-nix' || github.event.label.name == 'run-bundling')) || (github.event.action == 'synchronize' && (contains(github.event.pull_request.labels.*.name, 'run-nix') || contains(github.event.pull_request.labels.*.name, 'run-bundling')))) + runs-on: namespace-profile-32x64-ubuntu-2004 + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' + steps: + - name: steps::checkout_repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + clean: false + - name: steps::cache_nix_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 + with: + cache: nix + - name: nix_build::build_nix::install_nix + uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - name: nix_build::build_nix::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + pushFilter: -zed-editor-[0-9.]* + - name: nix_build::build_nix::build + run: nix build .#default -L --accept-flake-config + timeout-minutes: 60 + continue-on-error: true + build_nix_mac_aarch64: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && (github.event.label.name == 'run-nix' || github.event.label.name == 'run-bundling')) || (github.event.action == 'synchronize' && (contains(github.event.pull_request.labels.*.name, 'run-nix') || contains(github.event.pull_request.labels.*.name, 'run-bundling')))) + runs-on: namespace-profile-mac-large + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' + steps: + - name: steps::checkout_repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + clean: false + - name: steps::cache_nix_store_macos + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 + with: + path: ~/nix-cache + - name: nix_build::build_nix::install_nix + uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - name: nix_build::build_nix::configure_local_nix_cache + run: | + mkdir -p ~/nix-cache + echo "extra-substituters = file://$HOME/nix-cache?priority=10" | sudo tee -a /etc/nix/nix.conf + echo "require-sigs = false" | sudo tee -a /etc/nix/nix.conf + sudo launchctl kickstart -k system/org.nixos.nix-daemon + - name: nix_build::build_nix::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + pushFilter: -zed-editor-[0-9.]* + - name: nix_build::build_nix::build + run: nix build .#default -L --accept-flake-config + - name: nix_build::build_nix::export_to_local_nix_cache + if: always() + run: | + if [ -L result ]; then + echo "Copying build closure to local binary cache..." + nix copy --to "file://$HOME/nix-cache" ./result || echo "Warning: nix copy to local cache failed" + else + echo "No build result found, skipping cache export." + fi + timeout-minutes: 60 + continue-on-error: true +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true +defaults: + run: + shell: bash -euxo pipefail {0} diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index bc7408f3a21..3d3d7a7ac51 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -264,85 +264,6 @@ jobs: path: target/zed-remote-server-windows-x86_64.zip if-no-files-found: error timeout-minutes: 60 - build_nix_linux_x86_64: - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling'))) - runs-on: namespace-profile-32x64-ubuntu-2004 - env: - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} - ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} - GIT_LFS_SKIP_SMUDGE: '1' - steps: - - name: steps::checkout_repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - with: - clean: false - - name: steps::cache_nix_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 - with: - cache: nix - - name: nix_build::build_nix::install_nix - uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f - with: - github_access_token: ${{ secrets.GITHUB_TOKEN }} - - name: nix_build::build_nix::cachix_action - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad - with: - name: zed - authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - cachixArgs: -v - pushFilter: -zed-editor-[0-9.]* - - name: nix_build::build_nix::build - run: nix build .#default -L --accept-flake-config - timeout-minutes: 60 - continue-on-error: true - build_nix_mac_aarch64: - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling'))) - runs-on: namespace-profile-mac-large - env: - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} - ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} - GIT_LFS_SKIP_SMUDGE: '1' - steps: - - name: steps::checkout_repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - with: - clean: false - - name: steps::cache_nix_store_macos - uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 - with: - path: ~/nix-cache - - name: nix_build::build_nix::install_nix - uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f - with: - github_access_token: ${{ secrets.GITHUB_TOKEN }} - - name: nix_build::build_nix::configure_local_nix_cache - run: | - mkdir -p ~/nix-cache - echo "extra-substituters = file://$HOME/nix-cache?priority=10" | sudo tee -a /etc/nix/nix.conf - echo "require-sigs = false" | sudo tee -a /etc/nix/nix.conf - sudo launchctl kickstart -k system/org.nixos.nix-daemon - - name: nix_build::build_nix::cachix_action - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad - with: - name: zed - authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - cachixArgs: -v - pushFilter: -zed-editor-[0-9.]* - - name: nix_build::build_nix::build - run: nix build .#default -L --accept-flake-config - - name: nix_build::build_nix::export_to_local_nix_cache - if: always() - run: | - if [ -L result ]; then - echo "Copying build closure to local binary cache..." - nix copy --to "file://$HOME/nix-cache" ./result || echo "Warning: nix copy to local cache failed" - else - echo "No build result found, skipping cache export." - fi - timeout-minutes: 60 - continue-on-error: true concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 1043c1fc009..6f21be93907 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -237,6 +237,7 @@ pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> { WorkflowFile::zed(extension_auto_bump::extension_auto_bump), WorkflowFile::zed(extension_tests::extension_tests), WorkflowFile::zed(extension_workflow_rollout::extension_workflow_rollout), + WorkflowFile::zed(nix_build::nix_build), WorkflowFile::zed(publish_extension_cli::publish_extension_cli), WorkflowFile::zed(release::release), WorkflowFile::zed(release_nightly::release_nightly), diff --git a/tooling/xtask/src/tasks/workflows/nix_build.rs b/tooling/xtask/src/tasks/workflows/nix_build.rs index 9e401ccac08..cc04ec5b855 100644 --- a/tooling/xtask/src/tasks/workflows/nix_build.rs +++ b/tooling/xtask/src/tasks/workflows/nix_build.rs @@ -1,11 +1,69 @@ use crate::tasks::workflows::{ runners::{Arch, Platform}, - steps::{CommonJobConditions, NamedJob}, + steps::{CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob}, }; use super::{runners, steps, steps::named, vars}; use gh_workflow::*; +/// Generates the nix_build.yml workflow, which builds the Nix package on PRs +/// that carry the `run-nix` or `run-bundling` label. The Nix jobs live only +/// here (not in run_bundling.yml) so that setting both labels doesn't build +/// them twice. +pub fn nix_build() -> Workflow { + let [nix_linux_x86_64, nix_mac_aarch64] = nix_pr_jobs(&["run-nix", "run-bundling"]); + named::workflow() + .on(Event::default().pull_request( + PullRequest::default().types([PullRequestType::Labeled, PullRequestType::Synchronize]), + )) + .concurrency( + Concurrency::new(Expression::new( + "${{ github.workflow }}-${{ github.head_ref || github.ref }}", + )) + .cancel_in_progress(true), + ) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", "1")) + .add_job(nix_linux_x86_64.name, nix_linux_x86_64.job) + .add_job(nix_mac_aarch64.name, nix_mac_aarch64.job) +} + +/// Builds the pair of PR Nix jobs (Linux x86_64 + macOS aarch64), each gated so +/// they run when any of the given PR `labels` is present (on +/// `labeled`/`synchronize` events). +fn nix_pr_jobs(labels: &[&str]) -> [NamedJob; 2] { + let labeled = labels + .iter() + .map(|label| format!("github.event.label.name == '{label}'")) + .collect::>() + .join(" || "); + let synchronized = labels + .iter() + .map(|label| format!("contains(github.event.pull_request.labels.*.name, '{label}')")) + .collect::>() + .join(" || "); + [ + (Platform::Linux, Arch::X86_64), + (Platform::Mac, Arch::AARCH64), + ] + .map(|(platform, arch)| { + let mut job = build_nix( + platform, + arch, + "default", + // don't push PR builds to the cache + Some("-zed-editor-[0-9.]*"), + &[], + ); + job.job = job.job.cond(Expression::new(format!( + "{DEFAULT_REPOSITORY_OWNER_GUARD} && \ + ((github.event.action == 'labeled' && ({labeled})) || \ + (github.event.action == 'synchronize' && ({synchronized})))" + ))); + job + }) +} + pub(crate) fn build_nix( platform: Platform, arch: Arch, diff --git a/tooling/xtask/src/tasks/workflows/run_bundling.rs b/tooling/xtask/src/tasks/workflows/run_bundling.rs index 5fa6b870b02..7546e93c418 100644 --- a/tooling/xtask/src/tasks/workflows/run_bundling.rs +++ b/tooling/xtask/src/tasks/workflows/run_bundling.rs @@ -1,13 +1,9 @@ use std::path::Path; use crate::tasks::workflows::{ - nix_build::build_nix, release::ReleaseBundleJobs, runners::{Arch, Platform, ReleaseChannel}, - steps::{ - DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, IfNoFilesFound, NamedJob, - UploadArtifactStep, dependant_job, named, - }, + steps::{FluentBuilder, IfNoFilesFound, NamedJob, UploadArtifactStep, dependant_job, named}, vars::{assets, bundle_envs}, }; @@ -24,8 +20,6 @@ pub fn run_bundling() -> Workflow { windows_aarch64: bundle_windows(Arch::AARCH64, None, &[]), windows_x86_64: bundle_windows(Arch::X86_64, None, &[]), }; - let nix_linux_x86_64 = nix_job(Platform::Linux, Arch::X86_64); - let nix_mac_aarch64 = nix_job(Platform::Mac, Arch::AARCH64); named::workflow() .on(Event::default().pull_request( PullRequest::default().types([PullRequestType::Labeled, PullRequestType::Synchronize]), @@ -44,25 +38,6 @@ pub fn run_bundling() -> Workflow { } workflow }) - .add_job(nix_linux_x86_64.name, nix_linux_x86_64.job) - .add_job(nix_mac_aarch64.name, nix_mac_aarch64.job) -} - -fn nix_job(platform: Platform, arch: Arch) -> NamedJob { - let mut job = build_nix( - platform, - arch, - "default", - // don't push PR builds to the cache - Some("-zed-editor-[0-9.]*"), - &[], - ); - job.job = job.job.cond(Expression::new(format!( - "{} && ((github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || \ - (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')))", - DEFAULT_REPOSITORY_OWNER_GUARD - ))); - job } fn bundle_job(deps: &[&NamedJob]) -> Job { From e4b81180c10b76ee7fe14495cf89d8c5a140dba3 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 29 May 2026 09:06:57 +0200 Subject: [PATCH 67/93] component_preview: Clean up `Component` trait (#57731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This primarily - requires components to have a description as well as a preview (especially having no preview makes no sense) - implements some basic previews where missing - adds a scrollbar to the preview navigation with a sadly large diff due to reformatting (less indentation 🎉 ), but very little changes at its core. Release Notes: - N/A --- crates/agent_ui/src/ui/end_trial_upsell.rs | 19 +- crates/ai_onboarding/src/ai_onboarding.rs | 78 ++- crates/component/src/component.rs | 42 +- .../src/component_preview.rs | 151 ++--- crates/git_ui/src/git_panel.rs | 347 +++++----- crates/git_ui/src/git_ui.rs | 33 +- crates/language_models/src/provider/cloud.rs | 184 ++--- crates/notifications/src/status_toast.rs | 75 +-- crates/onboarding/src/theme_preview.rs | 82 ++- .../src/components/number_field.rs | 64 +- .../src/components/ai/agent_setup_button.rs | 17 +- .../ui/src/components/ai/ai_setting_item.rs | 9 +- .../src/components/ai/configured_api_card.rs | 9 +- crates/ui/src/components/ai/thread_item.rs | 15 +- crates/ui/src/components/avatar.rs | 12 +- crates/ui/src/components/banner.rs | 15 +- crates/ui/src/components/button/button.rs | 244 ++++--- .../ui/src/components/button/button_like.rs | 158 +++-- .../ui/src/components/button/button_link.rs | 28 +- .../ui/src/components/button/copy_button.rs | 8 +- .../ui/src/components/button/icon_button.rs | 324 ++++----- .../ui/src/components/button/toggle_button.rs | 573 +++++++--------- crates/ui/src/components/callout.rs | 23 +- crates/ui/src/components/chip.rs | 9 +- .../components/collab/collab_notification.rs | 21 +- .../ui/src/components/collab/update_button.rs | 75 +-- crates/ui/src/components/count_badge.rs | 40 +- crates/ui/src/components/data_table.rs | 200 +++--- crates/ui/src/components/diff_stat.rs | 15 +- crates/ui/src/components/disclosure.rs | 66 +- crates/ui/src/components/divider.rs | 145 ++-- crates/ui/src/components/dropdown_menu.rs | 117 ++-- crates/ui/src/components/facepile.rs | 67 +- crates/ui/src/components/icon.rs | 93 ++- .../ui/src/components/icon/decorated_icon.rs | 67 +- crates/ui/src/components/image.rs | 108 ++- crates/ui/src/components/indicator.rs | 161 +++-- crates/ui/src/components/keybinding.rs | 151 ++--- crates/ui/src/components/keybinding_hint.rs | 118 ++-- .../src/components/label/highlighted_label.rs | 169 ++--- crates/ui/src/components/label/label.rs | 11 +- crates/ui/src/components/label/label_like.rs | 13 +- .../ui/src/components/label/spinner_label.rs | 9 +- crates/ui/src/components/list/list.rs | 71 +- .../src/components/list/list_bullet_item.rs | 16 +- crates/ui/src/components/list/list_header.rs | 131 ++-- crates/ui/src/components/list/list_item.rs | 201 +++--- .../ui/src/components/list/list_sub_header.rs | 99 ++- .../components/notification/alert_modal.rs | 12 +- .../notification/announcement_toast.rs | 16 +- .../components/progress/circular_progress.rs | 75 +-- .../src/components/progress/progress_bar.rs | 84 ++- crates/ui/src/components/tab.rs | 91 ++- crates/ui/src/components/tab_bar.rs | 71 +- crates/ui/src/components/toggle.rs | 630 +++++++++--------- crates/ui/src/components/tooltip.rs | 25 +- crates/ui/src/components/tree_view_item.rs | 113 ++-- crates/ui/src/styles/animation.rs | 298 ++++----- crates/ui/src/styles/color.rs | 216 +++--- crates/ui/src/styles/typography.rs | 74 +- crates/ui_input/src/input_field.rs | 36 +- crates/ui_macros/src/ui_macros.rs | 10 +- crates/workspace/src/notifications.rs | 21 +- 63 files changed, 3155 insertions(+), 3300 deletions(-) diff --git a/crates/agent_ui/src/ui/end_trial_upsell.rs b/crates/agent_ui/src/ui/end_trial_upsell.rs index 1bf939522ef..ffca837b8f8 100644 --- a/crates/agent_ui/src/ui/end_trial_upsell.rs +++ b/crates/agent_ui/src/ui/end_trial_upsell.rs @@ -103,13 +103,16 @@ impl Component for EndTrialUpsell { "End of Trial Upsell Banner" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .child(EndTrialUpsell { - dismiss_upsell: Arc::new(|_, _| {}), - }) - .into_any_element(), - ) + fn description() -> &'static str { + "A banner shown in the agent panel when a user's trial has ended, \ + inviting them to upgrade to a paid plan to continue using the agent." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .child(EndTrialUpsell { + dismiss_upsell: Arc::new(|_, _| {}), + }) + .into_any_element() } } diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 30aaa4206fe..30b022843c3 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -376,7 +376,13 @@ impl Component for ZedAiOnboarding { "Agent New User Onboarding" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn description() -> &'static str { + "The onboarding surface shown to new agent panel users, \ + guiding them through signing in to Zed and selecting a plan \ + before they can start using the agent." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { fn onboarding( sign_in_status: SignInStatus, plan: Option, @@ -402,41 +408,39 @@ impl Component for ZedAiOnboarding { .into_any_element() } - Some( - v_flex() - .min_w_0() - .gap_4() - .children(vec![ - single_example( - "Not Signed-in", - onboarding(SignInStatus::SignedOut, None, false), - ), - single_example( - "Young Account", - onboarding(SignInStatus::SignedIn, None, true), - ), - single_example( - "Free Plan", - onboarding(SignInStatus::SignedIn, Some(Plan::ZedFree), false), - ), - single_example( - "Pro Trial", - onboarding(SignInStatus::SignedIn, Some(Plan::ZedProTrial), false), - ), - single_example( - "Pro Plan", - onboarding(SignInStatus::SignedIn, Some(Plan::ZedPro), false), - ), - single_example( - "Business Plan", - onboarding(SignInStatus::SignedIn, Some(Plan::ZedBusiness), false), - ), - single_example( - "Student Plan", - onboarding(SignInStatus::SignedIn, Some(Plan::ZedStudent), false), - ), - ]) - .into_any_element(), - ) + v_flex() + .min_w_0() + .gap_4() + .children(vec![ + single_example( + "Not Signed-in", + onboarding(SignInStatus::SignedOut, None, false), + ), + single_example( + "Young Account", + onboarding(SignInStatus::SignedIn, None, true), + ), + single_example( + "Free Plan", + onboarding(SignInStatus::SignedIn, Some(Plan::ZedFree), false), + ), + single_example( + "Pro Trial", + onboarding(SignInStatus::SignedIn, Some(Plan::ZedProTrial), false), + ), + single_example( + "Pro Plan", + onboarding(SignInStatus::SignedIn, Some(Plan::ZedPro), false), + ), + single_example( + "Business Plan", + onboarding(SignInStatus::SignedIn, Some(Plan::ZedBusiness), false), + ), + single_example( + "Student Plan", + onboarding(SignInStatus::SignedIn, Some(Plan::ZedStudent), false), + ), + ]) + .into_any_element() } } diff --git a/crates/component/src/component.rs b/crates/component/src/component.rs index 8c7b7ea4d73..abceb848dfe 100644 --- a/crates/component/src/component.rs +++ b/crates/component/src/component.rs @@ -48,9 +48,9 @@ pub fn register_component() { let id = T::id(); let metadata = ComponentMetadata { id: id.clone(), - description: T::description().map(Into::into), + description: SharedString::new_static(T::description()), name: SharedString::new_static(T::name()), - preview: Some(T::preview), + preview: T::preview, scope: T::scope(), sort_name: SharedString::new_static(T::sort_name()), status: T::status(), @@ -69,15 +69,12 @@ pub struct ComponentRegistry { } impl ComponentRegistry { - pub fn previews(&self) -> Vec<&ComponentMetadata> { - self.components - .values() - .filter(|c| c.preview.is_some()) - .collect() + pub fn previews(&self) -> impl Iterator { + self.components.values() } pub fn sorted_previews(&self) -> Vec { - let mut previews: Vec = self.previews().into_iter().cloned().collect(); + let mut previews: Vec<_> = self.previews().cloned().collect(); previews.sort_by_key(|a| a.name()); previews } @@ -112,9 +109,9 @@ pub struct ComponentId(pub &'static str); #[derive(Clone)] pub struct ComponentMetadata { id: ComponentId, - description: Option, + description: SharedString, name: SharedString, - preview: Option Option>, + preview: fn(&mut Window, &mut App) -> AnyElement, scope: ComponentScope, sort_name: SharedString, status: ComponentStatus, @@ -125,7 +122,7 @@ impl ComponentMetadata { self.id.clone() } - pub fn description(&self) -> Option { + pub fn description(&self) -> SharedString { self.description.clone() } @@ -133,7 +130,7 @@ impl ComponentMetadata { self.name.clone() } - pub fn preview(&self) -> Option Option> { + pub fn preview(&self) -> fn(&mut Window, &mut App) -> AnyElement { self.preview } @@ -234,17 +231,15 @@ pub trait Component { /// struct MyComponent; /// /// impl MyComponent { - /// fn description() -> Option<&'static str> { - /// Some(Self::DOCS) + /// fn description() -> &'static str { + /// Self::DOCS /// } /// } /// ``` /// /// This will result in "This is a doc comment." being passed /// to the component's description. - fn description() -> Option<&'static str> { - None - } + fn description() -> &'static str; /// The component's preview. /// /// An element returned here will be shown in the component's preview. @@ -259,9 +254,7 @@ pub trait Component { /// This is useful for displaying related UI to the component you are /// trying to preview, such as a button that opens a modal or shows a /// tooltip on hover, or a grid of icons showcasing all the icons available. - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - None - } + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement; } /// The ready status of this component. @@ -286,14 +279,17 @@ impl ComponentStatus { pub fn description(&self) -> &str { match self { ComponentStatus::WorkInProgress => { - "These components are still being designed or refined. They shouldn't be used in the app yet." + "These components are still being designed or refined. \ + They shouldn't be used in the app yet." } ComponentStatus::EngineeringReady => { - "These components are design complete or partially implemented, and are ready for an engineer to complete their implementation." + "These components are design complete or partially implemented, \ + and are ready for an engineer to complete their implementation." } ComponentStatus::Live => "These components are ready for use in the app.", ComponentStatus::Deprecated => { - "These components are no longer recommended for use in the app, and may be removed in a future release." + "These components are no longer recommended for use in the app, \ + and may be removed in a future release." } } } diff --git a/crates/component_preview/src/component_preview.rs b/crates/component_preview/src/component_preview.rs index 27eb75a6199..1409105b12e 100644 --- a/crates/component_preview/src/component_preview.rs +++ b/crates/component_preview/src/component_preview.rs @@ -12,7 +12,10 @@ use notifications::status_toast::StatusToast; use persistence::ComponentPreviewDb; use project::Project; use std::{iter::Iterator, ops::Range, sync::Arc}; -use ui::{ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Tooltip, prelude::*}; +use ui::{ + ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Scrollbars, Tooltip, + WithScrollbar, prelude::*, +}; use ui_input::InputField; use workspace::AppState; use workspace::{ @@ -197,10 +200,7 @@ impl ComponentPreview { .filter(|component| { let component_name = component.name().to_lowercase(); let scope_name = component.scope().to_string().to_lowercase(); - let description = component - .description() - .map(|d| d.to_lowercase()) - .unwrap_or_default(); + let description = component.description().to_lowercase(); component_name.contains(&filter) || scope_name.contains(&filter) @@ -231,7 +231,7 @@ impl ComponentPreview { // let full_component_name = component.name(); let scopeless_name = component.scopeless_name(); let scope_name = component.scope().to_string(); - let description = component.description().unwrap_or_default(); + let description = component.description(); let lowercase_scopeless = scopeless_name.to_lowercase(); let lowercase_scope = scope_name.to_lowercase(); @@ -445,45 +445,40 @@ impl ComponentPreview { let description = component.description(); // Build the content container - let mut preview_container = v_flex().py_2().child( - v_flex() - .border_1() - .border_color(cx.theme().colors().border) - .rounded_sm() - .w_full() - .gap_4() - .py_4() - .px_6() - .flex_none() - .child( - v_flex() - .gap_1() - .child( - h_flex() - .gap_1() - .text_xl() - .child(div().child(name)) - .when(!matches!(scope, ComponentScope::None), |this| { - this.child(div().opacity(0.5).child(format!("({})", scope))) - }), - ) - .when_some(description, |this, description| { - this.child( + v_flex() + .py_2() + .child( + v_flex() + .border_1() + .border_color(cx.theme().colors().border) + .rounded_sm() + .w_full() + .gap_4() + .py_4() + .px_6() + .flex_none() + .child( + v_flex() + .gap_1() + .child( + h_flex().gap_1().text_xl().child(div().child(name)).when( + scope != ComponentScope::None, + |this| { + this.child(div().opacity(0.5).child(format!("({})", scope))) + }, + ), + ) + .child( div() .text_ui_sm(cx) .text_color(cx.theme().colors().text_muted) .max_w(px(600.0)) .child(description), - ) - }), - ), - ); - - if let Some(preview) = component.preview() { - preview_container = preview_container.children(preview(window, cx)); - } - - preview_container.into_any_element() + ), + ), + ) + .child((component.preview())(window, cx)) + .into_any_element() } fn render_all_components(&self, cx: &Context) -> impl IntoElement { @@ -593,6 +588,7 @@ impl Render for ComponentPreview { } let sidebar_entries = self.scope_ordered_entries(); let active_page = self.active_page.clone(); + let background_color = cx.theme().colors().editor_background; h_flex() .id("component-preview") @@ -601,37 +597,45 @@ impl Render for ComponentPreview { .overflow_hidden() .size_full() .track_focus(&self.focus_handle) - .bg(cx.theme().colors().editor_background) + .bg(background_color) .child( v_flex() .h_full() .border_r_1() .border_color(cx.theme().colors().border) .child( - gpui::uniform_list( - "component-nav", - sidebar_entries.len(), - cx.processor(move |this, range: Range, _window, cx| { - range - .filter_map(|ix| { - if ix < sidebar_entries.len() { - Some(this.render_sidebar_entry( - ix, - &sidebar_entries[ix], - cx, - )) - } else { - None - } - }) - .collect() - }), - ) - .track_scroll(&self.nav_scroll_handle) - .p_2p5() - .w(px(231.)) // Matches perfectly with the size of the "Component Preview" tab, if that's the first one in the pane - .h_full() - .flex_1(), + div() + .size_full() + .child( + gpui::uniform_list( + "component-nav", + sidebar_entries.len(), + cx.processor(move |this, range: Range, _window, cx| { + range + .filter(|ix| ix < &sidebar_entries.len()) + .map(|ix| { + this.render_sidebar_entry( + ix, + &sidebar_entries[ix], + cx, + ) + }) + .collect() + }), + ) + .track_scroll(&self.nav_scroll_handle) + .p_2p5() + .w(px(231.)) // Matches perfectly with the size of the "Component Preview" tab, if that's the first one in the pane + .h_full() + .flex_1(), + ) + .custom_scrollbars( + Scrollbars::new(ui::ScrollAxes::Vertical) + .with_track_along(ui::ScrollAxes::Vertical, background_color) + .tracked_scroll_handle(&self.nav_scroll_handle), + window, + cx, + ), ) .child( div() @@ -961,23 +965,10 @@ impl ComponentPreviewPage { .children(self.render_component_status(cx)), ), ) - .when_some(self.component.description(), |this, description| { - this.child(Label::new(description).size(LabelSize::Small)) - }) + .child(Label::new(self.component.description()).size(LabelSize::Small)) } fn render_preview(&self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let content = if let Some(preview) = self.component.preview() { - // Fall back to component preview - preview(window, cx).unwrap_or_else(|| { - div() - .child("Failed to load preview. This path should be unreachable") - .into_any_element() - }) - } else { - div().child("No preview available").into_any_element() - }; - v_flex() .id(("component-preview", self.reset_key)) .size_full() @@ -985,7 +976,7 @@ impl ComponentPreviewPage { .px_12() .py_6() .bg(cx.theme().colors().editor_background) - .child(content) + .child((self.component.preview())(window, cx)) } } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 8a7f3c5a134..a0e7571ccdb 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -7147,7 +7147,11 @@ impl Component for PanelRepoFooter { ComponentScope::VersionControl } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn description() -> &'static str { + "The footer shown at the bottom of the git panel." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let unknown_upstream = None; let no_remote_upstream = Some(UpstreamTracking::Gone); let ahead_of_upstream = Some( @@ -7221,177 +7225,176 @@ impl Component for PanelRepoFooter { } let example_width = px(340.); - Some( - v_flex() - .gap_6() - .w_full() - .flex_none() - .children(vec![ - example_group_with_title( - "Action Button States", - vec![ - single_example( - "No Branch", - div() - .w(example_width) - .overflow_hidden() - .child(PanelRepoFooter::new_preview(active_repository(1), None)) - .into_any_element(), - ), - single_example( - "Remote status unknown", - div() - .w(example_width) - .overflow_hidden() - .child(PanelRepoFooter::new_preview( - active_repository(2), - Some(branch(unknown_upstream)), - )) - .into_any_element(), - ), - single_example( - "No Remote Upstream", - div() - .w(example_width) - .overflow_hidden() - .child(PanelRepoFooter::new_preview( - active_repository(3), - Some(branch(no_remote_upstream)), - )) - .into_any_element(), - ), - single_example( - "Not Ahead or Behind", - div() - .w(example_width) - .overflow_hidden() - .child(PanelRepoFooter::new_preview( - active_repository(4), - Some(branch(not_ahead_or_behind_upstream)), - )) - .into_any_element(), - ), - single_example( - "Behind remote", - div() - .w(example_width) - .overflow_hidden() - .child(PanelRepoFooter::new_preview( - active_repository(5), - Some(branch(behind_upstream)), - )) - .into_any_element(), - ), - single_example( - "Ahead of remote", - div() - .w(example_width) - .overflow_hidden() - .child(PanelRepoFooter::new_preview( - active_repository(6), - Some(branch(ahead_of_upstream)), - )) - .into_any_element(), - ), - single_example( - "Ahead and behind remote", - div() - .w(example_width) - .overflow_hidden() - .child(PanelRepoFooter::new_preview( - active_repository(7), - Some(branch(ahead_and_behind_upstream)), - )) - .into_any_element(), - ), - ], - ) - .grow() - .vertical(), - ]) - .children(vec![ - example_group_with_title( - "Labels", - vec![ - single_example( - "Short Branch & Repo", - div() - .w(example_width) - .overflow_hidden() - .child(PanelRepoFooter::new_preview( - SharedString::from("zed"), - Some(custom("main", behind_upstream)), - )) - .into_any_element(), - ), - single_example( - "Long Branch", - div() - .w(example_width) - .overflow_hidden() - .child(PanelRepoFooter::new_preview( - SharedString::from("zed"), - Some(custom( - "redesign-and-update-git-ui-list-entry-style", - behind_upstream, - )), - )) - .into_any_element(), - ), - single_example( - "Long Repo", - div() - .w(example_width) - .overflow_hidden() - .child(PanelRepoFooter::new_preview( - SharedString::from("zed-industries-community-examples"), - Some(custom("gpui", ahead_of_upstream)), - )) - .into_any_element(), - ), - single_example( - "Long Repo & Branch", - div() - .w(example_width) - .overflow_hidden() - .child(PanelRepoFooter::new_preview( - SharedString::from("zed-industries-community-examples"), - Some(custom( - "redesign-and-update-git-ui-list-entry-style", - behind_upstream, - )), - )) - .into_any_element(), - ), - single_example( - "Uppercase Repo", - div() - .w(example_width) - .overflow_hidden() - .child(PanelRepoFooter::new_preview( - SharedString::from("LICENSES"), - Some(custom("main", ahead_of_upstream)), - )) - .into_any_element(), - ), - single_example( - "Uppercase Branch", - div() - .w(example_width) - .overflow_hidden() - .child(PanelRepoFooter::new_preview( - SharedString::from("zed"), - Some(custom("update-README", behind_upstream)), - )) - .into_any_element(), - ), - ], - ) - .grow() - .vertical(), - ]) - .into_any_element(), - ) + + v_flex() + .gap_6() + .w_full() + .flex_none() + .children(vec![ + example_group_with_title( + "Action Button States", + vec![ + single_example( + "No Branch", + div() + .w(example_width) + .overflow_hidden() + .child(PanelRepoFooter::new_preview(active_repository(1), None)) + .into_any_element(), + ), + single_example( + "Remote status unknown", + div() + .w(example_width) + .overflow_hidden() + .child(PanelRepoFooter::new_preview( + active_repository(2), + Some(branch(unknown_upstream)), + )) + .into_any_element(), + ), + single_example( + "No Remote Upstream", + div() + .w(example_width) + .overflow_hidden() + .child(PanelRepoFooter::new_preview( + active_repository(3), + Some(branch(no_remote_upstream)), + )) + .into_any_element(), + ), + single_example( + "Not Ahead or Behind", + div() + .w(example_width) + .overflow_hidden() + .child(PanelRepoFooter::new_preview( + active_repository(4), + Some(branch(not_ahead_or_behind_upstream)), + )) + .into_any_element(), + ), + single_example( + "Behind remote", + div() + .w(example_width) + .overflow_hidden() + .child(PanelRepoFooter::new_preview( + active_repository(5), + Some(branch(behind_upstream)), + )) + .into_any_element(), + ), + single_example( + "Ahead of remote", + div() + .w(example_width) + .overflow_hidden() + .child(PanelRepoFooter::new_preview( + active_repository(6), + Some(branch(ahead_of_upstream)), + )) + .into_any_element(), + ), + single_example( + "Ahead and behind remote", + div() + .w(example_width) + .overflow_hidden() + .child(PanelRepoFooter::new_preview( + active_repository(7), + Some(branch(ahead_and_behind_upstream)), + )) + .into_any_element(), + ), + ], + ) + .grow() + .vertical(), + ]) + .children(vec![ + example_group_with_title( + "Labels", + vec![ + single_example( + "Short Branch & Repo", + div() + .w(example_width) + .overflow_hidden() + .child(PanelRepoFooter::new_preview( + SharedString::from("zed"), + Some(custom("main", behind_upstream)), + )) + .into_any_element(), + ), + single_example( + "Long Branch", + div() + .w(example_width) + .overflow_hidden() + .child(PanelRepoFooter::new_preview( + SharedString::from("zed"), + Some(custom( + "redesign-and-update-git-ui-list-entry-style", + behind_upstream, + )), + )) + .into_any_element(), + ), + single_example( + "Long Repo", + div() + .w(example_width) + .overflow_hidden() + .child(PanelRepoFooter::new_preview( + SharedString::from("zed-industries-community-examples"), + Some(custom("gpui", ahead_of_upstream)), + )) + .into_any_element(), + ), + single_example( + "Long Repo & Branch", + div() + .w(example_width) + .overflow_hidden() + .child(PanelRepoFooter::new_preview( + SharedString::from("zed-industries-community-examples"), + Some(custom( + "redesign-and-update-git-ui-list-entry-style", + behind_upstream, + )), + )) + .into_any_element(), + ), + single_example( + "Uppercase Repo", + div() + .w(example_width) + .overflow_hidden() + .child(PanelRepoFooter::new_preview( + SharedString::from("LICENSES"), + Some(custom("main", ahead_of_upstream)), + )) + .into_any_element(), + ), + single_example( + "Uppercase Branch", + div() + .w(example_width) + .overflow_hidden() + .child(PanelRepoFooter::new_preview( + SharedString::from("zed"), + Some(custom("update-README", behind_upstream)), + )) + .into_any_element(), + ), + ], + ) + .grow() + .vertical(), + ]) + .into_any_element() } } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 740c5fbc837..58b1af74fb6 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -1038,7 +1038,12 @@ impl Component for GitStatusIcon { ComponentScope::VersionControl } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn description() -> &'static str { + "An icon that visually represents the git status of a file, \ + using a distinct glyph and color for modified, added, deleted, and conflicted states." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { fn tracked_file_status(code: StatusCode) -> FileStatus { FileStatus::Tracked(git::status::TrackedStatus { index_status: code, @@ -1055,20 +1060,18 @@ impl Component for GitStatusIcon { } .into(); - Some( - v_flex() - .gap_6() - .children(vec![example_group(vec![ - single_example("Modified", GitStatusIcon::new(modified).into_any_element()), - single_example("Added", GitStatusIcon::new(added).into_any_element()), - single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()), - single_example( - "Conflicted", - GitStatusIcon::new(conflict).into_any_element(), - ), - ])]) - .into_any_element(), - ) + v_flex() + .gap_6() + .children(vec![example_group(vec![ + single_example("Modified", GitStatusIcon::new(modified).into_any_element()), + single_example("Added", GitStatusIcon::new(added).into_any_element()), + single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()), + single_example( + "Conflicted", + GitStatusIcon::new(conflict).into_any_element(), + ), + ])]) + .into_any_element() } } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 7a4f292c2ac..8f7cbe6864c 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -721,7 +721,13 @@ impl Component for ZedAiConfiguration { ComponentScope::Onboarding } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn description() -> &'static str { + "The configuration surface for Zed's hosted AI models, \ + showing the user's connection status, current plan, trial eligibility, \ + and entry points for enabling the Zed model provider." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { struct PreviewConfiguration { plan: Option, is_connected: bool, @@ -741,94 +747,92 @@ impl Component for ZedAiConfiguration { .into_any_element() }; - Some( - v_flex() - .p_4() - .gap_4() - .children(vec![ - single_example( - "Not connected", - configuration(PreviewConfiguration { - plan: None, - is_connected: false, - is_zed_model_provider_enabled: true, - eligible_for_trial: false, - }), - ), - single_example( - "Accept Terms of Service", - configuration(PreviewConfiguration { - plan: None, - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: true, - }), - ), - single_example( - "No Plan - Not eligible for trial", - configuration(PreviewConfiguration { - plan: None, - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: false, - }), - ), - single_example( - "No Plan - Eligible for trial", - configuration(PreviewConfiguration { - plan: None, - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: true, - }), - ), - single_example( - "Free Plan", - configuration(PreviewConfiguration { - plan: Some(Plan::ZedFree), - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: true, - }), - ), - single_example( - "Zed Pro Trial Plan", - configuration(PreviewConfiguration { - plan: Some(Plan::ZedProTrial), - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: true, - }), - ), - single_example( - "Zed Pro Plan", - configuration(PreviewConfiguration { - plan: Some(Plan::ZedPro), - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: true, - }), - ), - single_example( - "Business Plan - Zed models enabled", - configuration(PreviewConfiguration { - plan: Some(Plan::ZedBusiness), - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: false, - }), - ), - single_example( - "Business Plan - Zed models disabled", - configuration(PreviewConfiguration { - plan: Some(Plan::ZedBusiness), - is_connected: true, - is_zed_model_provider_enabled: false, - eligible_for_trial: false, - }), - ), - ]) - .into_any_element(), - ) + v_flex() + .p_4() + .gap_4() + .children(vec![ + single_example( + "Not connected", + configuration(PreviewConfiguration { + plan: None, + is_connected: false, + is_zed_model_provider_enabled: true, + eligible_for_trial: false, + }), + ), + single_example( + "Accept Terms of Service", + configuration(PreviewConfiguration { + plan: None, + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), + ), + single_example( + "No Plan - Not eligible for trial", + configuration(PreviewConfiguration { + plan: None, + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: false, + }), + ), + single_example( + "No Plan - Eligible for trial", + configuration(PreviewConfiguration { + plan: None, + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), + ), + single_example( + "Free Plan", + configuration(PreviewConfiguration { + plan: Some(Plan::ZedFree), + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), + ), + single_example( + "Zed Pro Trial Plan", + configuration(PreviewConfiguration { + plan: Some(Plan::ZedProTrial), + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), + ), + single_example( + "Zed Pro Plan", + configuration(PreviewConfiguration { + plan: Some(Plan::ZedPro), + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), + ), + single_example( + "Business Plan - Zed models enabled", + configuration(PreviewConfiguration { + plan: Some(Plan::ZedBusiness), + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: false, + }), + ), + single_example( + "Business Plan - Zed models disabled", + configuration(PreviewConfiguration { + plan: Some(Plan::ZedBusiness), + is_connected: true, + is_zed_model_provider_enabled: false, + eligible_for_trial: false, + }), + ), + ]) + .into_any_element() } } diff --git a/crates/notifications/src/status_toast.rs b/crates/notifications/src/status_toast.rs index d9ffcddc6ff..e07cb99df59 100644 --- a/crates/notifications/src/status_toast.rs +++ b/crates/notifications/src/status_toast.rs @@ -150,7 +150,13 @@ impl Component for StatusToast { ComponentScope::Notification } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A compact, transient toast used to surface status updates \ + such as completed operations or pending updates, with optional icon, \ + action, and dismiss affordances." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let text_example = StatusToast::new("Operation completed", cx, |this, _| this); let action_example = StatusToast::new("Update ready to install", cx, |this, _cx| { @@ -214,44 +220,33 @@ impl Component for StatusToast { }) }); - Some( - v_flex() - .gap_6() - .p_4() - .children(vec![ - example_group_with_title( - "Basic Toast", - vec![ - single_example("Text", div().child(text_example).into_any_element()), - single_example( - "Action", - div().child(action_example).into_any_element(), - ), - single_example("Icon", div().child(icon_example).into_any_element()), - single_example( - "Dismiss Button", - div().child(dismiss_button_example).into_any_element(), - ), - ], - ), - example_group_with_title( - "Examples", - vec![ - single_example( - "Success", - div().child(success_example).into_any_element(), - ), - single_example("Error", div().child(error_example).into_any_element()), - single_example( - "Warning", - div().child(warning_example).into_any_element(), - ), - single_example("Create PR", div().child(pr_example).into_any_element()), - ], - ) - .vertical(), - ]) - .into_any_element(), - ) + v_flex() + .gap_6() + .p_4() + .children(vec![ + example_group_with_title( + "Basic Toast", + vec![ + single_example("Text", div().child(text_example).into_any_element()), + single_example("Action", div().child(action_example).into_any_element()), + single_example("Icon", div().child(icon_example).into_any_element()), + single_example( + "Dismiss Button", + div().child(dismiss_button_example).into_any_element(), + ), + ], + ), + example_group_with_title( + "Examples", + vec![ + single_example("Success", div().child(success_example).into_any_element()), + single_example("Error", div().child(error_example).into_any_element()), + single_example("Warning", div().child(warning_example).into_any_element()), + single_example("Create PR", div().child(pr_example).into_any_element()), + ], + ) + .vertical(), + ]) + .into_any_element() } } diff --git a/crates/onboarding/src/theme_preview.rs b/crates/onboarding/src/theme_preview.rs index 99990d1273f..534272ecda8 100644 --- a/crates/onboarding/src/theme_preview.rs +++ b/crates/onboarding/src/theme_preview.rs @@ -315,11 +315,11 @@ impl Component for ThemePreviewTile { "Theme Preview Tile" } - fn description() -> Option<&'static str> { - Some(Self::DOCS) + fn description() -> &'static str { + Self::DOCS } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let theme_registry = ThemeRegistry::global(cx); let one_dark = theme_registry.get("One Dark"); @@ -337,45 +337,43 @@ impl Component for ThemePreviewTile { .flatten() .collect::>(); - Some( - v_flex() - .gap_6() - .p_4() - .children({ - if let Some(one_dark) = one_dark.ok() { - vec![example_group(vec![single_example( - "Default", - div() - .w(px(240.)) - .h(px(180.)) - .child(ThemePreviewTile::new(one_dark, 0.42)) - .into_any_element(), - )])] - } else { - vec![] - } - }) - .child( - example_group(vec![single_example( - "Default Themes", - h_flex() - .gap_4() - .children( - themes_to_preview - .into_iter() - .map(|theme| { - div() - .w(px(200.)) - .h(px(140.)) - .child(ThemePreviewTile::new(theme, 0.42)) - }) - .collect::>(), - ) + v_flex() + .gap_6() + .p_4() + .children({ + if let Some(one_dark) = one_dark.ok() { + vec![example_group(vec![single_example( + "Default", + div() + .w(px(240.)) + .h(px(180.)) + .child(ThemePreviewTile::new(one_dark, 0.42)) .into_any_element(), - )]) - .grow(), - ) - .into_any_element(), - ) + )])] + } else { + vec![] + } + }) + .child( + example_group(vec![single_example( + "Default Themes", + h_flex() + .gap_4() + .children( + themes_to_preview + .into_iter() + .map(|theme| { + div() + .w(px(200.)) + .h(px(140.)) + .child(ThemePreviewTile::new(theme, 0.42)) + }) + .collect::>(), + ) + .into_any_element(), + )]) + .grow(), + ) + .into_any_element() } } diff --git a/crates/settings_ui/src/components/number_field.rs b/crates/settings_ui/src/components/number_field.rs index 9ddac4263e2..7ffec007fe6 100644 --- a/crates/settings_ui/src/components/number_field.rs +++ b/crates/settings_ui/src/components/number_field.rs @@ -727,43 +727,41 @@ impl Component for NumberField { "Number Field" } - fn description() -> Option<&'static str> { - Some("A numeric input element with increment and decrement buttons.") + fn description() -> &'static str { + "A numeric input element with increment and decrement buttons." } - fn preview(window: &mut Window, cx: &mut App) -> Option { + fn preview(window: &mut Window, cx: &mut App) -> AnyElement { let default_ex = window.use_state(cx, |_, _| 100.0); let edit_ex = window.use_state(cx, |_, _| 500.0); - Some( - v_flex() - .gap_6() - .children(vec![ - single_example( - "Button-Only Number Field", - NumberField::new("number-field", *default_ex.read(cx), window, cx) - .on_change({ - let default_ex = default_ex.clone(); - move |value, _, cx| default_ex.write(cx, *value) - }) - .min(1.0) - .max(100.0) - .into_any_element(), - ), - single_example( - "Editable Number Field", - NumberField::new("editable-number-field", *edit_ex.read(cx), window, cx) - .on_change({ - let edit_ex = edit_ex.clone(); - move |value, _, cx| edit_ex.write(cx, *value) - }) - .min(100.0) - .max(500.0) - .mode(NumberFieldMode::Edit, cx) - .into_any_element(), - ), - ]) - .into_any_element(), - ) + v_flex() + .gap_6() + .children(vec![ + single_example( + "Button-Only Number Field", + NumberField::new("number-field", *default_ex.read(cx), window, cx) + .on_change({ + let default_ex = default_ex.clone(); + move |value, _, cx| default_ex.write(cx, *value) + }) + .min(1.0) + .max(100.0) + .into_any_element(), + ), + single_example( + "Editable Number Field", + NumberField::new("editable-number-field", *edit_ex.read(cx), window, cx) + .on_change({ + let edit_ex = edit_ex.clone(); + move |value, _, cx| edit_ex.write(cx, *value) + }) + .min(100.0) + .max(500.0) + .mode(NumberFieldMode::Edit, cx) + .into_any_element(), + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/ai/agent_setup_button.rs b/crates/ui/src/components/ai/agent_setup_button.rs index d56baf91e13..c30add743b6 100644 --- a/crates/ui/src/components/ai/agent_setup_button.rs +++ b/crates/ui/src/components/ai/agent_setup_button.rs @@ -57,8 +57,21 @@ impl Component for AgentSetupButton { ComponentScope::Agent } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - None + fn description() -> &'static str { + "A large, two-section button used in agent onboarding flows \ + to launch the setup of a provider or tool, showing an icon, name, \ + and current setup state." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + single_example( + "Default", + AgentSetupButton::new("preview") + .icon(Icon::new(IconName::ZedAgent)) + .name("Zed Agent") + .into_any_element(), + ) + .into_any_element() } } diff --git a/crates/ui/src/components/ai/ai_setting_item.rs b/crates/ui/src/components/ai/ai_setting_item.rs index 6651ee1b769..372ff75597d 100644 --- a/crates/ui/src/components/ai/ai_setting_item.rs +++ b/crates/ui/src/components/ai/ai_setting_item.rs @@ -244,7 +244,12 @@ impl Component for AiSettingItem { ComponentScope::Agent } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A reusable row used in AI-related configuration lists to display a \ + server or provider's name, source, current status, and associated actions." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let container = || { v_flex() .w_80() @@ -403,6 +408,6 @@ impl Component for AiSettingItem { ), ]; - Some(example_group(examples).vertical().into_any_element()) + example_group(examples).vertical().into_any_element() } } diff --git a/crates/ui/src/components/ai/configured_api_card.rs b/crates/ui/src/components/ai/configured_api_card.rs index c9fd129a678..9e89acc4962 100644 --- a/crates/ui/src/components/ai/configured_api_card.rs +++ b/crates/ui/src/components/ai/configured_api_card.rs @@ -57,7 +57,12 @@ impl Component for ConfiguredApiCard { ComponentScope::Agent } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A card used in AI provider settings to indicate that an API has been \ + configured, with an optional action button to manage or reconfigure it." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let container = || { v_flex() .w_72() @@ -101,7 +106,7 @@ impl Component for ConfiguredApiCard { ), ]; - Some(example_group(examples).into_any_element()) + example_group(examples).into_any_element() } } diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 99f07f6faae..439e11f9d22 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -610,7 +610,12 @@ impl Component for ThreadItem { ComponentScope::Agent } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A row representing an agent thread in a list, showing its title, status, \ + timestamp, and contextual metadata such as worktree and branch information." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let color = cx.theme().colors(); let bg = color .title_bar_background @@ -947,10 +952,8 @@ impl Component for ThreadItem { ), ]; - Some( - example_group(thread_item_examples) - .vertical() - .into_any_element(), - ) + example_group(thread_item_examples) + .vertical() + .into_any_element() } } diff --git a/crates/ui/src/components/avatar.rs b/crates/ui/src/components/avatar.rs index 6ae969246f8..6d12a0d3d22 100644 --- a/crates/ui/src/components/avatar.rs +++ b/crates/ui/src/components/avatar.rs @@ -235,15 +235,14 @@ impl Component for Avatar { ComponentScope::Collaboration } - fn description() -> Option<&'static str> { - Some(Avatar::DOCS) + fn description() -> &'static str { + Avatar::DOCS } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let example_avatar = "https://avatars.githubusercontent.com/u/1714999?v=4"; - Some( - v_flex() + v_flex() .gap_6() .children(vec![ example_group(vec![ @@ -297,7 +296,6 @@ impl Component for Avatar { ], ), ]) - .into_any_element(), - ) + .into_any_element() } } diff --git a/crates/ui/src/components/banner.rs b/crates/ui/src/components/banner.rs index 19795c2c7c8..4568959a373 100644 --- a/crates/ui/src/components/banner.rs +++ b/crates/ui/src/components/banner.rs @@ -134,7 +134,12 @@ impl Component for Banner { ComponentScope::DataDisplay } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn description() -> &'static str { + "A non-blocking, severity-aware message strip used to surface informative, \ + success, warning, or error messages without interrupting the user." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let severity_examples = vec![ single_example( "Default", @@ -179,10 +184,8 @@ impl Component for Banner { ), ]; - Some( - example_group(severity_examples) - .vertical() - .into_any_element(), - ) + example_group(severity_examples) + .vertical() + .into_any_element() } } diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index 61cb4a88556..5278ffa2118 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -448,130 +448,128 @@ impl Component for Button { "ButtonA" } - fn description() -> Option<&'static str> { - Some("A button triggers an event or action.") + fn description() -> &'static str { + "A button triggers an event or action." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Button Styles", - vec![ - single_example( - "Default", - Button::new("default", "Default").into_any_element(), - ), - single_example( - "Filled", - Button::new("filled", "Filled") - .style(ButtonStyle::Filled) - .into_any_element(), - ), - single_example( - "Subtle", - Button::new("outline", "Subtle") - .style(ButtonStyle::Subtle) - .into_any_element(), - ), - single_example( - "Tinted", - Button::new("tinted_accent_style", "Accent") - .style(ButtonStyle::Tinted(TintColor::Accent)) - .into_any_element(), - ), - single_example( - "Transparent", - Button::new("transparent", "Transparent") - .style(ButtonStyle::Transparent) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Tint Styles", - vec![ - single_example( - "Accent", - Button::new("tinted_accent", "Accent") - .style(ButtonStyle::Tinted(TintColor::Accent)) - .into_any_element(), - ), - single_example( - "Error", - Button::new("tinted_negative", "Error") - .style(ButtonStyle::Tinted(TintColor::Error)) - .into_any_element(), - ), - single_example( - "Warning", - Button::new("tinted_warning", "Warning") - .style(ButtonStyle::Tinted(TintColor::Warning)) - .into_any_element(), - ), - single_example( - "Success", - Button::new("tinted_positive", "Success") - .style(ButtonStyle::Tinted(TintColor::Success)) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Special States", - vec![ - single_example( - "Default", - Button::new("default_state", "Default").into_any_element(), - ), - single_example( - "Disabled", - Button::new("disabled", "Disabled") - .disabled(true) - .into_any_element(), - ), - single_example( - "Selected", - Button::new("selected", "Selected") - .toggle_state(true) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Buttons with Icons", - vec![ - single_example( - "Start Icon", - Button::new("icon_start", "Start Icon") - .start_icon(Icon::new(IconName::Check)) - .into_any_element(), - ), - single_example( - "End Icon", - Button::new("icon_end", "End Icon") - .end_icon(Icon::new(IconName::Check)) - .into_any_element(), - ), - single_example( - "Both Icons", - Button::new("both_icons", "Both Icons") - .start_icon(Icon::new(IconName::Check)) - .end_icon(Icon::new(IconName::ChevronDown)) - .into_any_element(), - ), - single_example( - "Icon Color", - Button::new("icon_color", "Icon Color") - .start_icon(Icon::new(IconName::Check).color(Color::Accent)) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Button Styles", + vec![ + single_example( + "Default", + Button::new("default", "Default").into_any_element(), + ), + single_example( + "Filled", + Button::new("filled", "Filled") + .style(ButtonStyle::Filled) + .into_any_element(), + ), + single_example( + "Subtle", + Button::new("outline", "Subtle") + .style(ButtonStyle::Subtle) + .into_any_element(), + ), + single_example( + "Tinted", + Button::new("tinted_accent_style", "Accent") + .style(ButtonStyle::Tinted(TintColor::Accent)) + .into_any_element(), + ), + single_example( + "Transparent", + Button::new("transparent", "Transparent") + .style(ButtonStyle::Transparent) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Tint Styles", + vec![ + single_example( + "Accent", + Button::new("tinted_accent", "Accent") + .style(ButtonStyle::Tinted(TintColor::Accent)) + .into_any_element(), + ), + single_example( + "Error", + Button::new("tinted_negative", "Error") + .style(ButtonStyle::Tinted(TintColor::Error)) + .into_any_element(), + ), + single_example( + "Warning", + Button::new("tinted_warning", "Warning") + .style(ButtonStyle::Tinted(TintColor::Warning)) + .into_any_element(), + ), + single_example( + "Success", + Button::new("tinted_positive", "Success") + .style(ButtonStyle::Tinted(TintColor::Success)) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Special States", + vec![ + single_example( + "Default", + Button::new("default_state", "Default").into_any_element(), + ), + single_example( + "Disabled", + Button::new("disabled", "Disabled") + .disabled(true) + .into_any_element(), + ), + single_example( + "Selected", + Button::new("selected", "Selected") + .toggle_state(true) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Buttons with Icons", + vec![ + single_example( + "Start Icon", + Button::new("icon_start", "Start Icon") + .start_icon(Icon::new(IconName::Check)) + .into_any_element(), + ), + single_example( + "End Icon", + Button::new("icon_end", "End Icon") + .end_icon(Icon::new(IconName::Check)) + .into_any_element(), + ), + single_example( + "Both Icons", + Button::new("both_icons", "Both Icons") + .start_icon(Icon::new(IconName::Check)) + .end_icon(Icon::new(IconName::ChevronDown)) + .into_any_element(), + ), + single_example( + "Icon Color", + Button::new("icon_color", "Icon Color") + .start_icon(Icon::new(IconName::Check).color(Color::Accent)) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index 2b0fa20683c..05999daef48 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -793,88 +793,86 @@ impl Component for ButtonLike { "ButtonZ" } - fn description() -> Option<&'static str> { - Some(ButtonLike::DOCS) + fn description() -> &'static str { + ButtonLike::DOCS } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group(vec![ - single_example( - "Default", - ButtonLike::new("default") - .child(Label::new("Default")) - .into_any_element(), - ), - single_example( - "Filled", - ButtonLike::new("filled") - .style(ButtonStyle::Filled) - .child(Label::new("Filled")) - .into_any_element(), - ), - single_example( - "Subtle", - ButtonLike::new("outline") - .style(ButtonStyle::Subtle) - .child(Label::new("Subtle")) - .into_any_element(), - ), - single_example( - "Tinted", - ButtonLike::new("tinted_accent_style") - .style(ButtonStyle::Tinted(TintColor::Accent)) - .child(Label::new("Accent")) - .into_any_element(), - ), - single_example( - "Transparent", - ButtonLike::new("transparent") - .style(ButtonStyle::Transparent) - .child(Label::new("Transparent")) - .into_any_element(), - ), - ]), - example_group_with_title( - "Button Group Constructors", - vec![ - single_example( - "Left Rounded", - ButtonLike::new_rounded_left("left_rounded") - .child(Label::new("Left Rounded")) - .style(ButtonStyle::Filled) - .into_any_element(), - ), - single_example( - "Right Rounded", - ButtonLike::new_rounded_right("right_rounded") - .child(Label::new("Right Rounded")) - .style(ButtonStyle::Filled) - .into_any_element(), - ), - single_example( - "Button Group", - h_flex() - .gap_px() - .child( - ButtonLike::new_rounded_left("bg_left") - .child(Label::new("Left")) - .style(ButtonStyle::Filled), - ) - .child( - ButtonLike::new_rounded_right("bg_right") - .child(Label::new("Right")) - .style(ButtonStyle::Filled), - ) - .into_any_element(), - ), - ], + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group(vec![ + single_example( + "Default", + ButtonLike::new("default") + .child(Label::new("Default")) + .into_any_element(), ), - ]) - .into_any_element(), - ) + single_example( + "Filled", + ButtonLike::new("filled") + .style(ButtonStyle::Filled) + .child(Label::new("Filled")) + .into_any_element(), + ), + single_example( + "Subtle", + ButtonLike::new("outline") + .style(ButtonStyle::Subtle) + .child(Label::new("Subtle")) + .into_any_element(), + ), + single_example( + "Tinted", + ButtonLike::new("tinted_accent_style") + .style(ButtonStyle::Tinted(TintColor::Accent)) + .child(Label::new("Accent")) + .into_any_element(), + ), + single_example( + "Transparent", + ButtonLike::new("transparent") + .style(ButtonStyle::Transparent) + .child(Label::new("Transparent")) + .into_any_element(), + ), + ]), + example_group_with_title( + "Button Group Constructors", + vec![ + single_example( + "Left Rounded", + ButtonLike::new_rounded_left("left_rounded") + .child(Label::new("Left Rounded")) + .style(ButtonStyle::Filled) + .into_any_element(), + ), + single_example( + "Right Rounded", + ButtonLike::new_rounded_right("right_rounded") + .child(Label::new("Right Rounded")) + .style(ButtonStyle::Filled) + .into_any_element(), + ), + single_example( + "Button Group", + h_flex() + .gap_px() + .child( + ButtonLike::new_rounded_left("bg_left") + .child(Label::new("Left")) + .style(ButtonStyle::Filled), + ) + .child( + ButtonLike::new_rounded_right("bg_right") + .child(Label::new("Right")) + .style(ButtonStyle::Filled), + ) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/button/button_link.rs b/crates/ui/src/components/button/button_link.rs index caffe2772bc..841df96f555 100644 --- a/crates/ui/src/components/button/button_link.rs +++ b/crates/ui/src/components/button/button_link.rs @@ -81,22 +81,20 @@ impl Component for ButtonLink { ComponentScope::Navigation } - fn description() -> Option<&'static str> { - Some("A button that opens a URL.") + fn description() -> &'static str { + "A button that opens a URL." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .child( - example_group(vec![single_example( - "Simple", - ButtonLink::new("zed.dev", "https://zed.dev").into_any_element(), - )]) - .vertical(), - ) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .child( + example_group(vec![single_example( + "Simple", + ButtonLink::new("zed.dev", "https://zed.dev").into_any_element(), + )]) + .vertical(), + ) + .into_any_element() } } diff --git a/crates/ui/src/components/button/copy_button.rs b/crates/ui/src/components/button/copy_button.rs index c31d0eabbd6..924d34e7c2f 100644 --- a/crates/ui/src/components/button/copy_button.rs +++ b/crates/ui/src/components/button/copy_button.rs @@ -139,11 +139,11 @@ impl Component for CopyButton { ComponentScope::Input } - fn description() -> Option<&'static str> { - Some("An icon button that encapsulates the logic to copy a string into the clipboard.") + fn description() -> &'static str { + "An icon button that encapsulates the logic to copy a string into the clipboard." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let label_text = "Here's an example label"; let examples = vec![ @@ -195,6 +195,6 @@ impl Component for CopyButton { ), ]; - Some(example_group(examples).vertical().into_any_element()) + example_group(examples).vertical().into_any_element() } } diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index a103ddf169a..246f46b911e 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -2,7 +2,8 @@ use gpui::{AnyView, DefiniteLength, Hsla}; use super::button_like::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle}; use crate::{ - ElevationIndex, Icon, IconWithIndicator, Indicator, SelectableButton, TintColor, prelude::*, + ElevationIndex, Icon, IconWithIndicator, Indicator, SelectableButton, TintColor, Tooltip, + prelude::*, }; use crate::{IconName, IconSize}; @@ -240,160 +241,171 @@ impl Component for IconButton { "ButtonB" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Icon Button Styles", - vec![ - single_example( - "Default", - IconButton::new("default", IconName::Check) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "Filled", - IconButton::new("filled", IconName::Check) - .layer(ElevationIndex::Background) - .style(ButtonStyle::Filled) - .into_any_element(), - ), - single_example( - "Subtle", - IconButton::new("subtle", IconName::Check) - .layer(ElevationIndex::Background) - .style(ButtonStyle::Subtle) - .into_any_element(), - ), - single_example( - "Tinted", - IconButton::new("tinted", IconName::Check) - .layer(ElevationIndex::Background) - .style(ButtonStyle::Tinted(TintColor::Accent)) - .into_any_element(), - ), - single_example( - "Transparent", - IconButton::new("transparent", IconName::Check) - .layer(ElevationIndex::Background) - .style(ButtonStyle::Transparent) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Icon Button Shapes", - vec![ - single_example( - "Square", - IconButton::new("square", IconName::Check) - .shape(IconButtonShape::Square) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "Wide", - IconButton::new("wide", IconName::Check) - .shape(IconButtonShape::Wide) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Icon Button Sizes", - vec![ - single_example( - "XSmall", - IconButton::new("xsmall", IconName::Check) - .icon_size(IconSize::XSmall) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "Small", - IconButton::new("small", IconName::Check) - .icon_size(IconSize::Small) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "Medium", - IconButton::new("medium", IconName::Check) - .icon_size(IconSize::Medium) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "XLarge", - IconButton::new("xlarge", IconName::Check) - .icon_size(IconSize::XLarge) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Special States", - vec![ - single_example( - "Disabled", - IconButton::new("disabled", IconName::Check) - .disabled(true) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "Selected", - IconButton::new("selected", IconName::Check) - .toggle_state(true) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "With Indicator", - IconButton::new("indicator", IconName::Check) - .indicator(Indicator::dot().color(Color::Success)) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Custom Colors", - vec![ - single_example( - "Custom Icon Color", - IconButton::new("custom_color", IconName::Check) - .icon_color(Color::Accent) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "With Alpha", - IconButton::new("alpha", IconName::Check) - .alpha(0.5) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + fn description() -> &'static str { + "A compact button that displays a single icon with an optional tooltip.\ + The most frequently used button in the Zed codebase." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Icon Button Styles", + vec![ + single_example( + "Default", + IconButton::new("default", IconName::Check) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "Filled", + IconButton::new("filled", IconName::Check) + .layer(ElevationIndex::Background) + .style(ButtonStyle::Filled) + .into_any_element(), + ), + single_example( + "Subtle", + IconButton::new("subtle", IconName::Check) + .layer(ElevationIndex::Background) + .style(ButtonStyle::Subtle) + .into_any_element(), + ), + single_example( + "Tinted", + IconButton::new("tinted", IconName::Check) + .layer(ElevationIndex::Background) + .style(ButtonStyle::Tinted(TintColor::Accent)) + .into_any_element(), + ), + single_example( + "Transparent", + IconButton::new("transparent", IconName::Check) + .layer(ElevationIndex::Background) + .style(ButtonStyle::Transparent) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Icon Button Shapes", + vec![ + single_example( + "Square", + IconButton::new("square", IconName::Check) + .shape(IconButtonShape::Square) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "Wide", + IconButton::new("wide", IconName::Check) + .shape(IconButtonShape::Wide) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Icon Button Sizes", + vec![ + single_example( + "XSmall", + IconButton::new("xsmall", IconName::Check) + .icon_size(IconSize::XSmall) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "Small", + IconButton::new("small", IconName::Check) + .icon_size(IconSize::Small) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "Medium", + IconButton::new("medium", IconName::Check) + .icon_size(IconSize::Medium) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "XLarge", + IconButton::new("xlarge", IconName::Check) + .icon_size(IconSize::XLarge) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Special States", + vec![ + single_example( + "Disabled", + IconButton::new("disabled", IconName::Check) + .disabled(true) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "Selected", + IconButton::new("selected", IconName::Check) + .toggle_state(true) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "With Indicator", + IconButton::new("indicator", IconName::Check) + .indicator(Indicator::dot().color(Color::Success)) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "With Tooltip", + IconButton::new("tooltip", IconName::Check) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .tooltip(Tooltip::text("As mentioned - with a tooltip")) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Custom Colors", + vec![ + single_example( + "Custom Icon Color", + IconButton::new("custom_color", IconName::Check) + .icon_color(Color::Accent) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "With Alpha", + IconButton::new("alpha", IconName::Check) + .alpha(0.5) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/button/toggle_button.rs b/crates/ui/src/components/button/toggle_button.rs index 5cecfef0625..395154cdf6e 100644 --- a/crates/ui/src/components/button/toggle_button.rs +++ b/crates/ui/src/components/button/toggle_button.rs @@ -424,346 +424,241 @@ impl Component "ButtonG" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![example_group_with_title( - "Transparent Variant", - vec![ - single_example( - "Single Row Group", - ToggleButtonGroup::single_row( - "single_row_test", - [ - ToggleButtonSimple::new("First", |_, _, _| {}), - ToggleButtonSimple::new("Second", |_, _, _| {}), - ToggleButtonSimple::new("Third", |_, _, _| {}), - ], - ) - .selected_index(1) - .into_any_element(), - ), - single_example( - "Single Row Group with icons", - ToggleButtonGroup::single_row( - "single_row_test_icon", - [ - ToggleButtonWithIcon::new( - "First", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Second", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Third", - IconName::AiZed, - |_, _, _| {}, - ), - ], - ) - .selected_index(1) - .into_any_element(), - ), - single_example( - "Multiple Row Group", - ToggleButtonGroup::two_rows( - "multiple_row_test", - [ - ToggleButtonSimple::new("First", |_, _, _| {}), - ToggleButtonSimple::new("Second", |_, _, _| {}), - ToggleButtonSimple::new("Third", |_, _, _| {}), - ], - [ - ToggleButtonSimple::new("Fourth", |_, _, _| {}), - ToggleButtonSimple::new("Fifth", |_, _, _| {}), - ToggleButtonSimple::new("Sixth", |_, _, _| {}), - ], - ) - .selected_index(3) - .into_any_element(), - ), - single_example( - "Multiple Row Group with Icons", - ToggleButtonGroup::two_rows( - "multiple_row_test_icons", - [ - ToggleButtonWithIcon::new( - "First", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Second", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Third", - IconName::AiZed, - |_, _, _| {}, - ), - ], - [ - ToggleButtonWithIcon::new( - "Fourth", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Fifth", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Sixth", - IconName::AiZed, - |_, _, _| {}, - ), - ], - ) - .selected_index(3) - .into_any_element(), - ), + fn description() -> &'static str { + "A grouped set of toggle buttons arranged in rows and columns, \ + where each button represents a mutually exclusive option in a segmented control." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Transparent Variant", + vec![ + single_example( + "Single Row Group", + ToggleButtonGroup::single_row( + "single_row_test", + [ + ToggleButtonSimple::new("First", |_, _, _| {}), + ToggleButtonSimple::new("Second", |_, _, _| {}), + ToggleButtonSimple::new("Third", |_, _, _| {}), + ], + ) + .selected_index(1) + .into_any_element(), + ), + single_example( + "Single Row Group with icons", + ToggleButtonGroup::single_row( + "single_row_test_icon", + [ + ToggleButtonWithIcon::new("First", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Second", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Third", IconName::AiZed, |_, _, _| {}), + ], + ) + .selected_index(1) + .into_any_element(), + ), + single_example( + "Multiple Row Group", + ToggleButtonGroup::two_rows( + "multiple_row_test", + [ + ToggleButtonSimple::new("First", |_, _, _| {}), + ToggleButtonSimple::new("Second", |_, _, _| {}), + ToggleButtonSimple::new("Third", |_, _, _| {}), + ], + [ + ToggleButtonSimple::new("Fourth", |_, _, _| {}), + ToggleButtonSimple::new("Fifth", |_, _, _| {}), + ToggleButtonSimple::new("Sixth", |_, _, _| {}), + ], + ) + .selected_index(3) + .into_any_element(), + ), + single_example( + "Multiple Row Group with Icons", + ToggleButtonGroup::two_rows( + "multiple_row_test_icons", + [ + ToggleButtonWithIcon::new("First", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Second", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Third", IconName::AiZed, |_, _, _| {}), + ], + [ + ToggleButtonWithIcon::new("Fourth", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Fifth", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Sixth", IconName::AiZed, |_, _, _| {}), + ], + ) + .selected_index(3) + .into_any_element(), + ), + ], + )]) + .children(vec![example_group_with_title( + "Outlined Variant", + vec![ + single_example( + "Single Row Group", + ToggleButtonGroup::single_row( + "single_row_test_outline", + [ + ToggleButtonSimple::new("First", |_, _, _| {}), + ToggleButtonSimple::new("Second", |_, _, _| {}), + ToggleButtonSimple::new("Third", |_, _, _| {}), + ], + ) + .selected_index(1) + .style(ToggleButtonGroupStyle::Outlined) + .into_any_element(), + ), + single_example( + "Single Row Group with icons", + ToggleButtonGroup::single_row( + "single_row_test_icon_outlined", + [ + ToggleButtonWithIcon::new("First", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Second", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Third", IconName::AiZed, |_, _, _| {}), + ], + ) + .selected_index(1) + .style(ToggleButtonGroupStyle::Outlined) + .into_any_element(), + ), + single_example( + "Multiple Row Group", + ToggleButtonGroup::two_rows( + "multiple_row_test", + [ + ToggleButtonSimple::new("First", |_, _, _| {}), + ToggleButtonSimple::new("Second", |_, _, _| {}), + ToggleButtonSimple::new("Third", |_, _, _| {}), + ], + [ + ToggleButtonSimple::new("Fourth", |_, _, _| {}), + ToggleButtonSimple::new("Fifth", |_, _, _| {}), + ToggleButtonSimple::new("Sixth", |_, _, _| {}), + ], + ) + .selected_index(3) + .style(ToggleButtonGroupStyle::Outlined) + .into_any_element(), + ), + single_example( + "Multiple Row Group with Icons", + ToggleButtonGroup::two_rows( + "multiple_row_test", + [ + ToggleButtonWithIcon::new("First", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Second", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Third", IconName::AiZed, |_, _, _| {}), + ], + [ + ToggleButtonWithIcon::new("Fourth", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Fifth", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Sixth", IconName::AiZed, |_, _, _| {}), + ], + ) + .selected_index(3) + .style(ToggleButtonGroupStyle::Outlined) + .into_any_element(), + ), + ], + )]) + .children(vec![example_group_with_title( + "Filled Variant", + vec![ + single_example( + "Single Row Group", + ToggleButtonGroup::single_row( + "single_row_test_outline", + [ + ToggleButtonSimple::new("First", |_, _, _| {}), + ToggleButtonSimple::new("Second", |_, _, _| {}), + ToggleButtonSimple::new("Third", |_, _, _| {}), + ], + ) + .selected_index(2) + .style(ToggleButtonGroupStyle::Filled) + .into_any_element(), + ), + single_example( + "Single Row Group with icons", + ToggleButtonGroup::single_row( + "single_row_test_icon_outlined", + [ + ToggleButtonWithIcon::new("First", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Second", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Third", IconName::AiZed, |_, _, _| {}), + ], + ) + .selected_index(1) + .style(ToggleButtonGroupStyle::Filled) + .into_any_element(), + ), + single_example( + "Multiple Row Group", + ToggleButtonGroup::two_rows( + "multiple_row_test", + [ + ToggleButtonSimple::new("First", |_, _, _| {}), + ToggleButtonSimple::new("Second", |_, _, _| {}), + ToggleButtonSimple::new("Third", |_, _, _| {}), + ], + [ + ToggleButtonSimple::new("Fourth", |_, _, _| {}), + ToggleButtonSimple::new("Fifth", |_, _, _| {}), + ToggleButtonSimple::new("Sixth", |_, _, _| {}), + ], + ) + .selected_index(3) + .width(rems_from_px(100.)) + .style(ToggleButtonGroupStyle::Filled) + .into_any_element(), + ), + single_example( + "Multiple Row Group with Icons", + ToggleButtonGroup::two_rows( + "multiple_row_test", + [ + ToggleButtonWithIcon::new("First", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Second", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Third", IconName::AiZed, |_, _, _| {}), + ], + [ + ToggleButtonWithIcon::new("Fourth", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Fifth", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Sixth", IconName::AiZed, |_, _, _| {}), + ], + ) + .selected_index(3) + .width(rems_from_px(100.)) + .style(ToggleButtonGroupStyle::Filled) + .into_any_element(), + ), + ], + )]) + .children(vec![single_example( + "With Tooltips", + ToggleButtonGroup::single_row( + "with_tooltips", + [ + ToggleButtonSimple::new("First", |_, _, _| {}) + .tooltip(Tooltip::text("This is a tooltip. Hello!")), + ToggleButtonSimple::new("Second", |_, _, _| {}) + .tooltip(Tooltip::text("This is a tooltip. Hey?")), + ToggleButtonSimple::new("Third", |_, _, _| {}) + .tooltip(Tooltip::text("This is a tooltip. Get out of here now!")), ], - )]) - .children(vec![example_group_with_title( - "Outlined Variant", - vec![ - single_example( - "Single Row Group", - ToggleButtonGroup::single_row( - "single_row_test_outline", - [ - ToggleButtonSimple::new("First", |_, _, _| {}), - ToggleButtonSimple::new("Second", |_, _, _| {}), - ToggleButtonSimple::new("Third", |_, _, _| {}), - ], - ) - .selected_index(1) - .style(ToggleButtonGroupStyle::Outlined) - .into_any_element(), - ), - single_example( - "Single Row Group with icons", - ToggleButtonGroup::single_row( - "single_row_test_icon_outlined", - [ - ToggleButtonWithIcon::new( - "First", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Second", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Third", - IconName::AiZed, - |_, _, _| {}, - ), - ], - ) - .selected_index(1) - .style(ToggleButtonGroupStyle::Outlined) - .into_any_element(), - ), - single_example( - "Multiple Row Group", - ToggleButtonGroup::two_rows( - "multiple_row_test", - [ - ToggleButtonSimple::new("First", |_, _, _| {}), - ToggleButtonSimple::new("Second", |_, _, _| {}), - ToggleButtonSimple::new("Third", |_, _, _| {}), - ], - [ - ToggleButtonSimple::new("Fourth", |_, _, _| {}), - ToggleButtonSimple::new("Fifth", |_, _, _| {}), - ToggleButtonSimple::new("Sixth", |_, _, _| {}), - ], - ) - .selected_index(3) - .style(ToggleButtonGroupStyle::Outlined) - .into_any_element(), - ), - single_example( - "Multiple Row Group with Icons", - ToggleButtonGroup::two_rows( - "multiple_row_test", - [ - ToggleButtonWithIcon::new( - "First", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Second", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Third", - IconName::AiZed, - |_, _, _| {}, - ), - ], - [ - ToggleButtonWithIcon::new( - "Fourth", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Fifth", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Sixth", - IconName::AiZed, - |_, _, _| {}, - ), - ], - ) - .selected_index(3) - .style(ToggleButtonGroupStyle::Outlined) - .into_any_element(), - ), - ], - )]) - .children(vec![example_group_with_title( - "Filled Variant", - vec![ - single_example( - "Single Row Group", - ToggleButtonGroup::single_row( - "single_row_test_outline", - [ - ToggleButtonSimple::new("First", |_, _, _| {}), - ToggleButtonSimple::new("Second", |_, _, _| {}), - ToggleButtonSimple::new("Third", |_, _, _| {}), - ], - ) - .selected_index(2) - .style(ToggleButtonGroupStyle::Filled) - .into_any_element(), - ), - single_example( - "Single Row Group with icons", - ToggleButtonGroup::single_row( - "single_row_test_icon_outlined", - [ - ToggleButtonWithIcon::new( - "First", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Second", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Third", - IconName::AiZed, - |_, _, _| {}, - ), - ], - ) - .selected_index(1) - .style(ToggleButtonGroupStyle::Filled) - .into_any_element(), - ), - single_example( - "Multiple Row Group", - ToggleButtonGroup::two_rows( - "multiple_row_test", - [ - ToggleButtonSimple::new("First", |_, _, _| {}), - ToggleButtonSimple::new("Second", |_, _, _| {}), - ToggleButtonSimple::new("Third", |_, _, _| {}), - ], - [ - ToggleButtonSimple::new("Fourth", |_, _, _| {}), - ToggleButtonSimple::new("Fifth", |_, _, _| {}), - ToggleButtonSimple::new("Sixth", |_, _, _| {}), - ], - ) - .selected_index(3) - .width(rems_from_px(100.)) - .style(ToggleButtonGroupStyle::Filled) - .into_any_element(), - ), - single_example( - "Multiple Row Group with Icons", - ToggleButtonGroup::two_rows( - "multiple_row_test", - [ - ToggleButtonWithIcon::new( - "First", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Second", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Third", - IconName::AiZed, - |_, _, _| {}, - ), - ], - [ - ToggleButtonWithIcon::new( - "Fourth", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Fifth", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Sixth", - IconName::AiZed, - |_, _, _| {}, - ), - ], - ) - .selected_index(3) - .width(rems_from_px(100.)) - .style(ToggleButtonGroupStyle::Filled) - .into_any_element(), - ), - ], - )]) - .children(vec![single_example( - "With Tooltips", - ToggleButtonGroup::single_row( - "with_tooltips", - [ - ToggleButtonSimple::new("First", |_, _, _| {}) - .tooltip(Tooltip::text("This is a tooltip. Hello!")), - ToggleButtonSimple::new("Second", |_, _, _| {}) - .tooltip(Tooltip::text("This is a tooltip. Hey?")), - ToggleButtonSimple::new("Third", |_, _, _| {}) - .tooltip(Tooltip::text("This is a tooltip. Get out of here now!")), - ], - ) - .selected_index(1) - .into_any_element(), - )]) + ) + .selected_index(1) .into_any_element(), - ) + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/callout.rs b/crates/ui/src/components/callout.rs index 23c820cd545..9a897df1ac4 100644 --- a/crates/ui/src/components/callout.rs +++ b/crates/ui/src/components/callout.rs @@ -224,13 +224,14 @@ impl Component for Callout { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some( - "Used to display a callout for situations where the user needs to know some information, and likely make a decision. This might be a thread running out of tokens, or running out of prompts on a plan and needing to upgrade.", - ) + fn description() -> &'static str { + "Used to display a callout for situations where the user \ + needs to know some information, and likely make a decision. \ + This might be a thread running out of tokens, \ + or running out of prompts on a plan and needing to upgrade." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let single_action = || Button::new("got-it", "Got it").label_size(LabelSize::Small); let multiple_actions = || { h_flex() @@ -354,12 +355,10 @@ impl Component for Callout { ), ]; - Some( - v_flex() - .gap_4() - .child(example_group(basic_examples).vertical()) - .child(example_group_with_title("Severity", severity_examples).vertical()) - .into_any_element(), - ) + v_flex() + .gap_4() + .child(example_group(basic_examples).vertical()) + .child(example_group_with_title("Severity", severity_examples).vertical()) + .into_any_element() } } diff --git a/crates/ui/src/components/chip.rs b/crates/ui/src/components/chip.rs index bcb21fb379c..2609f2fc97b 100644 --- a/crates/ui/src/components/chip.rs +++ b/crates/ui/src/components/chip.rs @@ -138,7 +138,12 @@ impl Component for Chip { ComponentScope::DataDisplay } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A small, compact label container used to display tags, statuses, \ + or other short informative pieces of metadata, optionally with an icon." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let chip_examples = vec![ single_example("Default", Chip::new("Chip Example").into_any_element()), single_example( @@ -162,6 +167,6 @@ impl Component for Chip { ), ]; - Some(example_group(chip_examples).vertical().into_any_element()) + example_group(chip_examples).vertical().into_any_element() } } diff --git a/crates/ui/src/components/collab/collab_notification.rs b/crates/ui/src/components/collab/collab_notification.rs index 28d28b0a292..382749da37d 100644 --- a/crates/ui/src/components/collab/collab_notification.rs +++ b/crates/ui/src/components/collab/collab_notification.rs @@ -63,7 +63,12 @@ impl Component for CollabNotification { ComponentScope::Collaboration } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn description() -> &'static str { + "A toast-style notification surface for collaboration events, \ + such as incoming calls or shared project invites, with an accept and dismiss action." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let avatar = "https://avatars.githubusercontent.com/u/67129314?v=4"; let container = || div().h(px(72.)).w(px(400.)); // Size of the actual notification window @@ -173,14 +178,10 @@ impl Component for CollabNotification { ), ]; - Some( - v_flex() - .gap_6() - .child(example_group_with_title("Calls & Projects", call_examples).vertical()) - .child( - example_group_with_title("Contact & Channel Toasts", toast_examples).vertical(), - ) - .into_any_element(), - ) + v_flex() + .gap_6() + .child(example_group_with_title("Calls & Projects", call_examples).vertical()) + .child(example_group_with_title("Contact & Channel Toasts", toast_examples).vertical()) + .into_any_element() } } diff --git a/crates/ui/src/components/collab/update_button.rs b/crates/ui/src/components/collab/update_button.rs index 56d7b0da151..c0e74867cc6 100644 --- a/crates/ui/src/components/collab/update_button.rs +++ b/crates/ui/src/components/collab/update_button.rs @@ -172,48 +172,45 @@ impl Component for UpdateButton { "UpdateButton" } - fn description() -> Option<&'static str> { - Some( - "A button component displayed in the title bar to show auto-update status and allow users to restart Zed.", - ) + fn description() -> &'static str { + "A button component displayed in the title bar to \ + show auto-update status and allow users to restart Zed." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let version = "1.3.0+stable.2025051"; - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Progress States", - vec![ - single_example("Checking", UpdateButton::checking().into_any_element()), - single_example( - "Downloading", - UpdateButton::downloading(version).into_any_element(), - ), - single_example( - "Installing", - UpdateButton::installing(version).into_any_element(), - ), - ], - ), - example_group_with_title( - "Actionable States", - vec![ - single_example( - "Ready to Update", - UpdateButton::updated(version).into_any_element(), - ), - single_example( - "Error", - UpdateButton::errored("Network timeout").into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Progress States", + vec![ + single_example("Checking", UpdateButton::checking().into_any_element()), + single_example( + "Downloading", + UpdateButton::downloading(version).into_any_element(), + ), + single_example( + "Installing", + UpdateButton::installing(version).into_any_element(), + ), + ], + ), + example_group_with_title( + "Actionable States", + vec![ + single_example( + "Ready to Update", + UpdateButton::updated(version).into_any_element(), + ), + single_example( + "Error", + UpdateButton::errored("Network timeout").into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/count_badge.rs b/crates/ui/src/components/count_badge.rs index c546d69e6d1..bf42e5c6fd3 100644 --- a/crates/ui/src/components/count_badge.rs +++ b/crates/ui/src/components/count_badge.rs @@ -57,11 +57,11 @@ impl Component for CountBadge { ComponentScope::Status } - fn description() -> Option<&'static str> { - Some("A small, pill-shaped badge that displays a numeric count.") + fn description() -> &'static str { + "A small, pill-shaped badge that displays a numeric count." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let container = || { div() .relative() @@ -71,23 +71,21 @@ impl Component for CountBadge { .bg(cx.theme().colors().background) }; - Some( - v_flex() - .gap_6() - .child(example_group_with_title( - "Count Badge", - vec![ - single_example( - "Basic Count", - container().child(CountBadge::new(3)).into_any_element(), - ), - single_example( - "Capped Count", - container().child(CountBadge::new(150)).into_any_element(), - ), - ], - )) - .into_any_element(), - ) + v_flex() + .gap_6() + .child(example_group_with_title( + "Count Badge", + vec![ + single_example( + "Basic Count", + container().child(CountBadge::new(3)).into_any_element(), + ), + single_example( + "Capped Count", + container().child(CountBadge::new(150)).into_any_element(), + ), + ], + )) + .into_any_element() } } diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 1700699a875..42f6a9150fb 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -1289,109 +1289,107 @@ impl Component for Table { ComponentScope::Layout } - fn description() -> Option<&'static str> { - Some("A table component for displaying data in rows and columns with optional styling.") + fn description() -> &'static str { + "A table component for displaying data in rows and columns with optional styling." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Tables", - vec![ - single_example( - "Simple Table", - Table::new(3) - .width(px(400.)) - .header(vec!["Name", "Age", "City"]) - .row(vec!["Alice", "28", "New York"]) - .row(vec!["Bob", "32", "San Francisco"]) - .row(vec!["Charlie", "25", "London"]) - .into_any_element(), - ), - single_example( - "Two Column Table", - Table::new(2) - .header(vec!["Category", "Value"]) - .width(px(300.)) - .row(vec!["Revenue", "$100,000"]) - .row(vec!["Expenses", "$75,000"]) - .row(vec!["Profit", "$25,000"]) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Styled Tables", - vec![ - single_example( - "Default", - Table::new(3) - .width(px(400.)) - .header(vec!["Product", "Price", "Stock"]) - .row(vec!["Laptop", "$999", "In Stock"]) - .row(vec!["Phone", "$599", "Low Stock"]) - .row(vec!["Tablet", "$399", "Out of Stock"]) - .into_any_element(), - ), - single_example( - "Striped", - Table::new(3) - .width(px(400.)) - .striped() - .header(vec!["Product", "Price", "Stock"]) - .row(vec!["Laptop", "$999", "In Stock"]) - .row(vec!["Phone", "$599", "Low Stock"]) - .row(vec!["Tablet", "$399", "Out of Stock"]) - .row(vec!["Headphones", "$199", "In Stock"]) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Mixed Content Table", - vec![single_example( - "Table with Elements", - Table::new(5) - .width(px(840.)) - .header(vec!["Status", "Name", "Priority", "Deadline", "Action"]) - .row(vec![ - Indicator::dot().color(Color::Success).into_any_element(), - "Project A".into_any_element(), - "High".into_any_element(), - "2023-12-31".into_any_element(), - Button::new("view_a", "View") - .style(ButtonStyle::Filled) - .full_width() - .into_any_element(), - ]) - .row(vec![ - Indicator::dot().color(Color::Warning).into_any_element(), - "Project B".into_any_element(), - "Medium".into_any_element(), - "2024-03-15".into_any_element(), - Button::new("view_b", "View") - .style(ButtonStyle::Filled) - .full_width() - .into_any_element(), - ]) - .row(vec![ - Indicator::dot().color(Color::Error).into_any_element(), - "Project C".into_any_element(), - "Low".into_any_element(), - "2024-06-30".into_any_element(), - Button::new("view_c", "View") - .style(ButtonStyle::Filled) - .full_width() - .into_any_element(), - ]) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Tables", + vec![ + single_example( + "Simple Table", + Table::new(3) + .width(px(400.)) + .header(vec!["Name", "Age", "City"]) + .row(vec!["Alice", "28", "New York"]) + .row(vec!["Bob", "32", "San Francisco"]) + .row(vec!["Charlie", "25", "London"]) .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + single_example( + "Two Column Table", + Table::new(2) + .header(vec!["Category", "Value"]) + .width(px(300.)) + .row(vec!["Revenue", "$100,000"]) + .row(vec!["Expenses", "$75,000"]) + .row(vec!["Profit", "$25,000"]) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Styled Tables", + vec![ + single_example( + "Default", + Table::new(3) + .width(px(400.)) + .header(vec!["Product", "Price", "Stock"]) + .row(vec!["Laptop", "$999", "In Stock"]) + .row(vec!["Phone", "$599", "Low Stock"]) + .row(vec!["Tablet", "$399", "Out of Stock"]) + .into_any_element(), + ), + single_example( + "Striped", + Table::new(3) + .width(px(400.)) + .striped() + .header(vec!["Product", "Price", "Stock"]) + .row(vec!["Laptop", "$999", "In Stock"]) + .row(vec!["Phone", "$599", "Low Stock"]) + .row(vec!["Tablet", "$399", "Out of Stock"]) + .row(vec!["Headphones", "$199", "In Stock"]) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Mixed Content Table", + vec![single_example( + "Table with Elements", + Table::new(5) + .width(px(840.)) + .header(vec!["Status", "Name", "Priority", "Deadline", "Action"]) + .row(vec![ + Indicator::dot().color(Color::Success).into_any_element(), + "Project A".into_any_element(), + "High".into_any_element(), + "2023-12-31".into_any_element(), + Button::new("view_a", "View") + .style(ButtonStyle::Filled) + .full_width() + .into_any_element(), + ]) + .row(vec![ + Indicator::dot().color(Color::Warning).into_any_element(), + "Project B".into_any_element(), + "Medium".into_any_element(), + "2024-03-15".into_any_element(), + Button::new("view_b", "View") + .style(ButtonStyle::Filled) + .full_width() + .into_any_element(), + ]) + .row(vec![ + Indicator::dot().color(Color::Error).into_any_element(), + "Project C".into_any_element(), + "Low".into_any_element(), + "2024-06-30".into_any_element(), + Button::new("view_c", "View") + .style(ButtonStyle::Filled) + .full_width() + .into_any_element(), + ]) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/diff_stat.rs b/crates/ui/src/components/diff_stat.rs index 5fd88903470..d6d644db437 100644 --- a/crates/ui/src/components/diff_stat.rs +++ b/crates/ui/src/components/diff_stat.rs @@ -63,7 +63,12 @@ impl Component for DiffStat { ComponentScope::VersionControl } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A compact summary of additions and deletions for a diff, \ + displayed as colored insertion and deletion counts." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let container = || { h_flex() .py_4() @@ -81,10 +86,8 @@ impl Component for DiffStat { .into_any_element(), )]; - Some( - example_group(diff_stat_example) - .vertical() - .into_any_element(), - ) + example_group(diff_stat_example) + .vertical() + .into_any_element() } } diff --git a/crates/ui/src/components/disclosure.rs b/crates/ui/src/components/disclosure.rs index 320751890da..09df75a5d5b 100644 --- a/crates/ui/src/components/disclosure.rs +++ b/crates/ui/src/components/disclosure.rs @@ -109,43 +109,37 @@ impl Component for Disclosure { ComponentScope::Input } - fn description() -> Option<&'static str> { - Some( - "An interactive element used to show or hide content, typically used in expandable sections or tree-like structures.", - ) + fn description() -> &'static str { + "An interactive element used to show or hide content, \ + typically used in expandable sections or tree-like structures." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Disclosure States", - vec![ - single_example( - "Closed", - Disclosure::new("closed", false).into_any_element(), - ), - single_example( - "Open", - Disclosure::new("open", true).into_any_element(), - ), - ], - ), - example_group_with_title( - "Interactive Example", - vec![single_example( - "Toggleable", - v_flex() - .gap_2() - .child(Disclosure::new("interactive", false).into_any_element()) - .child(Label::new("Click to toggle")) - .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Disclosure States", + vec![ + single_example( + "Closed", + Disclosure::new("closed", false).into_any_element(), + ), + single_example("Open", Disclosure::new("open", true).into_any_element()), + ], + ), + example_group_with_title( + "Interactive Example", + vec![single_example( + "Toggleable", + v_flex() + .gap_2() + .child(Disclosure::new("interactive", false).into_any_element()) + .child(Label::new("Click to toggle")) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/divider.rs b/crates/ui/src/components/divider.rs index 5ad2187cfae..ff1f84adaa4 100644 --- a/crates/ui/src/components/divider.rs +++ b/crates/ui/src/components/divider.rs @@ -168,85 +168,76 @@ impl Component for Divider { ComponentScope::Layout } - fn description() -> Option<&'static str> { - Some( - "Visual separator used to create divisions between groups of content or sections in a layout.", - ) + fn description() -> &'static str { + "Visual separator used to create divisions between groups of content \ + or sections in a layout." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Horizontal Dividers", - vec![ - single_example("Default", Divider::horizontal().into_any_element()), - single_example( - "Border Color", - Divider::horizontal() - .color(DividerColor::Border) - .into_any_element(), - ), - single_example( - "Inset", - Divider::horizontal().inset().into_any_element(), - ), - single_example( - "Dashed", - Divider::horizontal_dashed().into_any_element(), - ), - ], - ), - example_group_with_title( - "Vertical Dividers", - vec![ - single_example( - "Default", - div().h_16().child(Divider::vertical()).into_any_element(), - ), - single_example( - "Border Color", - div() - .h_16() - .child(Divider::vertical().color(DividerColor::Border)) - .into_any_element(), - ), - single_example( - "Inset", - div() - .h_16() - .child(Divider::vertical().inset()) - .into_any_element(), - ), - single_example( - "Dashed", - div() - .h_16() - .child(Divider::vertical_dashed()) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Example Usage", - vec![single_example( - "Between Content", - v_flex() - .w_full() - .gap_4() - .px_4() - .child(Label::new("Section One")) - .child(Divider::horizontal()) - .child(Label::new("Section Two")) - .child(Divider::horizontal_dashed()) - .child(Label::new("Section Three")) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Horizontal Dividers", + vec![ + single_example("Default", Divider::horizontal().into_any_element()), + single_example( + "Border Color", + Divider::horizontal() + .color(DividerColor::Border) .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + single_example("Inset", Divider::horizontal().inset().into_any_element()), + single_example("Dashed", Divider::horizontal_dashed().into_any_element()), + ], + ), + example_group_with_title( + "Vertical Dividers", + vec![ + single_example( + "Default", + div().h_16().child(Divider::vertical()).into_any_element(), + ), + single_example( + "Border Color", + div() + .h_16() + .child(Divider::vertical().color(DividerColor::Border)) + .into_any_element(), + ), + single_example( + "Inset", + div() + .h_16() + .child(Divider::vertical().inset()) + .into_any_element(), + ), + single_example( + "Dashed", + div() + .h_16() + .child(Divider::vertical_dashed()) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Example Usage", + vec![single_example( + "Between Content", + v_flex() + .w_full() + .gap_4() + .px_4() + .child(Label::new("Section One")) + .child(Divider::horizontal()) + .child(Label::new("Section Two")) + .child(Divider::horizontal_dashed()) + .child(Label::new("Section Three")) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/dropdown_menu.rs b/crates/ui/src/components/dropdown_menu.rs index c3cb3bcf0d5..ebce559d7d4 100644 --- a/crates/ui/src/components/dropdown_menu.rs +++ b/crates/ui/src/components/dropdown_menu.rs @@ -231,13 +231,12 @@ impl Component for DropdownMenu { "DropdownMenu" } - fn description() -> Option<&'static str> { - Some( - "A dropdown menu displays a list of actions or options. A dropdown menu is always activated by clicking a trigger (or via a keybinding).", - ) + fn description() -> &'static str { + "A dropdown menu displays a list of actions or options. \ + A dropdown menu is always activated by clicking a trigger (or via a keybinding)." } - fn preview(window: &mut Window, cx: &mut App) -> Option { + fn preview(window: &mut Window, cx: &mut App) -> AnyElement { let menu = ContextMenu::build(window, cx, |this, _, _| { this.entry("Option 1", None, |_, _| {}) .entry("Option 2", None, |_, _| {}) @@ -270,66 +269,60 @@ impl Component for DropdownMenu { }) }); - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Usage", - vec![ - single_example( - "Default", - DropdownMenu::new("default", "Select an option", menu.clone()) - .into_any_element(), - ), - single_example( - "Full Width", - DropdownMenu::new( - "full-width", - "Full Width Dropdown", - menu.clone(), - ) + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Usage", + vec![ + single_example( + "Default", + DropdownMenu::new("default", "Select an option", menu.clone()) + .into_any_element(), + ), + single_example( + "Full Width", + DropdownMenu::new("full-width", "Full Width Dropdown", menu.clone()) .full_width(true) .into_any_element(), - ), - ], - ), - example_group_with_title( - "Submenus", - vec![single_example( - "With Submenus", - DropdownMenu::new("submenu", "Submenu", menu_with_submenu) + ), + ], + ), + example_group_with_title( + "Submenus", + vec![single_example( + "With Submenus", + DropdownMenu::new("submenu", "Submenu", menu_with_submenu) + .into_any_element(), + )], + ), + example_group_with_title( + "Styles", + vec![ + single_example( + "Outlined", + DropdownMenu::new("outlined", "Outlined Dropdown", menu.clone()) + .style(DropdownStyle::Outlined) .into_any_element(), - )], - ), - example_group_with_title( - "Styles", - vec![ - single_example( - "Outlined", - DropdownMenu::new("outlined", "Outlined Dropdown", menu.clone()) - .style(DropdownStyle::Outlined) - .into_any_element(), - ), - single_example( - "Ghost", - DropdownMenu::new("ghost", "Ghost Dropdown", menu.clone()) - .style(DropdownStyle::Ghost) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "States", - vec![single_example( - "Disabled", - DropdownMenu::new("disabled", "Disabled Dropdown", menu) - .disabled(true) + ), + single_example( + "Ghost", + DropdownMenu::new("ghost", "Ghost Dropdown", menu.clone()) + .style(DropdownStyle::Ghost) .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + ], + ), + example_group_with_title( + "States", + vec![single_example( + "Disabled", + DropdownMenu::new("disabled", "Disabled Dropdown", menu) + .disabled(true) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/facepile.rs b/crates/ui/src/components/facepile.rs index 79a36871d60..4960cb98601 100644 --- a/crates/ui/src/components/facepile.rs +++ b/crates/ui/src/components/facepile.rs @@ -94,42 +94,39 @@ impl Component for Facepile { ComponentScope::Collaboration } - fn description() -> Option<&'static str> { - Some( - "Displays a collection of avatars or initials in a compact format. Often used to represent active collaborators or a subset of contributors.", - ) + fn description() -> &'static str { + "Displays a collection of avatars or initials in a compact format. \ + Often used to represent active collaborators or a subset of contributors." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![example_group_with_title( - "Facepile Examples", - vec![ - single_example( - "Default", - Facepile::new( - EXAMPLE_FACES - .iter() - .map(|&url| Avatar::new(url).into_any_element()) - .collect(), - ) - .into_any_element(), - ), - single_example( - "Custom Size", - Facepile::new( - EXAMPLE_FACES - .iter() - .map(|&url| Avatar::new(url).size(px(24.)).into_any_element()) - .collect(), - ) - .into_any_element(), - ), - ], - )]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Facepile Examples", + vec![ + single_example( + "Default", + Facepile::new( + EXAMPLE_FACES + .iter() + .map(|&url| Avatar::new(url).into_any_element()) + .collect(), + ) + .into_any_element(), + ), + single_example( + "Custom Size", + Facepile::new( + EXAMPLE_FACES + .iter() + .map(|&url| Avatar::new(url).size(px(24.)).into_any_element()) + .collect(), + ) + .into_any_element(), + ), + ], + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index d9a1553b7da..6089bba4b86 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -288,57 +288,54 @@ impl Component for Icon { ComponentScope::Images } - fn description() -> Option<&'static str> { - Some( - "A versatile icon component that supports SVG and image-based icons with customizable size, color, and transformations.", - ) + fn description() -> &'static str { + "A versatile icon component that supports SVG and image-based icons \ + with customizable size, color, and transformations." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Sizes", - vec![single_example( - "XSmall, Small, Default, Large", - h_flex() - .gap_1() - .child(Icon::new(IconName::Star).size(IconSize::XSmall)) - .child(Icon::new(IconName::Star).size(IconSize::Small)) - .child(Icon::new(IconName::Star)) - .child(Icon::new(IconName::Star).size(IconSize::XLarge)) - .into_any_element(), - )], - ), - example_group(vec![single_example( - "All Icons", + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Sizes", + vec![single_example( + "XSmall, Small, Default, Large", h_flex() - .image_cache(gpui::retain_all("all icons")) - .flex_wrap() - .gap_2() - .children(::iter().map( - |icon_name: IconName| { - let name: SharedString = format!("{icon_name:?}").into(); - v_flex() - .min_w_0() - .w_24() - .p_1p5() - .gap_2() - .border_1() - .border_color(cx.theme().colors().border_variant) - .bg(cx.theme().colors().element_disabled) - .rounded_sm() - .items_center() - .child(Icon::new(icon_name)) - .child(Label::new(name).size(LabelSize::XSmall).truncate()) - }, - )) + .gap_1() + .child(Icon::new(IconName::Star).size(IconSize::XSmall)) + .child(Icon::new(IconName::Star).size(IconSize::Small)) + .child(Icon::new(IconName::Star)) + .child(Icon::new(IconName::Star).size(IconSize::XLarge)) .into_any_element(), - )]), - ]) - .into_any_element(), - ) + )], + ), + example_group(vec![single_example( + "All Icons", + h_flex() + .image_cache(gpui::retain_all("all icons")) + .flex_wrap() + .gap_2() + .children(::iter().map( + |icon_name: IconName| { + let name: SharedString = format!("{icon_name:?}").into(); + v_flex() + .min_w_0() + .w_24() + .p_1p5() + .gap_2() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().element_disabled) + .rounded_sm() + .items_center() + .child(Icon::new(icon_name)) + .child(Label::new(name).size(LabelSize::XSmall).truncate()) + }, + )) + .into_any_element(), + )]), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/icon/decorated_icon.rs b/crates/ui/src/components/icon/decorated_icon.rs index 82ca844c384..5ea8cdc48e4 100644 --- a/crates/ui/src/components/icon/decorated_icon.rs +++ b/crates/ui/src/components/icon/decorated_icon.rs @@ -29,13 +29,12 @@ impl Component for DecoratedIcon { ComponentScope::Images } - fn description() -> Option<&'static str> { - Some( - "An icon with an optional decoration overlay (like an X, triangle, or dot) that can be positioned relative to the icon", - ) + fn description() -> &'static str { + "An icon with an optional decoration overlay (like an X, triangle, or dot) \ + that can be positioned relative to the icon" } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let decoration_x = IconDecoration::new( IconDecorationKind::X, cx.theme().colors().surface_background, @@ -69,38 +68,32 @@ impl Component for DecoratedIcon { y: px(-2.), }); - Some( - v_flex() - .gap_6() - .children(vec![example_group_with_title( - "Decorations", - vec![ - single_example( - "No Decoration", - DecoratedIcon::new(Icon::new(IconName::FileDoc), None) - .into_any_element(), - ), - single_example( - "X Decoration", - DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_x)) - .into_any_element(), - ), - single_example( - "Triangle Decoration", - DecoratedIcon::new( - Icon::new(IconName::FileDoc), - Some(decoration_triangle), - ) + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Decorations", + vec![ + single_example( + "No Decoration", + DecoratedIcon::new(Icon::new(IconName::FileDoc), None).into_any_element(), + ), + single_example( + "X Decoration", + DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_x)) .into_any_element(), - ), - single_example( - "Dot Decoration", - DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_dot)) - .into_any_element(), - ), - ], - )]) - .into_any_element(), - ) + ), + single_example( + "Triangle Decoration", + DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_triangle)) + .into_any_element(), + ), + single_example( + "Dot Decoration", + DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_dot)) + .into_any_element(), + ), + ], + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/image.rs b/crates/ui/src/components/image.rs index 759d637d451..25b1a3003f4 100644 --- a/crates/ui/src/components/image.rs +++ b/crates/ui/src/components/image.rs @@ -106,66 +106,64 @@ impl Component for Vector { "Vector" } - fn description() -> Option<&'static str> { - Some("A vector image component that can be displayed at specific sizes.") + fn description() -> &'static str { + "A vector image component that can be displayed at specific sizes." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let size = rems_from_px(60.); - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Usage", - vec![ - single_example( - "Default", - Vector::square(VectorName::ZedLogo, size).into_any_element(), - ), - single_example( - "Custom Size", - h_flex() - .h(rems_from_px(120.)) - .justify_center() - .child(Vector::new( - VectorName::ZedLogo, - rems_from_px(120.), - rems_from_px(200.), - )) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Colored", - vec![ - single_example( - "Accent Color", - Vector::square(VectorName::ZedLogo, size) - .color(Color::Accent) - .into_any_element(), - ), - single_example( - "Error Color", - Vector::square(VectorName::ZedLogo, size) - .color(Color::Error) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Different Vectors", - vec![single_example( - "Zed X Copilot", - Vector::square(VectorName::ZedXCopilot, rems_from_px(100.)) + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Usage", + vec![ + single_example( + "Default", + Vector::square(VectorName::ZedLogo, size).into_any_element(), + ), + single_example( + "Custom Size", + h_flex() + .h(rems_from_px(120.)) + .justify_center() + .child(Vector::new( + VectorName::ZedLogo, + rems_from_px(120.), + rems_from_px(200.), + )) .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + ], + ), + example_group_with_title( + "Colored", + vec![ + single_example( + "Accent Color", + Vector::square(VectorName::ZedLogo, size) + .color(Color::Accent) + .into_any_element(), + ), + single_example( + "Error Color", + Vector::square(VectorName::ZedLogo, size) + .color(Color::Error) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Different Vectors", + vec![single_example( + "Zed X Copilot", + Vector::square(VectorName::ZedXCopilot, rems_from_px(100.)) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/indicator.rs b/crates/ui/src/components/indicator.rs index 59d69a068b3..41f6f676685 100644 --- a/crates/ui/src/components/indicator.rs +++ b/crates/ui/src/components/indicator.rs @@ -89,89 +89,86 @@ impl Component for Indicator { ComponentScope::Status } - fn description() -> Option<&'static str> { - Some( - "Visual indicators used to represent status, notifications, or draw attention to specific elements.", - ) + fn description() -> &'static str { + "Visual indicators used to represent status, notifications, \ + or draw attention to specific elements." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Dot Indicators", - vec![ - single_example("Default", Indicator::dot().into_any_element()), - single_example( - "Success", - Indicator::dot().color(Color::Success).into_any_element(), - ), - single_example( - "Warning", - Indicator::dot().color(Color::Warning).into_any_element(), - ), - single_example( - "Error", - Indicator::dot().color(Color::Error).into_any_element(), - ), - single_example( - "With Border", - Indicator::dot() - .color(Color::Accent) - .border_color(Color::Default) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Bar Indicators", - vec![ - single_example("Default", Indicator::bar().into_any_element()), - single_example( - "Success", - Indicator::bar().color(Color::Success).into_any_element(), - ), - single_example( - "Warning", - Indicator::bar().color(Color::Warning).into_any_element(), - ), - single_example( - "Error", - Indicator::bar().color(Color::Error).into_any_element(), - ), - ], - ), - example_group_with_title( - "Icon Indicators", - vec![ - single_example( - "Default", - Indicator::icon(Icon::new(IconName::Circle)).into_any_element(), - ), - single_example( - "Success", - Indicator::icon(Icon::new(IconName::Check)) - .color(Color::Success) - .into_any_element(), - ), - single_example( - "Warning", - Indicator::icon(Icon::new(IconName::Warning)) - .color(Color::Warning) - .into_any_element(), - ), - single_example( - "Error", - Indicator::icon(Icon::new(IconName::Close)) - .color(Color::Error) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Dot Indicators", + vec![ + single_example("Default", Indicator::dot().into_any_element()), + single_example( + "Success", + Indicator::dot().color(Color::Success).into_any_element(), + ), + single_example( + "Warning", + Indicator::dot().color(Color::Warning).into_any_element(), + ), + single_example( + "Error", + Indicator::dot().color(Color::Error).into_any_element(), + ), + single_example( + "With Border", + Indicator::dot() + .color(Color::Accent) + .border_color(Color::Default) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Bar Indicators", + vec![ + single_example("Default", Indicator::bar().into_any_element()), + single_example( + "Success", + Indicator::bar().color(Color::Success).into_any_element(), + ), + single_example( + "Warning", + Indicator::bar().color(Color::Warning).into_any_element(), + ), + single_example( + "Error", + Indicator::bar().color(Color::Error).into_any_element(), + ), + ], + ), + example_group_with_title( + "Icon Indicators", + vec![ + single_example( + "Default", + Indicator::icon(Icon::new(IconName::Circle)).into_any_element(), + ), + single_example( + "Success", + Indicator::icon(Icon::new(IconName::Check)) + .color(Color::Success) + .into_any_element(), + ), + single_example( + "Warning", + Indicator::icon(Icon::new(IconName::Warning)) + .color(Color::Warning) + .into_any_element(), + ), + single_example( + "Error", + Indicator::icon(Icon::new(IconName::Close)) + .color(Color::Error) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index 016181ee9bd..af30dd68659 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -130,6 +130,11 @@ impl KeyBinding { self.disabled = disabled; self } + + fn vim_mode(mut self, vim_mode: bool) -> Self { + self.vim_mode = vim_mode; + self + } } fn render_key( @@ -563,85 +568,77 @@ impl Component for KeyBinding { "KeyBinding" } - fn description() -> Option<&'static str> { - Some( - "A component that displays a key binding, supporting different platform styles and vim mode.", - ) + fn description() -> &'static str { + "A component that displays a key binding, \ + supporting different platform styles and vim mode." } - // fn preview(_window: &mut Window, cx: &mut App) -> Option { - // Some( - // v_flex() - // .gap_6() - // .children(vec![ - // example_group_with_title( - // "Basic Usage", - // vec![ - // single_example( - // "Default", - // KeyBinding::new_from_gpui( - // gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None), - // cx, - // ) - // .into_any_element(), - // ), - // single_example( - // "Mac Style", - // KeyBinding::new_from_gpui( - // gpui::KeyBinding::new("cmd-s", gpui::NoAction, None), - // cx, - // ) - // .platform_style(PlatformStyle::Mac) - // .into_any_element(), - // ), - // single_example( - // "Windows Style", - // KeyBinding::new_from_gpui( - // gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None), - // cx, - // ) - // .platform_style(PlatformStyle::Windows) - // .into_any_element(), - // ), - // ], - // ), - // example_group_with_title( - // "Vim Mode", - // vec![single_example( - // "Vim Mode Enabled", - // KeyBinding::new_from_gpui( - // gpui::KeyBinding::new("dd", gpui::NoAction, None), - // cx, - // ) - // .vim_mode(true) - // .into_any_element(), - // )], - // ), - // example_group_with_title( - // "Complex Bindings", - // vec![ - // single_example( - // "Multiple Keys", - // KeyBinding::new_from_gpui( - // gpui::KeyBinding::new("ctrl-k ctrl-b", gpui::NoAction, None), - // cx, - // ) - // .into_any_element(), - // ), - // single_example( - // "With Shift", - // KeyBinding::new_from_gpui( - // gpui::KeyBinding::new("shift-cmd-p", gpui::NoAction, None), - // cx, - // ) - // .into_any_element(), - // ), - // ], - // ), - // ]) - // .into_any_element(), - // ) - // } + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + fn keybinding(input: &str) -> KeyBinding { + let keystrokes: Rc<[KeybindingKeystroke]> = input + .split_whitespace() + .filter_map(|chunk| Keystroke::parse(chunk).ok()) + .map(KeybindingKeystroke::from_keystroke) + .collect::>() + .into(); + KeyBinding::from_keystrokes(keystrokes, false) + } + + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Platform Styles", + vec![ + single_example( + "Mac Style", + keybinding("cmd-s") + .platform_style(PlatformStyle::Mac) + .into_any_element(), + ), + single_example( + "Linux Style", + keybinding("ctrl-s") + .platform_style(PlatformStyle::Linux) + .into_any_element(), + ), + single_example( + "Windows Style", + keybinding("ctrl-s") + .platform_style(PlatformStyle::Windows) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Vim Mode Style", + vec![ + single_example( + "Simple", + keybinding("s") + .platform_style(PlatformStyle::Mac) + .vim_mode(true) + .into_any_element(), + ), + single_example( + "With Modifiers", + keybinding("ctrl-s") + .platform_style(PlatformStyle::Linux) + .vim_mode(true) + .into_any_element(), + ), + single_example( + "With other special key", + keybinding("ctrl-escape") + .platform_style(PlatformStyle::Windows) + .vim_mode(true) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() + } } #[cfg(test)] diff --git a/crates/ui/src/components/keybinding_hint.rs b/crates/ui/src/components/keybinding_hint.rs index 2d6f1f72f9c..0e74ad07e62 100644 --- a/crates/ui/src/components/keybinding_hint.rs +++ b/crates/ui/src/components/keybinding_hint.rs @@ -260,74 +260,68 @@ impl Component for KeybindingHint { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some("Displays a keyboard shortcut hint with optional prefix and suffix text") + fn description() -> &'static str { + "Displays a keyboard shortcut hint with optional prefix and suffix text" } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let enter = KeyBinding::for_action(&menu::Confirm, cx); let bg_color = cx.theme().colors().surface_background; - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic", - vec![ - single_example( - "With Prefix", - KeybindingHint::with_prefix( - "Go to Start:", - enter.clone(), - bg_color, - ) + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic", + vec![ + single_example( + "With Prefix", + KeybindingHint::with_prefix("Go to Start:", enter.clone(), bg_color) .into_any_element(), - ), - single_example( - "With Suffix", - KeybindingHint::with_suffix(enter.clone(), "Go to End", bg_color) - .into_any_element(), - ), - single_example( - "With Prefix and Suffix", - KeybindingHint::new(enter.clone(), bg_color) - .prefix("Confirm:") - .suffix("Execute selected action") - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Sizes", - vec![ - single_example( - "Small", - KeybindingHint::new(enter.clone(), bg_color) - .size(Pixels::from(12.0)) - .prefix("Small:") - .into_any_element(), - ), - single_example( - "Medium", - KeybindingHint::new(enter.clone(), bg_color) - .size(Pixels::from(16.0)) - .suffix("Medium") - .into_any_element(), - ), - single_example( - "Large", - KeybindingHint::new(enter, bg_color) - .size(Pixels::from(20.0)) - .prefix("Large:") - .suffix("Size") - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + ), + single_example( + "With Suffix", + KeybindingHint::with_suffix(enter.clone(), "Go to End", bg_color) + .into_any_element(), + ), + single_example( + "With Prefix and Suffix", + KeybindingHint::new(enter.clone(), bg_color) + .prefix("Confirm:") + .suffix("Execute selected action") + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Sizes", + vec![ + single_example( + "Small", + KeybindingHint::new(enter.clone(), bg_color) + .size(Pixels::from(12.0)) + .prefix("Small:") + .into_any_element(), + ), + single_example( + "Medium", + KeybindingHint::new(enter.clone(), bg_color) + .size(Pixels::from(16.0)) + .suffix("Medium") + .into_any_element(), + ), + single_example( + "Large", + KeybindingHint::new(enter, bg_color) + .size(Pixels::from(20.0)) + .prefix("Large:") + .suffix("Size") + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index 6d5b1d5645a..b3f19e00604 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -223,89 +223,94 @@ impl Component for HighlightedLabel { "HighlightedLabel" } - fn description() -> Option<&'static str> { - Some("A label with highlighted characters based on specified indices.") + fn description() -> &'static str { + "A label with highlighted characters based on specified indices." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Usage", - vec![ - single_example( - "Default", - HighlightedLabel::new("Highlighted Text", vec![0, 1, 2, 3]).into_any_element(), - ), - single_example( - "Custom Color", - HighlightedLabel::new("Colored Highlight", vec![0, 1, 7, 8, 9]) - .color(Color::Accent) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Styles", - vec![ - single_example( - "Bold", - HighlightedLabel::new("Bold Highlight", vec![0, 1, 2, 3]) - .weight(FontWeight::BOLD) - .into_any_element(), - ), - single_example( - "Italic", - HighlightedLabel::new("Italic Highlight", vec![0, 1, 6, 7, 8]) - .italic() - .into_any_element(), - ), - single_example( - "Underline", - HighlightedLabel::new("Underlined Highlight", vec![0, 1, 10, 11, 12]) - .underline() - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Sizes", - vec![ - single_example( - "Small", - HighlightedLabel::new("Small Highlight", vec![0, 1, 5, 6, 7]) - .size(LabelSize::Small) - .into_any_element(), - ), - single_example( - "Large", - HighlightedLabel::new("Large Highlight", vec![0, 1, 5, 6, 7]) - .size(LabelSize::Large) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Special Cases", - vec![ - single_example( - "Single Line", - HighlightedLabel::new("Single Line Highlight\nWith Newline", vec![0, 1, 7, 8, 9]) - .single_line() - .into_any_element(), - ), - single_example( - "Truncate", - HighlightedLabel::new("This is a very long text that should be truncated with highlights", vec![0, 1, 2, 3, 4, 5]) - .truncate() - .into_any_element(), - ), - ], - ), - ]) - .into_any_element() - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Usage", + vec![ + single_example( + "Default", + HighlightedLabel::new("Highlighted Text", vec![0, 1, 2, 3]) + .into_any_element(), + ), + single_example( + "Custom Color", + HighlightedLabel::new("Colored Highlight", vec![0, 1, 7, 8, 9]) + .color(Color::Accent) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Styles", + vec![ + single_example( + "Bold", + HighlightedLabel::new("Bold Highlight", vec![0, 1, 2, 3]) + .weight(FontWeight::BOLD) + .into_any_element(), + ), + single_example( + "Italic", + HighlightedLabel::new("Italic Highlight", vec![0, 1, 6, 7, 8]) + .italic() + .into_any_element(), + ), + single_example( + "Underline", + HighlightedLabel::new("Underlined Highlight", vec![0, 1, 10, 11, 12]) + .underline() + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Sizes", + vec![ + single_example( + "Small", + HighlightedLabel::new("Small Highlight", vec![0, 1, 5, 6, 7]) + .size(LabelSize::Small) + .into_any_element(), + ), + single_example( + "Large", + HighlightedLabel::new("Large Highlight", vec![0, 1, 5, 6, 7]) + .size(LabelSize::Large) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Special Cases", + vec![ + single_example( + "Single Line", + HighlightedLabel::new( + "Single Line Highlight\nWith Newline", + vec![0, 1, 7, 8, 9], + ) + .single_line() + .into_any_element(), + ), + single_example( + "Truncate", + HighlightedLabel::new( + "This is a very long text that should be truncated with highlights", + vec![0, 1, 2, 3, 4, 5], + ) + .truncate() + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index 871f53fbe4d..370c9166c36 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -352,13 +352,13 @@ impl Component for Label { ComponentScope::Typography } - fn description() -> Option<&'static str> { - Some("A text label component that supports various styles, sizes, and formatting options.") + fn description() -> &'static str { + "A text label component that supports various styles, \ + sizes, and formatting options." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { - Some( - v_flex() + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + v_flex() .gap_6() .children(vec![ example_group_with_title( @@ -405,6 +405,5 @@ impl Component for Label { ), ]) .into_any_element() - ) } } diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 5cad04efcfa..27eb9e797fa 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -271,15 +271,13 @@ impl Component for LabelLike { "LabelLike" } - fn description() -> Option<&'static str> { - Some( - "A flexible, customizable label-like component that serves as a base for other label types.", - ) + fn description() -> &'static str { + "A flexible, customizable label-like component \ + that serves as a base for other label types." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { - Some( - v_flex() + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + v_flex() .gap_6() .children(vec![ example_group_with_title( @@ -326,6 +324,5 @@ impl Component for LabelLike { ), ]) .into_any_element() - ) } } diff --git a/crates/ui/src/components/label/spinner_label.rs b/crates/ui/src/components/label/spinner_label.rs index 33eeeae1251..583debde9cb 100644 --- a/crates/ui/src/components/label/spinner_label.rs +++ b/crates/ui/src/components/label/spinner_label.rs @@ -190,7 +190,12 @@ impl Component for SpinnerLabel { "Spinner Label" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn description() -> &'static str { + "A text-based loading indicator that animates through a sequence of \ + unicode glyphs to show ongoing or indeterminate work." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let examples = vec![ single_example("Default", SpinnerLabel::new().into_any_element()), single_example( @@ -200,6 +205,6 @@ impl Component for SpinnerLabel { single_example("Sand Variant", SpinnerLabel::sand().into_any_element()), ]; - Some(example_group(examples).vertical().into_any_element()) + example_group(examples).vertical().into_any_element() } } diff --git a/crates/ui/src/components/list/list.rs b/crates/ui/src/components/list/list.rs index ccae5bed23d..4659c7e16b5 100644 --- a/crates/ui/src/components/list/list.rs +++ b/crates/ui/src/components/list/list.rs @@ -99,44 +99,41 @@ impl Component for List { ComponentScope::Layout } - fn description() -> Option<&'static str> { - Some( - "A container component for displaying a collection of list items with optional header and empty state.", - ) + fn description() -> &'static str { + "A container component for displaying a collection of list items \ + with optional header and empty state." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![example_group_with_title( - "Basic Lists", - vec![ - single_example( - "Simple List", - List::new() - .child(ListItem::new("item1").child(Label::new("Item 1"))) - .child(ListItem::new("item2").child(Label::new("Item 2"))) - .child(ListItem::new("item3").child(Label::new("Item 3"))) - .into_any_element(), - ), - single_example( - "With Header", - List::new() - .header(ListHeader::new("Section Header")) - .child(ListItem::new("item1").child(Label::new("Item 1"))) - .child(ListItem::new("item2").child(Label::new("Item 2"))) - .into_any_element(), - ), - single_example( - "Empty List", - List::new() - .empty_message("No items to display") - .into_any_element(), - ), - ], - )]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Basic Lists", + vec![ + single_example( + "Simple List", + List::new() + .child(ListItem::new("item1").child(Label::new("Item 1"))) + .child(ListItem::new("item2").child(Label::new("Item 2"))) + .child(ListItem::new("item3").child(Label::new("Item 3"))) + .into_any_element(), + ), + single_example( + "With Header", + List::new() + .header(ListHeader::new("Section Header")) + .child(ListItem::new("item1").child(Label::new("Item 1"))) + .child(ListItem::new("item2").child(Label::new("Item 2"))) + .into_any_element(), + ), + single_example( + "Empty List", + List::new() + .empty_message("No items to display") + .into_any_element(), + ), + ], + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/list/list_bullet_item.rs b/crates/ui/src/components/list/list_bullet_item.rs index 934f0853dbe..8fed3158756 100644 --- a/crates/ui/src/components/list/list_bullet_item.rs +++ b/crates/ui/src/components/list/list_bullet_item.rs @@ -71,11 +71,11 @@ impl Component for ListBulletItem { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some("A list item with a dash indicator for unordered lists.") + fn description() -> &'static str { + "A list item with a dash indicator for unordered lists." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let basic_examples = vec![ single_example( "Simple", @@ -105,11 +105,9 @@ impl Component for ListBulletItem { ), ]; - Some( - v_flex() - .gap_6() - .child(example_group(basic_examples).vertical()) - .into_any_element(), - ) + v_flex() + .gap_6() + .child(example_group(basic_examples).vertical()) + .into_any_element() } } diff --git a/crates/ui/src/components/list/list_header.rs b/crates/ui/src/components/list/list_header.rs index 9d72366c3be..741f60ba34d 100644 --- a/crates/ui/src/components/list/list_header.rs +++ b/crates/ui/src/components/list/list_header.rs @@ -144,74 +144,71 @@ impl Component for ListHeader { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some( - "A header component for lists with support for icons, actions, and collapsible sections.", - ) + fn description() -> &'static str { + "A header component for lists with support for icons, actions, \ + and collapsible sections." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Headers", - vec![ - single_example( - "Simple", - ListHeader::new("Section Header").into_any_element(), - ), - single_example( - "With Icon", - ListHeader::new("Files") - .start_slot(Icon::new(IconName::File)) - .into_any_element(), - ), - single_example( - "With End Slot", - ListHeader::new("Recent") - .end_slot(Label::new("5").color(Color::Muted)) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Collapsible Headers", - vec![ - single_example( - "Expanded", - ListHeader::new("Expanded Section") - .toggle(Some(true)) - .into_any_element(), - ), - single_example( - "Collapsed", - ListHeader::new("Collapsed Section") - .toggle(Some(false)) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "States", - vec![ - single_example( - "Selected", - ListHeader::new("Selected Header") - .toggle_state(true) - .into_any_element(), - ), - single_example( - "Inset", - ListHeader::new("Inset Header") - .inset(true) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Headers", + vec![ + single_example( + "Simple", + ListHeader::new("Section Header").into_any_element(), + ), + single_example( + "With Icon", + ListHeader::new("Files") + .start_slot(Icon::new(IconName::File)) + .into_any_element(), + ), + single_example( + "With End Slot", + ListHeader::new("Recent") + .end_slot(Label::new("5").color(Color::Muted)) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Collapsible Headers", + vec![ + single_example( + "Expanded", + ListHeader::new("Expanded Section") + .toggle(Some(true)) + .into_any_element(), + ), + single_example( + "Collapsed", + ListHeader::new("Collapsed Section") + .toggle(Some(false)) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "States", + vec![ + single_example( + "Selected", + ListHeader::new("Selected Header") + .toggle_state(true) + .into_any_element(), + ), + single_example( + "Inset", + ListHeader::new("Inset Header") + .inset(true) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index ece1fd3c61e..06be27abab2 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -385,109 +385,106 @@ impl Component for ListItem { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some( - "A flexible list item component with support for icons, actions, disclosure toggles, and hierarchical display.", - ) + fn description() -> &'static str { + "A flexible list item component with support for icons, actions, \ + disclosure toggles, and hierarchical display." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic List Items", - vec![ - single_example( - "Simple", - ListItem::new("simple") - .child(Label::new("Simple list item")) - .into_any_element(), - ), - single_example( - "With Icon", - ListItem::new("with_icon") - .start_slot(Icon::new(IconName::File)) - .child(Label::new("List item with icon")) - .into_any_element(), - ), - single_example( - "Selected", - ListItem::new("selected") - .toggle_state(true) - .start_slot(Icon::new(IconName::Check)) - .child(Label::new("Selected item")) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "List Item Spacing", - vec![ - single_example( - "Dense", - ListItem::new("dense") - .spacing(ListItemSpacing::Dense) - .child(Label::new("Dense spacing")) - .into_any_element(), - ), - single_example( - "Extra Dense", - ListItem::new("extra_dense") - .spacing(ListItemSpacing::ExtraDense) - .child(Label::new("Extra dense spacing")) - .into_any_element(), - ), - single_example( - "Sparse", - ListItem::new("sparse") - .spacing(ListItemSpacing::Sparse) - .child(Label::new("Sparse spacing")) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "With Slots", - vec![ - single_example( - "End Slot", - ListItem::new("end_slot") - .child(Label::new("Item with end slot")) - .end_slot(Icon::new(IconName::ChevronRight)) - .into_any_element(), - ), - single_example( - "With Toggle", - ListItem::new("with_toggle") - .toggle(Some(true)) - .child(Label::new("Expandable item")) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "States", - vec![ - single_example( - "Disabled", - ListItem::new("disabled") - .disabled(true) - .child(Label::new("Disabled item")) - .into_any_element(), - ), - single_example( - "Non-selectable", - ListItem::new("non_selectable") - .selectable(false) - .child(Label::new("Non-selectable item")) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic List Items", + vec![ + single_example( + "Simple", + ListItem::new("simple") + .child(Label::new("Simple list item")) + .into_any_element(), + ), + single_example( + "With Icon", + ListItem::new("with_icon") + .start_slot(Icon::new(IconName::File)) + .child(Label::new("List item with icon")) + .into_any_element(), + ), + single_example( + "Selected", + ListItem::new("selected") + .toggle_state(true) + .start_slot(Icon::new(IconName::Check)) + .child(Label::new("Selected item")) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "List Item Spacing", + vec![ + single_example( + "Dense", + ListItem::new("dense") + .spacing(ListItemSpacing::Dense) + .child(Label::new("Dense spacing")) + .into_any_element(), + ), + single_example( + "Extra Dense", + ListItem::new("extra_dense") + .spacing(ListItemSpacing::ExtraDense) + .child(Label::new("Extra dense spacing")) + .into_any_element(), + ), + single_example( + "Sparse", + ListItem::new("sparse") + .spacing(ListItemSpacing::Sparse) + .child(Label::new("Sparse spacing")) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "With Slots", + vec![ + single_example( + "End Slot", + ListItem::new("end_slot") + .child(Label::new("Item with end slot")) + .end_slot(Icon::new(IconName::ChevronRight)) + .into_any_element(), + ), + single_example( + "With Toggle", + ListItem::new("with_toggle") + .toggle(Some(true)) + .child(Label::new("Expandable item")) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "States", + vec![ + single_example( + "Disabled", + ListItem::new("disabled") + .disabled(true) + .child(Label::new("Disabled item")) + .into_any_element(), + ), + single_example( + "Non-selectable", + ListItem::new("non_selectable") + .selectable(false) + .child(Label::new("Non-selectable item")) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/list/list_sub_header.rs b/crates/ui/src/components/list/list_sub_header.rs index b4a82fb2edf..6c0f8df3aa1 100644 --- a/crates/ui/src/components/list/list_sub_header.rs +++ b/crates/ui/src/components/list/list_sub_header.rs @@ -91,59 +91,54 @@ impl Component for ListSubHeader { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some( - "A sub-header component for organizing list content into subsections with optional icons and end slots.", - ) + fn description() -> &'static str { + "A sub-header component for organizing list content into subsections \ + with optional icons and end slots." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Sub-headers", - vec![ - single_example( - "Simple", - ListSubHeader::new("Subsection").into_any_element(), - ), - single_example( - "With Icon", - ListSubHeader::new("Documents") - .left_icon(Some(IconName::File)) - .into_any_element(), - ), - single_example( - "With End Slot", - ListSubHeader::new("Recent") - .end_slot( - Label::new("3").color(Color::Muted).into_any_element(), - ) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "States", - vec![ - single_example( - "Selected", - ListSubHeader::new("Selected") - .toggle_state(true) - .into_any_element(), - ), - single_example( - "Inset", - ListSubHeader::new("Inset Sub-header") - .inset(true) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Sub-headers", + vec![ + single_example( + "Simple", + ListSubHeader::new("Subsection").into_any_element(), + ), + single_example( + "With Icon", + ListSubHeader::new("Documents") + .left_icon(Some(IconName::File)) + .into_any_element(), + ), + single_example( + "With End Slot", + ListSubHeader::new("Recent") + .end_slot(Label::new("3").color(Color::Muted).into_any_element()) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "States", + vec![ + single_example( + "Selected", + ListSubHeader::new("Selected") + .toggle_state(true) + .into_any_element(), + ), + single_example( + "Inset", + ListSubHeader::new("Inset Sub-header") + .inset(true) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/notification/alert_modal.rs b/crates/ui/src/components/notification/alert_modal.rs index 52a084c8478..5c368a9a745 100644 --- a/crates/ui/src/components/notification/alert_modal.rs +++ b/crates/ui/src/components/notification/alert_modal.rs @@ -174,13 +174,12 @@ impl Component for AlertModal { ComponentStatus::WorkInProgress } - fn description() -> Option<&'static str> { - Some("A modal dialog that presents an alert message with primary and dismiss actions.") + fn description() -> &'static str { + "A modal dialog that presents an alert message with primary and dismiss actions." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { - Some( - v_flex() + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + v_flex() .gap_6() .p_4() .children(vec![ @@ -246,7 +245,6 @@ Review .zed/settings.json for any extensions or commands configured by this proj .into_any_element(), )]), ]) - .into_any_element(), - ) + .into_any_element() } } diff --git a/crates/ui/src/components/notification/announcement_toast.rs b/crates/ui/src/components/notification/announcement_toast.rs index 215d8b9aedf..d47d264843c 100644 --- a/crates/ui/src/components/notification/announcement_toast.rs +++ b/crates/ui/src/components/notification/announcement_toast.rs @@ -160,11 +160,11 @@ impl Component for AnnouncementToast { ComponentScope::Notification } - fn description() -> Option<&'static str> { - Some("A special toast for announcing new and exciting features.") + fn description() -> &'static str { + "A special toast for announcing new and exciting features." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let examples = vec![single_example( "Basic", div() @@ -188,11 +188,9 @@ impl Component for AnnouncementToast { .into_any_element(), )]; - Some( - v_flex() - .gap_6() - .child(example_group(examples).vertical()) - .into_any_element(), - ) + v_flex() + .gap_6() + .child(example_group(examples).vertical()) + .into_any_element() } } diff --git a/crates/ui/src/components/progress/circular_progress.rs b/crates/ui/src/components/progress/circular_progress.rs index f571332edae..69f93b740cd 100644 --- a/crates/ui/src/components/progress/circular_progress.rs +++ b/crates/ui/src/components/progress/circular_progress.rs @@ -175,49 +175,46 @@ impl Component for CircularProgress { ComponentScope::Status } - fn description() -> Option<&'static str> { - Some( - "A circular progress indicator that displays progress as an arc growing clockwise from the top.", - ) + fn description() -> &'static str { + "A circular progress indicator that displays progress as an arc \ + growing clockwise from the top." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let max_value = 100.0; let container = || v_flex().items_center().gap_1(); - Some( - example_group(vec![single_example( - "Examples", - h_flex() - .gap_6() - .child( - container() - .child(CircularProgress::new(0.0, max_value, px(48.0), cx)) - .child(Label::new("0%").size(LabelSize::Small)), - ) - .child( - container() - .child(CircularProgress::new(25.0, max_value, px(48.0), cx)) - .child(Label::new("25%").size(LabelSize::Small)), - ) - .child( - container() - .child(CircularProgress::new(50.0, max_value, px(48.0), cx)) - .child(Label::new("50%").size(LabelSize::Small)), - ) - .child( - container() - .child(CircularProgress::new(75.0, max_value, px(48.0), cx)) - .child(Label::new("75%").size(LabelSize::Small)), - ) - .child( - container() - .child(CircularProgress::new(100.0, max_value, px(48.0), cx)) - .child(Label::new("100%").size(LabelSize::Small)), - ) - .into_any_element(), - )]) - .into_any_element(), - ) + example_group(vec![single_example( + "Examples", + h_flex() + .gap_6() + .child( + container() + .child(CircularProgress::new(0.0, max_value, px(48.0), cx)) + .child(Label::new("0%").size(LabelSize::Small)), + ) + .child( + container() + .child(CircularProgress::new(25.0, max_value, px(48.0), cx)) + .child(Label::new("25%").size(LabelSize::Small)), + ) + .child( + container() + .child(CircularProgress::new(50.0, max_value, px(48.0), cx)) + .child(Label::new("50%").size(LabelSize::Small)), + ) + .child( + container() + .child(CircularProgress::new(75.0, max_value, px(48.0), cx)) + .child(Label::new("75%").size(LabelSize::Small)), + ) + .child( + container() + .child(CircularProgress::new(100.0, max_value, px(48.0), cx)) + .child(Label::new("100%").size(LabelSize::Small)), + ) + .into_any_element(), + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/progress/progress_bar.rs b/crates/ui/src/components/progress/progress_bar.rs index 31e9e742894..b726b10b1c7 100644 --- a/crates/ui/src/components/progress/progress_bar.rs +++ b/crates/ui/src/components/progress/progress_bar.rs @@ -94,53 +94,51 @@ impl Component for ProgressBar { ComponentScope::Status } - fn description() -> Option<&'static str> { - Some(Self::DOCS) + fn description() -> &'static str { + Self::DOCS } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let max_value = 180.0; let container = || v_flex().w_full().gap_1(); - Some( - example_group(vec![single_example( - "Examples", - v_flex() - .w_full() - .gap_2() - .child( - container() - .child( - h_flex() - .justify_between() - .child(Label::new("0%")) - .child(Label::new("Empty")), - ) - .child(ProgressBar::new("empty", 0.0, max_value, cx)), - ) - .child( - container() - .child( - h_flex() - .justify_between() - .child(Label::new("38%")) - .child(Label::new("Partial")), - ) - .child(ProgressBar::new("partial", max_value * 0.35, max_value, cx)), - ) - .child( - container() - .child( - h_flex() - .justify_between() - .child(Label::new("100%")) - .child(Label::new("Complete")), - ) - .child(ProgressBar::new("filled", max_value, max_value, cx)), - ) - .into_any_element(), - )]) - .into_any_element(), - ) + example_group(vec![single_example( + "Examples", + v_flex() + .w_full() + .gap_2() + .child( + container() + .child( + h_flex() + .justify_between() + .child(Label::new("0%")) + .child(Label::new("Empty")), + ) + .child(ProgressBar::new("empty", 0.0, max_value, cx)), + ) + .child( + container() + .child( + h_flex() + .justify_between() + .child(Label::new("38%")) + .child(Label::new("Partial")), + ) + .child(ProgressBar::new("partial", max_value * 0.35, max_value, cx)), + ) + .child( + container() + .child( + h_flex() + .justify_between() + .child(Label::new("100%")) + .child(Label::new("Complete")), + ) + .child(ProgressBar::new("filled", max_value, max_value, cx)), + ) + .into_any_element(), + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/tab.rs b/crates/ui/src/components/tab.rs index e6823f46b75..6a25ad43457 100644 --- a/crates/ui/src/components/tab.rs +++ b/crates/ui/src/components/tab.rs @@ -185,54 +185,51 @@ impl Component for Tab { ComponentScope::Navigation } - fn description() -> Option<&'static str> { - Some( - "A tab component that can be used in a tabbed interface, supporting different positions and states.", - ) + fn description() -> &'static str { + "A tab component that can be used in a tabbed interface, \ + supporting different positions and states." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![example_group_with_title( - "Variations", - vec![ - single_example( - "Default", - Tab::new("default").child("Default Tab").into_any_element(), - ), - single_example( - "Selected", - Tab::new("selected") - .toggle_state(true) - .child("Selected Tab") - .into_any_element(), - ), - single_example( - "First", - Tab::new("first") - .position(TabPosition::First) - .child("First Tab") - .into_any_element(), - ), - single_example( - "Middle", - Tab::new("middle") - .position(TabPosition::Middle(Ordering::Equal)) - .child("Middle Tab") - .into_any_element(), - ), - single_example( - "Last", - Tab::new("last") - .position(TabPosition::Last) - .child("Last Tab") - .into_any_element(), - ), - ], - )]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Variations", + vec![ + single_example( + "Default", + Tab::new("default").child("Default Tab").into_any_element(), + ), + single_example( + "Selected", + Tab::new("selected") + .toggle_state(true) + .child("Selected Tab") + .into_any_element(), + ), + single_example( + "First", + Tab::new("first") + .position(TabPosition::First) + .child("First Tab") + .into_any_element(), + ), + single_example( + "Middle", + Tab::new("middle") + .position(TabPosition::Middle(Ordering::Equal)) + .child("Middle Tab") + .into_any_element(), + ), + single_example( + "Last", + Tab::new("last") + .position(TabPosition::Last) + .child("Last Tab") + .into_any_element(), + ), + ], + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/tab_bar.rs b/crates/ui/src/components/tab_bar.rs index 7ebbaab0719..0891b3ac1f4 100644 --- a/crates/ui/src/components/tab_bar.rs +++ b/crates/ui/src/components/tab_bar.rs @@ -161,47 +161,46 @@ impl Component for TabBar { "TabBar" } - fn description() -> Option<&'static str> { - Some("A horizontal bar containing tabs for navigation between different views or sections.") + fn description() -> &'static str { + "A horizontal bar containing tabs for navigation between different views \ + or sections." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Usage", - vec![ - single_example( - "Empty TabBar", - TabBar::new("empty_tab_bar").into_any_element(), - ), - single_example( - "With Tabs", - TabBar::new("tab_bar_with_tabs") - .child(Tab::new("tab1")) - .child(Tab::new("tab2")) - .child(Tab::new("tab3")) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "With Start and End Children", - vec![single_example( - "Full TabBar", - TabBar::new("full_tab_bar") - .start_child(Button::new("start_button", "Start")) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Usage", + vec![ + single_example( + "Empty TabBar", + TabBar::new("empty_tab_bar").into_any_element(), + ), + single_example( + "With Tabs", + TabBar::new("tab_bar_with_tabs") .child(Tab::new("tab1")) .child(Tab::new("tab2")) .child(Tab::new("tab3")) - .end_child(Button::new("end_button", "End")) .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + ], + ), + example_group_with_title( + "With Start and End Children", + vec![single_example( + "Full TabBar", + TabBar::new("full_tab_bar") + .start_child(Button::new("start_button", "Start")) + .child(Tab::new("tab1")) + .child(Tab::new("tab2")) + .child(Tab::new("tab3")) + .end_child(Button::new("end_button", "End")) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/toggle.rs b/crates/ui/src/components/toggle.rs index 0b1d7884687..85568dbb01b 100644 --- a/crates/ui/src/components/toggle.rs +++ b/crates/ui/src/components/toggle.rs @@ -696,131 +696,129 @@ impl Component for SwitchField { ComponentScope::Input } - fn description() -> Option<&'static str> { - Some("A field component that combines a label, description, and switch") + fn description() -> &'static str { + "A field component that combines a label, description, and switch" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "States", - vec![ - single_example( - "Unselected", - SwitchField::new( - "switch_field_unselected", - Some("Enable notifications"), - Some("Receive notifications when new messages arrive.".into()), - ToggleState::Unselected, - |_, _, _| {}, - ) - .into_any_element(), - ), - single_example( - "Selected", - SwitchField::new( - "switch_field_selected", - Some("Enable notifications"), - Some("Receive notifications when new messages arrive.".into()), - ToggleState::Selected, - |_, _, _| {}, - ) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Colors", - vec![ - single_example( - "Default", - SwitchField::new( - "switch_field_default", - Some("Default color"), - Some("This uses the default switch color.".into()), - ToggleState::Selected, - |_, _, _| {}, - ) - .into_any_element(), - ), - single_example( - "Accent", - SwitchField::new( - "switch_field_accent", - Some("Accent color"), - Some("This uses the accent color scheme.".into()), - ToggleState::Selected, - |_, _, _| {}, - ) - .color(SwitchColor::Accent) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Disabled", - vec![single_example( - "Disabled", + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "States", + vec![ + single_example( + "Unselected", SwitchField::new( - "switch_field_disabled", - Some("Disabled field"), - Some("This field is disabled and cannot be toggled.".into()), + "switch_field_unselected", + Some("Enable notifications"), + Some("Receive notifications when new messages arrive.".into()), + ToggleState::Unselected, + |_, _, _| {}, + ) + .into_any_element(), + ), + single_example( + "Selected", + SwitchField::new( + "switch_field_selected", + Some("Enable notifications"), + Some("Receive notifications when new messages arrive.".into()), ToggleState::Selected, |_, _, _| {}, ) - .disabled(true) .into_any_element(), - )], - ), - example_group_with_title( - "No Description", - vec![single_example( - "No Description", + ), + ], + ), + example_group_with_title( + "Colors", + vec![ + single_example( + "Default", SwitchField::new( - "switch_field_disabled", - Some("Disabled field"), + "switch_field_default", + Some("Default color"), + Some("This uses the default switch color.".into()), + ToggleState::Selected, + |_, _, _| {}, + ) + .into_any_element(), + ), + single_example( + "Accent", + SwitchField::new( + "switch_field_accent", + Some("Accent color"), + Some("This uses the accent color scheme.".into()), + ToggleState::Selected, + |_, _, _| {}, + ) + .color(SwitchColor::Accent) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Disabled", + vec![single_example( + "Disabled", + SwitchField::new( + "switch_field_disabled", + Some("Disabled field"), + Some("This field is disabled and cannot be toggled.".into()), + ToggleState::Selected, + |_, _, _| {}, + ) + .disabled(true) + .into_any_element(), + )], + ), + example_group_with_title( + "No Description", + vec![single_example( + "No Description", + SwitchField::new( + "switch_field_disabled", + Some("Disabled field"), + None, + ToggleState::Selected, + |_, _, _| {}, + ) + .into_any_element(), + )], + ), + example_group_with_title( + "With Tooltip", + vec![ + single_example( + "Tooltip with Description", + SwitchField::new( + "switch_field_tooltip_with_desc", + Some("Nice Feature"), + Some("Enable advanced configuration options.".into()), + ToggleState::Unselected, + |_, _, _| {}, + ) + .tooltip(Tooltip::text("This is content for this tooltip!")) + .into_any_element(), + ), + single_example( + "Tooltip without Description", + SwitchField::new( + "switch_field_tooltip_no_desc", + Some("Nice Feature"), None, ToggleState::Selected, |_, _, _| {}, ) + .tooltip(Tooltip::text("This is content for this tooltip!")) .into_any_element(), - )], - ), - example_group_with_title( - "With Tooltip", - vec![ - single_example( - "Tooltip with Description", - SwitchField::new( - "switch_field_tooltip_with_desc", - Some("Nice Feature"), - Some("Enable advanced configuration options.".into()), - ToggleState::Unselected, - |_, _, _| {}, - ) - .tooltip(Tooltip::text("This is content for this tooltip!")) - .into_any_element(), - ), - single_example( - "Tooltip without Description", - SwitchField::new( - "switch_field_tooltip_no_desc", - Some("Nice Feature"), - None, - ToggleState::Selected, - |_, _, _| {}, - ) - .tooltip(Tooltip::text("This is content for this tooltip!")) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + ), + ], + ), + ]) + .into_any_element() } } @@ -829,112 +827,105 @@ impl Component for Checkbox { ComponentScope::Input } - fn description() -> Option<&'static str> { - Some("A checkbox component that can be used for multiple choice selections") + fn description() -> &'static str { + "A checkbox component that can be used for multiple choice selections" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "States", - vec![ - single_example( - "Unselected", - Checkbox::new("checkbox_unselected", ToggleState::Unselected) - .into_any_element(), - ), - single_example( - "Placeholder", - Checkbox::new("checkbox_indeterminate", ToggleState::Selected) - .placeholder(true) - .into_any_element(), - ), - single_example( - "Indeterminate", - Checkbox::new("checkbox_indeterminate", ToggleState::Indeterminate) - .into_any_element(), - ), - single_example( - "Selected", - Checkbox::new("checkbox_selected", ToggleState::Selected) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Styles", - vec![ - single_example( - "Default", - Checkbox::new("checkbox_default", ToggleState::Selected) - .into_any_element(), - ), - single_example( - "Filled", - Checkbox::new("checkbox_filled", ToggleState::Selected) - .fill() - .into_any_element(), - ), - single_example( - "ElevationBased", - Checkbox::new("checkbox_elevation", ToggleState::Selected) - .style(ToggleStyle::ElevationBased( - ElevationIndex::EditorSurface, - )) - .into_any_element(), - ), - single_example( - "Custom Color", - Checkbox::new("checkbox_custom", ToggleState::Selected) - .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Disabled", - vec![ - single_example( - "Unselected", - Checkbox::new( - "checkbox_disabled_unselected", - ToggleState::Unselected, - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "States", + vec![ + single_example( + "Unselected", + Checkbox::new("checkbox_unselected", ToggleState::Unselected) + .into_any_element(), + ), + single_example( + "Placeholder", + Checkbox::new("checkbox_indeterminate", ToggleState::Selected) + .placeholder(true) + .into_any_element(), + ), + single_example( + "Indeterminate", + Checkbox::new("checkbox_indeterminate", ToggleState::Indeterminate) + .into_any_element(), + ), + single_example( + "Selected", + Checkbox::new("checkbox_selected", ToggleState::Selected) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Styles", + vec![ + single_example( + "Default", + Checkbox::new("checkbox_default", ToggleState::Selected) + .into_any_element(), + ), + single_example( + "Filled", + Checkbox::new("checkbox_filled", ToggleState::Selected) + .fill() + .into_any_element(), + ), + single_example( + "ElevationBased", + Checkbox::new("checkbox_elevation", ToggleState::Selected) + .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)) + .into_any_element(), + ), + single_example( + "Custom Color", + Checkbox::new("checkbox_custom", ToggleState::Selected) + .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Disabled", + vec![ + single_example( + "Unselected", + Checkbox::new("checkbox_disabled_unselected", ToggleState::Unselected) .disabled(true) .into_any_element(), - ), - single_example( - "Selected", - Checkbox::new("checkbox_disabled_selected", ToggleState::Selected) - .disabled(true) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "With Label", - vec![single_example( - "Default", - Checkbox::new("checkbox_with_label", ToggleState::Selected) - .label("Always save on quit") + ), + single_example( + "Selected", + Checkbox::new("checkbox_disabled_selected", ToggleState::Selected) + .disabled(true) .into_any_element(), - )], - ), - example_group_with_title( - "Extra", - vec![single_example( - "Visualization-Only", - Checkbox::new("viz_only", ToggleState::Selected) - .visualization_only(true) - .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + ], + ), + example_group_with_title( + "With Label", + vec![single_example( + "Default", + Checkbox::new("checkbox_with_label", ToggleState::Selected) + .label("Always save on quit") + .into_any_element(), + )], + ), + example_group_with_title( + "Extra", + vec![single_example( + "Visualization-Only", + Checkbox::new("viz_only", ToggleState::Selected) + .visualization_only(true) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } @@ -943,120 +934,115 @@ impl Component for Switch { ComponentScope::Input } - fn description() -> Option<&'static str> { - Some("A switch component that represents binary states like on/off") + fn description() -> &'static str { + "A switch component that represents binary states like on/off" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "States", - vec![ - single_example( - "Off", - Switch::new("switch_off", ToggleState::Unselected) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - single_example( - "On", - Switch::new("switch_on", ToggleState::Selected) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Colors", - vec![ - single_example( - "Accent (Default)", - Switch::new("switch_accent_style", ToggleState::Selected) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - single_example( - "Custom", - Switch::new("switch_custom_style", ToggleState::Selected) - .color(SwitchColor::Custom(hsla(300.0 / 360.0, 0.6, 0.6, 1.0))) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Disabled", - vec![ - single_example( - "Off", - Switch::new("switch_disabled_off", ToggleState::Unselected) - .disabled(true) - .into_any_element(), - ), - single_example( - "On", - Switch::new("switch_disabled_on", ToggleState::Selected) - .disabled(true) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "With Label", - vec![ - single_example( - "Start Label", - Switch::new("switch_with_label_start", ToggleState::Selected) - .label("Always save on quit") - .label_position(SwitchLabelPosition::Start) - .into_any_element(), - ), - single_example( - "End Label", - Switch::new("switch_with_label_end", ToggleState::Selected) - .label("Always save on quit") - .label_position(SwitchLabelPosition::End) - .into_any_element(), - ), - single_example( - "Default Size Label", - Switch::new( - "switch_with_label_default_size", - ToggleState::Selected, - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "States", + vec![ + single_example( + "Off", + Switch::new("switch_off", ToggleState::Unselected) + .on_click(|_, _, _cx| {}) + .into_any_element(), + ), + single_example( + "On", + Switch::new("switch_on", ToggleState::Selected) + .on_click(|_, _, _cx| {}) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Colors", + vec![ + single_example( + "Accent (Default)", + Switch::new("switch_accent_style", ToggleState::Selected) + .on_click(|_, _, _cx| {}) + .into_any_element(), + ), + single_example( + "Custom", + Switch::new("switch_custom_style", ToggleState::Selected) + .color(SwitchColor::Custom(hsla(300.0 / 360.0, 0.6, 0.6, 1.0))) + .on_click(|_, _, _cx| {}) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Disabled", + vec![ + single_example( + "Off", + Switch::new("switch_disabled_off", ToggleState::Unselected) + .disabled(true) + .into_any_element(), + ), + single_example( + "On", + Switch::new("switch_disabled_on", ToggleState::Selected) + .disabled(true) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "With Label", + vec![ + single_example( + "Start Label", + Switch::new("switch_with_label_start", ToggleState::Selected) + .label("Always save on quit") + .label_position(SwitchLabelPosition::Start) + .into_any_element(), + ), + single_example( + "End Label", + Switch::new("switch_with_label_end", ToggleState::Selected) + .label("Always save on quit") + .label_position(SwitchLabelPosition::End) + .into_any_element(), + ), + single_example( + "Default Size Label", + Switch::new("switch_with_label_default_size", ToggleState::Selected) .label("Always save on quit") .label_size(LabelSize::Default) .into_any_element(), - ), - single_example( - "Small Size Label", - Switch::new("switch_with_label_small_size", ToggleState::Selected) - .label("Always save on quit") - .label_size(LabelSize::Small) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "With Keybinding", - vec![single_example( - "Keybinding", - Switch::new("switch_with_keybinding", ToggleState::Selected) - .key_binding(Some(KeyBinding::from_keystrokes( - vec![KeybindingKeystroke::from_keystroke( - Keystroke::parse("cmd-s").unwrap(), - )] - .into(), - false, - ))) + ), + single_example( + "Small Size Label", + Switch::new("switch_with_label_small_size", ToggleState::Selected) + .label("Always save on quit") + .label_size(LabelSize::Small) .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + ], + ), + example_group_with_title( + "With Keybinding", + vec![single_example( + "Keybinding", + Switch::new("switch_with_keybinding", ToggleState::Selected) + .key_binding(Some(KeyBinding::from_keystrokes( + vec![KeybindingKeystroke::from_keystroke( + Keystroke::parse("cmd-s").unwrap(), + )] + .into(), + false, + ))) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/tooltip.rs b/crates/ui/src/components/tooltip.rs index 8124b4ecbaf..82e7d9b1118 100644 --- a/crates/ui/src/components/tooltip.rs +++ b/crates/ui/src/components/tooltip.rs @@ -274,21 +274,18 @@ impl Component for Tooltip { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some( - "A tooltip that appears when hovering over an element, optionally showing a keybinding or additional metadata.", - ) + fn description() -> &'static str { + "A tooltip that appears when hovering over an element, \ + optionally showing a keybinding or additional metadata." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - example_group(vec![single_example( - "Text only", - Button::new("delete-example", "Delete") - .tooltip(Tooltip::text("This is a tooltip!")) - .into_any_element(), - )]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + example_group(vec![single_example( + "Text only", + Button::new("delete-example", "Delete") + .tooltip(Tooltip::text("This is a tooltip!")) + .into_any_element(), + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/tree_view_item.rs b/crates/ui/src/components/tree_view_item.rs index f6d90fceff5..9474548675d 100644 --- a/crates/ui/src/components/tree_view_item.rs +++ b/crates/ui/src/components/tree_view_item.rs @@ -223,13 +223,12 @@ impl Component for TreeViewItem { ComponentScope::Navigation } - fn description() -> Option<&'static str> { - Some( - "A hierarchical list of items that may have a parent-child relationship where children can be toggled into view by expanding or collapsing their parent item.", - ) + fn description() -> &'static str { + "A hierarchical list of items that may have a parent-child relationship \ + where children can be toggled into view by expanding or collapsing their parent item." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let container = || { v_flex() .p_2() @@ -239,58 +238,56 @@ impl Component for TreeViewItem { .bg(cx.theme().colors().panel_background) }; - Some( - example_group(vec![ - single_example( - "Basic Tree View", - container() - .child( - TreeViewItem::new("index-1", "Tree Item Root #1") - .root_item(true) - .toggle_state(true), - ) - .child(TreeViewItem::new("index-2", "Tree Item #2")) - .child(TreeViewItem::new("index-3", "Tree Item #3")) - .child(TreeViewItem::new("index-4", "Tree Item Root #2").root_item(true)) - .child(TreeViewItem::new("index-5", "Tree Item #5")) - .child(TreeViewItem::new("index-6", "Tree Item #6")) - .into_any_element(), - ), - single_example( - "Active Child", - container() - .child(TreeViewItem::new("index-1", "Tree Item Root #1").root_item(true)) - .child(TreeViewItem::new("index-2", "Tree Item #2").toggle_state(true)) - .child(TreeViewItem::new("index-3", "Tree Item #3")) - .into_any_element(), - ), - single_example( - "Focused Parent", - container() - .child( - TreeViewItem::new("index-1", "Tree Item Root #1") - .root_item(true) - .focused(true) - .toggle_state(true), - ) - .child(TreeViewItem::new("index-2", "Tree Item #2")) - .child(TreeViewItem::new("index-3", "Tree Item #3")) - .into_any_element(), - ), - single_example( - "Focused Child", - container() - .child( - TreeViewItem::new("index-1", "Tree Item Root #1") - .root_item(true) - .toggle_state(true), - ) - .child(TreeViewItem::new("index-2", "Tree Item #2").focused(true)) - .child(TreeViewItem::new("index-3", "Tree Item #3")) - .into_any_element(), - ), - ]) - .into_any_element(), - ) + example_group(vec![ + single_example( + "Basic Tree View", + container() + .child( + TreeViewItem::new("index-1", "Tree Item Root #1") + .root_item(true) + .toggle_state(true), + ) + .child(TreeViewItem::new("index-2", "Tree Item #2")) + .child(TreeViewItem::new("index-3", "Tree Item #3")) + .child(TreeViewItem::new("index-4", "Tree Item Root #2").root_item(true)) + .child(TreeViewItem::new("index-5", "Tree Item #5")) + .child(TreeViewItem::new("index-6", "Tree Item #6")) + .into_any_element(), + ), + single_example( + "Active Child", + container() + .child(TreeViewItem::new("index-1", "Tree Item Root #1").root_item(true)) + .child(TreeViewItem::new("index-2", "Tree Item #2").toggle_state(true)) + .child(TreeViewItem::new("index-3", "Tree Item #3")) + .into_any_element(), + ), + single_example( + "Focused Parent", + container() + .child( + TreeViewItem::new("index-1", "Tree Item Root #1") + .root_item(true) + .focused(true) + .toggle_state(true), + ) + .child(TreeViewItem::new("index-2", "Tree Item #2")) + .child(TreeViewItem::new("index-3", "Tree Item #3")) + .into_any_element(), + ), + single_example( + "Focused Child", + container() + .child( + TreeViewItem::new("index-1", "Tree Item Root #1") + .root_item(true) + .toggle_state(true), + ) + .child(TreeViewItem::new("index-2", "Tree Item #2").focused(true)) + .child(TreeViewItem::new("index-3", "Tree Item #3")) + .into_any_element(), + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/styles/animation.rs b/crates/ui/src/styles/animation.rs index aa64fc1907b..ea08b74ca9a 100644 --- a/crates/ui/src/styles/animation.rs +++ b/crates/ui/src/styles/animation.rs @@ -107,11 +107,11 @@ impl Component for Animation { ComponentScope::Utilities } - fn description() -> Option<&'static str> { - Some("Demonstrates various animation patterns and transitions available in the UI system.") + fn description() -> &'static str { + "Demonstrates various animation patterns and transitions available in the UI system." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let container_size = 128.0; let element_size = 32.0; let offset = container_size / 2.0 - element_size / 2.0; @@ -126,152 +126,150 @@ impl Component for Animation { .rounded_sm() }; - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Animate In", - vec![ - single_example( - "From Bottom", - container() - .size(px(container_size)) - .child( - div() - .id("animate-in-from-bottom") - .absolute() - .size(px(element_size)) - .left(px(offset)) - .rounded_md() - .bg(gpui::red()) - .animate_in_from_bottom(false), - ) - .into_any_element(), - ), - single_example( - "From Top", - container() - .size(px(container_size)) - .child( - div() - .id("animate-in-from-top") - .absolute() - .size(px(element_size)) - .left(px(offset)) - .rounded_md() - .bg(gpui::blue()) - .animate_in_from_top(false), - ) - .into_any_element(), - ), - single_example( - "From Left", - container() - .size(px(container_size)) - .child( - div() - .id("animate-in-from-left") - .absolute() - .size(px(element_size)) - .top(px(offset)) - .rounded_md() - .bg(gpui::green()) - .animate_in_from_left(false), - ) - .into_any_element(), - ), - single_example( - "From Right", - container() - .size(px(container_size)) - .child( - div() - .id("animate-in-from-right") - .absolute() - .size(px(element_size)) - .top(px(offset)) - .rounded_md() - .bg(gpui::yellow()) - .animate_in_from_right(false), - ) - .into_any_element(), - ), - ], - ) - .grow(), - example_group_with_title( - "Fade and Animate In", - vec![ - single_example( - "From Bottom", - container() - .size(px(container_size)) - .child( - div() - .id("fade-animate-in-from-bottom") - .absolute() - .size(px(element_size)) - .left(px(offset)) - .rounded_md() - .bg(gpui::red()) - .animate_in_from_bottom(true), - ) - .into_any_element(), - ), - single_example( - "From Top", - container() - .size(px(container_size)) - .child( - div() - .id("fade-animate-in-from-top") - .absolute() - .size(px(element_size)) - .left(px(offset)) - .rounded_md() - .bg(gpui::blue()) - .animate_in_from_top(true), - ) - .into_any_element(), - ), - single_example( - "From Left", - container() - .size(px(container_size)) - .child( - div() - .id("fade-animate-in-from-left") - .absolute() - .size(px(element_size)) - .top(px(offset)) - .rounded_md() - .bg(gpui::green()) - .animate_in_from_left(true), - ) - .into_any_element(), - ), - single_example( - "From Right", - container() - .size(px(container_size)) - .child( - div() - .id("fade-animate-in-from-right") - .absolute() - .size(px(element_size)) - .top(px(offset)) - .rounded_md() - .bg(gpui::yellow()) - .animate_in_from_right(true), - ) - .into_any_element(), - ), - ], - ) - .grow(), - ]) - .into_any_element(), - ) + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Animate In", + vec![ + single_example( + "From Bottom", + container() + .size(px(container_size)) + .child( + div() + .id("animate-in-from-bottom") + .absolute() + .size(px(element_size)) + .left(px(offset)) + .rounded_md() + .bg(gpui::red()) + .animate_in_from_bottom(false), + ) + .into_any_element(), + ), + single_example( + "From Top", + container() + .size(px(container_size)) + .child( + div() + .id("animate-in-from-top") + .absolute() + .size(px(element_size)) + .left(px(offset)) + .rounded_md() + .bg(gpui::blue()) + .animate_in_from_top(false), + ) + .into_any_element(), + ), + single_example( + "From Left", + container() + .size(px(container_size)) + .child( + div() + .id("animate-in-from-left") + .absolute() + .size(px(element_size)) + .top(px(offset)) + .rounded_md() + .bg(gpui::green()) + .animate_in_from_left(false), + ) + .into_any_element(), + ), + single_example( + "From Right", + container() + .size(px(container_size)) + .child( + div() + .id("animate-in-from-right") + .absolute() + .size(px(element_size)) + .top(px(offset)) + .rounded_md() + .bg(gpui::yellow()) + .animate_in_from_right(false), + ) + .into_any_element(), + ), + ], + ) + .grow(), + example_group_with_title( + "Fade and Animate In", + vec![ + single_example( + "From Bottom", + container() + .size(px(container_size)) + .child( + div() + .id("fade-animate-in-from-bottom") + .absolute() + .size(px(element_size)) + .left(px(offset)) + .rounded_md() + .bg(gpui::red()) + .animate_in_from_bottom(true), + ) + .into_any_element(), + ), + single_example( + "From Top", + container() + .size(px(container_size)) + .child( + div() + .id("fade-animate-in-from-top") + .absolute() + .size(px(element_size)) + .left(px(offset)) + .rounded_md() + .bg(gpui::blue()) + .animate_in_from_top(true), + ) + .into_any_element(), + ), + single_example( + "From Left", + container() + .size(px(container_size)) + .child( + div() + .id("fade-animate-in-from-left") + .absolute() + .size(px(element_size)) + .top(px(offset)) + .rounded_md() + .bg(gpui::green()) + .animate_in_from_left(true), + ) + .into_any_element(), + ), + single_example( + "From Right", + container() + .size(px(container_size)) + .child( + div() + .id("fade-animate-in-from-right") + .absolute() + .size(px(element_size)) + .top(px(offset)) + .rounded_md() + .bg(gpui::yellow()) + .animate_in_from_right(true), + ) + .into_any_element(), + ), + ], + ) + .grow(), + ]) + .into_any_element() } } diff --git a/crates/ui/src/styles/color.rs b/crates/ui/src/styles/color.rs index 586b2ccc576..b28e40bff09 100644 --- a/crates/ui/src/styles/color.rs +++ b/crates/ui/src/styles/color.rs @@ -129,116 +129,114 @@ impl Component for Color { ComponentScope::Utilities } - fn description() -> Option<&'static str> { - Some(Color::DOCS) + fn description() -> &'static str { + Color::DOCS } - fn preview(_window: &mut gpui::Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Text Colors", - vec![ - single_example( - "Default", - Label::new("Default text color") - .color(Color::Default) - .into_any_element(), - ) - .description(Color::Default.get_variant_docs()), - single_example( - "Muted", - Label::new("Muted text color") - .color(Color::Muted) - .into_any_element(), - ) - .description(Color::Muted.get_variant_docs()), - single_example( - "Accent", - Label::new("Accent text color") - .color(Color::Accent) - .into_any_element(), - ) - .description(Color::Accent.get_variant_docs()), - single_example( - "Disabled", - Label::new("Disabled text color") - .color(Color::Disabled) - .into_any_element(), - ) - .description(Color::Disabled.get_variant_docs()), - ], - ), - example_group_with_title( - "Status Colors", - vec![ - single_example( - "Success", - Label::new("Success status") - .color(Color::Success) - .into_any_element(), - ) - .description(Color::Success.get_variant_docs()), - single_example( - "Warning", - Label::new("Warning status") - .color(Color::Warning) - .into_any_element(), - ) - .description(Color::Warning.get_variant_docs()), - single_example( - "Error", - Label::new("Error status") - .color(Color::Error) - .into_any_element(), - ) - .description(Color::Error.get_variant_docs()), - single_example( - "Info", - Label::new("Info status") - .color(Color::Info) - .into_any_element(), - ) - .description(Color::Info.get_variant_docs()), - ], - ), - example_group_with_title( - "Version Control Colors", - vec![ - single_example( - "Created", - Label::new("Created item") - .color(Color::Created) - .into_any_element(), - ) - .description(Color::Created.get_variant_docs()), - single_example( - "Modified", - Label::new("Modified item") - .color(Color::Modified) - .into_any_element(), - ) - .description(Color::Modified.get_variant_docs()), - single_example( - "Deleted", - Label::new("Deleted item") - .color(Color::Deleted) - .into_any_element(), - ) - .description(Color::Deleted.get_variant_docs()), - single_example( - "Conflict", - Label::new("Conflict item") - .color(Color::Conflict) - .into_any_element(), - ) - .description(Color::Conflict.get_variant_docs()), - ], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut gpui::Window, _cx: &mut App) -> gpui::AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Text Colors", + vec![ + single_example( + "Default", + Label::new("Default text color") + .color(Color::Default) + .into_any_element(), + ) + .description(Color::Default.get_variant_docs()), + single_example( + "Muted", + Label::new("Muted text color") + .color(Color::Muted) + .into_any_element(), + ) + .description(Color::Muted.get_variant_docs()), + single_example( + "Accent", + Label::new("Accent text color") + .color(Color::Accent) + .into_any_element(), + ) + .description(Color::Accent.get_variant_docs()), + single_example( + "Disabled", + Label::new("Disabled text color") + .color(Color::Disabled) + .into_any_element(), + ) + .description(Color::Disabled.get_variant_docs()), + ], + ), + example_group_with_title( + "Status Colors", + vec![ + single_example( + "Success", + Label::new("Success status") + .color(Color::Success) + .into_any_element(), + ) + .description(Color::Success.get_variant_docs()), + single_example( + "Warning", + Label::new("Warning status") + .color(Color::Warning) + .into_any_element(), + ) + .description(Color::Warning.get_variant_docs()), + single_example( + "Error", + Label::new("Error status") + .color(Color::Error) + .into_any_element(), + ) + .description(Color::Error.get_variant_docs()), + single_example( + "Info", + Label::new("Info status") + .color(Color::Info) + .into_any_element(), + ) + .description(Color::Info.get_variant_docs()), + ], + ), + example_group_with_title( + "Version Control Colors", + vec![ + single_example( + "Created", + Label::new("Created item") + .color(Color::Created) + .into_any_element(), + ) + .description(Color::Created.get_variant_docs()), + single_example( + "Modified", + Label::new("Modified item") + .color(Color::Modified) + .into_any_element(), + ) + .description(Color::Modified.get_variant_docs()), + single_example( + "Deleted", + Label::new("Deleted item") + .color(Color::Deleted) + .into_any_element(), + ) + .description(Color::Deleted.get_variant_docs()), + single_example( + "Conflict", + Label::new("Conflict item") + .color(Color::Conflict) + .into_any_element(), + ) + .description(Color::Conflict.get_variant_docs()), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index 69790d3d3da..9d384c37957 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -250,45 +250,43 @@ impl Component for Headline { ComponentScope::Typography } - fn description() -> Option<&'static str> { - Some("A headline element used to emphasize text and create visual hierarchy in the UI.") + fn description() -> &'static str { + "A headline element used to emphasize text and create visual hierarchy in the UI." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_1() - .children(vec![ - single_example( - "XLarge", - Headline::new("XLarge Headline") - .size(HeadlineSize::XLarge) - .into_any_element(), - ), - single_example( - "Large", - Headline::new("Large Headline") - .size(HeadlineSize::Large) - .into_any_element(), - ), - single_example( - "Medium (Default)", - Headline::new("Medium Headline").into_any_element(), - ), - single_example( - "Small", - Headline::new("Small Headline") - .size(HeadlineSize::Small) - .into_any_element(), - ), - single_example( - "XSmall", - Headline::new("XSmall Headline") - .size(HeadlineSize::XSmall) - .into_any_element(), - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_1() + .children(vec![ + single_example( + "XLarge", + Headline::new("XLarge Headline") + .size(HeadlineSize::XLarge) + .into_any_element(), + ), + single_example( + "Large", + Headline::new("Large Headline") + .size(HeadlineSize::Large) + .into_any_element(), + ), + single_example( + "Medium (Default)", + Headline::new("Medium Headline").into_any_element(), + ), + single_example( + "Small", + Headline::new("Small Headline") + .size(HeadlineSize::Small) + .into_any_element(), + ), + single_example( + "XSmall", + Headline::new("XSmall Headline") + .size(HeadlineSize::XSmall) + .into_any_element(), + ), + ]) + .into_any_element() } } diff --git a/crates/ui_input/src/input_field.rs b/crates/ui_input/src/input_field.rs index 16932b58e87..8a95651950a 100644 --- a/crates/ui_input/src/input_field.rs +++ b/crates/ui_input/src/input_field.rs @@ -224,7 +224,13 @@ impl Component for InputField { ComponentScope::Input } - fn preview(window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A single-line text field used for search inputs, \ + form fields, and similar inputs, supporting labels, placeholders, \ + leading icons, and masked content." + } + + fn preview(window: &mut Window, cx: &mut App) -> AnyElement { let input_small = cx.new(|cx| InputField::new(window, cx, "placeholder").label("Small Label")); @@ -234,20 +240,18 @@ impl Component for InputField { .label_size(LabelSize::Default) }); - Some( - v_flex() - .gap_6() - .children(vec![example_group(vec![ - single_example( - "Small Label (Default)", - div().child(input_small).into_any_element(), - ), - single_example( - "Regular Label", - div().child(input_regular).into_any_element(), - ), - ])]) - .into_any_element(), - ) + v_flex() + .gap_6() + .children(vec![example_group(vec![ + single_example( + "Small Label (Default)", + div().child(input_small).into_any_element(), + ), + single_example( + "Regular Label", + div().child(input_regular).into_any_element(), + ), + ])]) + .into_any_element() } } diff --git a/crates/ui_macros/src/ui_macros.rs b/crates/ui_macros/src/ui_macros.rs index ce48a21c115..75c1db3a5ee 100644 --- a/crates/ui_macros/src/ui_macros.rs +++ b/crates/ui_macros/src/ui_macros.rs @@ -19,14 +19,20 @@ pub fn derive_dynamic_spacing(input: TokenStream) -> TokenStream { /// # Example /// /// ``` -/// use ui::Component; +/// use ui::{AnyElement, App, Component, div, IntoElement, Window}; /// use ui_macros::RegisterComponent; /// /// #[derive(RegisterComponent)] /// struct MyComponent; /// /// impl Component for MyComponent { -/// // Component implementation +/// fn description() -> &'static str { +/// "My component description" +/// } +/// +/// fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { +/// div().into_any_element() +/// } /// } /// ``` /// diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 689160b435f..ffe70b8301b 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -697,7 +697,26 @@ impl RenderOnce for NotificationFrame { } } -impl Component for NotificationFrame {} +impl Component for NotificationFrame { + fn description() -> &'static str { + "The standard container used by workspace notifications, \ + providing a consistent title row, close and suppress affordances, \ + and a slot for the notification's contents." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + single_example( + "Default", + NotificationFrame::new() + .with_title(Some("Notification Title")) + .with_content(Label::new( + "This is the content of a workspace notification.", + )) + .into_any_element(), + ) + .into_any_element() + } +} pub mod simple_message_notification { use std::sync::Arc; From 46845bf2f5a4cb2a229a276f30e58eb2b6884c16 Mon Sep 17 00:00:00 2001 From: Seth Wood Date: Fri, 29 May 2026 02:21:30 -0500 Subject: [PATCH 68/93] edit_prediction: Skip cloud Zeta requests when not signed in (#57615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #57962 ## What When a user is not signed in to their Zed account, the edit prediction system was still attempting a cloud API request on every keystroke. The request would fail deep in the credential check (`CloudApiClient::build_request`) with a `ClientApiError::NotSignedIn` error, which propagated back up and was logged at `ERROR` level via `.log_err()` at line 2389 of `edit_prediction.rs`. ## Why The sign-in check was happening too late — only discovered after async tasks were already spawned and the full request pipeline entered. This fix gates the request at the top of `request_prediction_internal`, returning `Task::ready(Ok(None))` immediately before any inputs are built or tasks spawned. The guard mirrors the existing `is_cloud` provider check already used elsewhere in the same file, and only applies to the `Zeta` model on the cloud provider path. Local providers (Ollama, `OpenAiCompatibleApi`) and other models (Mercury, Fim) are unaffected. Note: I haven't added a test for this — testing the early-return would require mocking auth state, which I wasn't sure was worth the complexity for a one-liner guard. Happy to add one if preferred. Release Notes: - Fixed noisy `not signed in` error log on every keystroke when not signed in to Zed --------- Co-authored-by: Oleksiy Syvokon Co-authored-by: MrSubidubi Co-authored-by: David3u <3udavid@gmail.com> --- crates/edit_prediction/src/edit_prediction.rs | 9 +++++++ .../src/edit_prediction_tests.rs | 24 +++++++++++-------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index f310e9865e3..53bd4bfbbcf 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -2550,6 +2550,15 @@ impl EditPredictionStore { allow_jump: bool, cx: &mut Context, ) -> Task>> { + let is_cloud_zeta = matches!(self.edit_prediction_model, EditPredictionModel::Zeta) + && !matches!( + all_language_settings(None, cx).edit_predictions.provider, + EditPredictionProvider::Ollama | EditPredictionProvider::OpenAiCompatibleApi + ); + if is_cloud_zeta && !self.client.cloud_client().has_credentials() { + return Task::ready(Ok(None)); + } + self.get_or_init_project(&project, cx); let project_state = self.projects.get(&project.entity_id()).unwrap(); let stored_events = project_state.events(cx); diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index fb348616ba6..52c6948cdc5 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -3204,11 +3204,18 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let http_client = FakeHttpClient::create(|_req| async move { - Ok(gpui::http_client::Response::builder() - .status(401) - .body("Unauthorized".into()) - .unwrap()) + let request_count = Arc::new(std::sync::atomic::AtomicUsize::default()); + let http_client = FakeHttpClient::create({ + let request_count = request_count.clone(); + move |_req| { + request_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + Ok(gpui::http_client::Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()) + } + } }); let client = @@ -3241,11 +3248,8 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut ep_store.request_prediction(&project, &buffer, cursor, Default::default(), cx) }); - let result = completion_task.await; - assert!( - result.is_err(), - "Without authentication and without custom URL, prediction should fail" - ); + assert!(completion_task.await.unwrap().is_none()); + assert_eq!(request_count.load(std::sync::atomic::Ordering::SeqCst), 0); } #[gpui::test] From 3d9852ae040e50d3b43e2a6a382a7e3a9861de38 Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki <40514306+aviatesk@users.noreply.github.com> Date: Fri, 29 May 2026 16:31:00 +0900 Subject: [PATCH 69/93] lsp: Fix duplicate workspace/didChangeConfiguration notifications (#56853) Editing the `lsp` section of `.zed/settings.json` caused two identical `workspace/didChangeConfiguration` notifications to be sent to each language server, e.g.: // Send: {"jsonrpc":"2.0","method":"workspace/didChangeConfiguration","params":{"settings":{"jetls":{"code_lens":{"references":true},"completion":{"method_signature":{"prepend_inference_result":true}},"full_analysis":{"debounce":2},"inlay_hint":{"block_end":{"min_lines":25}}}}}} // Send: {"jsonrpc":"2.0","method":"workspace/didChangeConfiguration","params":{"settings":{"jetls":{"code_lens":{"references":true},"completion":{"method_signature":{"prepend_inference_result":true}},"full_analysis":{"debounce":2},"inlay_hint":{"block_end":{"min_lines":25}}}}}} `maintain_workspace_config` observed `SettingsStore` directly while `on_settings_changed` also fed the same loop through `request_workspace_config_refresh`, so every settings change drove the refresh loop twice and sent two identical `workspace/didChangeConfiguration` notifications to each language server. Drop the in-loop observer and drive the loop from `external_refresh_requests` alone. Settings changes still arrive via `on_settings_changed -> request_workspace_config_refresh`, and toolchain activation continues to use the same channel. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - Fixed language servers receiving duplicate `workspace/didChangeConfiguration` notifications on every settings change. --- crates/project/src/lsp_store.rs | 18 ++---- .../tests/integration/project_tests.rs | 62 +++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index f73ecfa81ef..811b9aebac6 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -8437,26 +8437,21 @@ impl LspStore { } fn maintain_workspace_config( - external_refresh_requests: watch::Receiver<()>, + mut external_refresh_requests: watch::Receiver<()>, cx: &mut Context, ) -> Task> { - let (mut settings_changed_tx, mut settings_changed_rx) = watch::channel(); - let _ = postage::stream::Stream::try_recv(&mut settings_changed_rx); - - let settings_observation = cx.observe_global::(move |_, _| { - *settings_changed_tx.borrow_mut() = (); - }); - - let mut joint_future = - futures::stream::select(settings_changed_rx, external_refresh_requests); // Multiple things can happen when a workspace environment (selected toolchain + settings) change: // - We might shut down a language server if it's no longer enabled for a given language (and there are no buffers using it otherwise). // - We might also shut it down when the workspace configuration of all of the users of a given language server converges onto that of the other. // - In the same vein, we might also decide to start a new language server if the workspace configuration *diverges* from the other. // - In the easiest case (where we're not wrangling the lifetime of a language server anyhow), if none of the roots of a single language server diverge in their configuration, // but it is still different to what we had before, we're gonna send out a workspace configuration update. + // + // Settings-store changes reach this loop via `on_settings_changed` -> `request_workspace_config_refresh`, + // which writes to `external_refresh_requests`. Observing `SettingsStore` here as well would cause every + // settings change to drive the loop twice and emit duplicate `workspace/didChangeConfiguration` notifications. cx.spawn(async move |this, cx| { - while let Some(()) = joint_future.next().await { + while let Some(()) = external_refresh_requests.next().await { this.update(cx, |this, cx| { this.refresh_server_tree(cx); }) @@ -8465,7 +8460,6 @@ impl LspStore { Self::refresh_workspace_configurations(&this, cx).await; } - drop(settings_observation); anyhow::Ok(()) }) } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 97f6b4b437b..daaaa0bd2c6 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -3355,6 +3355,68 @@ async fn test_toggling_enable_language_server(cx: &mut gpui::TestAppContext) { .await; } +#[gpui::test] +async fn test_updating_lsp_settings_sends_one_did_change_configuration( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "" })).await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + let mut fake_rust_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "rust-lsp", + ..Default::default() + }, + ); + language_registry.add(rust_lang()); + + let _rs_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let fake_rust_server = fake_rust_servers.next().await.unwrap(); + + let did_change_count = Arc::new(atomic::AtomicUsize::new(0)); + fake_rust_server.handle_notification::({ + let did_change_count = did_change_count.clone(); + move |_, _| { + did_change_count.fetch_add(1, atomic::Ordering::SeqCst); + } + }); + cx.executor().run_until_parked(); + did_change_count.store(0, atomic::Ordering::SeqCst); + + cx.update(|cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.project.lsp.0.insert( + "rust-lsp".into(), + settings::LspSettings { + settings: Some(json!({ "foo": true })), + ..Default::default() + }, + ); + }); + }) + }); + cx.executor().run_until_parked(); + + assert_eq!( + did_change_count.load(atomic::Ordering::SeqCst), + 1, + "expected exactly one workspace/didChangeConfiguration after a settings change" + ); +} + #[gpui::test(iterations = 3)] async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) { init_test(cx); From a3669a29e6dc254839fd5fe2e91ce264dda583b0 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 29 May 2026 10:10:32 +0200 Subject: [PATCH 70/93] nix: Fix dev shell on Darwin (#58032) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A --- nix/modules/devshells.nix | 46 ++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/nix/modules/devshells.nix b/nix/modules/devshells.nix index d898d62541d..da6ba6fd113 100644 --- a/nix/modules/devshells.nix +++ b/nix/modules/devshells.nix @@ -37,30 +37,32 @@ name = "zed-editor-dev"; inputsFrom = [ zed-editor ]; - packages = with pkgs; [ - wrappedCargo # must be first, to shadow the `cargo` provided by `rustToolchain` - rustToolchain # cargo, rustc, and rust-toolchain.toml components included - cargo-nextest - cargo-hakari - cargo-machete - cargo-zigbuild - # TODO: package protobuf-language-server for editing zed.proto - # TODO: add other tools used in our scripts + packages = + with pkgs; + [ + wrappedCargo # must be first, to shadow the `cargo` provided by `rustToolchain` + rustToolchain # cargo, rustc, and rust-toolchain.toml components included + cargo-nextest + cargo-hakari + cargo-machete + cargo-zigbuild + # TODO: package protobuf-language-server for editing zed.proto + # TODO: add other tools used in our scripts - # `build.nix` adds this to the `zed-editor` wrapper (see `postFixup`) - # we'll just put it on `$PATH`: - nodejs_22 - zig + # `build.nix` adds this to the `zed-editor` wrapper (see `postFixup`) + # we'll just put it on `$PATH`: + nodejs_22 + zig - # A11y testing infra - gobject-introspection - at-spi2-core - (python3.withPackages (ps: [ - ps.pyatspi - ps.pygobject3 - ])) - accerciser - ]; + # A11y testing infra + gobject-introspection + at-spi2-core + (python3.withPackages (ps: [ + ps.pyatspi + ps.pygobject3 + ])) + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ accerciser ]; env = (removeAttrs baseEnv [ From ef5da3ccc25cab62bb6d719da717a2ce12769df7 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 29 May 2026 05:16:05 -0300 Subject: [PATCH 71/93] Remove unused --nc flag and nc crate (#55962) The `nc` crate and `zed --nc ` flag were added in #34577 to let the Claude Code integration spawn the running zed binary as a netcat-style bridge between stdio and a Unix socket for its MCP server. That integration was removed in #37120 in favor of the external `@agentclientprotocol/claude-agent-acp` npm package, which dropped the only caller of `--nc`. The flag, its dispatch in `main.rs`, and the `nc` crate itself were left behind and have been unused since. Nothing in the Zed codebase spawns `zed --nc` anymore, so remove the flag and delete the crate. The unrelated `--askpass` netcat bridge (in the `askpass` crate) is unaffected. Release Notes: - N/A --- .github/CODEOWNERS.hold | 1 - Cargo.lock | 11 --------- Cargo.toml | 2 -- crates/nc/Cargo.toml | 19 --------------- crates/nc/LICENSE-GPL | 1 - crates/nc/src/nc.rs | 51 ----------------------------------------- crates/zed/Cargo.toml | 1 - crates/zed/src/main.rs | 16 ------------- 8 files changed, 102 deletions(-) delete mode 100644 crates/nc/Cargo.toml delete mode 120000 crates/nc/LICENSE-GPL delete mode 100644 crates/nc/src/nc.rs diff --git a/.github/CODEOWNERS.hold b/.github/CODEOWNERS.hold index c0dec880c71..410d473c1f7 100644 --- a/.github/CODEOWNERS.hold +++ b/.github/CODEOWNERS.hold @@ -181,7 +181,6 @@ /crates/fs_benchmarks/ @zed-industries/infrastructure-team /crates/http_client/ @zed-industries/infrastructure-team /crates/http_client_tls/ @zed-industries/infrastructure-team -/crates/nc/ @zed-industries/infrastructure-team /crates/net/ @zed-industries/infrastructure-team /crates/paths/ @zed-industries/infrastructure-team /crates/release_channel/ @zed-industries/infrastructure-team diff --git a/Cargo.lock b/Cargo.lock index 97bafe5737e..34657ef738a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11725,16 +11725,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "nc" -version = "0.1.0" -dependencies = [ - "anyhow", - "futures 0.3.32", - "net", - "smol", -] - [[package]] name = "ndk" version = "0.9.0" @@ -23598,7 +23588,6 @@ dependencies = [ "migrator", "mimalloc", "miniprofiler_ui", - "nc", "node_runtime", "notifications", "onboarding", diff --git a/Cargo.toml b/Cargo.toml index a3bd7b2e32f..57088d9e56d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,7 +137,6 @@ members = [ "crates/miniprofiler_ui", "crates/mistral", "crates/multi_buffer", - "crates/nc", "crates/net", "crates/node_runtime", "crates/notifications", @@ -399,7 +398,6 @@ migrator = { path = "crates/migrator" } mistral = { path = "crates/mistral" } multi_buffer = { path = "crates/multi_buffer" } miniprofiler_ui = { path = "crates/miniprofiler_ui" } -nc = { path = "crates/nc" } net = { path = "crates/net" } node_runtime = { path = "crates/node_runtime" } notifications = { path = "crates/notifications" } diff --git a/crates/nc/Cargo.toml b/crates/nc/Cargo.toml deleted file mode 100644 index 534ec2271ca..00000000000 --- a/crates/nc/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "nc" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/nc.rs" -doctest = false - -[dependencies] -anyhow.workspace = true -futures.workspace = true -net.workspace = true -smol.workspace = true diff --git a/crates/nc/LICENSE-GPL b/crates/nc/LICENSE-GPL deleted file mode 120000 index 89e542f750c..00000000000 --- a/crates/nc/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/nc/src/nc.rs b/crates/nc/src/nc.rs deleted file mode 100644 index d1849e23610..00000000000 --- a/crates/nc/src/nc.rs +++ /dev/null @@ -1,51 +0,0 @@ -use anyhow::Result; - -#[cfg(windows)] -pub fn main(_socket: &str) -> Result<()> { - // It looks like we can't get an async stdio stream on Windows from smol. - panic!("--nc isn't yet supported on Windows"); -} - -/// The main function for when Zed is running in netcat mode -#[cfg(not(windows))] -pub fn main(socket: &str) -> Result<()> { - use futures::{AsyncReadExt as _, AsyncWriteExt as _, FutureExt as _, io::BufReader, select}; - use net::async_net::UnixStream; - use smol::{Unblock, io::AsyncBufReadExt}; - - smol::block_on(async { - let socket_stream = UnixStream::connect(socket).await?; - let (socket_read, mut socket_write) = socket_stream.split(); - let mut socket_reader = BufReader::new(socket_read); - - let mut stdout = Unblock::new(std::io::stdout()); - let stdin = Unblock::new(std::io::stdin()); - let mut stdin_reader = BufReader::new(stdin); - - let mut socket_line = Vec::new(); - let mut stdin_line = Vec::new(); - - loop { - select! { - bytes_read = socket_reader.read_until(b'\n', &mut socket_line).fuse() => { - if bytes_read? == 0 { - break - } - stdout.write_all(&socket_line).await?; - stdout.flush().await?; - socket_line.clear(); - } - bytes_read = stdin_reader.read_until(b'\n', &mut stdin_line).fuse() => { - if bytes_read? == 0 { - break - } - socket_write.write_all(&stdin_line).await?; - socket_write.flush().await?; - stdin_line.clear(); - } - } - } - - anyhow::Ok(()) - }) -} diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 691f06719bb..6bd33833b53 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -156,7 +156,6 @@ menu.workspace = true migrator.workspace = true miniprofiler_ui.workspace = true mimalloc = { version = "0.1", optional = true } -nc.workspace = true node_runtime.workspace = true notifications.workspace = true onboarding.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 6a832da767c..8476392b7af 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -244,17 +244,6 @@ fn main() { return; } - // `zed --nc` Makes zed operate in nc/netcat mode for use with MCP - if let Some(socket) = &args.nc { - match nc::main(socket) { - Ok(()) => return, - Err(err) => { - eprintln!("Error: {}", err); - process::exit(1); - } - } - } - #[cfg(all(not(debug_assertions), target_os = "windows"))] unsafe { use windows::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole}; @@ -1831,11 +1820,6 @@ struct Args { #[arg(long)] system_specs: bool, - /// Used for the MCP Server, to remove the need for netcat as a dependency, - /// by having Zed act like netcat communicating over a Unix socket. - #[arg(long, hide = true)] - nc: Option, - /// Used for recording minidumps on crashes by having Zed run a separate /// process communicating over a socket. #[arg(long, hide = true)] From 6bca2136a1e021ddcffb7c1d2462d4424477d345 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 29 May 2026 05:49:55 -0400 Subject: [PATCH 72/93] agent: Batch streaming edit operations (#58037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary While profiling agent sessions that make a lot of `edit_file` operations, I noticed the LSP `textDocument/didChange` handler firing excessively. Looking into this, I found out that the streaming edit pipeline was applying each `CharOperation` from `StreamingDiff` as its own `buffer.edit` transaction, and every transaction emits a `BufferEvent::Edited` event. Each event can trigger several other expensive events depending on whether the buffer is being rendered in an editor or is registered with a language. For example, there are `didChange` LSP events, the editor's on edit work (matching brackets, bracket colorization, code actions, outline), and more. A single `edit_file` could trigger hundreds of these at the higher end in a single synchronous app update, which would block the foreground thread for a bit and cause Zed to drop frames. I fixed this by collecting all of a chunk's `CharOperation`s and applying them in one `buffer.edit` call, so only a single `BufferEvent::Edited` event gets emitted. This is safe because operations are non overlapping by design of streaming diff (the edit cursor only advances). ## Why this wasn't caught earlier The cost only fully appears when a buffer is both registered with a language server and rendered in an editor. Without that, most of the per transaction observers never run, so the existing `edit_file_tool` benchmark (which ran the tool against a bare buffer) didn't surface it. I reworked the benchmark to open the edited buffer in an editor view, register a fake language server with per edit diagnostics, and lay out a frame, so it exercises the same cascade as the real editor. I also added a larger fixture. ## Results Measured with the `release-fast` profile on the reworked benchmark: | Fixture | Initial file | Before | After | Improvement | | --- | --- | --- | --- | --- | | `tiny_function_rewrite` | 1.4 KB | 31.1 ms | 12.1 ms | −61% | | `small_function_rewrite` | 3.0 KB | 42.4 ms | 19.3 ms | −55% | | `medium_many_small_changes` | 4.6 KB | 309.2 ms | 151.5 ms | −51% | | `medium_insertions` | 4.6 KB | 171.8 ms | 126.1 ms | −27% | | `large_multi_edit` | 44 KB | 9,549 ms | 919 ms | −90% | Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the UI/UX checklist - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Improved agent's edit file tool performance --- Cargo.lock | 2 + crates/agent/Cargo.toml | 2 + crates/agent/benches/edit_file_tool.rs | 414 +++++++++++++++++++------ crates/agent/src/tools/edit_session.rs | 29 +- 4 files changed, 336 insertions(+), 111 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34657ef738a..1cf4f6f284a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -234,6 +234,7 @@ dependencies = [ "agent_settings", "agent_skills", "anyhow", + "assets", "async-channel 2.5.0", "async-io", "chrono", @@ -290,6 +291,7 @@ dependencies = [ "tempfile", "text", "theme", + "theme_settings", "thiserror 2.0.17", "ui", "unindent", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 8c36cff81d0..d1f9877af3e 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -78,6 +78,7 @@ zed_env_vars.workspace = true zstd.workspace = true [dev-dependencies] +assets.workspace = true async-io.workspace = true agent_servers = { workspace = true, "features" = ["test-support"] } client = { workspace = true, "features" = ["test-support"] } @@ -103,6 +104,7 @@ reqwest_client.workspace = true settings = { workspace = true, "features" = ["test-support"] } theme = { workspace = true, "features" = ["test-support"] } +theme_settings.workspace = true unindent = { workspace = true } diff --git a/crates/agent/benches/edit_file_tool.rs b/crates/agent/benches/edit_file_tool.rs index 5a26d0a3d4d..7080b01200e 100644 --- a/crates/agent/benches/edit_file_tool.rs +++ b/crates/agent/benches/edit_file_tool.rs @@ -1,4 +1,5 @@ use std::{ + any::Any, future::Future, path::Path, sync::Arc, @@ -14,26 +15,40 @@ use agent_settings::{AgentSettings, ToolRules}; use criterion::{ BatchSize, BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main, }; -use futures::{pin_mut, task::noop_waker}; -use gpui::{AppContext as _, BackgroundExecutor, Entity, TestAppContext, UpdateGlobal as _}; +use editor::{Editor, EditorStyle}; +use futures::{StreamExt as _, pin_mut, task::noop_waker}; +use gpui::{ + AnyWindowHandle, AppContext as _, BackgroundExecutor, Entity, Focusable as _, TestAppContext, + UpdateGlobal as _, +}; +use language::{FakeLspAdapter, rust_lang}; use language_model::fake_provider::FakeLanguageModel; use project::{FakeFs, Project}; use prompt_store::ProjectContext; use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; use serde_json::{Value, json}; use settings::{Settings as _, SettingsStore}; +use ui::IntoElement as _; const SEED: u64 = 0x5EED_5EED; const OLD_TEXT_CHUNK_SIZE: usize = 512; const NEW_TEXT_CHUNK_SIZE: usize = 512; +const FILE_PROJECT_PATH: &str = "root/src/workspace_snapshot.rs"; +const FILE_ABS_PATH: &str = "/root/src/workspace_snapshot.rs"; + +#[derive(Clone)] +struct EditOp { + old_text: String, + new_text: String, +} + #[derive(Clone)] struct EditFixture { name: &'static str, old_file_text: String, expected_file_text: String, - old_text: String, - new_text: String, + edits: Vec, } struct BenchmarkHarness { @@ -43,6 +58,12 @@ struct BenchmarkHarness { partial_payloads: Vec, final_payload: Value, expected_file_text: String, + editor: Option>, + window: Option, + // Keeps the LSP buffer-registration handle and the fake language server alive + // for the lifetime of the benchmark so `didChange`/diagnostics keep flowing + // while edits are applied. + keep_alive: Vec>, } impl Drop for BenchmarkHarness { @@ -50,19 +71,18 @@ impl Drop for BenchmarkHarness { // Release our handles to the entities first. self.edit_tool.take(); self.thread.take(); + self.editor.take(); + self.keep_alive.clear(); - if let Some(cx) = self.cx.take() { - // `ActionLog` holds buffers strongly via `tracked_buffers`, and spawns a background - // diff-maintenance task that also captures a strong `Entity`. Releasing the - // last handle to the action log only marks its entity for deferred release; the - // entity's value (and the buffer handles inside) is not actually dropped until - // `flush_effects` runs `release_dropped_entities`. Even then, the cancelled task's - // captured handle does not drop until the executor pumps the cancellation through. - // - // Without this two-step teardown, GPUI's test leak detector panics on - // `TestAppContext` drop because the buffer still appears alive. See - // `ActionLog::track_buffer_internal` and `LeakDetector::drop` in - // `crates/gpui/src/app/entity_map.rs`. + if let Some(mut cx) = self.cx.take() { + // Close the editor window so the editor entity and the buffer handles + // it holds are released, then pump the executor so cancelled editor / + // action-log background tasks drop their captured handles before the + // leak detector runs on `TestAppContext` drop. + if let Some(window) = self.window.take() { + cx.update_window(window, |_, window, _| window.remove_window()) + .ok(); + } cx.update(|_| {}); cx.executor().run_until_parked(); cx.quit(); @@ -76,9 +96,10 @@ fn edit_file_tool_streaming(c: &mut Criterion) { group.sample_size(10); for fixture in fixtures { - group.throughput(Throughput::Bytes(fixture.new_text.len() as u64)); + let new_bytes: usize = fixture.edits.iter().map(|edit| edit.new_text.len()).sum(); + group.throughput(Throughput::Bytes(new_bytes as u64)); group.bench_with_input( - BenchmarkId::new(fixture.name, fixture.old_text.len()), + BenchmarkId::new(fixture.name, fixture.old_file_text.len()), &fixture, |bench, fixture| { bench.iter_batched( @@ -107,26 +128,168 @@ fn edit_file_tool_streaming(c: &mut Criterion) { fn setup_harness(fixture: EditFixture) -> BenchmarkHarness { let mut cx = init_context(); let executor = cx.executor(); - let (edit_tool, thread) = block_on_executor( + let parts = block_on_executor( &executor, - setup_edit_tool(&mut cx, fixture.old_file_text.clone()), + setup_editor_and_tool(&mut cx, fixture.old_file_text.clone()), ); - let partial_payloads = streamed_partial_payloads(&fixture.old_text, &fixture.new_text); + // Let the LSP handshake, initial parse, and first layout settle before timing. + cx.executor().run_until_parked(); + + let partial_payloads = streamed_partial_payloads(&fixture.edits); let final_payload = json!({ - "path": "root/src/workspace_snapshot.rs", - "edits": [{ - "old_text": fixture.old_text, - "new_text": fixture.new_text, - }], + "path": FILE_PROJECT_PATH, + "edits": fixture + .edits + .iter() + .map(|edit| json!({ "old_text": edit.old_text, "new_text": edit.new_text })) + .collect::>(), }); BenchmarkHarness { cx: Some(cx), - edit_tool: Some(edit_tool), - thread: Some(thread), + edit_tool: Some(parts.edit_tool), + thread: Some(parts.thread), partial_payloads, final_payload, expected_file_text: fixture.expected_file_text, + editor: Some(parts.editor), + window: Some(parts.window), + keep_alive: parts.keep_alive, + } +} + +struct HarnessParts { + edit_tool: Arc, + thread: Entity, + editor: Entity, + window: AnyWindowHandle, + keep_alive: Vec>, +} + +/// Builds a project + edit tool, opens the target buffer in an editor view inside +/// a window, and attaches a fake Rust language server. This mirrors the real app: +/// the edited file is open in a pane with a language server, so each buffer edit +/// drives the editor's observer cascade (matching brackets, code actions, outline, +/// bracket colorization), a tree-sitter reparse, and an LSP `didChange` + +/// diagnostics round-trip — the costs that dominate a real agent edit. +async fn setup_editor_and_tool(cx: &mut TestAppContext, file_text: String) -> HarnessParts { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "src": { + "workspace_snapshot.rs": file_text, + }, + }), + ) + .await; + + let project = Project::test(fs, [Path::new("/root")], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + text_document_sync: Some(lsp::TextDocumentSyncCapability::Kind( + lsp::TextDocumentSyncKind::INCREMENTAL, + )), + ..Default::default() + }, + ..Default::default() + }, + ); + + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let action_log: Entity = + thread.read_with(cx, |thread, _cx| thread.action_log().clone()); + let edit_tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + action_log, + language_registry, + )); + + // Open the same buffer the tool will edit and register it with the language + // servers so edits produce `didChange` notifications. + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(FILE_ABS_PATH, cx) + }) + .await + .expect("failed to open buffer"); + let lsp_handle = project.update(cx, |project, cx| { + project.register_buffer_with_language_servers(&buffer, cx) + }); + + let fake_server = fake_servers + .next() + .await + .expect("fake language server should start"); + // Publish diagnostics on every edit, mirroring a real server reacting to + // `didChange`, so the editor's diagnostics path runs per edit. + let server = fake_server.clone(); + fake_server.handle_notification::( + move |params, _cx| { + server.notify::(lsp::PublishDiagnosticsParams { + uri: params.text_document.uri.clone(), + version: Some(params.text_document.version), + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)), + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: "bench diagnostic".to_string(), + ..Default::default() + }], + }); + }, + ); + + // Attach an editor view in a window and lay it out once so the viewport-gated + // observers (bracket colorization, selection highlights) have a visible range. + let window = cx.add_window(|window, cx| { + let mut editor = Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx); + editor.set_style(EditorStyle::default(), window, cx); + window.focus(&editor.focus_handle(cx), cx); + editor + }); + let editor = window.root(cx).expect("window should have an editor root"); + let window: AnyWindowHandle = window.into(); + // Lay out and paint a real frame so the editor establishes a viewport (this + // is what makes the viewport-gated observers like bracket colorization run). + { + let mut visual_cx = gpui::VisualTestContext::from_window(window, &*cx); + visual_cx.draw( + gpui::point(gpui::px(0.0), gpui::px(0.0)), + gpui::size(gpui::px(1024.0), gpui::px(768.0)), + |_, _| editor.clone().into_any_element(), + ); + } + + let keep_alive: Vec> = vec![ + Box::new(lsp_handle), + Box::new(fake_server), + Box::new(fake_servers), + Box::new(buffer), + ]; + + HarnessParts { + edit_tool, + thread, + editor, + window, + keep_alive, } } @@ -135,6 +298,9 @@ fn init_context() -> TestAppContext { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); + assets::Assets.load_test_fonts(cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { store.update_user_settings(cx, |settings| { settings @@ -142,6 +308,7 @@ fn init_context() -> TestAppContext { .all_languages .defaults .ensure_final_newline_on_save = Some(false); + settings.project.all_languages.defaults.colorize_brackets = Some(true); }); }); @@ -161,48 +328,6 @@ fn init_context() -> TestAppContext { cx } -async fn setup_edit_tool( - cx: &mut TestAppContext, - file_text: String, -) -> (Arc, Entity) { - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "src": { - "workspace_snapshot.rs": file_text, - }, - }), - ) - .await; - - let project = Project::test(fs, [Path::new("/root")], cx).await; - let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let action_log: Entity = - thread.read_with(cx, |thread, _cx| thread.action_log().clone()); - - let edit_tool = Arc::new(EditFileTool::new( - project, - thread.downgrade(), - action_log, - language_registry, - )); - (edit_tool, thread) -} - fn run_streamed_edit(harness: &mut BenchmarkHarness) -> EditFileToolOutput { let (mut sender, input): (_, ToolInput) = ToolInput::test(); for payload in &harness.partial_payloads { @@ -247,33 +372,36 @@ fn block_on_executor(executor: &BackgroundExecutor, future: impl Future Vec { - let path = "root/src/workspace_snapshot.rs"; - let mut payloads = Vec::new(); +/// Builds the streamed partial payloads for a (possibly multi-edit) session, +/// mirroring how the agent reveals one edit at a time: earlier edits stay +/// complete in the array while the current edit streams its `old_text` then its +/// `new_text` in chunks. +fn streamed_partial_payloads(edits: &[EditOp]) -> Vec { + let path = FILE_PROJECT_PATH; + let mut payloads = vec![json!({ "path": path }), json!({ "path": path })]; - payloads.push(json!({ "path": path })); - payloads.push(json!({ "path": path })); + for index in 0..edits.len() { + let completed: Vec = edits[..index] + .iter() + .map(|edit| json!({ "old_text": edit.old_text, "new_text": edit.new_text })) + .collect(); + let edit = &edits[index]; - for old_end in chunk_ends(old_text, OLD_TEXT_CHUNK_SIZE) { - payloads.push(json!({ - "path": path, - "edits": [{ "old_text": &old_text[..old_end] }], - })); - } + for old_end in chunk_ends(&edit.old_text, OLD_TEXT_CHUNK_SIZE) { + let mut arr = completed.clone(); + arr.push(json!({ "old_text": &edit.old_text[..old_end] })); + payloads.push(json!({ "path": path, "edits": arr })); + } - payloads.push(json!({ - "path": path, - "edits": [{ "old_text": old_text, "new_text": "" }], - })); + let mut arr = completed.clone(); + arr.push(json!({ "old_text": edit.old_text, "new_text": "" })); + payloads.push(json!({ "path": path, "edits": arr })); - for new_end in chunk_ends(new_text, NEW_TEXT_CHUNK_SIZE) { - payloads.push(json!({ - "path": path, - "edits": [{ - "old_text": old_text, - "new_text": &new_text[..new_end], - }], - })); + for new_end in chunk_ends(&edit.new_text, NEW_TEXT_CHUNK_SIZE) { + let mut arr = completed.clone(); + arr.push(json!({ "old_text": edit.old_text, "new_text": &edit.new_text[..new_end] })); + payloads.push(json!({ "path": path, "edits": arr })); + } } payloads @@ -326,6 +454,7 @@ fn fixtures() -> Vec { EditPattern::InsertHelperBlocks { every_nth_line: 9 }, SEED + 3, ), + make_large_multi_edit_fixture("large_multi_edit", 80, 16, SEED + 4), ] } @@ -375,11 +504,106 @@ fn make_fixture( name, old_file_text, expected_file_text, - old_text, - new_text, + edits: vec![EditOp { old_text, new_text }], } } +fn make_large_multi_edit_fixture( + name: &'static str, + function_count: usize, + edit_count: usize, + seed: u64, +) -> EditFixture { + const HEADER_LINES: usize = 10; + const FUNCTION_LINES: usize = 12; + const FUNCTION_BODY_LINES: usize = 11; + + let mut rng = StdRng::seed_from_u64(seed); + let old_lines = random_rust_module(&mut rng, function_count); + let old_file_text = old_lines.join("\n"); + + let step = (function_count / edit_count).max(1); + let mut picks: Vec = (0..edit_count) + .map(|k| (k * step).min(function_count - 1)) + .collect(); + picks.dedup(); + + let replacements: Vec<(usize, Vec)> = picks + .iter() + .map(|&function_index| { + ( + function_index, + large_function_lines(&mut rng, function_index), + ) + }) + .collect(); + + let edits = replacements + .iter() + .map(|(function_index, new_function)| { + let start = HEADER_LINES + function_index * FUNCTION_LINES; + let end = start + FUNCTION_BODY_LINES; + EditOp { + old_text: old_lines[start..end].join("\n"), + new_text: new_function.join("\n"), + } + }) + .collect(); + + let mut new_lines = old_lines; + for (function_index, new_function) in replacements.iter().rev() { + let start = HEADER_LINES + function_index * FUNCTION_LINES; + let end = start + FUNCTION_BODY_LINES; + new_lines.splice(start..end, new_function.iter().cloned()); + } + let expected_file_text = new_lines.join("\n"); + + EditFixture { + name, + old_file_text, + expected_file_text, + edits, + } +} + +fn large_function_lines(rng: &mut StdRng, index: usize) -> Vec { + let function_name = identifier(rng, index + 40_000); + let argument_name = identifier(rng, index + 41_000); + + let mut lines = vec![ + format!( + " pub fn {function_name}(&mut self, {argument_name}: usize) -> Result {{" + ), + format!(" let mut accumulator = {argument_name};"), + ]; + + let body_lines = rng.random_range(30..42); + for body_index in 0..body_lines { + let local_name = identifier(rng, index + 50_000 + body_index); + let multiplier = rng.random_range(2..19); + let offset = rng.random_range(1..256); + match body_index % 4 { + 0 => lines.push(format!( + " let {local_name} = accumulator.saturating_mul({multiplier}).saturating_add({offset});" + )), + 1 => lines.push(format!( + " accumulator = {local_name}.saturating_sub(self.version % {offset}.max(1));" + )), + 2 => lines.push(format!( + " if {local_name} % {multiplier} == 0 {{ accumulator = accumulator.saturating_add({local_name}); }}" + )), + _ => lines.push(format!( + " self.buffers.insert(\"{local_name}\".to_string(), accumulator);" + )), + } + } + + lines.push(" self.version = self.version.saturating_add(accumulator);".to_string()); + lines.push(" Ok(accumulator)".to_string()); + lines.push(" }".to_string()); + lines +} + fn edit_range(lines: &[String], pattern: &EditPattern) -> std::ops::Range { let mut range = match pattern { EditPattern::LocalizedRewrite { diff --git a/crates/agent/src/tools/edit_session.rs b/crates/agent/src/tools/edit_session.rs index fcf3a98f678..016058318bf 100644 --- a/crates/agent/src/tools/edit_session.rs +++ b/crates/agent/src/tools/edit_session.rs @@ -620,21 +620,14 @@ impl EditPipeline { log::debug!("new_text_chunk: done=true, final_text='{}'", final_text); - if !final_text.is_empty() { - let char_ops = streaming_diff.push_new(&final_text); - apply_char_operations( - &char_ops, - buffer, - &original_snapshot, - &mut edit_cursor, - &context.action_log, - cx, - ); - } - - let remaining_ops = streaming_diff.finish(); + let mut char_ops = if final_text.is_empty() { + Vec::new() + } else { + streaming_diff.push_new(&final_text) + }; + char_ops.extend(streaming_diff.finish()); apply_char_operations( - &remaining_ops, + &char_ops, buffer, &original_snapshot, &mut edit_cursor, @@ -902,16 +895,17 @@ fn apply_char_operations( action_log: &Entity, cx: &mut AsyncApp, ) { + let mut edits: Vec<_> = Vec::new(); for op in ops { match op { CharOperation::Insert { text } => { let anchor = snapshot.anchor_after(*edit_cursor); - agent_edit_buffer(&buffer, [(anchor..anchor, text.as_str())], action_log, cx); + edits.push((anchor..anchor, text.as_str().into())); } CharOperation::Delete { bytes } => { let delete_end = *edit_cursor + bytes; let anchor_range = snapshot.anchor_range_inside(*edit_cursor..delete_end); - agent_edit_buffer(&buffer, [(anchor_range, "")], action_log, cx); + edits.push((anchor_range, Arc::::from(""))); *edit_cursor = delete_end; } CharOperation::Keep { bytes } => { @@ -919,6 +913,9 @@ fn apply_char_operations( } } } + if !edits.is_empty() { + agent_edit_buffer(buffer, edits, action_log, cx); + } } fn extract_match( From da87558cc2a3c13380309963e5743f6691aa42cc Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 29 May 2026 13:23:56 +0200 Subject: [PATCH 73/93] Revert "Improve ChatGPT subscription response resilience" (#58035) Reverts zed-industries/zed#57891 --- crates/agent/src/tests/mod.rs | 57 ---- .../src/provider/openai_subscribed.rs | 204 +----------- .../src/provider/vercel_ai_gateway.rs | 6 - crates/open_ai/src/open_ai.rs | 8 +- crates/open_ai/src/responses.rs | 297 +----------------- 5 files changed, 15 insertions(+), 557 deletions(-) diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 72ce5227975..16faa56c786 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -4069,63 +4069,6 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) { }); } -#[gpui::test] -async fn test_send_retry_on_http_send_error(cx: &mut TestAppContext) { - let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - - let mut events = thread - .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello!"], cx) - }) - .expect("thread send should start"); - cx.run_until_parked(); - - fake_model.send_last_completion_stream_error(LanguageModelCompletionError::HttpSend { - provider: LanguageModelProviderName::new("OpenAI"), - error: anyhow::anyhow!("response headers timed out after 10s"), - }); - fake_model.end_last_completion_stream(); - - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - - fake_model.send_last_completion_stream_text_chunk("Recovered!"); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - let mut retry_events = Vec::new(); - while let Some(Ok(event)) = events.next().await { - match event { - ThreadEvent::Retry(retry_status) => { - retry_events.push(retry_status); - } - ThreadEvent::Stop(..) => break, - _ => {} - } - } - - assert_eq!(retry_events.len(), 1); - assert!(matches!( - retry_events[0], - acp_thread::RetryStatus { attempt: 1, .. } - )); - thread.read_with(cx, |thread, _cx| { - assert_eq!( - thread.to_markdown(), - indoc! {" - ## User - - Hello! - - ## Assistant - - Recovered! - "} - ) - }); -} - #[gpui::test] async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 8d44101c181..66716ebdadb 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -12,15 +12,12 @@ use language_model::{ LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, }; -use open_ai::{ - ReasoningEffort, - responses::{StreamResponseOptions, stream_response_with_options}, -}; +use open_ai::{ReasoningEffort, responses::stream_response}; use rand::RngCore as _; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; use ui::{ConfiguredApiCard, prelude::*}; use url::form_urlencoded; use util::ResultExt as _; @@ -38,31 +35,6 @@ const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const CREDENTIALS_KEY: &str = "https://chatgpt.com/backend-api/codex"; const TOKEN_REFRESH_BUFFER_MS: u64 = 5 * 60 * 1000; -const CODEX_RESPONSE_HEADER_TIMEOUT: Duration = Duration::from_secs(10); - -fn codex_extra_headers( - account_id: Option<&str>, - session_id: Option<&str>, -) -> Vec<(String, String)> { - let mut extra_headers: Vec<(String, String)> = vec![ - ("originator".into(), "zed".into()), - ("OpenAI-Beta".into(), "responses=experimental".into()), - ]; - - if let Some(id) = account_id { - if !id.is_empty() { - extra_headers.push(("ChatGPT-Account-Id".into(), id.into())); - } - } - - if let Some(id) = session_id { - if !id.is_empty() { - extra_headers.push(("session-id".into(), id.into())); - } - } - - extra_headers -} #[derive(Serialize, Deserialize, Clone, Debug)] struct CodexCredentials { @@ -500,7 +472,6 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { // The Codex backend rejects `max_output_tokens` (`Unsupported parameter`), // unlike the public OpenAI Responses API. Pass `None` so the field is // omitted from the serialized request body entirely. - let session_id = request.thread_id.clone(); let mut responses_request = into_open_ai_response( request, self.model.id(), @@ -539,24 +510,26 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { let future = cx.spawn(async move |cx| { let creds = get_fresh_credentials(&state, &http_client, cx).await?; - let extra_headers = - codex_extra_headers(creds.account_id.as_deref(), session_id.as_deref()); + let mut extra_headers: Vec<(String, String)> = vec![ + ("originator".into(), "zed".into()), + ("OpenAI-Beta".into(), "responses=experimental".into()), + ]; + if let Some(ref id) = creds.account_id { + if !id.is_empty() { + extra_headers.push(("ChatGPT-Account-Id".into(), id.clone())); + } + } let access_token = creds.access_token.clone(); - let background_executor = cx.background_executor().clone(); request_limiter .stream(async move { - stream_response_with_options( + stream_response( http_client.as_ref(), PROVIDER_NAME.0.as_str(), CODEX_BASE_URL, &access_token, responses_request, extra_headers, - StreamResponseOptions::response_header_timeout( - CODEX_RESPONSE_HEADER_TIMEOUT, - background_executor.timer(CODEX_RESPONSE_HEADER_TIMEOUT), - ), ) .await .map_err(LanguageModelCompletionError::from) @@ -1135,7 +1108,6 @@ mod tests { use super::*; use gpui::TestAppContext; use http_client::FakeHttpClient; - use language_model::{LanguageModelRequestMessage, Role}; use parking_lot::Mutex; use std::future::Future; use std::pin::Pin; @@ -1185,30 +1157,6 @@ mod tests { } } - #[test] - fn test_codex_extra_headers_include_session_id() { - assert_eq!( - codex_extra_headers(Some("account-1"), Some("thread-1")), - vec![ - ("originator".into(), "zed".into()), - ("OpenAI-Beta".into(), "responses=experimental".into()), - ("ChatGPT-Account-Id".into(), "account-1".into()), - ("session-id".into(), "thread-1".into()), - ] - ); - } - - #[test] - fn test_codex_extra_headers_omit_empty_optional_ids() { - assert_eq!( - codex_extra_headers(Some(""), Some("")), - vec![ - ("originator".into(), "zed".into()), - ("OpenAI-Beta".into(), "responses=experimental".into()), - ] - ); - } - fn make_expired_credentials() -> CodexCredentials { CodexCredentials { access_token: "old_access".to_string(), @@ -1229,13 +1177,6 @@ mod tests { } } - fn make_fresh_credentials_with_account() -> CodexCredentials { - CodexCredentials { - account_id: Some("account-1".to_string()), - ..make_fresh_credentials() - } - } - fn fake_token_response() -> String { serde_json::json!({ "access_token": "fresh_access", @@ -1245,127 +1186,6 @@ mod tests { .to_string() } - #[gpui::test] - async fn test_stream_completion_sends_codex_session_header(cx: &mut TestAppContext) { - let captured_headers = Arc::new(Mutex::new(None::)); - let captured_headers_clone = captured_headers.clone(); - let http_client = FakeHttpClient::create(move |request| { - *captured_headers_clone.lock() = Some(request.headers().clone()); - async move { - let body = r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}"#; - Ok(http_client::Response::builder() - .status(200) - .body(http_client::AsyncBody::from(format!("{body}\n\n")))?) - } - }); - - let state = cx.new(|_cx| State { - credentials: Some(make_fresh_credentials_with_account()), - sign_in_task: None, - refresh_task: None, - load_task: None, - credentials_provider: Arc::new(FakeCredentialsProvider::new()), - auth_generation: 0, - last_auth_error: None, - }); - - let model = OpenAiSubscribedLanguageModel { - id: LanguageModelId::from(ChatGptModel::Gpt55.id().to_string()), - model: ChatGptModel::Gpt55, - state, - http_client, - request_limiter: RateLimiter::new(4), - }; - let request = LanguageModelRequest { - thread_id: Some("thread-1".to_string()), - prompt_id: Some("prompt-1".to_string()), - messages: vec![LanguageModelRequestMessage { - role: Role::User, - content: vec!["Hello".into()], - cache: false, - reasoning_details: None, - }], - ..Default::default() - }; - - let mut stream = model - .stream_completion(request, &cx.to_async()) - .await - .expect("stream should start"); - stream - .next() - .await - .expect("stream should emit event") - .expect("event should parse"); - - let captured_headers = captured_headers - .lock() - .clone() - .expect("request headers should be captured"); - assert_eq!( - captured_headers - .get("session-id") - .and_then(|value| value.to_str().ok()), - Some("thread-1") - ); - assert_eq!( - captured_headers - .get("ChatGPT-Account-Id") - .and_then(|value| value.to_str().ok()), - Some("account-1") - ); - } - - #[gpui::test] - async fn test_stream_completion_times_out_before_codex_headers(cx: &mut TestAppContext) { - let http_client = FakeHttpClient::create(|_request| { - futures::future::pending::>>() - }); - - let state = cx.new(|_cx| State { - credentials: Some(make_fresh_credentials()), - sign_in_task: None, - refresh_task: None, - load_task: None, - credentials_provider: Arc::new(FakeCredentialsProvider::new()), - auth_generation: 0, - last_auth_error: None, - }); - - let model = OpenAiSubscribedLanguageModel { - id: LanguageModelId::from(ChatGptModel::Gpt55.id().to_string()), - model: ChatGptModel::Gpt55, - state, - http_client, - request_limiter: RateLimiter::new(4), - }; - let request = LanguageModelRequest { - thread_id: Some("thread-1".to_string()), - prompt_id: Some("prompt-1".to_string()), - messages: vec![LanguageModelRequestMessage { - role: Role::User, - content: vec!["Hello".into()], - cache: false, - reasoning_details: None, - }], - ..Default::default() - }; - - let stream_completion = model.stream_completion(request, &cx.to_async()); - cx.run_until_parked(); - cx.executor().advance_clock(CODEX_RESPONSE_HEADER_TIMEOUT); - - let error = match stream_completion.await { - Ok(_) => panic!("stream should time out before headers arrive"), - Err(error) => error, - }; - assert!(matches!( - error, - LanguageModelCompletionError::HttpSend { provider, .. } - if provider == PROVIDER_NAME - )); - } - #[gpui::test] async fn test_concurrent_refresh_deduplicates(cx: &mut TestAppContext) { let refresh_count = Arc::new(AtomicUsize::new(0)); diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index ea6f9d3d4d7..312cdee5a66 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -316,12 +316,6 @@ fn map_open_ai_error(error: open_ai::RequestError) -> LanguageModelCompletionErr retry_after, ) } - open_ai::RequestError::ResponseHeaderTimeout { timeout, .. } => { - LanguageModelCompletionError::HttpSend { - provider: PROVIDER_NAME, - error: anyhow::anyhow!("response headers timed out after {timeout:?}"), - } - } open_ai::RequestError::Other(error) => LanguageModelCompletionError::Other(error), } } diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 5b9e5958267..0ff1308d52a 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -11,7 +11,7 @@ use http_client::{ pub use language_model_core::ReasoningEffort; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::{convert::TryFrom, future::Future, time::Duration}; +use std::{convert::TryFrom, future::Future}; use strum::EnumIter; use thiserror::Error; @@ -684,8 +684,6 @@ pub enum RequestError { body: String, headers: HeaderMap, }, - #[error("response headers from {provider}'s API timed out after {timeout:?}")] - ResponseHeaderTimeout { provider: String, timeout: Duration }, #[error(transparent)] Other(#[from] anyhow::Error), } @@ -905,10 +903,6 @@ impl From for language_model_core::LanguageModelCompletionError { Self::from_http_status(provider.into(), status_code, body, retry_after) } - RequestError::ResponseHeaderTimeout { provider, timeout } => Self::HttpSend { - provider: provider.into(), - error: anyhow!("response headers timed out after {timeout:?}"), - }, RequestError::Other(e) => Self::Other(e), } } diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index c3ee619fe73..6cc05699254 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -1,266 +1,11 @@ use anyhow::{Result, anyhow}; -use futures::{ - AsyncBufReadExt, AsyncReadExt, FutureExt, StreamExt, future::BoxFuture, io::BufReader, - stream::BoxStream, -}; +use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::{future::Future, time::Duration}; use crate::{ReasoningEffort, RequestError, Role, ServiceTier, ToolChoice}; -#[derive(Default)] -pub struct StreamResponseOptions { - response_header_timeout: Option<(Duration, BoxFuture<'static, ()>)>, -} - -impl StreamResponseOptions { - pub fn response_header_timeout( - timeout: Duration, - timer: impl Future + Send + 'static, - ) -> Self { - Self { - response_header_timeout: Some((timeout, timer.boxed())), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use futures::{FutureExt, StreamExt, future}; - use http_client::{ - AsyncBody, HttpClient, Request as HttpRequest, Response as HttpResponse, Url, - }; - use std::{ - io::{Cursor, Read}, - pin::Pin, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, - task::{Context, Poll, Waker}, - }; - - struct TestHttpClient { - handler: Arc< - dyn Fn( - HttpRequest, - ) -> BoxFuture<'static, anyhow::Result>> - + Send - + Sync, - >, - } - - impl TestHttpClient { - fn new(handler: F) -> Self - where - F: Fn( - HttpRequest, - ) -> BoxFuture<'static, anyhow::Result>> - + Send - + Sync - + 'static, - { - Self { - handler: Arc::new(handler), - } - } - } - - impl HttpClient for TestHttpClient { - fn user_agent(&self) -> Option<&http_client::http::HeaderValue> { - None - } - - fn proxy(&self) -> Option<&Url> { - None - } - - fn send( - &self, - request: HttpRequest, - ) -> BoxFuture<'static, anyhow::Result>> { - (self.handler)(request) - } - } - - struct DelayedBody { - state: Arc, - bytes: Cursor>, - } - - struct DelayedBodyState { - released: AtomicBool, - waker: Mutex>, - } - - struct DelayedBodyHandle { - state: Arc, - } - - impl DelayedBody { - fn new(bytes: Vec) -> (Self, DelayedBodyHandle) { - let state = Arc::new(DelayedBodyState { - released: AtomicBool::new(false), - waker: Mutex::new(None), - }); - - ( - Self { - state: state.clone(), - bytes: Cursor::new(bytes), - }, - DelayedBodyHandle { state }, - ) - } - } - - impl DelayedBodyHandle { - fn release(&self) { - self.state.released.store(true, Ordering::SeqCst); - if let Some(waker) = self.state.waker.lock().expect("lock poisoned").take() { - waker.wake(); - } - } - } - - impl futures::AsyncRead for DelayedBody { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buffer: &mut [u8], - ) -> Poll> { - if !self.state.released.load(Ordering::SeqCst) { - self.state - .waker - .lock() - .expect("lock poisoned") - .replace(cx.waker().clone()); - return Poll::Pending; - } - - Poll::Ready(self.bytes.read(buffer)) - } - } - - fn test_request() -> Request { - Request { - model: "gpt-test".into(), - instructions: None, - input: Vec::new(), - include: Vec::new(), - stream: true, - temperature: None, - top_p: None, - max_output_tokens: None, - parallel_tool_calls: None, - tool_choice: None, - tools: Vec::new(), - prompt_cache_key: None, - reasoning: None, - store: None, - service_tier: None, - } - } - - #[test] - fn stream_response_times_out_before_headers() { - futures::executor::block_on(async { - let client = TestHttpClient::new(|_| { - future::pending::>>().boxed() - }); - - let result = stream_response_with_options( - &client, - "Test Provider", - "https://api.test/v1", - "test-key", - test_request(), - Vec::new(), - StreamResponseOptions::response_header_timeout( - Duration::from_secs(10), - future::ready(()), - ), - ) - .await; - - assert!(matches!( - result, - Err(RequestError::ResponseHeaderTimeout { - provider, - timeout - }) if provider == "Test Provider" && timeout == Duration::from_secs(10) - )); - }); - } - - #[test] - fn stream_response_does_not_timeout_after_headers_arrive() { - futures::executor::block_on(async { - let body = r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}"#; - let (delayed_body, delayed_body_handle) = - DelayedBody::new(format!("{body}\n\n").into_bytes()); - let delayed_body = Mutex::new(Some(delayed_body)); - let client = TestHttpClient::new(move |_| { - let delayed_body = delayed_body - .lock() - .expect("lock poisoned") - .take() - .expect("test sends only one request"); - async { - Ok(HttpResponse::builder() - .status(200) - .body(AsyncBody::from_reader(delayed_body))?) - } - .boxed() - }); - let (timeout_tx, timeout_rx) = futures::channel::oneshot::channel::<()>(); - - let mut stream = stream_response_with_options( - &client, - "Test Provider", - "https://api.test/v1", - "test-key", - test_request(), - Vec::new(), - StreamResponseOptions::response_header_timeout( - Duration::from_secs(10), - async move { - assert!( - timeout_rx.await.is_ok(), - "timer should be dropped after headers arrive" - ); - }, - ), - ) - .await - .expect("headers should arrive before timeout"); - - assert!( - timeout_tx.send(()).is_err(), - "timeout future should be dropped after headers arrive" - ); - - assert!( - stream.next().now_or_never().is_none(), - "stream should wait for delayed body bytes" - ); - - delayed_body_handle.release(); - - let event = stream - .next() - .await - .expect("stream should produce an event") - .expect("event should parse"); - - assert!(matches!(event, StreamEvent::Completed { .. })); - }); - } -} - #[derive(Serialize, Debug)] pub struct Request { pub model: String, @@ -695,27 +440,6 @@ pub async fn stream_response( api_key: &str, request: Request, extra_headers: Vec<(String, String)>, -) -> Result>, RequestError> { - stream_response_with_options( - client, - provider_name, - api_url, - api_key, - request, - extra_headers, - StreamResponseOptions::default(), - ) - .await -} - -pub async fn stream_response_with_options( - client: &dyn HttpClient, - provider_name: &str, - api_url: &str, - api_key: &str, - request: Request, - extra_headers: Vec<(String, String)>, - options: StreamResponseOptions, ) -> Result>, RequestError> { let uri = format!("{api_url}/responses"); let mut request_builder = HttpRequest::builder() @@ -734,24 +458,7 @@ pub async fn stream_response_with_options( )) .map_err(|e| RequestError::Other(e.into()))?; - let mut response = if let Some((timeout, timer)) = options.response_header_timeout { - let send_request = client.send(request).fuse(); - let timer = timer.fuse(); - futures::pin_mut!(send_request); - futures::pin_mut!(timer); - - futures::select! { - response = send_request => response?, - () = timer => { - return Err(RequestError::ResponseHeaderTimeout { - provider: provider_name.to_owned(), - timeout, - }); - } - } - } else { - client.send(request).await? - }; + let mut response = client.send(request).await?; if response.status().is_success() { if is_streaming { let reader = BufReader::new(response.into_body()); From 85f8bf7393660657a119ce53aef13166b4bee083 Mon Sep 17 00:00:00 2001 From: Mikhail Pertsev Date: Fri, 29 May 2026 13:51:17 +0200 Subject: [PATCH 74/93] editor: Extract `header` and `mouse` out of `element.rs` (#57472) cc @SomeoneToIgnore ## Summary Follow-up to [this comment](https://github.com/zed-industries/zed/discussions/55352#discussioncomment-16961889). This extracts the buffer header and breadcrumb rendering helpers out of `element.rs` into a `header.rs` and mouse related things to `mouse.rs` Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/editor/src/element.rs | 2277 +-------------------------- crates/editor/src/element/header.rs | 1055 +++++++++++++ crates/editor/src/element/mouse.rs | 1187 ++++++++++++++ 3 files changed, 2291 insertions(+), 2228 deletions(-) create mode 100644 crates/editor/src/element/header.rs create mode 100644 crates/editor/src/element/mouse.rs diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 7e03201b278..026a3032a1c 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1,15 +1,19 @@ +mod header; +mod mouse; + +#[cfg(test)] +pub(crate) use header::StickyHeader; +pub(crate) use header::{header_jump_data, render_buffer_header}; + use crate::{ - BUFFER_HEADER_PADDING, BlockId, CURSORS_VISIBLE_FOR, ChunkRendererContext, ChunkReplacement, - CodeActionSource, ColumnarMode, ConflictsOurs, ConflictsOursMarker, ConflictsOuter, - ConflictsTheirs, ConflictsTheirsMarker, ContextMenuPlacement, CursorShape, CustomBlockId, - DisplayDiffHunk, DisplayPoint, DisplayRow, EditDisplayMode, EditPrediction, Editor, EditorMode, - EditorSettings, EditorSnapshot, EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock, - GutterDimensions, GutterHoverButton, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor, - InlayHintRefreshReason, JumpData, LineDown, LineHighlight, LineUp, MAX_LINE_LEN, - MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown, PageUp, - PhantomDiffReviewIndicator, Point, RowExt, RowRangeExt, SelectPhase, Selection, - SelectionDragState, SelectionEffects, SizingBehavior, SoftWrap, StickyHeaderExcerpt, ToPoint, - ToggleFold, ToggleFoldAll, + BUFFER_HEADER_PADDING, BlockId, ChunkRendererContext, ChunkReplacement, CodeActionSource, + ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker, + ContextMenuPlacement, CursorShape, CustomBlockId, DisplayDiffHunk, DisplayPoint, DisplayRow, + EditDisplayMode, EditPrediction, Editor, EditorMode, EditorSettings, EditorSnapshot, + EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp, + HandleInput, HoveredCursor, InlayHintRefreshReason, LineDown, LineHighlight, LineUp, + MAX_LINE_LEN, MINIMAP_FONT_SIZE, PageDown, PageUp, Point, RowExt, RowRangeExt, Selection, + SelectionDragState, SizingBehavior, SoftWrap, ToPoint, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, column_pixels, display_map::{ @@ -17,38 +21,34 @@ use crate::{ HighlightKey, HighlightedChunk, ToDisplayPoint, }, editor_settings::{ - CurrentLineHighlight, DocumentColorsRenderMode, DoubleClickInMultibuffer, Minimap, - MinimapThumb, MinimapThumbBorder, ScrollBeyondLastLine, ScrollbarAxes, - ScrollbarDiagnostics, ShowMinimap, + CurrentLineHighlight, DocumentColorsRenderMode, Minimap, MinimapThumb, MinimapThumbBorder, + ScrollBeyondLastLine, ScrollbarAxes, ScrollbarDiagnostics, ShowMinimap, }, git::blame::{BlameRenderer, GitBlame, GlobalBlameRenderer}, hover_popover::{ self, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT, - POPOVER_RIGHT_OFFSET, hover_at, + POPOVER_RIGHT_OFFSET, }, inlay_hint_settings, - mouse_context_menu::{self, MenuPosition}, scroll::{ - ActiveScrollbarState, Autoscroll, ScrollOffset, ScrollPixelOffset, ScrollbarThumbState, + ActiveScrollbarState, ScrollOffset, ScrollPixelOffset, ScrollbarThumbState, scroll_amount::ScrollAmount, }, }; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use collections::{BTreeMap, HashMap, HashSet}; use feature_flags::{DiffReviewFeatureFlag, FeatureFlagAppExt as _}; -use file_icons::FileIcons; -use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus}; +use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage}; use gpui::{ Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, - Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corners, CursorStyle, DispatchPhase, - Edges, Element, ElementInputHandler, Entity, Focusable as _, Font, FontId, FontWeight, - GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, Length, - Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, - MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, PressureStage, ScrollDelta, - ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, - Style, Styled, StyledText, TaskExt, TextAlign, TextRun, TextStyleRefinement, WeakEntity, - Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, - pattern_slash, point, px, quad, relative, size, solid_background, transparent_black, + Bounds, ClipboardItem, ContentMask, Context, Corners, CursorStyle, DispatchPhase, Edges, + Element, ElementInputHandler, Entity, Focusable as _, Font, FontId, FontWeight, + GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, + ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, + ParentElement, Pixels, ScrollHandle, ShapedLine, SharedString, Size, + StatefulInteractiveElement, Style, Styled, StyledText, TaskExt, TextAlign, TextRun, + TextStyleRefinement, WeakEntity, Window, div, fill, outline, pattern_slash, point, px, quad, + relative, size, solid_background, transparent_black, }; use itertools::Itertools; use language::{ @@ -57,18 +57,16 @@ use language::{ }; use markdown::Markdown; use multi_buffer::{ - Anchor, ExcerptBoundaryInfo, ExpandExcerptDirection, ExpandInfo, MultiBufferPoint, - MultiBufferRow, RowInfo, + Anchor, ExpandExcerptDirection, ExpandInfo, MultiBufferPoint, MultiBufferRow, RowInfo, }; use project::{ - DisableAiSettings, Entry, debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, project_settings::ProjectSettings, }; use settings::{ GitGutterSetting, GitHunkStyleSetting, IndentGuideBackgroundColoring, IndentGuideColoring, - RelativeLineNumbers, Settings, + Settings, }; use smallvec::{SmallVec, smallvec}; use std::{ @@ -79,26 +77,20 @@ use std::{ fmt::{self, Write}, iter, mem, ops::{Deref, Range}, - path::{self, Path}, rc::Rc, sync::Arc, - time::{Duration, Instant}, + time::Duration, }; use sum_tree::Bias; -use text::{BufferId, SelectionGoal}; +use text::BufferId; use theme::{ActiveTheme, Appearance, PlayerColor}; use theme_settings::BufferLineHeight; use ui::utils::ensure_minimum_contrast; -use ui::{ - ButtonLike, ContextMenu, Indicator, KeyBinding, POPOVER_Y_PADDING, Tooltip, prelude::*, - right_click_menu, scrollbars::ShowScrollbar, text_for_keystroke, -}; +use ui::{ButtonLike, POPOVER_Y_PADDING, Tooltip, prelude::*, scrollbars::ShowScrollbar}; use unicode_segmentation::UnicodeSegmentation; -use util::post_inc; -use util::{RangeExt, ResultExt, debug_panic}; +use util::{ResultExt, debug_panic}; use workspace::{ - CollaboratorId, ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel, - Workspace, + CollaboratorId, ItemHandle, Workspace, item::{Item, ItemBufferKind}, }; @@ -222,10 +214,6 @@ impl EditorElement { self.split_side = Some(side); } - fn should_show_buffer_headers(&self) -> bool { - self.split_side.is_none() - } - fn register_actions(&self, window: &mut Window, cx: &mut App) { let editor = &self.editor; editor.update(cx, |editor, cx| { @@ -713,840 +701,6 @@ impl EditorElement { }); } - fn mouse_left_down( - editor: &mut Editor, - event: &MouseDownEvent, - position_map: &PositionMap, - line_numbers: &HashMap, - window: &mut Window, - cx: &mut Context, - ) { - if window.default_prevented() { - return; - } - - let text_hitbox = &position_map.text_hitbox; - let gutter_hitbox = &position_map.gutter_hitbox; - let point_for_position = position_map.point_for_position(event.position); - let mut click_count = event.click_count; - let mut modifiers = event.modifiers; - - if let Some(hovered_hunk) = - position_map - .display_hunks - .iter() - .find_map(|(hunk, hunk_hitbox)| match hunk { - DisplayDiffHunk::Folded { .. } => None, - DisplayDiffHunk::Unfolded { - multi_buffer_range, .. - } => hunk_hitbox - .as_ref() - .is_some_and(|hitbox| hitbox.is_hovered(window)) - .then(|| multi_buffer_range.clone()), - }) - { - editor.toggle_single_diff_hunk(hovered_hunk, cx); - cx.notify(); - return; - } else if gutter_hitbox.is_hovered(window) { - click_count = 3; // Simulate triple-click when clicking the gutter to select lines - } else if !text_hitbox.is_hovered(window) { - return; - } - - if EditorSettings::get_global(cx) - .drag_and_drop_selection - .enabled - && click_count == 1 - && !modifiers.shift - { - let newest_anchor = editor.selections.newest_anchor(); - let snapshot = editor.snapshot(window, cx); - let selection = newest_anchor.map(|anchor| anchor.to_display_point(&snapshot)); - if point_for_position.intersects_selection(&selection) { - editor.selection_drag_state = SelectionDragState::ReadyToDrag { - selection: newest_anchor.clone(), - click_position: event.position, - mouse_down_time: Instant::now(), - }; - cx.stop_propagation(); - return; - } - } - - let is_singleton = editor.buffer().read(cx).is_singleton(); - - if click_count == 2 && !is_singleton { - match EditorSettings::get_global(cx).double_click_in_multibuffer { - DoubleClickInMultibuffer::Select => { - // do nothing special on double click, all selection logic is below - } - DoubleClickInMultibuffer::Open => { - if modifiers.alt { - // if double click is made with alt, pretend it's a regular double click without opening and alt, - // and run the selection logic. - modifiers.alt = false; - } else { - let scroll_position_row = position_map.scroll_position.y; - let display_row = (((event.position - gutter_hitbox.bounds.origin).y - / position_map.line_height) - as f64 - + position_map.scroll_position.y) - as u32; - let multi_buffer_row = position_map - .snapshot - .display_point_to_point( - DisplayPoint::new(DisplayRow(display_row), 0), - Bias::Right, - ) - .row; - let line_offset_from_top = display_row - scroll_position_row as u32; - // if double click is made without alt, open the corresponding excerp - editor.open_excerpts_common( - Some(JumpData::MultiBufferRow { - row: MultiBufferRow(multi_buffer_row), - line_offset_from_top, - }), - false, - window, - cx, - ); - return; - } - } - } - } - - if !is_singleton { - let display_row = (ScrollPixelOffset::from( - (event.position - gutter_hitbox.bounds.origin).y / position_map.line_height, - ) + position_map.scroll_position.y) as u32; - let multi_buffer_row = position_map - .snapshot - .display_point_to_point(DisplayPoint::new(DisplayRow(display_row), 0), Bias::Right) - .row; - if line_numbers - .get(&MultiBufferRow(multi_buffer_row)) - .is_some_and(|line_layout| { - line_layout.segments.iter().any(|segment| { - segment - .hitbox - .as_ref() - .is_some_and(|hitbox| hitbox.contains(&event.position)) - }) - }) - { - let line_offset_from_top = display_row - position_map.scroll_position.y as u32; - - editor.open_excerpts_common( - Some(JumpData::MultiBufferRow { - row: MultiBufferRow(multi_buffer_row), - line_offset_from_top, - }), - modifiers.alt, - window, - cx, - ); - cx.stop_propagation(); - return; - } - } - - let position = point_for_position.nearest_valid; - if let Some(mode) = Editor::columnar_selection_mode(&modifiers, cx) { - editor.select( - SelectPhase::BeginColumnar { - position, - reset: match mode { - ColumnarMode::FromMouse => true, - ColumnarMode::FromSelection => false, - }, - mode, - goal_column: point_for_position.exact_unclipped.column(), - }, - window, - cx, - ); - } else if modifiers.shift && !modifiers.control && !modifiers.alt && !modifiers.secondary() - { - editor.select( - SelectPhase::Extend { - position, - click_count, - }, - window, - cx, - ); - } else { - editor.select( - SelectPhase::Begin { - position, - add: Editor::is_alt_pressed(&modifiers, cx), - click_count, - }, - window, - cx, - ); - } - cx.stop_propagation(); - } - - fn mouse_right_down( - editor: &mut Editor, - event: &MouseDownEvent, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - if position_map.gutter_hitbox.is_hovered(window) { - let gutter_right_padding = editor.gutter_dimensions.right_padding; - let hitbox = &position_map.gutter_hitbox; - - if event.position.x <= hitbox.bounds.right() - gutter_right_padding - // Don't show the gutter_context_menu in collab notes - && editor.project.is_some() - { - let point_for_position = position_map.point_for_position(event.position); - editor.set_gutter_context_menu( - point_for_position.nearest_valid.row(), - None, - event.position, - window, - cx, - ); - } - return; - } - - if !position_map.text_hitbox.is_hovered(window) { - return; - } - - let point_for_position = position_map.point_for_position(event.position); - mouse_context_menu::deploy_context_menu( - editor, - Some(event.position), - point_for_position.nearest_valid, - window, - cx, - ); - cx.stop_propagation(); - } - - fn mouse_middle_down( - editor: &mut Editor, - event: &MouseDownEvent, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - if !position_map.text_hitbox.is_hovered(window) || window.default_prevented() { - return; - } - - let point_for_position = position_map.point_for_position(event.position); - let position = point_for_position.nearest_valid; - - editor.select( - SelectPhase::BeginColumnar { - position, - reset: true, - mode: ColumnarMode::FromMouse, - goal_column: point_for_position.exact_unclipped.column(), - }, - window, - cx, - ); - } - - fn mouse_up( - editor: &mut Editor, - event: &MouseUpEvent, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - // Handle diff review drag completion - if editor.diff_review_drag_state.is_some() { - editor.end_diff_review_drag(window, cx); - cx.stop_propagation(); - return; - } - - let text_hitbox = &position_map.text_hitbox; - let end_selection = editor.has_pending_selection(); - let pending_nonempty_selections = editor.has_pending_nonempty_selection(); - let point_for_position = position_map.point_for_position(event.position); - - match editor.selection_drag_state { - SelectionDragState::ReadyToDrag { - selection: _, - ref click_position, - mouse_down_time: _, - } => { - if event.position == *click_position { - editor.select( - SelectPhase::Begin { - position: point_for_position.nearest_valid, - add: false, - click_count: 1, // ready to drag state only occurs on click count 1 - }, - window, - cx, - ); - editor.selection_drag_state = SelectionDragState::None; - cx.stop_propagation(); - return; - } else { - debug_panic!("drag state can never be in ready state after drag") - } - } - SelectionDragState::Dragging { ref selection, .. } => { - let snapshot = editor.snapshot(window, cx); - let selection_display = selection.map(|anchor| anchor.to_display_point(&snapshot)); - if !point_for_position.intersects_selection(&selection_display) - && text_hitbox.is_hovered(window) - { - let is_cut = !(cfg!(target_os = "macos") && event.modifiers.alt - || cfg!(not(target_os = "macos")) && event.modifiers.control); - editor.move_selection_on_drop( - &selection.clone(), - point_for_position.nearest_valid, - is_cut, - window, - cx, - ); - } - editor.selection_drag_state = SelectionDragState::None; - cx.stop_propagation(); - cx.notify(); - return; - } - _ => {} - } - - if end_selection { - editor.select(SelectPhase::End, window, cx); - } - - if end_selection && pending_nonempty_selections { - cx.stop_propagation(); - } else if cfg!(any(target_os = "linux", target_os = "freebsd")) - && event.button == MouseButton::Middle - { - #[allow( - clippy::collapsible_if, - clippy::needless_return, - reason = "The cfg-block below makes this a false positive" - )] - if !text_hitbox.is_hovered(window) || editor.read_only(cx) { - return; - } - - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - if EditorSettings::get_global(cx).middle_click_paste { - if let Some(text) = cx.read_from_primary().and_then(|item| item.text()) { - let point_for_position = position_map.point_for_position(event.position); - let position = point_for_position.nearest_valid; - - editor.select( - SelectPhase::Begin { - position, - add: false, - click_count: 1, - }, - window, - cx, - ); - editor.insert(&text, window, cx); - } - cx.stop_propagation() - } - } - } - - fn click( - editor: &mut Editor, - event: &ClickEvent, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - let text_hitbox = &position_map.text_hitbox; - let pending_nonempty_selections = editor.has_pending_nonempty_selection(); - - let hovered_link_modifier = Editor::is_cmd_or_ctrl_pressed(&event.modifiers(), cx); - let mouse_down_hovered_link_modifier = if let ClickEvent::Mouse(mouse_event) = event { - Editor::is_cmd_or_ctrl_pressed(&mouse_event.down.modifiers, cx) - } else { - true - }; - - if let Some(mouse_position) = event.mouse_position() - && !pending_nonempty_selections - && hovered_link_modifier - && mouse_down_hovered_link_modifier - && text_hitbox.is_hovered(window) - && !matches!( - editor.selection_drag_state, - SelectionDragState::Dragging { .. } - ) - { - let point = position_map.point_for_position(mouse_position); - editor.handle_click_hovered_link(point, event.modifiers(), window, cx); - editor.selection_drag_state = SelectionDragState::None; - - cx.stop_propagation(); - } - } - - fn pressure_click( - editor: &mut Editor, - event: &MousePressureEvent, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - let text_hitbox = &position_map.text_hitbox; - let force_click_possible = - matches!(editor.prev_pressure_stage, Some(PressureStage::Normal)) - && event.stage == PressureStage::Force; - - editor.prev_pressure_stage = Some(event.stage); - - if force_click_possible && text_hitbox.is_hovered(window) { - let point = position_map.point_for_position(event.position); - editor.handle_click_hovered_link(point, event.modifiers, window, cx); - editor.selection_drag_state = SelectionDragState::None; - cx.stop_propagation(); - } - } - - fn mouse_dragged( - editor: &mut Editor, - event: &MouseMoveEvent, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - if !editor.has_pending_selection() - && matches!(editor.selection_drag_state, SelectionDragState::None) - { - return; - } - - let point_for_position = position_map.point_for_position(event.position); - let text_hitbox = &position_map.text_hitbox; - - let scroll_delta = { - let text_bounds = text_hitbox.bounds; - let mut scroll_delta = gpui::Point::::default(); - let vertical_margin = position_map.line_height.min(text_bounds.size.height / 3.0); - let top = text_bounds.origin.y + vertical_margin; - let bottom = text_bounds.bottom_left().y - vertical_margin; - if event.position.y < top { - scroll_delta.y = -scale_vertical_mouse_autoscroll_delta(top - event.position.y); - } - if event.position.y > bottom { - scroll_delta.y = scale_vertical_mouse_autoscroll_delta(event.position.y - bottom); - } - - // We need horizontal width of text - let style = editor.style.clone().unwrap_or_default(); - let font_id = window.text_system().resolve_font(&style.text.font()); - let font_size = style.text.font_size.to_pixels(window.rem_size()); - let em_width = window.text_system().em_width(font_id, font_size).unwrap(); - - let scroll_margin_x = EditorSettings::get_global(cx).horizontal_scroll_margin; - - let scroll_space: Pixels = scroll_margin_x * em_width; - - let left = text_bounds.origin.x + scroll_space; - let right = text_bounds.top_right().x - scroll_space; - - if event.position.x < left { - scroll_delta.x = -scale_horizontal_mouse_autoscroll_delta(left - event.position.x); - } - if event.position.x > right { - scroll_delta.x = scale_horizontal_mouse_autoscroll_delta(event.position.x - right); - } - scroll_delta - }; - - if !editor.has_pending_selection() { - let drop_anchor = position_map - .snapshot - .display_point_to_anchor(point_for_position.nearest_valid, Bias::Left); - match editor.selection_drag_state { - SelectionDragState::Dragging { - ref mut drop_cursor, - ref mut hide_drop_cursor, - .. - } => { - drop_cursor.start = drop_anchor; - drop_cursor.end = drop_anchor; - *hide_drop_cursor = !text_hitbox.is_hovered(window); - editor.apply_scroll_delta(scroll_delta, window, cx); - cx.notify(); - } - SelectionDragState::ReadyToDrag { - ref selection, - ref click_position, - ref mouse_down_time, - } => { - let drag_and_drop_delay = Duration::from_millis( - EditorSettings::get_global(cx) - .drag_and_drop_selection - .delay - .0, - ); - if mouse_down_time.elapsed() >= drag_and_drop_delay { - let drop_cursor = Selection { - id: post_inc(&mut editor.selections.next_selection_id()), - start: drop_anchor, - end: drop_anchor, - reversed: false, - goal: SelectionGoal::None, - }; - editor.selection_drag_state = SelectionDragState::Dragging { - selection: selection.clone(), - drop_cursor, - hide_drop_cursor: false, - }; - editor.apply_scroll_delta(scroll_delta, window, cx); - cx.notify(); - } else { - let click_point = position_map.point_for_position(*click_position); - editor.selection_drag_state = SelectionDragState::None; - editor.select( - SelectPhase::Begin { - position: click_point.nearest_valid, - add: false, - click_count: 1, - }, - window, - cx, - ); - editor.select( - SelectPhase::Update { - position: point_for_position.nearest_valid, - goal_column: point_for_position.exact_unclipped.column(), - scroll_delta, - }, - window, - cx, - ); - } - } - _ => {} - } - } else { - editor.select( - SelectPhase::Update { - position: point_for_position.nearest_valid, - goal_column: point_for_position.exact_unclipped.column(), - scroll_delta, - }, - window, - cx, - ); - } - } - - pub(crate) fn mouse_moved( - editor: &mut Editor, - event: &MouseMoveEvent, - position_map: &PositionMap, - split_side: Option, - window: &mut Window, - cx: &mut Context, - ) { - let text_hitbox = &position_map.text_hitbox; - let gutter_hitbox = &position_map.gutter_hitbox; - let modifiers = event.modifiers; - let text_hovered = text_hitbox.is_hovered(window); - let gutter_hovered = gutter_hitbox.is_hovered(window); - editor.set_gutter_hovered(gutter_hovered, cx); - - let point_for_position = position_map.point_for_position(event.position); - let valid_point = point_for_position.nearest_valid; - - // Update diff review drag state if we're dragging - if editor.diff_review_drag_state.is_some() { - editor.update_diff_review_drag(valid_point.row(), window, cx); - } - - let hovered_diff_control = position_map - .diff_hunk_control_bounds - .iter() - .find(|(_, bounds)| bounds.contains(&event.position)) - .map(|(row, _)| *row); - - let hovered_diff_hunk_row = if let Some(control_row) = hovered_diff_control { - Some(control_row) - } else if text_hovered { - let current_row = valid_point.row(); - position_map.display_hunks.iter().find_map(|(hunk, _)| { - if let DisplayDiffHunk::Unfolded { - display_row_range, .. - } = hunk - { - if display_row_range.contains(¤t_row) { - Some(display_row_range.start) - } else { - None - } - } else { - None - } - }) - } else { - None - }; - - if hovered_diff_hunk_row != editor.hovered_diff_hunk_row { - editor.hovered_diff_hunk_row = hovered_diff_hunk_row; - cx.notify(); - } - - if text_hovered - && let Some((bounds, buffer_id, blame_entry)) = &position_map.inline_blame_bounds - { - let mouse_over_inline_blame = bounds.contains(&event.position); - let mouse_over_popover = editor - .inline_blame_popover - .as_ref() - .and_then(|state| state.popover_bounds) - .is_some_and(|bounds| bounds.contains(&event.position)); - let keyboard_grace = editor - .inline_blame_popover - .as_ref() - .is_some_and(|state| state.keyboard_grace); - - if mouse_over_inline_blame || mouse_over_popover { - editor.show_blame_popover(*buffer_id, blame_entry, event.position, false, cx); - } else if !keyboard_grace { - editor.hide_blame_popover(false, cx); - } - } else { - let keyboard_grace = editor - .inline_blame_popover - .as_ref() - .is_some_and(|state| state.keyboard_grace); - if !keyboard_grace { - editor.hide_blame_popover(false, cx); - } - } - - // Handle diff review indicator when gutter is hovered in diff mode with AI enabled - let show_diff_review = editor.show_diff_review_button() - && cx.has_flag::() - && !DisableAiSettings::is_ai_disabled_for_buffer( - editor.buffer.read(cx).as_singleton().as_ref(), - cx, - ); - - let diff_review_indicator = if gutter_hovered && show_diff_review { - let is_visible = editor - .gutter_diff_review_indicator - .0 - .is_some_and(|indicator| indicator.is_active); - - if !is_visible { - editor - .gutter_diff_review_indicator - .1 - .get_or_insert_with(|| { - cx.spawn(async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(200)) - .await; - - this.update(cx, |this, cx| { - if let Some(indicator) = - this.gutter_diff_review_indicator.0.as_mut() - { - indicator.is_active = true; - cx.notify(); - } - }) - .ok(); - }) - }); - } - - let anchor = position_map - .snapshot - .display_point_to_anchor(valid_point, Bias::Left); - Some(PhantomDiffReviewIndicator { - start: anchor, - end: anchor, - is_active: is_visible, - }) - } else { - editor.gutter_diff_review_indicator.1 = None; - None - }; - - if diff_review_indicator != editor.gutter_diff_review_indicator.0 { - editor.gutter_diff_review_indicator.0 = diff_review_indicator; - cx.notify(); - } - - // Don't show breakpoint indicator when diff review indicator is active on this row - let is_on_diff_review_button_row = diff_review_indicator.is_some_and(|indicator| { - let start_row = indicator - .start - .to_display_point(&position_map.snapshot.display_snapshot) - .row(); - indicator.is_active && start_row == valid_point.row() - }); - - let gutter_hover_button = if gutter_hovered - && !is_on_diff_review_button_row - && split_side != Some(SplitSide::Left) - { - let buffer_anchor = position_map - .snapshot - .display_point_to_anchor(valid_point, Bias::Left); - - if position_map - .snapshot - .buffer_snapshot() - .anchor_to_buffer_anchor(buffer_anchor) - .is_some() - { - let is_visible = editor - .gutter_hover_button - .0 - .is_some_and(|indicator| indicator.is_active); - - if !is_visible { - editor.gutter_hover_button.1.get_or_insert_with(|| { - cx.spawn(async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(200)) - .await; - - this.update(cx, |this, cx| { - if let Some(indicator) = this.gutter_hover_button.0.as_mut() { - indicator.is_active = true; - cx.notify(); - } - }) - .ok(); - }) - }); - } - - Some(GutterHoverButton { - display_row: valid_point.row(), - is_active: is_visible, - }) - } else { - editor.gutter_hover_button.1 = None; - None - } - } else if editor.has_mouse_context_menu() { - editor.gutter_hover_button.1 = None; - editor.gutter_hover_button.0 - } else { - editor.gutter_hover_button.1 = None; - None - }; - - if &gutter_hover_button != &editor.gutter_hover_button.0 { - editor.gutter_hover_button.0 = gutter_hover_button; - cx.notify(); - } - - // Don't trigger hover popover if mouse is hovering over context menu - if text_hovered { - editor.update_hovered_link( - point_for_position, - Some(event.position), - &position_map.snapshot, - modifiers, - window, - cx, - ); - - if let Some(point) = point_for_position.as_valid() { - let anchor = position_map - .snapshot - .buffer_snapshot() - .anchor_before(point.to_offset(&position_map.snapshot, Bias::Left)); - hover_at(editor, Some(anchor), Some(event.position), window, cx); - Self::update_visible_cursor(editor, point, position_map, window, cx); - } else { - editor.update_inlay_link_and_hover_points( - &position_map.snapshot, - point_for_position, - Some(event.position), - modifiers.secondary(), - modifiers.shift, - window, - cx, - ); - } - } else { - editor.hide_hovered_link(cx); - hover_at(editor, None, Some(event.position), window, cx); - } - } - - fn update_visible_cursor( - editor: &mut Editor, - point: DisplayPoint, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - let snapshot = &position_map.snapshot; - let Some(hub) = editor.collaboration_hub() else { - return; - }; - let start = snapshot.display_snapshot.clip_point( - DisplayPoint::new(point.row(), point.column().saturating_sub(1)), - Bias::Left, - ); - let end = snapshot.display_snapshot.clip_point( - DisplayPoint::new( - point.row(), - (point.column() + 1).min(snapshot.line_len(point.row())), - ), - Bias::Right, - ); - - let range = snapshot - .buffer_snapshot() - .anchor_before(start.to_point(&snapshot.display_snapshot)) - ..snapshot - .buffer_snapshot() - .anchor_after(end.to_point(&snapshot.display_snapshot)); - - let Some(selection) = snapshot.remote_selections_in_range(&range, hub, cx).next() else { - return; - }; - let key = crate::HoveredCursor { - replica_id: selection.replica_id, - selection_id: selection.selection.id, - }; - editor.hovered_cursors.insert( - key.clone(), - cx.spawn_in(window, async move |editor, cx| { - cx.background_executor().timer(CURSORS_VISIBLE_FOR).await; - editor - .update(cx, |editor, cx| { - editor.hovered_cursors.remove(&key); - cx.notify(); - }) - .ok(); - }), - ); - cx.notify() - } - fn layout_selections( &self, start_anchor: Anchor, @@ -4047,14 +3201,15 @@ impl EditorElement { if self.should_show_buffer_headers() { let selected = selected_buffer_ids.contains(&first_excerpt.buffer_id()); - let jump_data = header_jump_data( + let jump_data = header::header_jump_data( snapshot, block_row_start, *height, first_excerpt, latest_selection_anchors, ); - result = result.child(self.render_buffer_header( + result = result.child(header::render_buffer_header( + &self.editor, first_excerpt, true, selected, @@ -4093,7 +3248,7 @@ impl EditorElement { let mut result = v_flex().id(block_id).w_full(); if self.should_show_buffer_headers() { - let jump_data = header_jump_data( + let jump_data = header::header_jump_data( snapshot, block_row_start, *height, @@ -4105,8 +3260,15 @@ impl EditorElement { let selected = selected_buffer_ids.contains(&excerpt.buffer_id()); result = result.child(div().pr(editor_margins.right).child( - self.render_buffer_header( - excerpt, false, selected, false, jump_data, window, cx, + header::render_buffer_header( + &self.editor, + excerpt, + false, + selected, + false, + jump_data, + window, + cx, ), )); } else { @@ -4261,28 +3423,6 @@ impl EditorElement { .into_any() } - fn render_buffer_header( - &self, - for_excerpt: &ExcerptBoundaryInfo, - is_folded: bool, - is_selected: bool, - is_sticky: bool, - jump_data: JumpData, - window: &mut Window, - cx: &mut App, - ) -> impl IntoElement { - render_buffer_header( - &self.editor, - for_excerpt, - is_folded, - is_selected, - is_sticky, - jump_data, - window, - cx, - ) - } - fn render_blocks( &self, rows: Range, @@ -4569,233 +3709,6 @@ impl EditorElement { } } - fn layout_sticky_buffer_header( - &self, - StickyHeaderExcerpt { excerpt }: StickyHeaderExcerpt<'_>, - scroll_position: gpui::Point, - line_height: Pixels, - right_margin: Pixels, - snapshot: &EditorSnapshot, - hitbox: &Hitbox, - selected_buffer_ids: &Vec, - blocks: &[BlockLayout], - latest_selection_anchors: &HashMap, - window: &mut Window, - cx: &mut App, - ) -> AnyElement { - let jump_data = header_jump_data( - snapshot, - DisplayRow(scroll_position.y as u32), - FILE_HEADER_HEIGHT + MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, - excerpt, - latest_selection_anchors, - ); - - let editor_bg_color = cx.theme().colors().editor_background; - - let selected = selected_buffer_ids.contains(&excerpt.buffer_id()); - - let available_width = hitbox.bounds.size.width - right_margin; - - let mut header = v_flex() - .w_full() - .relative() - .child( - div() - .w(available_width) - .h(FILE_HEADER_HEIGHT as f32 * line_height) - .bg(linear_gradient( - 0., - linear_color_stop(editor_bg_color.opacity(0.), 0.), - linear_color_stop(editor_bg_color, 0.6), - )) - .absolute() - .top_0(), - ) - .child( - self.render_buffer_header(excerpt, false, selected, true, jump_data, window, cx) - .into_any_element(), - ) - .into_any_element(); - - let mut origin = hitbox.origin; - // Move floating header up to avoid colliding with the next buffer header. - for block in blocks.iter() { - if !block.is_buffer_header { - continue; - } - - let Some(display_row) = block.row.filter(|row| row.0 > scroll_position.y as u32) else { - continue; - }; - - let max_row = display_row.0.saturating_sub(FILE_HEADER_HEIGHT); - let offset = scroll_position.y - max_row as f64; - - if offset > 0.0 { - origin.y -= Pixels::from(offset * ScrollPixelOffset::from(line_height)); - } - break; - } - - let size = size( - AvailableSpace::Definite(available_width), - AvailableSpace::MinContent, - ); - - header.prepaint_as_root(origin, size, window, cx); - - header - } - - fn layout_sticky_headers( - &self, - snapshot: &EditorSnapshot, - editor_width: Pixels, - is_row_soft_wrapped: impl Copy + Fn(usize) -> bool, - line_height: Pixels, - scroll_pixel_position: gpui::Point, - content_origin: gpui::Point, - gutter_dimensions: &GutterDimensions, - gutter_hitbox: &Hitbox, - text_hitbox: &Hitbox, - relative_line_numbers: RelativeLineNumbers, - relative_to: Option, - window: &mut Window, - cx: &mut App, - ) -> Option { - let show_line_numbers = snapshot - .show_line_numbers - .unwrap_or_else(|| EditorSettings::get_global(cx).gutter.line_numbers); - - let rows = Self::sticky_headers(self.editor.read(cx), snapshot); - - let mut lines = Vec::::new(); - - for StickyHeader { - sticky_row, - start_point, - offset, - } in rows.into_iter().rev() - { - let line = layout_line( - sticky_row, - snapshot, - &self.style, - editor_width, - is_row_soft_wrapped, - window, - cx, - ); - - let line_number = show_line_numbers.then(|| { - let start_display_row = start_point.to_display_point(snapshot).row(); - let relative_number = relative_to - .filter(|_| relative_line_numbers != RelativeLineNumbers::Disabled) - .map(|base| { - snapshot.relative_line_delta( - base, - start_display_row, - relative_line_numbers == RelativeLineNumbers::Wrapped, - ) - }); - 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.to_string()), color, window) - }); - - lines.push(StickyHeaderLine::new( - sticky_row, - line_height * offset as f32, - line, - line_number, - line_height, - scroll_pixel_position, - content_origin, - gutter_hitbox, - text_hitbox, - window, - cx, - )); - } - - lines.reverse(); - if lines.is_empty() { - return None; - } - - Some(StickyHeaders { - lines, - gutter_background: cx.theme().colors().editor_gutter_background, - content_background: self.style.background, - gutter_right_padding: gutter_dimensions.right_padding, - }) - } - - pub(crate) fn sticky_headers(editor: &Editor, snapshot: &EditorSnapshot) -> Vec { - let scroll_top = snapshot.scroll_position().y; - - let mut end_rows = Vec::::new(); - let mut rows = Vec::::new(); - - for item in editor.sticky_headers.iter().flatten() { - let start_point = item - .source_range_for_text - .start - .to_point(snapshot.buffer_snapshot()); - let end_point = item.range.end.to_point(snapshot.buffer_snapshot()); - - let sticky_row = snapshot - .display_snapshot - .point_to_display_point(start_point, Bias::Left) - .row(); - if rows - .last() - .is_some_and(|last| last.sticky_row == sticky_row) - { - continue; - } - - let end_row = snapshot - .display_snapshot - .point_to_display_point(end_point, Bias::Left) - .row(); - let max_sticky_row = end_row.previous_row(); - if max_sticky_row <= sticky_row { - continue; - } - - while end_rows - .last() - .is_some_and(|&last_end| last_end <= sticky_row) - { - end_rows.pop(); - } - let depth = end_rows.len(); - let adjusted_scroll_top = scroll_top + depth as f64; - - if sticky_row.as_f64() >= adjusted_scroll_top || end_row.as_f64() <= adjusted_scroll_top - { - continue; - } - - let max_scroll_offset = max_sticky_row.as_f64() - scroll_top; - let offset = (depth as f64).min(max_scroll_offset); - - end_rows.push(end_row); - rows.push(StickyHeader { - sticky_row, - start_point, - offset, - }); - } - - rows - } - fn layout_cursor_popovers( &self, line_height: Pixels, @@ -5327,83 +4240,6 @@ impl EditorElement { } } - fn layout_mouse_context_menu( - &self, - editor_snapshot: &EditorSnapshot, - visible_range: Range, - content_origin: gpui::Point, - window: &mut Window, - cx: &mut App, - ) -> Option { - let position = self.editor.update(cx, |editor, cx| { - let visible_start_point = editor.display_to_pixel_point( - DisplayPoint::new(visible_range.start, 0), - editor_snapshot, - window, - cx, - )?; - let visible_end_point = editor.display_to_pixel_point( - DisplayPoint::new(visible_range.end, 0), - editor_snapshot, - window, - cx, - )?; - - let mouse_context_menu = editor.mouse_context_menu.as_ref()?; - let (source_display_point, position) = match mouse_context_menu.position { - MenuPosition::PinnedToScreen(point) => (None, point), - MenuPosition::PinnedToEditor { source, offset } => { - let source_display_point = source.to_display_point(editor_snapshot); - let source_point = - editor.to_pixel_point(source, editor_snapshot, window, cx)?; - let position = content_origin + source_point + offset; - (Some(source_display_point), position) - } - }; - - let source_included = source_display_point.is_none_or(|source_display_point| { - visible_range - .to_inclusive() - .contains(&source_display_point.row()) - }); - let position_included = - visible_start_point.y <= position.y && position.y <= visible_end_point.y; - if !source_included && !position_included { - None - } else { - Some(position) - } - })?; - - let text_style = TextStyleRefinement { - line_height: Some(DefiniteLength::Fraction( - BufferLineHeight::Comfortable.value(), - )), - ..Default::default() - }; - window.with_text_style(Some(text_style), |window| { - let mut element = self.editor.read_with(cx, |editor, _| { - let mouse_context_menu = editor.mouse_context_menu.as_ref()?; - let context_menu = mouse_context_menu.context_menu.clone(); - - Some( - deferred( - anchored() - .position(position) - .child(context_menu) - .anchor(gpui::Anchor::TopLeft) - .snap_to_window_with_margin(px(8.)), - ) - .with_priority(1) - .into_any(), - ) - })?; - - element.prepaint_as_root(position, AvailableSpace::min_size(), window, cx); - Some(element) - }) - } - fn layout_hover_popovers( &self, snapshot: &EditorSnapshot, @@ -6742,108 +5578,6 @@ impl EditorElement { } } - fn paint_sticky_headers( - &mut self, - layout: &mut EditorLayout, - window: &mut Window, - cx: &mut App, - ) { - let Some(mut sticky_headers) = layout.sticky_headers.take() else { - return; - }; - - if sticky_headers.lines.is_empty() { - layout.sticky_headers = Some(sticky_headers); - return; - } - - let whitespace_setting = self - .editor - .read(cx) - .buffer - .read(cx) - .language_settings(cx) - .show_whitespaces; - sticky_headers.paint(layout, whitespace_setting, window, cx); - - let sticky_header_hitboxes: Vec = sticky_headers - .lines - .iter() - .map(|line| line.hitbox.clone()) - .collect(); - let hovered_hitbox = sticky_header_hitboxes - .iter() - .find_map(|hitbox| hitbox.is_hovered(window).then_some(hitbox.id)); - - window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, _cx| { - if !phase.bubble() { - return; - } - - let current_hover = sticky_header_hitboxes - .iter() - .find_map(|hitbox| hitbox.is_hovered(window).then_some(hitbox.id)); - if hovered_hitbox != current_hover { - window.refresh(); - } - }); - - let position_map = layout.position_map.clone(); - - for (line_index, line) in sticky_headers.lines.iter().enumerate() { - let editor = self.editor.clone(); - let hitbox = line.hitbox.clone(); - let row = line.row; - let line_layout = line.line.clone(); - let position_map = position_map.clone(); - window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { - if !phase.bubble() { - return; - } - - if event.button == MouseButton::Left && hitbox.is_hovered(window) { - let point_for_position = - position_map.point_for_position_on_line(event.position, row, &line_layout); - - editor.update(cx, |editor, cx| { - let snapshot = editor.snapshot(window, cx); - let anchor = snapshot - .display_snapshot - .display_point_to_anchor(point_for_position.nearest_valid, Bias::Left); - editor.change_selections( - SelectionEffects::scroll(Autoscroll::top_relative( - line_index as ScrollOffset, - )), - window, - cx, - |selections| { - selections.clear_disjoint(); - selections.set_pending_anchor_range( - anchor..anchor, - crate::SelectMode::Character, - ); - }, - ); - cx.stop_propagation(); - }); - } - }); - } - - let text_bounds = layout.position_map.text_hitbox.bounds; - let border_top = text_bounds.top() - + sticky_headers.lines.last().unwrap().offset - + layout.position_map.line_height; - let separator_height = px(1.); - let border_bounds = window.pixel_snap_bounds(Bounds::from_corners( - point(layout.gutter_hitbox.bounds.left(), border_top), - point(text_bounds.right(), border_top + separator_height), - )); - window.paint_quad(fill(border_bounds, cx.theme().colors().border_variant)); - - layout.sticky_headers = Some(sticky_headers); - } - fn paint_lines_background( &mut self, layout: &mut EditorLayout, @@ -7738,237 +6472,6 @@ impl EditorElement { } } - fn paint_scroll_wheel_listener( - &mut self, - layout: &EditorLayout, - window: &mut Window, - cx: &mut App, - ) { - window.on_mouse_event({ - let position_map = layout.position_map.clone(); - let editor = self.editor.clone(); - let hitbox = layout.hitbox.clone(); - let mut delta = ScrollDelta::default(); - - // Set a minimum scroll_sensitivity of 0.01 to make sure the user doesn't - // accidentally turn off their scrolling. - let base_scroll_sensitivity = - EditorSettings::get_global(cx).scroll_sensitivity.max(0.01); - - // Use a minimum fast_scroll_sensitivity for same reason above - let fast_scroll_sensitivity = EditorSettings::get_global(cx) - .fast_scroll_sensitivity - .max(0.01); - - move |event: &ScrollWheelEvent, phase, window, cx| { - if phase == DispatchPhase::Bubble && hitbox.should_handle_scroll(window) { - delta = delta.coalesce(event.delta); - - if event.modifiers.secondary() - && editor.read(cx).enable_mouse_wheel_zoom - && EditorSettings::get_global(cx).mouse_wheel_zoom - { - let delta_y = match event.delta { - ScrollDelta::Pixels(pixels) => pixels.y.into(), - ScrollDelta::Lines(lines) => lines.y, - }; - - if delta_y > 0.0 { - theme_settings::increase_buffer_font_size(cx); - } else if delta_y < 0.0 { - theme_settings::decrease_buffer_font_size(cx); - } - - cx.stop_propagation(); - } else { - let scroll_sensitivity = { - if event.modifiers.alt { - fast_scroll_sensitivity - } else { - base_scroll_sensitivity - } - }; - - editor.update(cx, |editor, cx| { - let line_height = position_map.line_height; - let glyph_width = position_map.em_layout_width; - let (delta, axis) = match delta { - gpui::ScrollDelta::Pixels(mut pixels) => { - //Trackpad - let axis = - position_map.snapshot.ongoing_scroll.filter(&mut pixels); - (pixels, axis) - } - - gpui::ScrollDelta::Lines(lines) => { - //Not trackpad - let pixels = - point(lines.x * glyph_width, lines.y * line_height); - (pixels, None) - } - }; - - let current_scroll_position = position_map.snapshot.scroll_position(); - let x = (current_scroll_position.x - * ScrollPixelOffset::from(glyph_width) - - ScrollPixelOffset::from(delta.x * scroll_sensitivity)) - / ScrollPixelOffset::from(glyph_width); - let y = (current_scroll_position.y - * ScrollPixelOffset::from(line_height) - - ScrollPixelOffset::from(delta.y * scroll_sensitivity)) - / ScrollPixelOffset::from(line_height); - let mut scroll_position = - point(x, y).clamp(&point(0., 0.), &position_map.scroll_max); - let forbid_vertical_scroll = - editor.scroll_manager.forbid_vertical_scroll(); - if forbid_vertical_scroll { - scroll_position.y = current_scroll_position.y; - } - - if scroll_position != current_scroll_position { - editor.scroll(scroll_position, axis, window, cx); - cx.stop_propagation(); - } else if y < 0. { - // Due to clamping, we may fail to detect cases of overscroll to the top; - // We want the scroll manager to get an update in such cases and detect the change of direction - // on the next frame. - cx.notify(); - } - }); - } - } - } - }); - } - - fn paint_mouse_listeners(&mut self, layout: &EditorLayout, window: &mut Window, cx: &mut App) { - if layout.mode.is_minimap() { - return; - } - - self.paint_scroll_wheel_listener(layout, window, cx); - - window.on_mouse_event({ - let position_map = layout.position_map.clone(); - let editor = self.editor.clone(); - let line_numbers = layout.line_numbers.clone(); - - move |event: &MouseDownEvent, phase, window, cx| { - if phase == DispatchPhase::Bubble { - match event.button { - MouseButton::Left => editor.update(cx, |editor, cx| { - let pending_mouse_down = editor - .pending_mouse_down - .get_or_insert_with(Default::default) - .clone(); - - *pending_mouse_down.borrow_mut() = Some(event.clone()); - - Self::mouse_left_down( - editor, - event, - &position_map, - line_numbers.as_ref(), - window, - cx, - ); - }), - MouseButton::Right => editor.update(cx, |editor, cx| { - Self::mouse_right_down(editor, event, &position_map, window, cx); - }), - MouseButton::Middle => editor.update(cx, |editor, cx| { - Self::mouse_middle_down(editor, event, &position_map, window, cx); - }), - _ => {} - }; - } - } - }); - - window.on_mouse_event({ - let editor = self.editor.clone(); - let position_map = layout.position_map.clone(); - - move |event: &MouseUpEvent, phase, window, cx| { - if phase == DispatchPhase::Bubble { - editor.update(cx, |editor, cx| { - Self::mouse_up(editor, event, &position_map, window, cx) - }); - } - } - }); - - window.on_mouse_event({ - let editor = self.editor.clone(); - let position_map = layout.position_map.clone(); - let mut captured_mouse_down = None; - - move |event: &MouseUpEvent, phase, window, cx| match phase { - // Clear the pending mouse down during the capture phase, - // so that it happens even if another event handler stops - // propagation. - DispatchPhase::Capture => editor.update(cx, |editor, _cx| { - let pending_mouse_down = editor - .pending_mouse_down - .get_or_insert_with(Default::default) - .clone(); - - let mut pending_mouse_down = pending_mouse_down.borrow_mut(); - if pending_mouse_down.is_some() && position_map.text_hitbox.is_hovered(window) { - captured_mouse_down = pending_mouse_down.take(); - window.refresh(); - } - }), - // Fire click handlers during the bubble phase. - DispatchPhase::Bubble => editor.update(cx, |editor, cx| { - if let Some(mouse_down) = captured_mouse_down.take() { - let event = ClickEvent::Mouse(MouseClickEvent { - down: mouse_down, - up: event.clone(), - }); - Self::click(editor, &event, &position_map, window, cx); - } - }), - } - }); - - window.on_mouse_event({ - let position_map = layout.position_map.clone(); - let editor = self.editor.clone(); - - move |event: &MousePressureEvent, phase, window, cx| { - if phase == DispatchPhase::Bubble { - editor.update(cx, |editor, cx| { - Self::pressure_click(editor, &event, &position_map, window, cx); - }) - } - } - }); - - window.on_mouse_event({ - let position_map = layout.position_map.clone(); - let editor = self.editor.clone(); - let split_side = self.split_side; - - move |event: &MouseMoveEvent, phase, window, cx| { - if phase == DispatchPhase::Bubble { - editor.update(cx, |editor, cx| { - if editor.hover_state.focused(window, cx) { - return; - } - if event.pressed_button == Some(MouseButton::Left) - || event.pressed_button == Some(MouseButton::Middle) - { - Self::mouse_dragged(editor, event, &position_map, window, cx) - } - - Self::mouse_moved(editor, event, &position_map, split_side, window, cx) - }); - } - } - }); - } - fn shape_line_number( &self, text: SharedString, @@ -8341,511 +6844,6 @@ fn apply_dirty_filename_style( ) } -fn file_status_label_color(file_status: Option) -> Color { - file_status.map_or(Color::Default, |status| { - if status.is_conflicted() { - Color::Conflict - } else if status.is_modified() { - Color::Modified - } else if status.is_deleted() { - Color::Disabled - } else if status.is_created() { - Color::Created - } else { - Color::Default - } - }) -} - -pub(crate) fn header_jump_data( - editor_snapshot: &EditorSnapshot, - block_row_start: DisplayRow, - height: u32, - first_excerpt: &ExcerptBoundaryInfo, - latest_selection_anchors: &HashMap, -) -> JumpData { - let multibuffer_snapshot = editor_snapshot.buffer_snapshot(); - let buffer = first_excerpt.buffer(multibuffer_snapshot); - let (jump_anchor, jump_buffer, excerpt_start) = if let Some(anchor) = - latest_selection_anchors.get(&first_excerpt.buffer_id()) - && let Some((jump_anchor, selection_buffer)) = - multibuffer_snapshot.anchor_to_buffer_anchor(*anchor) - { - let jump_offset = text::ToOffset::to_offset(&jump_anchor, selection_buffer); - let selection_excerpt_start = multibuffer_snapshot - .excerpts_for_buffer(jump_anchor.buffer_id) - .find(|excerpt| { - let start = text::ToOffset::to_offset(&excerpt.context.start, selection_buffer); - let end = text::ToOffset::to_offset(&excerpt.context.end, selection_buffer); - start <= jump_offset && jump_offset <= end - }) - .map(|excerpt| excerpt.context.start) - .unwrap_or(first_excerpt.range.context.start); - (jump_anchor, selection_buffer, selection_excerpt_start) - } else { - ( - first_excerpt.range.primary.start, - buffer, - first_excerpt.range.context.start, - ) - }; - let jump_position = language::ToPoint::to_point(&jump_anchor, jump_buffer); - let rows_from_excerpt_start = if jump_anchor == excerpt_start { - 0 - } else { - let excerpt_start_point = language::ToPoint::to_point(&excerpt_start, jump_buffer); - jump_position.row.saturating_sub(excerpt_start_point.row) - }; - - let line_offset_from_top = (block_row_start.0 + height + rows_from_excerpt_start) - .saturating_sub( - editor_snapshot - .scroll_anchor - .scroll_position(&editor_snapshot.display_snapshot) - .y as u32, - ); - - JumpData::MultiBufferPoint { - anchor: jump_anchor, - position: jump_position, - line_offset_from_top, - } -} - -pub(crate) fn render_buffer_header( - editor: &Entity, - for_excerpt: &ExcerptBoundaryInfo, - is_folded: bool, - is_selected: bool, - is_sticky: bool, - jump_data: JumpData, - window: &mut Window, - cx: &mut App, -) -> impl IntoElement { - let editor_read = editor.read(cx); - let multi_buffer = editor_read.buffer.read(cx); - let is_read_only = editor_read.read_only(cx); - let editor_handle: &dyn ItemHandle = editor; - let multibuffer_snapshot = multi_buffer.snapshot(cx); - let buffer = for_excerpt.buffer(&multibuffer_snapshot); - - let breadcrumbs = if is_selected { - editor_read.breadcrumbs_inner(cx) - } else { - None - }; - - let buffer_id = for_excerpt.buffer_id(); - let file_status = multi_buffer - .all_diff_hunks_expanded() - .then(|| editor_read.status_for_buffer_id(buffer_id, cx)) - .flatten(); - let indicator = multi_buffer.buffer(buffer_id).and_then(|buffer| { - let buffer = buffer.read(cx); - let indicator_color = match (buffer.has_conflict(), buffer.is_dirty()) { - (true, _) => Some(Color::Warning), - (_, true) => Some(Color::Accent), - (false, false) => None, - }; - indicator_color.map(|indicator_color| Indicator::dot().color(indicator_color)) - }); - - let include_root = editor_read - .project - .as_ref() - .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) - .unwrap_or_default(); - let file = buffer.file(); - let can_open_excerpts = file.is_none_or(|file| file.can_open()); - let path_style = file.map(|file| file.path_style(cx)); - let relative_path = buffer.resolve_file_path(include_root, cx); - let (parent_path, filename) = if let Some(path) = &relative_path { - if let Some(path_style) = path_style { - let (dir, file_name) = path_style.split(path); - (dir.map(|dir| dir.to_owned()), Some(file_name.to_owned())) - } else { - (None, Some(path.clone())) - } - } else { - (None, None) - }; - let focus_handle = editor_read.focus_handle(cx); - let colors = cx.theme().colors(); - - let header = div() - .id(("buffer-header", buffer_id.to_proto())) - .p(BUFFER_HEADER_PADDING) - .w_full() - .h(FILE_HEADER_HEIGHT as f32 * window.line_height()) - .child( - h_flex() - .group("buffer-header-group") - .size_full() - .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667))) - .pl_1() - .pr_2() - .rounded_sm() - .gap_1p5() - .when(is_sticky, |el| el.shadow_md()) - .border_1() - .map(|border| { - let border_color = - if is_selected && is_folded && focus_handle.contains_focused(window, cx) { - colors.border_focused - } else { - colors.border - }; - border.border_color(border_color) - }) - .bg(colors.editor_subheader_background) - .hover(|style| style.bg(colors.element_hover)) - .map(|header| { - let editor = editor.clone(); - let buffer_id = for_excerpt.buffer_id(); - let toggle_chevron_icon = - FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path); - let button_size = rems_from_px(28.); - - header.child( - div() - .hover(|style| style.bg(colors.element_selected)) - .rounded_xs() - .child( - ButtonLike::new("toggle-buffer-fold") - .style(ButtonStyle::Transparent) - .height(button_size.into()) - .width(button_size) - .children(toggle_chevron_icon) - .tooltip({ - let focus_handle = focus_handle.clone(); - let is_folded_for_tooltip = is_folded; - move |_window, cx| { - Tooltip::with_meta_in( - if is_folded_for_tooltip { - "Unfold Excerpt" - } else { - "Fold Excerpt" - }, - Some(&ToggleFold), - format!( - "{} to toggle all", - text_for_keystroke( - &Modifiers::alt(), - "click", - cx - ) - ), - &focus_handle, - cx, - ) - } - }) - .on_click(move |event, window, cx| { - if event.modifiers().alt { - editor.update(cx, |editor, cx| { - editor.toggle_fold_all(&ToggleFoldAll, window, cx); - }); - } else { - if is_folded { - editor.update(cx, |editor, cx| { - editor.unfold_buffer(buffer_id, cx); - }); - } else { - editor.update(cx, |editor, cx| { - editor.fold_buffer(buffer_id, cx); - }); - } - } - }), - ), - ) - }) - .children( - editor_read - .addons - .values() - .filter_map(|addon| { - addon.render_buffer_header_controls(for_excerpt, buffer, window, cx) - }) - .take(1), - ) - .when(!is_read_only, |this| { - this.child( - h_flex() - .size_3() - .justify_center() - .flex_shrink_0() - .children(indicator), - ) - }) - .child( - h_flex() - .cursor_pointer() - .id("path_header_block") - .min_w_0() - .size_full() - .gap_1() - .justify_between() - .overflow_hidden() - .child(h_flex().min_w_0().flex_1().gap_0p5().overflow_hidden().map( - |path_header| { - let filename = filename - .map(SharedString::from) - .unwrap_or_else(|| "untitled".into()); - - let full_path = match parent_path.as_deref() { - Some(parent) if !parent.is_empty() => { - format!("{}{}", parent, filename.as_str()) - } - _ => filename.as_str().to_string(), - }; - - path_header - .child( - ButtonLike::new("filename-button") - .when(ItemSettings::get_global(cx).file_icons, |this| { - let path = path::Path::new(filename.as_str()); - let icon = FileIcons::get_icon(path, cx) - .unwrap_or_default(); - - this.child( - Icon::from_path(icon).color(Color::Muted), - ) - }) - .child( - Label::new(filename) - .single_line() - .color(file_status_label_color(file_status)) - .buffer_font(cx) - .when( - file_status.is_some_and(|s| s.is_deleted()), - |label| label.strikethrough(), - ), - ) - .tooltip(move |_, cx| { - Tooltip::with_meta( - "Open File", - None, - full_path.clone(), - cx, - ) - }) - .on_click(window.listener_for(editor, { - let jump_data = jump_data.clone(); - move |editor, e: &ClickEvent, window, cx| { - editor.open_excerpts_common( - Some(jump_data.clone()), - e.modifiers().secondary(), - window, - cx, - ); - } - })), - ) - .when_some(parent_path, |then, path| { - then.child( - Label::new(path) - .buffer_font(cx) - .truncate_start() - .color( - if file_status - .is_some_and(FileStatus::is_deleted) - { - Color::Custom(colors.text_disabled) - } else { - Color::Custom(colors.text_muted) - }, - ), - ) - }) - .when(!buffer.capability.editable(), |el| { - el.child(Icon::new(IconName::FileLock).color(Color::Muted)) - }) - .when_some(breadcrumbs, |then, breadcrumbs| { - let font = theme_settings::ThemeSettings::get_global(cx) - .buffer_font - .clone(); - then.child(render_breadcrumb_text( - breadcrumbs, - Some(font), - None, - editor_handle, - true, - window, - cx, - )) - }) - }, - )) - .when(can_open_excerpts && relative_path.is_some(), |this| { - this.child( - div() - .when(!is_selected, |this| { - this.visible_on_hover("buffer-header-group") - }) - .child( - Button::new("open-file-button", "Open File") - .style(ButtonStyle::OutlinedGhost) - .when(is_selected, |this| { - this.key_binding(KeyBinding::for_action_in( - &OpenExcerpts, - &focus_handle, - cx, - )) - }) - .on_click(window.listener_for(editor, { - let jump_data = jump_data.clone(); - move |editor, e: &ClickEvent, window, cx| { - editor.open_excerpts_common( - Some(jump_data.clone()), - e.modifiers().secondary(), - window, - cx, - ); - } - })), - ), - ) - }) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(window.listener_for(editor, { - let buffer_id = for_excerpt.buffer_id(); - move |editor, e: &ClickEvent, window, cx| { - if e.modifiers().alt { - editor.open_excerpts_common( - Some(jump_data.clone()), - e.modifiers().secondary(), - window, - cx, - ); - return; - } - - if is_folded { - editor.unfold_buffer(buffer_id, cx); - } else { - editor.fold_buffer(buffer_id, cx); - } - } - })), - ), - ); - - let file = buffer.file().cloned(); - let editor = editor.clone(); - let buffer_snapshot = buffer.clone(); - - right_click_menu("buffer-header-context-menu") - .trigger(move |_, _, _| header) - .menu(move |window, cx| { - let menu_context = focus_handle.clone(); - let editor = editor.clone(); - let file = file.clone(); - let buffer_snapshot = buffer_snapshot.clone(); - ContextMenu::build(window, cx, move |mut menu, window, cx| { - if let Some(file) = file - && let Some(project) = editor.read(cx).project() - && let Some(worktree) = - project.read(cx).worktree_for_id(file.worktree_id(cx), cx) - { - let path_style = file.path_style(cx); - let worktree = worktree.read(cx); - let relative_path = file.path(); - let entry_for_path = worktree.entry_for_path(relative_path); - let abs_path = entry_for_path.map(|e| { - e.canonical_path - .as_deref() - .map_or_else(|| worktree.absolutize(relative_path), Path::to_path_buf) - }); - let has_relative_path = worktree.root_entry().is_some_and(Entry::is_dir); - - let parent_abs_path = abs_path - .as_ref() - .and_then(|abs_path| Some(abs_path.parent()?.to_path_buf())); - let relative_path = has_relative_path - .then_some(relative_path) - .map(ToOwned::to_owned); - - let visible_in_project_panel = relative_path.is_some() && worktree.is_visible(); - let reveal_in_project_panel = entry_for_path - .filter(|_| visible_in_project_panel) - .map(|entry| entry.id); - menu = menu - .when_some(abs_path, |menu, abs_path| { - menu.entry( - "Copy Path", - Some(Box::new(zed_actions::workspace::CopyPath)), - window.handler_for(&editor, move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - abs_path.to_string_lossy().into_owned(), - )); - }), - ) - }) - .when_some(relative_path, |menu, relative_path| { - menu.entry( - "Copy Relative Path", - Some(Box::new(zed_actions::workspace::CopyRelativePath)), - window.handler_for(&editor, move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - relative_path.display(path_style).to_string(), - )); - }), - ) - }) - .when( - reveal_in_project_panel.is_some() || parent_abs_path.is_some(), - |menu| menu.separator(), - ) - .when_some(reveal_in_project_panel, |menu, entry_id| { - menu.entry( - "Reveal In Project Panel", - Some(Box::new(RevealInProjectPanel::default())), - window.handler_for(&editor, move |editor, _, cx| { - if let Some(project) = &mut editor.project { - project.update(cx, |_, cx| { - cx.emit(project::Event::RevealInProjectPanel(entry_id)) - }); - } - }), - ) - }) - .when_some(parent_abs_path, |menu, parent_abs_path| { - menu.entry( - "Open in Terminal", - Some(Box::new(OpenInTerminal)), - window.handler_for(&editor, move |_, window, cx| { - window.dispatch_action( - OpenTerminal { - working_directory: parent_abs_path.clone(), - local: false, - } - .boxed_clone(), - cx, - ); - }), - ) - }); - } - - menu = editor.update(cx, |editor, cx| { - let mut menu = menu; - for addon in editor.addons.values() { - menu = addon.extend_buffer_header_context_menu( - menu, - &buffer_snapshot, - window, - cx, - ); - } - menu - }); - - menu.context(menu_context) - }) - }) -} - fn render_inline_blame_entry( blame_entry: BlameEntry, style: &EditorStyle, @@ -11529,181 +9527,18 @@ pub struct EditorLayout { tab_invisible: ShapedLine, space_invisible: ShapedLine, sticky_buffer_header: Option, - sticky_headers: Option, + sticky_headers: Option, document_colors: Option<(DocumentColorsRenderMode, Vec<(Range, Hsla)>)>, text_align: TextAlign, content_width: Pixels, } -struct StickyHeaders { - lines: Vec, - gutter_background: Hsla, - content_background: Hsla, - gutter_right_padding: Pixels, -} - -struct StickyHeaderLine { - row: DisplayRow, - offset: Pixels, - line: Rc, - line_number: Option, - elements: SmallVec<[AnyElement; 1]>, - available_text_width: Pixels, - hitbox: Hitbox, -} - impl EditorLayout { fn line_end_overshoot(&self) -> Pixels { 0.15 * self.position_map.line_height } } -impl StickyHeaders { - fn paint( - &mut self, - layout: &mut EditorLayout, - whitespace_setting: ShowWhitespaceSetting, - window: &mut Window, - cx: &mut App, - ) { - let line_height = layout.position_map.line_height; - - for line in self.lines.iter_mut().rev() { - window.paint_layer( - Bounds::new( - layout.gutter_hitbox.origin + point(Pixels::ZERO, line.offset), - size(line.hitbox.size.width, line_height), - ), - |window| { - let gutter_bounds = Bounds::new( - layout.gutter_hitbox.origin + point(Pixels::ZERO, line.offset), - size(layout.gutter_hitbox.size.width, line_height), - ); - window.paint_quad(fill(gutter_bounds, self.gutter_background)); - - let text_bounds = Bounds::new( - layout.position_map.text_hitbox.origin + point(Pixels::ZERO, line.offset), - size(line.available_text_width, line_height), - ); - window.paint_quad(fill(text_bounds, self.content_background)); - - if line.hitbox.is_hovered(window) { - let hover_overlay = cx.theme().colors().panel_overlay_hover; - window.paint_quad(fill(gutter_bounds, hover_overlay)); - window.paint_quad(fill(text_bounds, hover_overlay)); - } - - line.paint( - layout, - self.gutter_right_padding, - line.available_text_width, - layout.content_origin, - line_height, - whitespace_setting, - window, - cx, - ); - }, - ); - - window.set_cursor_style(CursorStyle::IBeam, &line.hitbox); - } - } -} - -impl StickyHeaderLine { - fn new( - row: DisplayRow, - offset: Pixels, - mut line: LineWithInvisibles, - line_number: Option, - line_height: Pixels, - scroll_pixel_position: gpui::Point, - content_origin: gpui::Point, - gutter_hitbox: &Hitbox, - text_hitbox: &Hitbox, - window: &mut Window, - cx: &mut App, - ) -> Self { - let mut elements = SmallVec::<[AnyElement; 1]>::new(); - line.prepaint_with_custom_offset( - line_height, - scroll_pixel_position, - content_origin, - offset, - &mut elements, - window, - cx, - ); - - let hitbox_bounds = Bounds::new( - gutter_hitbox.origin + point(Pixels::ZERO, offset), - size(text_hitbox.right() - gutter_hitbox.left(), line_height), - ); - let available_text_width = - (hitbox_bounds.size.width - gutter_hitbox.size.width).max(Pixels::ZERO); - - Self { - row, - offset, - line: Rc::new(line), - line_number, - elements, - available_text_width, - hitbox: window.insert_hitbox(hitbox_bounds, HitboxBehavior::BlockMouseExceptScroll), - } - } - - fn paint( - &mut self, - layout: &EditorLayout, - gutter_right_padding: Pixels, - available_text_width: Pixels, - content_origin: gpui::Point, - line_height: Pixels, - whitespace_setting: ShowWhitespaceSetting, - window: &mut Window, - cx: &mut App, - ) { - window.with_content_mask( - Some(ContentMask { - bounds: Bounds::new( - layout.position_map.text_hitbox.bounds.origin - + point(Pixels::ZERO, self.offset), - size(available_text_width, line_height), - ), - }), - |window| { - self.line.draw_with_custom_offset( - layout, - self.row, - content_origin, - self.offset, - whitespace_setting, - &[], - window, - cx, - ); - for element in &mut self.elements { - element.paint(window, cx); - } - }, - ); - - if let Some(line_number) = &self.line_number { - let gutter_origin = layout.gutter_hitbox.origin + point(Pixels::ZERO, self.offset); - let gutter_width = layout.gutter_hitbox.size.width; - let origin = point( - gutter_origin.x + gutter_width - gutter_right_padding - line_number.width, - gutter_origin.y, - ); - line_number - .paint(origin, line_height, TextAlign::Left, None, window, cx) - .log_err(); - } - } -} - #[derive(Debug)] struct LineNumberSegment { shaped_line: ShapedLine, @@ -12653,25 +10488,11 @@ impl HighlightedRange { } } -pub(crate) struct StickyHeader { - pub sticky_row: DisplayRow, - pub start_point: Point, - pub offset: ScrollOffset, -} - enum CursorPopoverType { CodeContextMenu, EditPrediction, } -pub fn scale_vertical_mouse_autoscroll_delta(delta: Pixels) -> f32 { - (delta.pow(1.2) / 100.0).min(px(3.0)).into() -} - -fn scale_horizontal_mouse_autoscroll_delta(delta: Pixels) -> f32 { - (delta.pow(1.2) / 300.0).into() -} - pub fn register_action( editor: &Entity, window: &mut Window, diff --git a/crates/editor/src/element/header.rs b/crates/editor/src/element/header.rs new file mode 100644 index 00000000000..02dfebdfc31 --- /dev/null +++ b/crates/editor/src/element/header.rs @@ -0,0 +1,1055 @@ +use std::path::Path; +use std::rc::Rc; + +use collections::HashMap; +use file_icons::FileIcons; +use git::status::FileStatus; +use gpui::{ + Action, AnyElement, App, AvailableSpace, Bounds, ClickEvent, ClipboardItem, ContentMask, + CursorStyle, DefiniteLength, Entity, Focusable as _, Hitbox, HitboxBehavior, Hsla, IntoElement, + Length, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, + ShapedLine, SharedString, Styled, TextAlign, Window, div, fill, linear_color_stop, + linear_gradient, point, px, size, +}; +use language::language_settings::ShowWhitespaceSetting; +use multi_buffer::{Anchor, ExcerptBoundaryInfo}; +use project::Entry; +use settings::{RelativeLineNumbers, Settings}; +use smallvec::SmallVec; +use sum_tree::Bias; +use text::BufferId; +use theme::ActiveTheme; +use ui::{ + ButtonLike, ContextMenu, Indicator, KeyBinding, Tooltip, prelude::*, right_click_menu, + text_for_keystroke, +}; +use util::ResultExt; +use workspace::{ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel}; + +use super::{ + BlockLayout, EditorElement, EditorLayout, LineWithInvisibles, layout_line, + render_breadcrumb_text, +}; +use crate::{ + BUFFER_HEADER_PADDING, DisplayRow, Editor, EditorSettings, EditorSnapshot, FILE_HEADER_HEIGHT, + GutterDimensions, JumpData, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, Point, RowExt, + SelectionEffects, StickyHeaderExcerpt, ToPoint, ToggleFold, ToggleFoldAll, + display_map::ToDisplayPoint, + scroll::{Autoscroll, ScrollOffset, ScrollPixelOffset}, +}; + +pub(crate) struct StickyHeader { + sticky_row: DisplayRow, + pub(crate) start_point: Point, + pub(crate) offset: ScrollOffset, +} + +pub(super) struct StickyHeaders { + pub(super) lines: Vec, + gutter_background: Hsla, + content_background: Hsla, + gutter_right_padding: Pixels, +} + +pub(super) struct StickyHeaderLine { + row: DisplayRow, + pub(super) offset: Pixels, + line: Rc, + line_number: Option, + elements: SmallVec<[AnyElement; 1]>, + available_text_width: Pixels, + hitbox: Hitbox, +} + +impl EditorElement { + pub(crate) fn sticky_headers(editor: &Editor, snapshot: &EditorSnapshot) -> Vec { + let scroll_top = snapshot.scroll_position().y; + + let mut end_rows = Vec::::new(); + let mut rows = Vec::::new(); + + for item in editor.sticky_headers.iter().flatten() { + let start_point = item + .source_range_for_text + .start + .to_point(snapshot.buffer_snapshot()); + let end_point = item.range.end.to_point(snapshot.buffer_snapshot()); + + let sticky_row = snapshot + .display_snapshot + .point_to_display_point(start_point, Bias::Left) + .row(); + if rows + .last() + .is_some_and(|last| last.sticky_row == sticky_row) + { + continue; + } + + let end_row = snapshot + .display_snapshot + .point_to_display_point(end_point, Bias::Left) + .row(); + let max_sticky_row = end_row.previous_row(); + if max_sticky_row <= sticky_row { + continue; + } + + while end_rows + .last() + .is_some_and(|&last_end| last_end <= sticky_row) + { + end_rows.pop(); + } + let depth = end_rows.len(); + let adjusted_scroll_top = scroll_top + depth as f64; + + if sticky_row.as_f64() >= adjusted_scroll_top || end_row.as_f64() <= adjusted_scroll_top + { + continue; + } + + let max_scroll_offset = max_sticky_row.as_f64() - scroll_top; + let offset = (depth as f64).min(max_scroll_offset); + + end_rows.push(end_row); + rows.push(StickyHeader { + sticky_row, + start_point, + offset, + }); + } + + rows + } + + pub(super) fn should_show_buffer_headers(&self) -> bool { + self.split_side.is_none() + } + + pub(super) fn layout_sticky_buffer_header( + &self, + StickyHeaderExcerpt { excerpt }: StickyHeaderExcerpt<'_>, + scroll_position: gpui::Point, + line_height: Pixels, + right_margin: Pixels, + snapshot: &EditorSnapshot, + hitbox: &Hitbox, + selected_buffer_ids: &Vec, + blocks: &[BlockLayout], + latest_selection_anchors: &HashMap, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let jump_data = header_jump_data( + snapshot, + DisplayRow(scroll_position.y as u32), + FILE_HEADER_HEIGHT + MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, + excerpt, + latest_selection_anchors, + ); + + let editor_bg_color = cx.theme().colors().editor_background; + + let selected = selected_buffer_ids.contains(&excerpt.buffer_id()); + + let available_width = hitbox.bounds.size.width - right_margin; + + let mut header = v_flex() + .w_full() + .relative() + .child( + div() + .w(available_width) + .h(FILE_HEADER_HEIGHT as f32 * line_height) + .bg(linear_gradient( + 0., + linear_color_stop(editor_bg_color.opacity(0.), 0.), + linear_color_stop(editor_bg_color, 0.6), + )) + .absolute() + .top_0(), + ) + .child( + render_buffer_header( + &self.editor, + excerpt, + false, + selected, + true, + jump_data, + window, + cx, + ) + .into_any_element(), + ) + .into_any_element(); + + let mut origin = hitbox.origin; + // Move floating header up to avoid colliding with the next buffer header. + for block in blocks.iter() { + if !block.is_buffer_header { + continue; + } + + let Some(display_row) = block.row.filter(|row| row.0 > scroll_position.y as u32) else { + continue; + }; + + let max_row = display_row.0.saturating_sub(FILE_HEADER_HEIGHT); + let offset = scroll_position.y - max_row as f64; + + if offset > 0.0 { + origin.y -= Pixels::from(offset * ScrollPixelOffset::from(line_height)); + } + break; + } + + let size = size( + AvailableSpace::Definite(available_width), + AvailableSpace::MinContent, + ); + + header.prepaint_as_root(origin, size, window, cx); + + header + } + + pub(super) fn layout_sticky_headers( + &self, + snapshot: &EditorSnapshot, + editor_width: Pixels, + is_row_soft_wrapped: impl Copy + Fn(usize) -> bool, + line_height: Pixels, + scroll_pixel_position: gpui::Point, + content_origin: gpui::Point, + gutter_dimensions: &GutterDimensions, + gutter_hitbox: &Hitbox, + text_hitbox: &Hitbox, + relative_line_numbers: RelativeLineNumbers, + relative_to: Option, + window: &mut Window, + cx: &mut App, + ) -> Option { + let show_line_numbers = snapshot + .show_line_numbers + .unwrap_or_else(|| EditorSettings::get_global(cx).gutter.line_numbers); + + let rows = Self::sticky_headers(self.editor.read(cx), snapshot); + + let mut lines = Vec::::new(); + + for StickyHeader { + sticky_row, + start_point, + offset, + } in rows.into_iter().rev() + { + let line = layout_line( + sticky_row, + snapshot, + &self.style, + editor_width, + is_row_soft_wrapped, + window, + cx, + ); + + let line_number = show_line_numbers.then(|| { + let start_display_row = start_point.to_display_point(snapshot).row(); + let relative_number = relative_to + .filter(|_| relative_line_numbers != RelativeLineNumbers::Disabled) + .map(|base| { + snapshot.relative_line_delta( + base, + start_display_row, + relative_line_numbers == RelativeLineNumbers::Wrapped, + ) + }); + 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.to_string()), color, window) + }); + + lines.push(StickyHeaderLine::new( + sticky_row, + line_height * offset as f32, + line, + line_number, + line_height, + scroll_pixel_position, + content_origin, + gutter_hitbox, + text_hitbox, + window, + cx, + )); + } + + lines.reverse(); + if lines.is_empty() { + return None; + } + + Some(StickyHeaders { + lines, + gutter_background: cx.theme().colors().editor_gutter_background, + content_background: self.style.background, + gutter_right_padding: gutter_dimensions.right_padding, + }) + } + + pub(super) fn paint_sticky_headers( + &mut self, + layout: &mut EditorLayout, + window: &mut Window, + cx: &mut App, + ) { + let Some(mut sticky_headers) = layout.sticky_headers.take() else { + return; + }; + + let Some(last_line_offset) = sticky_headers.lines.last().map(|line| line.offset) else { + layout.sticky_headers = Some(sticky_headers); + return; + }; + + let whitespace_setting = self + .editor + .read(cx) + .buffer + .read(cx) + .language_settings(cx) + .show_whitespaces; + sticky_headers.paint(layout, whitespace_setting, window, cx); + + let sticky_header_hitboxes: Vec = sticky_headers + .lines + .iter() + .map(|line| line.hitbox.clone()) + .collect(); + let hovered_hitbox = sticky_header_hitboxes + .iter() + .find_map(|hitbox| hitbox.is_hovered(window).then_some(hitbox.id)); + + window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, _cx| { + if !phase.bubble() { + return; + } + + let current_hover = sticky_header_hitboxes + .iter() + .find_map(|hitbox| hitbox.is_hovered(window).then_some(hitbox.id)); + if hovered_hitbox != current_hover { + window.refresh(); + } + }); + + let position_map = layout.position_map.clone(); + + for (line_index, line) in sticky_headers.lines.iter().enumerate() { + let editor = self.editor.clone(); + let hitbox = line.hitbox.clone(); + let row = line.row; + let line_layout = line.line.clone(); + let position_map = position_map.clone(); + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if !phase.bubble() { + return; + } + + if event.button == MouseButton::Left && hitbox.is_hovered(window) { + let point_for_position = + position_map.point_for_position_on_line(event.position, row, &line_layout); + + editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let anchor = snapshot + .display_snapshot + .display_point_to_anchor(point_for_position.nearest_valid, Bias::Left); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::top_relative( + line_index as ScrollOffset, + )), + window, + cx, + |selections| { + selections.clear_disjoint(); + selections.set_pending_anchor_range( + anchor..anchor, + crate::SelectMode::Character, + ); + }, + ); + cx.stop_propagation(); + }); + } + }); + } + + let text_bounds = layout.position_map.text_hitbox.bounds; + let border_top = text_bounds.top() + last_line_offset + layout.position_map.line_height; + let separator_height = px(1.); + let border_bounds = window.pixel_snap_bounds(Bounds::from_corners( + point(layout.gutter_hitbox.bounds.left(), border_top), + point(text_bounds.right(), border_top + separator_height), + )); + window.paint_quad(fill(border_bounds, cx.theme().colors().border_variant)); + + layout.sticky_headers = Some(sticky_headers); + } +} + +impl StickyHeaders { + fn paint( + &mut self, + layout: &mut EditorLayout, + whitespace_setting: ShowWhitespaceSetting, + window: &mut Window, + cx: &mut App, + ) { + let line_height = layout.position_map.line_height; + + for line in self.lines.iter_mut().rev() { + window.paint_layer( + Bounds::new( + layout.gutter_hitbox.origin + point(Pixels::ZERO, line.offset), + size(line.hitbox.size.width, line_height), + ), + |window| { + let gutter_bounds = Bounds::new( + layout.gutter_hitbox.origin + point(Pixels::ZERO, line.offset), + size(layout.gutter_hitbox.size.width, line_height), + ); + window.paint_quad(fill(gutter_bounds, self.gutter_background)); + + let text_bounds = Bounds::new( + layout.position_map.text_hitbox.origin + point(Pixels::ZERO, line.offset), + size(line.available_text_width, line_height), + ); + window.paint_quad(fill(text_bounds, self.content_background)); + + if line.hitbox.is_hovered(window) { + let hover_overlay = cx.theme().colors().panel_overlay_hover; + window.paint_quad(fill(gutter_bounds, hover_overlay)); + window.paint_quad(fill(text_bounds, hover_overlay)); + } + + line.paint( + layout, + self.gutter_right_padding, + line.available_text_width, + layout.content_origin, + line_height, + whitespace_setting, + window, + cx, + ); + }, + ); + + window.set_cursor_style(CursorStyle::IBeam, &line.hitbox); + } + } +} + +impl StickyHeaderLine { + fn new( + row: DisplayRow, + offset: Pixels, + mut line: LineWithInvisibles, + line_number: Option, + line_height: Pixels, + scroll_pixel_position: gpui::Point, + content_origin: gpui::Point, + gutter_hitbox: &Hitbox, + text_hitbox: &Hitbox, + window: &mut Window, + cx: &mut App, + ) -> Self { + let mut elements = SmallVec::<[AnyElement; 1]>::new(); + line.prepaint_with_custom_offset( + line_height, + scroll_pixel_position, + content_origin, + offset, + &mut elements, + window, + cx, + ); + + let hitbox_bounds = Bounds::new( + gutter_hitbox.origin + point(Pixels::ZERO, offset), + size(text_hitbox.right() - gutter_hitbox.left(), line_height), + ); + let available_text_width = + (hitbox_bounds.size.width - gutter_hitbox.size.width).max(Pixels::ZERO); + + Self { + row, + offset, + line: Rc::new(line), + line_number, + elements, + available_text_width, + hitbox: window.insert_hitbox(hitbox_bounds, HitboxBehavior::BlockMouseExceptScroll), + } + } + + fn paint( + &mut self, + layout: &EditorLayout, + gutter_right_padding: Pixels, + available_text_width: Pixels, + content_origin: gpui::Point, + line_height: Pixels, + whitespace_setting: ShowWhitespaceSetting, + window: &mut Window, + cx: &mut App, + ) { + window.with_content_mask( + Some(ContentMask { + bounds: Bounds::new( + layout.position_map.text_hitbox.bounds.origin + + point(Pixels::ZERO, self.offset), + size(available_text_width, line_height), + ), + }), + |window| { + self.line.draw_with_custom_offset( + layout, + self.row, + content_origin, + self.offset, + whitespace_setting, + &[], + window, + cx, + ); + for element in &mut self.elements { + element.paint(window, cx); + } + }, + ); + + if let Some(line_number) = &self.line_number { + let gutter_origin = layout.gutter_hitbox.origin + point(Pixels::ZERO, self.offset); + let gutter_width = layout.gutter_hitbox.size.width; + let origin = point( + gutter_origin.x + gutter_width - gutter_right_padding - line_number.width, + gutter_origin.y, + ); + line_number + .paint(origin, line_height, TextAlign::Left, None, window, cx) + .log_err(); + } + } +} + +pub(crate) fn header_jump_data( + editor_snapshot: &EditorSnapshot, + block_row_start: DisplayRow, + height: u32, + first_excerpt: &ExcerptBoundaryInfo, + latest_selection_anchors: &HashMap, +) -> JumpData { + let multibuffer_snapshot = editor_snapshot.buffer_snapshot(); + let buffer = first_excerpt.buffer(multibuffer_snapshot); + let (jump_anchor, jump_buffer, excerpt_start) = if let Some(anchor) = + latest_selection_anchors.get(&first_excerpt.buffer_id()) + && let Some((jump_anchor, selection_buffer)) = + multibuffer_snapshot.anchor_to_buffer_anchor(*anchor) + { + let jump_offset = text::ToOffset::to_offset(&jump_anchor, selection_buffer); + let selection_excerpt_start = multibuffer_snapshot + .excerpts_for_buffer(jump_anchor.buffer_id) + .find(|excerpt| { + let start = text::ToOffset::to_offset(&excerpt.context.start, selection_buffer); + let end = text::ToOffset::to_offset(&excerpt.context.end, selection_buffer); + start <= jump_offset && jump_offset <= end + }) + .map(|excerpt| excerpt.context.start) + .unwrap_or(first_excerpt.range.context.start); + (jump_anchor, selection_buffer, selection_excerpt_start) + } else { + ( + first_excerpt.range.primary.start, + buffer, + first_excerpt.range.context.start, + ) + }; + let jump_position = language::ToPoint::to_point(&jump_anchor, jump_buffer); + let rows_from_excerpt_start = if jump_anchor == excerpt_start { + 0 + } else { + let excerpt_start_point = language::ToPoint::to_point(&excerpt_start, jump_buffer); + jump_position.row.saturating_sub(excerpt_start_point.row) + }; + + let line_offset_from_top = (block_row_start.0 + height + rows_from_excerpt_start) + .saturating_sub( + editor_snapshot + .scroll_anchor + .scroll_position(&editor_snapshot.display_snapshot) + .y as u32, + ); + + JumpData::MultiBufferPoint { + anchor: jump_anchor, + position: jump_position, + line_offset_from_top, + } +} + +pub(crate) fn render_buffer_header( + editor: &Entity, + for_excerpt: &ExcerptBoundaryInfo, + is_folded: bool, + is_selected: bool, + is_sticky: bool, + jump_data: JumpData, + window: &mut Window, + cx: &mut App, +) -> impl IntoElement { + let editor_read = editor.read(cx); + let multi_buffer = editor_read.buffer.read(cx); + let is_read_only = editor_read.read_only(cx); + let editor_handle: &dyn ItemHandle = editor; + let multibuffer_snapshot = multi_buffer.snapshot(cx); + let buffer = for_excerpt.buffer(&multibuffer_snapshot); + + let breadcrumbs = if is_selected { + editor_read.breadcrumbs_inner(cx) + } else { + None + }; + + let buffer_id = for_excerpt.buffer_id(); + let file_status = multi_buffer + .all_diff_hunks_expanded() + .then(|| editor_read.status_for_buffer_id(buffer_id, cx)) + .flatten(); + let indicator = multi_buffer.buffer(buffer_id).and_then(|buffer| { + let buffer = buffer.read(cx); + let indicator_color = match (buffer.has_conflict(), buffer.is_dirty()) { + (true, _) => Some(Color::Warning), + (_, true) => Some(Color::Accent), + (false, false) => None, + }; + indicator_color.map(|indicator_color| Indicator::dot().color(indicator_color)) + }); + + let include_root = editor_read + .project + .as_ref() + .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) + .unwrap_or_default(); + let file = buffer.file(); + let can_open_excerpts = file.is_none_or(|file| file.can_open()); + let path_style = file.map(|file| file.path_style(cx)); + let relative_path = buffer.resolve_file_path(include_root, cx); + let (parent_path, filename) = if let Some(path) = &relative_path { + if let Some(path_style) = path_style { + let (dir, file_name) = path_style.split(path); + (dir.map(|dir| dir.to_owned()), Some(file_name.to_owned())) + } else { + (None, Some(path.clone())) + } + } else { + (None, None) + }; + let focus_handle = editor_read.focus_handle(cx); + let colors = cx.theme().colors(); + + let header = div() + .id(("buffer-header", buffer_id.to_proto())) + .p(BUFFER_HEADER_PADDING) + .w_full() + .h(FILE_HEADER_HEIGHT as f32 * window.line_height()) + .child( + h_flex() + .group("buffer-header-group") + .size_full() + .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667))) + .pl_1() + .pr_2() + .rounded_sm() + .gap_1p5() + .when(is_sticky, |el| el.shadow_md()) + .border_1() + .map(|border| { + let border_color = + if is_selected && is_folded && focus_handle.contains_focused(window, cx) { + colors.border_focused + } else { + colors.border + }; + border.border_color(border_color) + }) + .bg(colors.editor_subheader_background) + .hover(|style| style.bg(colors.element_hover)) + .map(|header| { + let editor = editor.clone(); + let buffer_id = for_excerpt.buffer_id(); + let toggle_chevron_icon = + FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path); + let button_size = rems_from_px(28.); + + header.child( + div() + .hover(|style| style.bg(colors.element_selected)) + .rounded_xs() + .child( + ButtonLike::new("toggle-buffer-fold") + .style(ButtonStyle::Transparent) + .height(button_size.into()) + .width(button_size) + .children(toggle_chevron_icon) + .tooltip({ + let focus_handle = focus_handle.clone(); + let is_folded_for_tooltip = is_folded; + move |_window, cx| { + Tooltip::with_meta_in( + if is_folded_for_tooltip { + "Unfold Excerpt" + } else { + "Fold Excerpt" + }, + Some(&ToggleFold), + format!( + "{} to toggle all", + text_for_keystroke( + &Modifiers::alt(), + "click", + cx + ) + ), + &focus_handle, + cx, + ) + } + }) + .on_click(move |event, window, cx| { + if event.modifiers().alt { + editor.update(cx, |editor, cx| { + editor.toggle_fold_all(&ToggleFoldAll, window, cx); + }); + } else { + if is_folded { + editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_id, cx); + }); + } else { + editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_id, cx); + }); + } + } + }), + ), + ) + }) + .children( + editor_read + .addons + .values() + .filter_map(|addon| { + addon.render_buffer_header_controls(for_excerpt, buffer, window, cx) + }) + .take(1), + ) + .when(!is_read_only, |this| { + this.child( + h_flex() + .size_3() + .justify_center() + .flex_shrink_0() + .children(indicator), + ) + }) + .child( + h_flex() + .cursor_pointer() + .id("path_header_block") + .min_w_0() + .size_full() + .gap_1() + .justify_between() + .overflow_hidden() + .child(h_flex().min_w_0().flex_1().gap_0p5().overflow_hidden().map( + |path_header| { + let filename = filename + .map(SharedString::from) + .unwrap_or_else(|| "untitled".into()); + + let full_path = match parent_path.as_deref() { + Some(parent) if !parent.is_empty() => { + format!("{}{}", parent, filename.as_str()) + } + _ => filename.as_str().to_string(), + }; + + path_header + .child( + ButtonLike::new("filename-button") + .when(ItemSettings::get_global(cx).file_icons, |this| { + let path = std::path::Path::new(filename.as_str()); + let icon = FileIcons::get_icon(path, cx) + .unwrap_or_default(); + + this.child( + Icon::from_path(icon).color(Color::Muted), + ) + }) + .child( + Label::new(filename) + .single_line() + .color(file_status_label_color(file_status)) + .buffer_font(cx) + .when( + file_status.is_some_and(|s| s.is_deleted()), + |label| label.strikethrough(), + ), + ) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Open File", + None, + full_path.clone(), + cx, + ) + }) + .on_click(window.listener_for(editor, { + let jump_data = jump_data.clone(); + move |editor, e: &ClickEvent, window, cx| { + editor.open_excerpts_common( + Some(jump_data.clone()), + e.modifiers().secondary(), + window, + cx, + ); + } + })), + ) + .when_some(parent_path, |then, path| { + then.child( + Label::new(path) + .buffer_font(cx) + .truncate_start() + .color( + if file_status + .is_some_and(FileStatus::is_deleted) + { + Color::Custom(colors.text_disabled) + } else { + Color::Custom(colors.text_muted) + }, + ), + ) + }) + .when(!buffer.capability.editable(), |el| { + el.child(Icon::new(IconName::FileLock).color(Color::Muted)) + }) + .when_some(breadcrumbs, |then, breadcrumbs| { + let font = theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(); + then.child(render_breadcrumb_text( + breadcrumbs, + Some(font), + None, + editor_handle, + true, + window, + cx, + )) + }) + }, + )) + .when(can_open_excerpts && relative_path.is_some(), |this| { + this.child( + div() + .when(!is_selected, |this| { + this.visible_on_hover("buffer-header-group") + }) + .child( + Button::new("open-file-button", "Open File") + .style(ButtonStyle::OutlinedGhost) + .when(is_selected, |this| { + this.key_binding(KeyBinding::for_action_in( + &OpenExcerpts, + &focus_handle, + cx, + )) + }) + .on_click(window.listener_for(editor, { + let jump_data = jump_data.clone(); + move |editor, e: &ClickEvent, window, cx| { + editor.open_excerpts_common( + Some(jump_data.clone()), + e.modifiers().secondary(), + window, + cx, + ); + } + })), + ), + ) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(window.listener_for(editor, { + let buffer_id = for_excerpt.buffer_id(); + move |editor, e: &ClickEvent, window, cx| { + if e.modifiers().alt { + editor.open_excerpts_common( + Some(jump_data.clone()), + e.modifiers().secondary(), + window, + cx, + ); + return; + } + + if is_folded { + editor.unfold_buffer(buffer_id, cx); + } else { + editor.fold_buffer(buffer_id, cx); + } + } + })), + ), + ); + + let file = buffer.file().cloned(); + let editor = editor.clone(); + let buffer_snapshot = buffer.clone(); + + right_click_menu("buffer-header-context-menu") + .trigger(move |_, _, _| header) + .menu(move |window, cx| { + let menu_context = focus_handle.clone(); + let editor = editor.clone(); + let file = file.clone(); + let buffer_snapshot = buffer_snapshot.clone(); + ContextMenu::build(window, cx, move |mut menu, window, cx| { + if let Some(file) = file + && let Some(project) = editor.read(cx).project() + && let Some(worktree) = + project.read(cx).worktree_for_id(file.worktree_id(cx), cx) + { + let path_style = file.path_style(cx); + let worktree = worktree.read(cx); + let relative_path = file.path(); + let entry_for_path = worktree.entry_for_path(relative_path); + let abs_path = entry_for_path.map(|e| { + e.canonical_path + .as_deref() + .map_or_else(|| worktree.absolutize(relative_path), Path::to_path_buf) + }); + let has_relative_path = worktree.root_entry().is_some_and(Entry::is_dir); + + let parent_abs_path = abs_path + .as_ref() + .and_then(|abs_path| Some(abs_path.parent()?.to_path_buf())); + let relative_path = has_relative_path + .then_some(relative_path) + .map(ToOwned::to_owned); + + let visible_in_project_panel = relative_path.is_some() && worktree.is_visible(); + let reveal_in_project_panel = entry_for_path + .filter(|_| visible_in_project_panel) + .map(|entry| entry.id); + menu = menu + .when_some(abs_path, |menu, abs_path| { + menu.entry( + "Copy Path", + Some(Box::new(zed_actions::workspace::CopyPath)), + window.handler_for(&editor, move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + abs_path.to_string_lossy().into_owned(), + )); + }), + ) + }) + .when_some(relative_path, |menu, relative_path| { + menu.entry( + "Copy Relative Path", + Some(Box::new(zed_actions::workspace::CopyRelativePath)), + window.handler_for(&editor, move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + relative_path.display(path_style).to_string(), + )); + }), + ) + }) + .when( + reveal_in_project_panel.is_some() || parent_abs_path.is_some(), + |menu| menu.separator(), + ) + .when_some(reveal_in_project_panel, |menu, entry_id| { + menu.entry( + "Reveal In Project Panel", + Some(Box::new(RevealInProjectPanel::default())), + window.handler_for(&editor, move |editor, _, cx| { + if let Some(project) = &mut editor.project { + project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(entry_id)) + }); + } + }), + ) + }) + .when_some(parent_abs_path, |menu, parent_abs_path| { + menu.entry( + "Open in Terminal", + Some(Box::new(OpenInTerminal)), + window.handler_for(&editor, move |_, window, cx| { + window.dispatch_action( + OpenTerminal { + working_directory: parent_abs_path.clone(), + local: false, + } + .boxed_clone(), + cx, + ); + }), + ) + }); + } + + menu = editor.update(cx, |editor, cx| { + let mut menu = menu; + for addon in editor.addons.values() { + menu = addon.extend_buffer_header_context_menu( + menu, + &buffer_snapshot, + window, + cx, + ); + } + menu + }); + + menu.context(menu_context) + }) + }) +} + +fn file_status_label_color(file_status: Option) -> Color { + file_status.map_or(Color::Default, |status| { + if status.is_conflicted() { + Color::Conflict + } else if status.is_modified() { + Color::Modified + } else if status.is_deleted() { + Color::Disabled + } else if status.is_created() { + Color::Created + } else { + Color::Default + } + }) +} diff --git a/crates/editor/src/element/mouse.rs b/crates/editor/src/element/mouse.rs new file mode 100644 index 00000000000..b3fc095dbbc --- /dev/null +++ b/crates/editor/src/element/mouse.rs @@ -0,0 +1,1187 @@ +use std::ops::Range; +use std::time::{Duration, Instant}; + +use collections::HashMap; +use feature_flags::{DiffReviewFeatureFlag, FeatureFlagAppExt as _}; +use gpui::{ + AnyElement, App, AvailableSpace, ClickEvent, Context, DefiniteLength, DispatchPhase, Element, + MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, + ParentElement, Pixels, PressureStage, ScrollDelta, ScrollWheelEvent, TextStyleRefinement, + Window, anchored, deferred, point, px, +}; +use multi_buffer::MultiBufferRow; +use project::DisableAiSettings; +use settings::Settings; +use sum_tree::Bias; +use text::SelectionGoal; +use theme_settings::BufferLineHeight; +use util::{RangeExt, debug_panic, post_inc}; + +use super::{EditorElement, EditorLayout, LineNumberLayout, PositionMap, SplitSide}; +use crate::{ + CURSORS_VISIBLE_FOR, ColumnarMode, DisplayDiffHunk, DisplayPoint, DisplayRow, Editor, + EditorSettings, EditorSnapshot, GutterHoverButton, HoveredCursor, JumpData, + PhantomDiffReviewIndicator, SelectPhase, Selection, SelectionDragState, + display_map::ToDisplayPoint, editor_settings::DoubleClickInMultibuffer, + hover_popover::hover_at, mouse_context_menu, scroll::ScrollPixelOffset, +}; + +impl EditorElement { + pub(crate) fn mouse_moved( + editor: &mut Editor, + event: &MouseMoveEvent, + position_map: &PositionMap, + split_side: Option, + window: &mut Window, + cx: &mut Context, + ) { + let text_hitbox = &position_map.text_hitbox; + let gutter_hitbox = &position_map.gutter_hitbox; + let modifiers = event.modifiers; + let text_hovered = text_hitbox.is_hovered(window); + let gutter_hovered = gutter_hitbox.is_hovered(window); + editor.set_gutter_hovered(gutter_hovered, cx); + + let point_for_position = position_map.point_for_position(event.position); + let valid_point = point_for_position.nearest_valid; + + // Update diff review drag state if we're dragging + if editor.diff_review_drag_state.is_some() { + editor.update_diff_review_drag(valid_point.row(), window, cx); + } + + let hovered_diff_control = position_map + .diff_hunk_control_bounds + .iter() + .find(|(_, bounds)| bounds.contains(&event.position)) + .map(|(row, _)| *row); + + let hovered_diff_hunk_row = if let Some(control_row) = hovered_diff_control { + Some(control_row) + } else if text_hovered { + let current_row = valid_point.row(); + position_map.display_hunks.iter().find_map(|(hunk, _)| { + if let DisplayDiffHunk::Unfolded { + display_row_range, .. + } = hunk + { + if display_row_range.contains(¤t_row) { + Some(display_row_range.start) + } else { + None + } + } else { + None + } + }) + } else { + None + }; + + if hovered_diff_hunk_row != editor.hovered_diff_hunk_row { + editor.hovered_diff_hunk_row = hovered_diff_hunk_row; + cx.notify(); + } + + if text_hovered + && let Some((bounds, buffer_id, blame_entry)) = &position_map.inline_blame_bounds + { + let mouse_over_inline_blame = bounds.contains(&event.position); + let mouse_over_popover = editor + .inline_blame_popover + .as_ref() + .and_then(|state| state.popover_bounds) + .is_some_and(|bounds| bounds.contains(&event.position)); + let keyboard_grace = editor + .inline_blame_popover + .as_ref() + .is_some_and(|state| state.keyboard_grace); + + if mouse_over_inline_blame || mouse_over_popover { + editor.show_blame_popover(*buffer_id, blame_entry, event.position, false, cx); + } else if !keyboard_grace { + editor.hide_blame_popover(false, cx); + } + } else { + let keyboard_grace = editor + .inline_blame_popover + .as_ref() + .is_some_and(|state| state.keyboard_grace); + if !keyboard_grace { + editor.hide_blame_popover(false, cx); + } + } + + // Handle diff review indicator when gutter is hovered in diff mode with AI enabled + let show_diff_review = editor.show_diff_review_button() + && cx.has_flag::() + && !DisableAiSettings::is_ai_disabled_for_buffer( + editor.buffer.read(cx).as_singleton().as_ref(), + cx, + ); + + let diff_review_indicator = if gutter_hovered && show_diff_review { + let is_visible = editor + .gutter_diff_review_indicator + .0 + .is_some_and(|indicator| indicator.is_active); + + if !is_visible { + editor + .gutter_diff_review_indicator + .1 + .get_or_insert_with(|| { + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(200)) + .await; + + this.update(cx, |this, cx| { + if let Some(indicator) = + this.gutter_diff_review_indicator.0.as_mut() + { + indicator.is_active = true; + cx.notify(); + } + }) + .ok(); + }) + }); + } + + let anchor = position_map + .snapshot + .display_point_to_anchor(valid_point, Bias::Left); + Some(PhantomDiffReviewIndicator { + start: anchor, + end: anchor, + is_active: is_visible, + }) + } else { + editor.gutter_diff_review_indicator.1 = None; + None + }; + + if diff_review_indicator != editor.gutter_diff_review_indicator.0 { + editor.gutter_diff_review_indicator.0 = diff_review_indicator; + cx.notify(); + } + + // Don't show breakpoint indicator when diff review indicator is active on this row + let is_on_diff_review_button_row = diff_review_indicator.is_some_and(|indicator| { + let start_row = indicator + .start + .to_display_point(&position_map.snapshot.display_snapshot) + .row(); + indicator.is_active && start_row == valid_point.row() + }); + + let gutter_hover_button = if gutter_hovered + && !is_on_diff_review_button_row + && split_side != Some(SplitSide::Left) + { + let buffer_anchor = position_map + .snapshot + .display_point_to_anchor(valid_point, Bias::Left); + + if position_map + .snapshot + .buffer_snapshot() + .anchor_to_buffer_anchor(buffer_anchor) + .is_some() + { + let is_visible = editor + .gutter_hover_button + .0 + .is_some_and(|indicator| indicator.is_active); + + if !is_visible { + editor.gutter_hover_button.1.get_or_insert_with(|| { + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(200)) + .await; + + this.update(cx, |this, cx| { + if let Some(indicator) = this.gutter_hover_button.0.as_mut() { + indicator.is_active = true; + cx.notify(); + } + }) + .ok(); + }) + }); + } + + Some(GutterHoverButton { + display_row: valid_point.row(), + is_active: is_visible, + }) + } else { + editor.gutter_hover_button.1 = None; + None + } + } else if editor.has_mouse_context_menu() { + editor.gutter_hover_button.1 = None; + editor.gutter_hover_button.0 + } else { + editor.gutter_hover_button.1 = None; + None + }; + + if &gutter_hover_button != &editor.gutter_hover_button.0 { + editor.gutter_hover_button.0 = gutter_hover_button; + cx.notify(); + } + + // Don't trigger hover popover if mouse is hovering over context menu + if text_hovered { + editor.update_hovered_link( + point_for_position, + Some(event.position), + &position_map.snapshot, + modifiers, + window, + cx, + ); + + if let Some(point) = point_for_position.as_valid() { + let anchor = position_map + .snapshot + .buffer_snapshot() + .anchor_before(point.to_offset(&position_map.snapshot, Bias::Left)); + hover_at(editor, Some(anchor), Some(event.position), window, cx); + Self::update_visible_cursor(editor, point, position_map, window, cx); + } else { + editor.update_inlay_link_and_hover_points( + &position_map.snapshot, + point_for_position, + Some(event.position), + modifiers.secondary(), + modifiers.shift, + window, + cx, + ); + } + } else { + editor.hide_hovered_link(cx); + hover_at(editor, None, Some(event.position), window, cx); + } + } + + pub(super) fn layout_mouse_context_menu( + &self, + editor_snapshot: &EditorSnapshot, + visible_range: Range, + content_origin: gpui::Point, + window: &mut Window, + cx: &mut App, + ) -> Option { + let position = self.editor.update(cx, |editor, cx| { + let visible_start_point = editor.display_to_pixel_point( + DisplayPoint::new(visible_range.start, 0), + editor_snapshot, + window, + cx, + )?; + let visible_end_point = editor.display_to_pixel_point( + DisplayPoint::new(visible_range.end, 0), + editor_snapshot, + window, + cx, + )?; + + let mouse_context_menu = editor.mouse_context_menu.as_ref()?; + let (source_display_point, position) = match mouse_context_menu.position { + mouse_context_menu::MenuPosition::PinnedToScreen(point) => (None, point), + mouse_context_menu::MenuPosition::PinnedToEditor { source, offset } => { + let source_display_point = source.to_display_point(editor_snapshot); + let source_point = + editor.to_pixel_point(source, editor_snapshot, window, cx)?; + let position = content_origin + source_point + offset; + (Some(source_display_point), position) + } + }; + + let source_included = source_display_point.is_none_or(|source_display_point| { + visible_range + .to_inclusive() + .contains(&source_display_point.row()) + }); + let position_included = + visible_start_point.y <= position.y && position.y <= visible_end_point.y; + if !source_included && !position_included { + None + } else { + Some(position) + } + })?; + + let text_style = TextStyleRefinement { + line_height: Some(DefiniteLength::Fraction( + BufferLineHeight::Comfortable.value(), + )), + ..Default::default() + }; + window.with_text_style(Some(text_style), |window| { + let mut element = self.editor.read_with(cx, |editor, _| { + let mouse_context_menu = editor.mouse_context_menu.as_ref()?; + let context_menu = mouse_context_menu.context_menu.clone(); + + Some( + deferred( + anchored() + .position(position) + .child(context_menu) + .anchor(gpui::Anchor::TopLeft) + .snap_to_window_with_margin(px(8.)), + ) + .with_priority(1) + .into_any(), + ) + })?; + + element.prepaint_as_root(position, AvailableSpace::min_size(), window, cx); + Some(element) + }) + } + + pub(super) fn paint_mouse_listeners( + &mut self, + layout: &EditorLayout, + window: &mut Window, + cx: &mut App, + ) { + if layout.mode.is_minimap() { + return; + } + + self.paint_scroll_wheel_listener(layout, window, cx); + + window.on_mouse_event({ + let position_map = layout.position_map.clone(); + let editor = self.editor.clone(); + let line_numbers = layout.line_numbers.clone(); + + move |event: &MouseDownEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + match event.button { + MouseButton::Left => editor.update(cx, |editor, cx| { + let pending_mouse_down = editor + .pending_mouse_down + .get_or_insert_with(Default::default) + .clone(); + + *pending_mouse_down.borrow_mut() = Some(event.clone()); + + Self::mouse_left_down( + editor, + event, + &position_map, + line_numbers.as_ref(), + window, + cx, + ); + }), + MouseButton::Right => editor.update(cx, |editor, cx| { + Self::mouse_right_down(editor, event, &position_map, window, cx); + }), + MouseButton::Middle => editor.update(cx, |editor, cx| { + Self::mouse_middle_down(editor, event, &position_map, window, cx); + }), + _ => {} + }; + } + } + }); + + window.on_mouse_event({ + let editor = self.editor.clone(); + let position_map = layout.position_map.clone(); + + move |event: &MouseUpEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + editor.update(cx, |editor, cx| { + Self::mouse_up(editor, event, &position_map, window, cx) + }); + } + } + }); + + window.on_mouse_event({ + let editor = self.editor.clone(); + let position_map = layout.position_map.clone(); + let mut captured_mouse_down = None; + + move |event: &MouseUpEvent, phase, window, cx| match phase { + // Clear the pending mouse down during the capture phase, + // so that it happens even if another event handler stops + // propagation. + DispatchPhase::Capture => editor.update(cx, |editor, _cx| { + let pending_mouse_down = editor + .pending_mouse_down + .get_or_insert_with(Default::default) + .clone(); + + let mut pending_mouse_down = pending_mouse_down.borrow_mut(); + if pending_mouse_down.is_some() && position_map.text_hitbox.is_hovered(window) { + captured_mouse_down = pending_mouse_down.take(); + window.refresh(); + } + }), + // Fire click handlers during the bubble phase. + DispatchPhase::Bubble => editor.update(cx, |editor, cx| { + if let Some(mouse_down) = captured_mouse_down.take() { + let event = ClickEvent::Mouse(MouseClickEvent { + down: mouse_down, + up: event.clone(), + }); + Self::click(editor, &event, &position_map, window, cx); + } + }), + } + }); + + window.on_mouse_event({ + let position_map = layout.position_map.clone(); + let editor = self.editor.clone(); + + move |event: &MousePressureEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + editor.update(cx, |editor, cx| { + Self::pressure_click(editor, &event, &position_map, window, cx); + }) + } + } + }); + + window.on_mouse_event({ + let position_map = layout.position_map.clone(); + let editor = self.editor.clone(); + let split_side = self.split_side; + + move |event: &MouseMoveEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + editor.update(cx, |editor, cx| { + if editor.hover_state.focused(window, cx) { + return; + } + if event.pressed_button == Some(MouseButton::Left) + || event.pressed_button == Some(MouseButton::Middle) + { + Self::mouse_dragged(editor, event, &position_map, window, cx) + } + + Self::mouse_moved(editor, event, &position_map, split_side, window, cx) + }); + } + } + }); + } + + fn paint_scroll_wheel_listener( + &mut self, + layout: &EditorLayout, + window: &mut Window, + cx: &mut App, + ) { + window.on_mouse_event({ + let position_map = layout.position_map.clone(); + let editor = self.editor.clone(); + let hitbox = layout.hitbox.clone(); + let mut delta = ScrollDelta::default(); + + // Set a minimum scroll_sensitivity of 0.01 to make sure the user doesn't + // accidentally turn off their scrolling. + let base_scroll_sensitivity = + EditorSettings::get_global(cx).scroll_sensitivity.max(0.01); + + // Use a minimum fast_scroll_sensitivity for same reason above + let fast_scroll_sensitivity = EditorSettings::get_global(cx) + .fast_scroll_sensitivity + .max(0.01); + + move |event: &ScrollWheelEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.should_handle_scroll(window) { + delta = delta.coalesce(event.delta); + + if event.modifiers.secondary() + && editor.read(cx).enable_mouse_wheel_zoom + && EditorSettings::get_global(cx).mouse_wheel_zoom + { + let delta_y = match event.delta { + ScrollDelta::Pixels(pixels) => pixels.y.into(), + ScrollDelta::Lines(lines) => lines.y, + }; + + if delta_y > 0.0 { + theme_settings::increase_buffer_font_size(cx); + } else if delta_y < 0.0 { + theme_settings::decrease_buffer_font_size(cx); + } + + cx.stop_propagation(); + } else { + let scroll_sensitivity = { + if event.modifiers.alt { + fast_scroll_sensitivity + } else { + base_scroll_sensitivity + } + }; + + editor.update(cx, |editor, cx| { + let line_height = position_map.line_height; + let glyph_width = position_map.em_layout_width; + let (delta, axis) = match delta { + gpui::ScrollDelta::Pixels(mut pixels) => { + //Trackpad + let axis = + position_map.snapshot.ongoing_scroll.filter(&mut pixels); + (pixels, axis) + } + + gpui::ScrollDelta::Lines(lines) => { + //Not trackpad + let pixels = + point(lines.x * glyph_width, lines.y * line_height); + (pixels, None) + } + }; + + let current_scroll_position = position_map.snapshot.scroll_position(); + let x = (current_scroll_position.x + * ScrollPixelOffset::from(glyph_width) + - ScrollPixelOffset::from(delta.x * scroll_sensitivity)) + / ScrollPixelOffset::from(glyph_width); + let y = (current_scroll_position.y + * ScrollPixelOffset::from(line_height) + - ScrollPixelOffset::from(delta.y * scroll_sensitivity)) + / ScrollPixelOffset::from(line_height); + let mut scroll_position = + point(x, y).clamp(&point(0., 0.), &position_map.scroll_max); + let forbid_vertical_scroll = + editor.scroll_manager.forbid_vertical_scroll(); + if forbid_vertical_scroll { + scroll_position.y = current_scroll_position.y; + } + + if scroll_position != current_scroll_position { + editor.scroll(scroll_position, axis, window, cx); + cx.stop_propagation(); + } else if y < 0. { + // Due to clamping, we may fail to detect cases of overscroll to the top; + // We want the scroll manager to get an update in such cases and detect the change of direction + // on the next frame. + cx.notify(); + } + }); + } + } + } + }); + } + + fn mouse_left_down( + editor: &mut Editor, + event: &MouseDownEvent, + position_map: &PositionMap, + line_numbers: &HashMap, + window: &mut Window, + cx: &mut Context, + ) { + if window.default_prevented() { + return; + } + + let text_hitbox = &position_map.text_hitbox; + let gutter_hitbox = &position_map.gutter_hitbox; + let point_for_position = position_map.point_for_position(event.position); + let mut click_count = event.click_count; + let mut modifiers = event.modifiers; + + if let Some(hovered_hunk) = + position_map + .display_hunks + .iter() + .find_map(|(hunk, hunk_hitbox)| match hunk { + DisplayDiffHunk::Folded { .. } => None, + DisplayDiffHunk::Unfolded { + multi_buffer_range, .. + } => hunk_hitbox + .as_ref() + .is_some_and(|hitbox| hitbox.is_hovered(window)) + .then(|| multi_buffer_range.clone()), + }) + { + editor.toggle_single_diff_hunk(hovered_hunk, cx); + cx.notify(); + return; + } else if gutter_hitbox.is_hovered(window) { + click_count = 3; // Simulate triple-click when clicking the gutter to select lines + } else if !text_hitbox.is_hovered(window) { + return; + } + + if EditorSettings::get_global(cx) + .drag_and_drop_selection + .enabled + && click_count == 1 + && !modifiers.shift + { + let newest_anchor = editor.selections.newest_anchor(); + let snapshot = editor.snapshot(window, cx); + let selection = newest_anchor.map(|anchor| anchor.to_display_point(&snapshot)); + if point_for_position.intersects_selection(&selection) { + editor.selection_drag_state = SelectionDragState::ReadyToDrag { + selection: newest_anchor.clone(), + click_position: event.position, + mouse_down_time: Instant::now(), + }; + cx.stop_propagation(); + return; + } + } + + let is_singleton = editor.buffer().read(cx).is_singleton(); + + if click_count == 2 && !is_singleton { + match EditorSettings::get_global(cx).double_click_in_multibuffer { + DoubleClickInMultibuffer::Select => { + // do nothing special on double click, all selection logic is below + } + DoubleClickInMultibuffer::Open => { + if modifiers.alt { + // if double click is made with alt, pretend it's a regular double click without opening and alt, + // and run the selection logic. + modifiers.alt = false; + } else { + let scroll_position_row = position_map.scroll_position.y; + let display_row = (((event.position - gutter_hitbox.bounds.origin).y + / position_map.line_height) + as f64 + + position_map.scroll_position.y) + as u32; + let multi_buffer_row = position_map + .snapshot + .display_point_to_point( + DisplayPoint::new(DisplayRow(display_row), 0), + Bias::Right, + ) + .row; + let line_offset_from_top = display_row - scroll_position_row as u32; + // if double click is made without alt, open the corresponding excerp + editor.open_excerpts_common( + Some(JumpData::MultiBufferRow { + row: MultiBufferRow(multi_buffer_row), + line_offset_from_top, + }), + false, + window, + cx, + ); + return; + } + } + } + } + + if !is_singleton { + let display_row = (ScrollPixelOffset::from( + (event.position - gutter_hitbox.bounds.origin).y / position_map.line_height, + ) + position_map.scroll_position.y) as u32; + let multi_buffer_row = position_map + .snapshot + .display_point_to_point(DisplayPoint::new(DisplayRow(display_row), 0), Bias::Right) + .row; + if line_numbers + .get(&MultiBufferRow(multi_buffer_row)) + .is_some_and(|line_layout| { + line_layout.segments.iter().any(|segment| { + segment + .hitbox + .as_ref() + .is_some_and(|hitbox| hitbox.contains(&event.position)) + }) + }) + { + let line_offset_from_top = display_row - position_map.scroll_position.y as u32; + + editor.open_excerpts_common( + Some(JumpData::MultiBufferRow { + row: MultiBufferRow(multi_buffer_row), + line_offset_from_top, + }), + modifiers.alt, + window, + cx, + ); + cx.stop_propagation(); + return; + } + } + + let position = point_for_position.nearest_valid; + if let Some(mode) = Editor::columnar_selection_mode(&modifiers, cx) { + editor.select( + SelectPhase::BeginColumnar { + position, + reset: match mode { + ColumnarMode::FromMouse => true, + ColumnarMode::FromSelection => false, + }, + mode, + goal_column: point_for_position.exact_unclipped.column(), + }, + window, + cx, + ); + } else if modifiers.shift && !modifiers.control && !modifiers.alt && !modifiers.secondary() + { + editor.select( + SelectPhase::Extend { + position, + click_count, + }, + window, + cx, + ); + } else { + editor.select( + SelectPhase::Begin { + position, + add: Editor::is_alt_pressed(&modifiers, cx), + click_count, + }, + window, + cx, + ); + } + cx.stop_propagation(); + } + + fn mouse_right_down( + editor: &mut Editor, + event: &MouseDownEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + if position_map.gutter_hitbox.is_hovered(window) { + let gutter_right_padding = editor.gutter_dimensions.right_padding; + let hitbox = &position_map.gutter_hitbox; + + if event.position.x <= hitbox.bounds.right() - gutter_right_padding + // Don't show the gutter_context_menu in collab notes + && editor.project.is_some() + { + let point_for_position = position_map.point_for_position(event.position); + editor.set_gutter_context_menu( + point_for_position.nearest_valid.row(), + None, + event.position, + window, + cx, + ); + } + return; + } + + if !position_map.text_hitbox.is_hovered(window) { + return; + } + + let point_for_position = position_map.point_for_position(event.position); + mouse_context_menu::deploy_context_menu( + editor, + Some(event.position), + point_for_position.nearest_valid, + window, + cx, + ); + cx.stop_propagation(); + } + + fn mouse_middle_down( + editor: &mut Editor, + event: &MouseDownEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + if !position_map.text_hitbox.is_hovered(window) || window.default_prevented() { + return; + } + + let point_for_position = position_map.point_for_position(event.position); + let position = point_for_position.nearest_valid; + + editor.select( + SelectPhase::BeginColumnar { + position, + reset: true, + mode: ColumnarMode::FromMouse, + goal_column: point_for_position.exact_unclipped.column(), + }, + window, + cx, + ); + } + + fn mouse_up( + editor: &mut Editor, + event: &MouseUpEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + // Handle diff review drag completion + if editor.diff_review_drag_state.is_some() { + editor.end_diff_review_drag(window, cx); + cx.stop_propagation(); + return; + } + + let text_hitbox = &position_map.text_hitbox; + let end_selection = editor.has_pending_selection(); + let pending_nonempty_selections = editor.has_pending_nonempty_selection(); + let point_for_position = position_map.point_for_position(event.position); + + match editor.selection_drag_state { + SelectionDragState::ReadyToDrag { + selection: _, + ref click_position, + mouse_down_time: _, + } => { + if event.position == *click_position { + editor.select( + SelectPhase::Begin { + position: point_for_position.nearest_valid, + add: false, + click_count: 1, // ready to drag state only occurs on click count 1 + }, + window, + cx, + ); + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + return; + } else { + debug_panic!("drag state can never be in ready state after drag") + } + } + SelectionDragState::Dragging { ref selection, .. } => { + let snapshot = editor.snapshot(window, cx); + let selection_display = selection.map(|anchor| anchor.to_display_point(&snapshot)); + if !point_for_position.intersects_selection(&selection_display) + && text_hitbox.is_hovered(window) + { + let is_cut = !(cfg!(target_os = "macos") && event.modifiers.alt + || cfg!(not(target_os = "macos")) && event.modifiers.control); + editor.move_selection_on_drop( + &selection.clone(), + point_for_position.nearest_valid, + is_cut, + window, + cx, + ); + } + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + cx.notify(); + return; + } + _ => {} + } + + if end_selection { + editor.select(SelectPhase::End, window, cx); + } + + if end_selection && pending_nonempty_selections { + cx.stop_propagation(); + } else if cfg!(any(target_os = "linux", target_os = "freebsd")) + && event.button == MouseButton::Middle + { + #[allow( + clippy::collapsible_if, + clippy::needless_return, + reason = "The cfg-block below makes this a false positive" + )] + if !text_hitbox.is_hovered(window) || editor.read_only(cx) { + return; + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + if EditorSettings::get_global(cx).middle_click_paste { + if let Some(text) = cx.read_from_primary().and_then(|item| item.text()) { + let point_for_position = position_map.point_for_position(event.position); + let position = point_for_position.nearest_valid; + + editor.select( + SelectPhase::Begin { + position, + add: false, + click_count: 1, + }, + window, + cx, + ); + editor.insert(&text, window, cx); + } + cx.stop_propagation() + } + } + } + + fn click( + editor: &mut Editor, + event: &ClickEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + let text_hitbox = &position_map.text_hitbox; + let pending_nonempty_selections = editor.has_pending_nonempty_selection(); + + let hovered_link_modifier = Editor::is_cmd_or_ctrl_pressed(&event.modifiers(), cx); + let mouse_down_hovered_link_modifier = if let ClickEvent::Mouse(mouse_event) = event { + Editor::is_cmd_or_ctrl_pressed(&mouse_event.down.modifiers, cx) + } else { + true + }; + + if let Some(mouse_position) = event.mouse_position() + && !pending_nonempty_selections + && hovered_link_modifier + && mouse_down_hovered_link_modifier + && text_hitbox.is_hovered(window) + && !matches!( + editor.selection_drag_state, + SelectionDragState::Dragging { .. } + ) + { + let point = position_map.point_for_position(mouse_position); + editor.handle_click_hovered_link(point, event.modifiers(), window, cx); + editor.selection_drag_state = SelectionDragState::None; + + cx.stop_propagation(); + } + } + + fn pressure_click( + editor: &mut Editor, + event: &MousePressureEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + let text_hitbox = &position_map.text_hitbox; + let force_click_possible = + matches!(editor.prev_pressure_stage, Some(PressureStage::Normal)) + && event.stage == PressureStage::Force; + + editor.prev_pressure_stage = Some(event.stage); + + if force_click_possible && text_hitbox.is_hovered(window) { + let point = position_map.point_for_position(event.position); + editor.handle_click_hovered_link(point, event.modifiers, window, cx); + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + } + } + + fn mouse_dragged( + editor: &mut Editor, + event: &MouseMoveEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + if !editor.has_pending_selection() + && matches!(editor.selection_drag_state, SelectionDragState::None) + { + return; + } + + let point_for_position = position_map.point_for_position(event.position); + let text_hitbox = &position_map.text_hitbox; + + let scroll_delta = { + let text_bounds = text_hitbox.bounds; + let mut scroll_delta = gpui::Point::::default(); + let vertical_margin = position_map.line_height.min(text_bounds.size.height / 3.0); + let top = text_bounds.origin.y + vertical_margin; + let bottom = text_bounds.bottom_left().y - vertical_margin; + if event.position.y < top { + scroll_delta.y = -scale_vertical_mouse_autoscroll_delta(top - event.position.y); + } + if event.position.y > bottom { + scroll_delta.y = scale_vertical_mouse_autoscroll_delta(event.position.y - bottom); + } + + // We need horizontal width of text + let style = editor.style.clone().unwrap_or_default(); + let font_id = window.text_system().resolve_font(&style.text.font()); + let font_size = style.text.font_size.to_pixels(window.rem_size()); + let em_width = window + .text_system() + .em_width(font_id, font_size) + .unwrap_or(font_size); + + let scroll_margin_x = EditorSettings::get_global(cx).horizontal_scroll_margin; + + let scroll_space: Pixels = scroll_margin_x * em_width; + + let left = text_bounds.origin.x + scroll_space; + let right = text_bounds.top_right().x - scroll_space; + + if event.position.x < left { + scroll_delta.x = -scale_horizontal_mouse_autoscroll_delta(left - event.position.x); + } + if event.position.x > right { + scroll_delta.x = scale_horizontal_mouse_autoscroll_delta(event.position.x - right); + } + scroll_delta + }; + + if !editor.has_pending_selection() { + let drop_anchor = position_map + .snapshot + .display_point_to_anchor(point_for_position.nearest_valid, Bias::Left); + match editor.selection_drag_state { + SelectionDragState::Dragging { + ref mut drop_cursor, + ref mut hide_drop_cursor, + .. + } => { + drop_cursor.start = drop_anchor; + drop_cursor.end = drop_anchor; + *hide_drop_cursor = !text_hitbox.is_hovered(window); + editor.apply_scroll_delta(scroll_delta, window, cx); + cx.notify(); + } + SelectionDragState::ReadyToDrag { + ref selection, + ref click_position, + ref mouse_down_time, + } => { + let drag_and_drop_delay = Duration::from_millis( + EditorSettings::get_global(cx) + .drag_and_drop_selection + .delay + .0, + ); + if mouse_down_time.elapsed() >= drag_and_drop_delay { + let drop_cursor = Selection { + id: post_inc(&mut editor.selections.next_selection_id()), + start: drop_anchor, + end: drop_anchor, + reversed: false, + goal: SelectionGoal::None, + }; + editor.selection_drag_state = SelectionDragState::Dragging { + selection: selection.clone(), + drop_cursor, + hide_drop_cursor: false, + }; + editor.apply_scroll_delta(scroll_delta, window, cx); + cx.notify(); + } else { + let click_point = position_map.point_for_position(*click_position); + editor.selection_drag_state = SelectionDragState::None; + editor.select( + SelectPhase::Begin { + position: click_point.nearest_valid, + add: false, + click_count: 1, + }, + window, + cx, + ); + editor.select( + SelectPhase::Update { + position: point_for_position.nearest_valid, + goal_column: point_for_position.exact_unclipped.column(), + scroll_delta, + }, + window, + cx, + ); + } + } + _ => {} + } + } else { + editor.select( + SelectPhase::Update { + position: point_for_position.nearest_valid, + goal_column: point_for_position.exact_unclipped.column(), + scroll_delta, + }, + window, + cx, + ); + } + } + + fn update_visible_cursor( + editor: &mut Editor, + point: DisplayPoint, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + let snapshot = &position_map.snapshot; + let Some(hub) = editor.collaboration_hub() else { + return; + }; + let start = snapshot.display_snapshot.clip_point( + DisplayPoint::new(point.row(), point.column().saturating_sub(1)), + Bias::Left, + ); + let end = snapshot.display_snapshot.clip_point( + DisplayPoint::new( + point.row(), + (point.column() + 1).min(snapshot.line_len(point.row())), + ), + Bias::Right, + ); + + let range = snapshot + .buffer_snapshot() + .anchor_before(start.to_point(&snapshot.display_snapshot)) + ..snapshot + .buffer_snapshot() + .anchor_after(end.to_point(&snapshot.display_snapshot)); + + let Some(selection) = snapshot.remote_selections_in_range(&range, hub, cx).next() else { + return; + }; + let key = HoveredCursor { + replica_id: selection.replica_id, + selection_id: selection.selection.id, + }; + editor.hovered_cursors.insert( + key.clone(), + cx.spawn_in(window, async move |editor, cx| { + cx.background_executor().timer(CURSORS_VISIBLE_FOR).await; + editor + .update(cx, |editor, cx| { + editor.hovered_cursors.remove(&key); + cx.notify(); + }) + .ok(); + }), + ); + cx.notify() + } +} + +fn scale_vertical_mouse_autoscroll_delta(delta: Pixels) -> f32 { + (delta.pow(1.2) / 100.0).min(px(3.0)).into() +} + +fn scale_horizontal_mouse_autoscroll_delta(delta: Pixels) -> f32 { + (delta.pow(1.2) / 300.0).into() +} From 3eab273b85355167aa865e1d89fa58bf10a66538 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 29 May 2026 13:59:18 +0200 Subject: [PATCH 75/93] Remove rules library (#58067) Rules library has been replaced by skills, so we can safely remove it Release Notes: - N/A --- .github/CODEOWNERS.hold | 1 - Cargo.lock | 28 - Cargo.toml | 2 - assets/keymaps/default-linux.json | 10 +- assets/keymaps/default-macos.json | 9 - assets/keymaps/default-windows.json | 9 - crates/agent_ui/Cargo.toml | 1 - crates/agent_ui/src/agent_ui.rs | 4 +- crates/rules_library/Cargo.toml | 36 - crates/rules_library/LICENSE-GPL | 1 - crates/rules_library/src/rules_library.rs | 1341 --------------------- crates/zed/src/zed.rs | 1 - 12 files changed, 3 insertions(+), 1440 deletions(-) delete mode 100644 crates/rules_library/Cargo.toml delete mode 120000 crates/rules_library/LICENSE-GPL delete mode 100644 crates/rules_library/src/rules_library.rs diff --git a/.github/CODEOWNERS.hold b/.github/CODEOWNERS.hold index 410d473c1f7..0e6ab04228d 100644 --- a/.github/CODEOWNERS.hold +++ b/.github/CODEOWNERS.hold @@ -55,7 +55,6 @@ /crates/open_ai/ @zed-industries/ai-team /crates/open_router/ @zed-industries/ai-team /crates/prompt_store/ @zed-industries/ai-team -/crates/rules_library/ @zed-industries/ai-team # SUGGESTED: Review needed - based on Richard Feldman (2 commits) /crates/shell_command_parser/ @zed-industries/ai-team /crates/vercel/ @zed-industries/ai-team diff --git a/Cargo.lock b/Cargo.lock index 1cf4f6f284a..40236d075f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,7 +516,6 @@ dependencies = [ "remote_server", "reqwest_client", "rope", - "rules_library", "schemars 1.0.4", "search", "semver", @@ -16009,33 +16008,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad8388ea1a9e0ea807e442e8263a699e7edcb320ecbcd21b4fa8ff859acce3ba" -[[package]] -name = "rules_library" -version = "0.1.0" -dependencies = [ - "anyhow", - "collections", - "editor", - "gpui", - "language", - "language_model", - "log", - "menu", - "picker", - "platform_title_bar", - "prompt_store", - "release_channel", - "rope", - "serde", - "settings", - "theme_settings", - "ui", - "ui_input", - "util", - "workspace", - "zed_actions", -] - [[package]] name = "runtimelib" version = "1.4.0" diff --git a/Cargo.toml b/Cargo.toml index 57088d9e56d..cf515b74e0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -171,7 +171,6 @@ members = [ "crates/reqwest_client", "crates/rope", "crates/rpc", - "crates/rules_library", "crates/sandbox", "crates/skill_creator", "crates/scheduler", @@ -432,7 +431,6 @@ reqwest_client = { path = "crates/reqwest_client" } rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", features = ["wav", "playback", "wav_output", "recording"] } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } -rules_library = { path = "crates/rules_library" } skill_creator = { path = "crates/skill_creator" } scheduler = { path = "crates/scheduler" } sandbox = { path = "crates/sandbox" } diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index ba1ed2fcd8e..47bd6f05aca 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -380,15 +380,7 @@ "shift-backspace": "agent::ArchiveSelectedThread", }, }, - { - "context": "RulesLibrary", - "bindings": { - "new": "rules_library::NewRule", - "ctrl-n": "rules_library::NewRule", - "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow", - }, - }, + { "context": "BufferSearchBar", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index efb2ecd01ea..dbd6d64a719 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -427,15 +427,6 @@ "backspace": "agent::ArchiveSelectedThread", }, }, - { - "context": "RulesLibrary", - "use_key_equivalents": true, - "bindings": { - "cmd-n": "rules_library::NewRule", - "cmd-shift-s": "rules_library::ToggleDefaultRule", - "cmd-w": "workspace::CloseWindow", - }, - }, { "context": "BufferSearchBar", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index ce8b775229b..1996fe19f66 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -383,15 +383,6 @@ "shift-backspace": "agent::ArchiveSelectedThread", }, }, - { - "context": "RulesLibrary", - "use_key_equivalents": true, - "bindings": { - "ctrl-n": "rules_library::NewRule", - "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow", - }, - }, { "context": "BufferSearchBar", "use_key_equivalents": true, diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 68b0b5faa41..acabc22a95c 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -89,7 +89,6 @@ release_channel.workspace = true remote.workspace = true remote_connection.workspace = true rope.workspace = true -rules_library.workspace = true skill_creator.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index fc67e12904a..ecaef031bac 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -58,7 +58,7 @@ use language_model::{ ConfiguredModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, }; use project::{AgentId, DisableAiSettings}; -use prompt_store::{PromptBuilder, rules_to_skills_migration}; +use prompt_store::{self, PromptBuilder, rules_to_skills_migration}; use rope::Point; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -550,7 +550,7 @@ pub fn init( cx: &mut App, ) { agent::ThreadStore::init_global(cx); - rules_library::init(cx); + prompt_store::init(cx); skill_creator::init(cx); if !is_eval { // Initializing the language model from the user settings messes with the eval, so we only initialize them when diff --git a/crates/rules_library/Cargo.toml b/crates/rules_library/Cargo.toml deleted file mode 100644 index 352f86bd72f..00000000000 --- a/crates/rules_library/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "rules_library" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/rules_library.rs" - - -[dependencies] -anyhow.workspace = true -collections.workspace = true -editor.workspace = true -gpui.workspace = true -language.workspace = true -language_model.workspace = true -log.workspace = true -menu.workspace = true -picker.workspace = true -platform_title_bar.workspace = true -prompt_store.workspace = true -release_channel.workspace = true -rope.workspace = true -serde.workspace = true -settings.workspace = true -theme_settings.workspace = true -ui.workspace = true -ui_input.workspace = true -util.workspace = true -workspace.workspace = true -zed_actions.workspace = true diff --git a/crates/rules_library/LICENSE-GPL b/crates/rules_library/LICENSE-GPL deleted file mode 120000 index 89e542f750c..00000000000 --- a/crates/rules_library/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs deleted file mode 100644 index 9f87d403e72..00000000000 --- a/crates/rules_library/src/rules_library.rs +++ /dev/null @@ -1,1341 +0,0 @@ -use anyhow::Result; -use collections::{HashMap, HashSet}; -use editor::SelectionEffects; -use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab}; -use gpui::{ - App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, PromptLevel, - Subscription, Task, TaskExt, TextStyle, Tiling, TitlebarOptions, WindowBounds, WindowHandle, - WindowOptions, actions, point, size, transparent_black, -}; -use language::{Buffer, LanguageRegistry, language_settings::SoftWrap}; -use language_model::{ConfiguredModel, LanguageModelRegistry}; -use picker::{Picker, PickerDelegate}; -use platform_title_bar::PlatformTitleBar; -use release_channel::ReleaseChannel; -use rope::Rope; -use settings::{ActionSequence, Settings}; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::time::Duration; -use theme_settings::ThemeSettings; -use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*}; -use ui_input::ErasedEditor; -use util::{ResultExt, TryFutureExt}; -use workspace::{MultiWorkspace, Workspace, WorkspaceSettings, client_side_decorations}; -use zed_actions::assistant::InlineAssist; - -use prompt_store::*; - -pub fn init(cx: &mut App) { - prompt_store::init(cx); -} - -actions!( - rules_library, - [ - /// Creates a new rule in the rules library. - NewRule, - /// Deletes the selected rule. - DeleteRule, - /// Duplicates the selected rule. - DuplicateRule, - /// Toggles whether the selected rule is a default rule. - ToggleDefaultRule, - /// Restores a built-in rule to its default content. - RestoreDefaultContent - ] -); - -pub trait InlineAssistDelegate { - fn assist( - &self, - prompt_editor: &Entity, - initial_prompt: Option, - window: &mut Window, - cx: &mut Context, - ); - - /// Returns whether the Agent panel was focused. - fn focus_agent_panel( - &self, - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) -> bool; -} - -/// This function opens a new rules library window if one doesn't exist already. -/// If one exists, it brings it to the foreground. -/// -/// Note that, when opening a new window, this waits for the PromptStore to be -/// initialized. If it was initialized successfully, it returns a window handle -/// to a rules library. -pub fn open_rules_library( - language_registry: Arc, - inline_assist_delegate: Box, - prompt_to_select: Option, - cx: &mut App, -) -> Task>> { - let store = PromptStore::global(cx); - cx.spawn(async move |cx| { - // We query windows in spawn so that all windows have been returned to GPUI - let existing_window = cx.update(|cx| { - let existing_window = cx - .windows() - .into_iter() - .find_map(|window| window.downcast::()); - if let Some(existing_window) = existing_window { - existing_window - .update(cx, |rules_library, window, cx| { - if let Some(prompt_to_select) = prompt_to_select { - rules_library.load_rule(prompt_to_select, true, window, cx); - } - window.activate_window() - }) - .ok(); - - Some(existing_window) - } else { - None - } - }); - - if let Some(existing_window) = existing_window { - return Ok(existing_window); - } - - let store = store.await?; - cx.update(|cx| { - let app_id = ReleaseChannel::global(cx).app_id(); - let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx); - let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") { - Ok(val) if val == "server" => gpui::WindowDecorations::Server, - Ok(val) if val == "client" => gpui::WindowDecorations::Client, - _ => match WorkspaceSettings::get_global(cx).window_decorations { - settings::WindowDecorations::Server => gpui::WindowDecorations::Server, - settings::WindowDecorations::Client => gpui::WindowDecorations::Client, - }, - }; - cx.open_window( - WindowOptions { - titlebar: Some(TitlebarOptions { - title: Some("Rules Library".into()), - appears_transparent: true, - traffic_light_position: Some(point(px(12.0), px(12.0))), - }), - app_id: Some(app_id.to_owned()), - window_bounds: Some(WindowBounds::Windowed(bounds)), - window_background: cx.theme().window_background_appearance(), - window_decorations: Some(window_decorations), - window_min_size: Some(DEFAULT_ADDITIONAL_WINDOW_SIZE), - kind: gpui::WindowKind::Floating, - ..Default::default() - }, - |window, cx| { - cx.new(|cx| { - RulesLibrary::new( - store, - language_registry, - inline_assist_delegate, - prompt_to_select, - window, - cx, - ) - }) - }, - ) - }) - }) -} - -pub struct RulesLibrary { - title_bar: Option>, - store: Entity, - language_registry: Arc, - rule_editors: HashMap, - active_rule_id: Option, - picker: Entity>, - pending_load: Task<()>, - inline_assist_delegate: Box, - _subscriptions: Vec, -} - -struct RuleEditor { - title_editor: Entity, - body_editor: Entity, - next_title_and_body_to_save: Option<(String, Rope)>, - pending_save: Option>>, - _subscriptions: Vec, -} - -enum RulePickerEntry { - Header(SharedString), - Rule(PromptMetadata), - Separator, -} - -struct RulePickerDelegate { - store: Entity, - selected_index: usize, - filtered_entries: Vec, -} - -enum RulePickerEvent { - Selected { prompt_id: PromptId }, - Confirmed { prompt_id: PromptId }, - Deleted { prompt_id: PromptId }, - ToggledDefault { prompt_id: PromptId }, -} - -impl EventEmitter for Picker {} - -impl PickerDelegate for RulePickerDelegate { - type ListItem = AnyElement; - - fn match_count(&self) -> usize { - self.filtered_entries.len() - } - - fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { - Some("No rules found matching your search.".into()) - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { - self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1)); - - if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) { - cx.emit(RulePickerEvent::Selected { prompt_id: rule.id }); - } - - cx.notify(); - } - - fn can_select(&self, ix: usize, _: &mut Window, _: &mut Context>) -> bool { - match self.filtered_entries.get(ix) { - Some(RulePickerEntry::Rule(_)) => true, - Some(RulePickerEntry::Header(_)) | Some(RulePickerEntry::Separator) | None => false, - } - } - - fn select_on_hover(&self) -> bool { - false - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search…".into() - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let cancellation_flag = Arc::new(AtomicBool::default()); - let search = self.store.read(cx).search(query, cancellation_flag, cx); - - let prev_prompt_id = self - .filtered_entries - .get(self.selected_index) - .and_then(|entry| { - if let RulePickerEntry::Rule(rule) = entry { - Some(rule.id) - } else { - None - } - }); - - cx.spawn_in(window, async move |this, cx| { - let (filtered_entries, selected_index) = cx - .background_spawn(async move { - let matches = search.await; - - let (built_in_rules, user_rules): (Vec<_>, Vec<_>) = - matches.into_iter().partition(|rule| rule.id.is_built_in()); - let (default_rules, other_rules): (Vec<_>, Vec<_>) = - user_rules.into_iter().partition(|rule| rule.default); - - let mut filtered_entries = Vec::new(); - - if !built_in_rules.is_empty() { - filtered_entries.push(RulePickerEntry::Header("Built-in Rules".into())); - - for rule in built_in_rules { - filtered_entries.push(RulePickerEntry::Rule(rule)); - } - - filtered_entries.push(RulePickerEntry::Separator); - } - - if !default_rules.is_empty() { - filtered_entries.push(RulePickerEntry::Header("Default Rules".into())); - - for rule in default_rules { - filtered_entries.push(RulePickerEntry::Rule(rule)); - } - - filtered_entries.push(RulePickerEntry::Separator); - } - - for rule in other_rules { - filtered_entries.push(RulePickerEntry::Rule(rule)); - } - - let selected_index = prev_prompt_id - .and_then(|prev_prompt_id| { - filtered_entries.iter().position(|entry| { - if let RulePickerEntry::Rule(rule) = entry { - rule.id == prev_prompt_id - } else { - false - } - }) - }) - .unwrap_or_else(|| { - filtered_entries - .iter() - .position(|entry| matches!(entry, RulePickerEntry::Rule(_))) - .unwrap_or(0) - }); - - (filtered_entries, selected_index) - }) - .await; - - this.update_in(cx, |this, window, cx| { - this.delegate.filtered_entries = filtered_entries; - this.set_selected_index( - selected_index, - Some(picker::Direction::Down), - true, - window, - cx, - ); - cx.notify(); - }) - .ok(); - }) - } - - fn confirm(&mut self, _secondary: bool, _: &mut Window, cx: &mut Context>) { - if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) { - cx.emit(RulePickerEvent::Confirmed { prompt_id: rule.id }); - } - } - - fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} - - fn render_match( - &self, - ix: usize, - selected: bool, - _: &mut Window, - cx: &mut Context>, - ) -> Option { - match self.filtered_entries.get(ix)? { - RulePickerEntry::Header(title) => { - let tooltip_text = if title.as_ref() == "Built-in Rules" { - "Built-in rules are those included out of the box with Zed." - } else { - "Default Rules are attached by default with every new thread." - }; - - Some( - ListSubHeader::new(title.clone()) - .end_slot( - IconButton::new("info", IconName::Info) - .style(ButtonStyle::Transparent) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text(tooltip_text)) - .into_any_element(), - ) - .inset(true) - .into_any_element(), - ) - } - RulePickerEntry::Separator => Some( - h_flex() - .py_1() - .child(Divider::horizontal()) - .into_any_element(), - ), - RulePickerEntry::Rule(rule) => { - let default = rule.default; - let prompt_id = rule.id; - - Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - Label::new(rule.title.clone().unwrap_or("Untitled".into())) - .truncate() - .mr_10(), - ) - .end_slot::((default && !prompt_id.is_built_in()).then(|| { - IconButton::new("toggle-default-rule", IconName::Paperclip) - .toggle_state(true) - .icon_color(Color::Accent) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Remove from Default Rules")) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(RulePickerEvent::ToggledDefault { prompt_id }) - })) - })) - .when(!prompt_id.is_built_in(), |this| { - this.end_slot_on_hover( - h_flex() - .child( - IconButton::new("delete-rule", IconName::Trash) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Delete Rule")) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(RulePickerEvent::Deleted { prompt_id }) - })), - ) - .child( - IconButton::new("toggle-default-rule", IconName::Plus) - .selected_icon(IconName::Dash) - .toggle_state(default) - .icon_size(IconSize::Small) - .icon_color(if default { - Color::Accent - } else { - Color::Muted - }) - .map(|this| { - if default { - this.tooltip(Tooltip::text( - "Remove from Default Rules", - )) - } else { - this.tooltip(move |_window, cx| { - Tooltip::with_meta( - "Add to Default Rules", - None, - "Always included in every thread.", - cx, - ) - }) - } - }) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(RulePickerEvent::ToggledDefault { - prompt_id, - }) - })), - ), - ) - }) - .into_any_element(), - ) - } - } - } - - fn render_editor( - &self, - editor: &Arc, - _: &mut Window, - cx: &mut Context>, - ) -> Div { - let editor = editor.as_any().downcast_ref::>().unwrap(); - - h_flex() - .py_1() - .px_1p5() - .mx_1() - .gap_1p5() - .rounded_sm() - .bg(cx.theme().colors().editor_background) - .border_1() - .border_color(cx.theme().colors().border) - .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted)) - .child(editor.clone()) - } -} - -impl RulesLibrary { - fn new( - store: Entity, - language_registry: Arc, - inline_assist_delegate: Box, - rule_to_select: Option, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let (_selected_index, _matches) = if let Some(rule_to_select) = rule_to_select { - let matches = store.read(cx).all_prompt_metadata(); - let selected_index = matches - .iter() - .enumerate() - .find(|(_, metadata)| metadata.id == rule_to_select) - .map_or(0, |(ix, _)| ix); - (selected_index, matches) - } else { - (0, vec![]) - }; - - let picker_delegate = RulePickerDelegate { - store: store.clone(), - selected_index: 0, - filtered_entries: Vec::new(), - }; - - let picker = cx.new(|cx| { - let picker = Picker::list(picker_delegate, window, cx) - .modal(false) - .max_height(None); - picker.focus(window, cx); - picker - }); - - Self { - title_bar: if !cfg!(target_os = "macos") { - Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx))) - } else { - None - }, - store, - language_registry, - rule_editors: HashMap::default(), - active_rule_id: None, - pending_load: Task::ready(()), - inline_assist_delegate, - _subscriptions: vec![cx.subscribe_in(&picker, window, Self::handle_picker_event)], - picker, - } - } - - fn handle_picker_event( - &mut self, - _: &Entity>, - event: &RulePickerEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - RulePickerEvent::Selected { prompt_id } => { - self.load_rule(*prompt_id, false, window, cx); - } - RulePickerEvent::Confirmed { prompt_id } => { - self.load_rule(*prompt_id, true, window, cx); - } - RulePickerEvent::ToggledDefault { prompt_id } => { - self.toggle_default_for_rule(*prompt_id, window, cx); - } - RulePickerEvent::Deleted { prompt_id } => { - self.delete_rule(*prompt_id, window, cx); - } - } - } - - pub fn new_rule(&mut self, window: &mut Window, cx: &mut Context) { - // If we already have an untitled rule, use that instead - // of creating a new one. - if let Some(metadata) = self.store.read(cx).first() - && metadata.title.is_none() - { - self.load_rule(metadata.id, true, window, cx); - return; - } - - let prompt_id = PromptId::new(); - let save = self.store.update(cx, |store, cx| { - store.save(prompt_id, None, false, "".into(), cx) - }); - self.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.spawn_in(window, async move |this, cx| { - save.await?; - this.update_in(cx, |this, window, cx| { - this.load_rule(prompt_id, true, window, cx) - }) - }) - .detach_and_log_err(cx); - } - - pub fn save_rule(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context) { - const SAVE_THROTTLE: Duration = Duration::from_millis(500); - - if !prompt_id.can_edit() { - return; - } - - let rule_metadata = self.store.read(cx).metadata(prompt_id).unwrap(); - let rule_editor = self.rule_editors.get_mut(&prompt_id).unwrap(); - let title = rule_editor.title_editor.read(cx).text(cx); - let body = rule_editor.body_editor.update(cx, |editor, cx| { - editor - .buffer() - .read(cx) - .as_singleton() - .unwrap() - .read(cx) - .as_rope() - .clone() - }); - - let store = self.store.clone(); - let executor = cx.background_executor().clone(); - - rule_editor.next_title_and_body_to_save = Some((title, body)); - if rule_editor.pending_save.is_none() { - rule_editor.pending_save = Some(cx.spawn_in(window, async move |this, cx| { - async move { - loop { - let title_and_body = this.update(cx, |this, _| { - this.rule_editors - .get_mut(&prompt_id)? - .next_title_and_body_to_save - .take() - })?; - - if let Some((title, body)) = title_and_body { - let title = if title.trim().is_empty() { - None - } else { - Some(SharedString::from(title)) - }; - cx.update(|_window, cx| { - store.update(cx, |store, cx| { - store.save(prompt_id, title, rule_metadata.default, body, cx) - }) - })? - .await - .log_err(); - this.update_in(cx, |this, window, cx| { - this.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.notify(); - })?; - - executor.timer(SAVE_THROTTLE).await; - } else { - break; - } - } - - this.update(cx, |this, _cx| { - if let Some(rule_editor) = this.rule_editors.get_mut(&prompt_id) { - rule_editor.pending_save = None; - } - }) - } - .log_err() - .await - })); - } - } - - pub fn delete_active_rule(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(active_rule_id) = self.active_rule_id { - self.delete_rule(active_rule_id, window, cx); - } - } - - pub fn duplicate_active_rule(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(active_rule_id) = self.active_rule_id { - self.duplicate_rule(active_rule_id, window, cx); - } - } - - pub fn toggle_default_for_active_rule(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(active_rule_id) = self.active_rule_id { - self.toggle_default_for_rule(active_rule_id, window, cx); - } - } - - pub fn restore_default_content_for_active_rule( - &mut self, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(active_rule_id) = self.active_rule_id { - self.restore_default_content(active_rule_id, window, cx); - } - } - - pub fn restore_default_content( - &mut self, - prompt_id: PromptId, - window: &mut Window, - cx: &mut Context, - ) { - let Some(built_in) = prompt_id.as_built_in() else { - return; - }; - - if let Some(rule_editor) = self.rule_editors.get(&prompt_id) { - rule_editor.body_editor.update(cx, |editor, cx| { - editor.set_text(built_in.default_content(), window, cx); - }); - } - } - - pub fn toggle_default_for_rule( - &mut self, - prompt_id: PromptId, - window: &mut Window, - cx: &mut Context, - ) { - self.store.update(cx, move |store, cx| { - if let Some(rule_metadata) = store.metadata(prompt_id) { - store - .save_metadata(prompt_id, rule_metadata.title, !rule_metadata.default, cx) - .detach_and_log_err(cx); - } - }); - self.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.notify(); - } - - pub fn load_rule( - &mut self, - prompt_id: PromptId, - focus: bool, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(rule_editor) = self.rule_editors.get(&prompt_id) { - if focus { - rule_editor - .body_editor - .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)); - } - self.set_active_rule(Some(prompt_id), window, cx); - } else if let Some(rule_metadata) = self.store.read(cx).metadata(prompt_id) { - let language_registry = self.language_registry.clone(); - let rule = self.store.read(cx).load(prompt_id, cx); - self.pending_load = cx.spawn_in(window, async move |this, cx| { - let rule = rule.await; - let markdown = language_registry.language_for_name("Markdown").await; - this.update_in(cx, |this, window, cx| match rule { - Ok(rule) => { - let title_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Untitled", window, cx); - editor.set_text(rule_metadata.title.unwrap_or_default(), window, cx); - if prompt_id.is_built_in() { - editor.set_read_only(true); - editor.set_show_edit_predictions(Some(false), window, cx); - } - editor - }); - let body_editor = cx.new(|cx| { - let buffer = cx.new(|cx| { - let mut buffer = Buffer::local(rule, cx); - buffer.set_language(markdown.log_err(), cx); - buffer.set_language_registry(language_registry); - buffer - }); - - let mut editor = Editor::for_buffer(buffer, None, window, cx); - if !prompt_id.can_edit() { - editor.set_read_only(true); - editor.set_show_edit_predictions(Some(false), window, cx); - } - editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); - editor.set_show_gutter(false, cx); - editor.set_show_wrap_guides(false, cx); - editor.set_show_indent_guides(false, cx); - editor.set_use_modal_editing(true); - editor.set_current_line_highlight(Some(CurrentLineHighlight::None)); - if focus { - window.focus(&editor.focus_handle(cx), cx); - } - editor - }); - let _subscriptions = vec![ - cx.subscribe_in( - &title_editor, - window, - move |this, editor, event, window, cx| { - this.handle_rule_title_editor_event( - prompt_id, editor, event, window, cx, - ) - }, - ), - cx.subscribe_in( - &body_editor, - window, - move |this, editor, event, window, cx| { - this.handle_rule_body_editor_event( - prompt_id, editor, event, window, cx, - ) - }, - ), - ]; - this.rule_editors.insert( - prompt_id, - RuleEditor { - title_editor, - body_editor, - next_title_and_body_to_save: None, - pending_save: None, - _subscriptions, - }, - ); - this.set_active_rule(Some(prompt_id), window, cx); - } - Err(error) => { - // TODO: we should show the error in the UI. - log::error!("error while loading rule: {:?}", error); - } - }) - .ok(); - }); - } - } - - fn set_active_rule( - &mut self, - prompt_id: Option, - window: &mut Window, - cx: &mut Context, - ) { - self.active_rule_id = prompt_id; - self.picker.update(cx, |picker, cx| { - if let Some(prompt_id) = prompt_id { - if picker - .delegate - .filtered_entries - .get(picker.delegate.selected_index()) - .is_none_or(|old_selected_prompt| { - if let RulePickerEntry::Rule(rule) = old_selected_prompt { - rule.id != prompt_id - } else { - true - } - }) - && let Some(ix) = picker.delegate.filtered_entries.iter().position(|mat| { - if let RulePickerEntry::Rule(rule) = mat { - rule.id == prompt_id - } else { - false - } - }) - { - picker.set_selected_index(ix, None, true, window, cx); - } - } else { - picker.focus(window, cx); - } - }); - cx.notify(); - } - - pub fn delete_rule( - &mut self, - prompt_id: PromptId, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(metadata) = self.store.read(cx).metadata(prompt_id) { - let confirmation = window.prompt( - PromptLevel::Warning, - &format!( - "Are you sure you want to delete {}", - metadata.title.unwrap_or("Untitled".into()) - ), - None, - &["Delete", "Cancel"], - cx, - ); - - cx.spawn_in(window, async move |this, cx| { - if confirmation.await.ok() == Some(0) { - this.update_in(cx, |this, window, cx| { - if this.active_rule_id == Some(prompt_id) { - this.set_active_rule(None, window, cx); - } - this.rule_editors.remove(&prompt_id); - this.store - .update(cx, |store, cx| store.delete(prompt_id, cx)) - .detach_and_log_err(cx); - this.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.notify(); - })?; - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - } - - pub fn duplicate_rule( - &mut self, - prompt_id: PromptId, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(rule) = self.rule_editors.get(&prompt_id) { - const DUPLICATE_SUFFIX: &str = " copy"; - let title_to_duplicate = rule.title_editor.read(cx).text(cx); - let existing_titles = self - .rule_editors - .iter() - .filter(|&(&id, _)| id != prompt_id) - .map(|(_, rule_editor)| rule_editor.title_editor.read(cx).text(cx)) - .filter(|title| title.starts_with(&title_to_duplicate)) - .collect::>(); - - let title = if existing_titles.is_empty() { - title_to_duplicate + DUPLICATE_SUFFIX - } else { - let mut i = 1; - loop { - let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}"); - if !existing_titles.contains(&new_title) { - break new_title; - } - i += 1; - } - }; - - let new_id = PromptId::new(); - let body = rule.body_editor.read(cx).text(cx); - let save = self.store.update(cx, |store, cx| { - store.save(new_id, Some(title.into()), false, body.into(), cx) - }); - self.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.spawn_in(window, async move |this, cx| { - save.await?; - this.update_in(cx, |rules_library, window, cx| { - rules_library.load_rule(new_id, true, window, cx) - }) - }) - .detach_and_log_err(cx); - } - } - - fn focus_active_rule(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { - if let Some(active_rule) = self.active_rule_id { - self.rule_editors[&active_rule] - .body_editor - .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)); - cx.stop_propagation(); - } - } - - fn focus_picker(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { - self.picker - .update(cx, |picker, cx| picker.focus(window, cx)); - } - - pub fn inline_assist( - &mut self, - action: &InlineAssist, - window: &mut Window, - cx: &mut Context, - ) { - let Some(active_rule_id) = self.active_rule_id else { - cx.propagate(); - return; - }; - - let rule_editor = &self.rule_editors[&active_rule_id].body_editor; - let Some(ConfiguredModel { provider, .. }) = - LanguageModelRegistry::read_global(cx).inline_assistant_model() - else { - return; - }; - - let initial_prompt = action.prompt.clone(); - if provider.is_authenticated(cx) { - self.inline_assist_delegate - .assist(rule_editor, initial_prompt, window, cx); - } else { - for window in cx.windows() { - if let Some(multi_workspace) = window.downcast::() { - let panel = multi_workspace - .update(cx, |multi_workspace, window, cx| { - window.activate_window(); - multi_workspace.workspace().update(cx, |workspace, cx| { - self.inline_assist_delegate - .focus_agent_panel(workspace, window, cx) - }) - }) - .ok(); - if panel == Some(true) { - return; - } - } - } - } - } - - fn move_down_from_title( - &mut self, - _: &zed_actions::editor::MoveDown, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(rule_id) = self.active_rule_id - && let Some(rule_editor) = self.rule_editors.get(&rule_id) - { - window.focus(&rule_editor.body_editor.focus_handle(cx), cx); - } - } - - fn move_up_from_body( - &mut self, - _: &zed_actions::editor::MoveUp, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(rule_id) = self.active_rule_id - && let Some(rule_editor) = self.rule_editors.get(&rule_id) - { - window.focus(&rule_editor.title_editor.focus_handle(cx), cx); - } - } - - fn handle_rule_title_editor_event( - &mut self, - prompt_id: PromptId, - title_editor: &Entity, - event: &EditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - EditorEvent::BufferEdited => { - self.save_rule(prompt_id, window, cx); - } - EditorEvent::Blurred => { - title_editor.update(cx, |title_editor, cx| { - title_editor.change_selections( - SelectionEffects::no_scroll(), - window, - cx, - |selections| { - let cursor = selections.oldest_anchor().head(); - selections.select_anchor_ranges([cursor..cursor]); - }, - ); - }); - } - _ => {} - } - } - - fn handle_rule_body_editor_event( - &mut self, - prompt_id: PromptId, - body_editor: &Entity, - event: &EditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - EditorEvent::BufferEdited => { - self.save_rule(prompt_id, window, cx); - } - EditorEvent::Blurred => { - body_editor.update(cx, |body_editor, cx| { - body_editor.change_selections( - SelectionEffects::no_scroll(), - window, - cx, - |selections| { - let cursor = selections.oldest_anchor().head(); - selections.select_anchor_ranges([cursor..cursor]); - }, - ); - }); - } - _ => {} - } - } - - fn render_rule_list(&mut self, cx: &mut Context) -> impl IntoElement { - v_flex() - .id("rule-list") - .capture_action(cx.listener(Self::focus_active_rule)) - .px_1p5() - .h_full() - .w_64() - .overflow_x_hidden() - .bg(cx.theme().colors().panel_background) - .map(|this| { - if cfg!(target_os = "macos") { - this.child( - h_flex() - .p(DynamicSpacing::Base04.rems(cx)) - .h_9() - .w_full() - .flex_none() - .justify_end() - .child( - IconButton::new("new-rule", IconName::Plus) - .tooltip(move |_window, cx| { - Tooltip::for_action("New Rule", &NewRule, cx) - }) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(NewRule), cx); - }), - ), - ) - } else { - this.child( - h_flex().p_1().w_full().child( - Button::new("new-rule", "New Rule") - .full_width() - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(NewRule), cx); - }), - ), - ) - } - }) - .child(div().flex_grow().child(self.picker.clone())) - } - - fn render_active_rule_editor( - &self, - editor: &Entity, - read_only: bool, - cx: &mut Context, - ) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_color = if read_only { - cx.theme().colors().text_muted - } else { - cx.theme().colors().text - }; - - div() - .w_full() - .pl_1() - .border_1() - .border_color(transparent_black()) - .rounded_sm() - .when(!read_only, |this| { - this.group_hover("active-editor-header", |this| { - this.border_color(cx.theme().colors().border_variant) - }) - }) - .on_action(cx.listener(Self::move_down_from_title)) - .child(EditorElement::new( - &editor, - EditorStyle { - background: cx.theme().system().transparent, - local_player: cx.theme().players().local(), - text: TextStyle { - color: text_color, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_size: HeadlineSize::Medium.rems().into(), - font_weight: settings.ui_font.weight, - line_height: relative(settings.buffer_line_height.value()), - ..Default::default() - }, - scrollbar_width: Pixels::ZERO, - syntax: cx.theme().syntax().clone(), - status: cx.theme().status().clone(), - inlay_hints_style: editor::make_inlay_hints_style(cx), - edit_prediction_styles: editor::make_suggestion_styles(cx), - ..EditorStyle::default() - }, - )) - } - - fn render_duplicate_rule_button(&self) -> impl IntoElement { - IconButton::new("duplicate-rule", IconName::BookCopy) - .tooltip(move |_window, cx| Tooltip::for_action("Duplicate Rule", &DuplicateRule, cx)) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(DuplicateRule), cx); - }) - } - - fn render_built_in_rule_controls(&self) -> impl IntoElement { - h_flex() - .gap_1() - .child(self.render_duplicate_rule_button()) - .child( - IconButton::new("restore-default", IconName::RotateCcw) - .tooltip(move |_window, cx| { - Tooltip::for_action( - "Restore to Default Content", - &RestoreDefaultContent, - cx, - ) - }) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(RestoreDefaultContent), cx); - }), - ) - } - - fn render_regular_rule_controls(&self, default: bool) -> impl IntoElement { - h_flex() - .gap_1() - .child( - IconButton::new("toggle-default-rule", IconName::Paperclip) - .toggle_state(default) - .when(default, |this| this.icon_color(Color::Accent)) - .map(|this| { - if default { - this.tooltip(Tooltip::text("Remove from Default Rules")) - } else { - this.tooltip(move |_window, cx| { - Tooltip::with_meta( - "Add to Default Rules", - None, - "Always included in every thread.", - cx, - ) - }) - } - }) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(ToggleDefaultRule), cx); - }), - ) - .child(self.render_duplicate_rule_button()) - .child( - IconButton::new("delete-rule", IconName::Trash) - .tooltip(move |_window, cx| Tooltip::for_action("Delete Rule", &DeleteRule, cx)) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(DeleteRule), cx); - }), - ) - } - - fn render_active_rule(&mut self, cx: &mut Context) -> gpui::Stateful
{ - div() - .id("rule-editor") - .h_full() - .flex_grow() - .border_l_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .children(self.active_rule_id.and_then(|prompt_id| { - let rule_metadata = self.store.read(cx).metadata(prompt_id)?; - let rule_editor = &self.rule_editors[&prompt_id]; - let focus_handle = rule_editor.body_editor.focus_handle(cx); - let built_in = prompt_id.is_built_in(); - - Some( - v_flex() - .id("rule-editor-inner") - .size_full() - .relative() - .overflow_hidden() - .on_click(cx.listener(move |_, _, window, cx| { - window.focus(&focus_handle, cx); - })) - .child( - h_flex() - .group("active-editor-header") - .h_12() - .px_2() - .gap_2() - .justify_between() - .child(self.render_active_rule_editor( - &rule_editor.title_editor, - built_in, - cx, - )) - .child(h_flex().h_full().flex_shrink_0().map(|this| { - if built_in { - this.child(self.render_built_in_rule_controls()) - } else { - this.child( - self.render_regular_rule_controls( - rule_metadata.default, - ), - ) - } - })), - ) - .child( - div() - .on_action(cx.listener(Self::focus_picker)) - .on_action(cx.listener(Self::inline_assist)) - .on_action(cx.listener(Self::move_up_from_body)) - .h_full() - .flex_grow() - .child( - h_flex() - .py_2() - .pl_2p5() - .h_full() - .flex_1() - .child(rule_editor.body_editor.clone()), - ), - ), - ) - })) - } -} - -impl Render for RulesLibrary { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme_settings::setup_ui_font(window, cx); - let theme = cx.theme().clone(); - - client_side_decorations( - v_flex() - .id("rules-library") - .key_context("RulesLibrary") - .on_action( - |action_sequence: &ActionSequence, window: &mut Window, cx: &mut App| { - for action in &action_sequence.0 { - window.dispatch_action(action.boxed_clone(), cx); - } - }, - ) - .on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx))) - .on_action( - cx.listener(|this, &DeleteRule, window, cx| { - this.delete_active_rule(window, cx) - }), - ) - .on_action(cx.listener(|this, &DuplicateRule, window, cx| { - this.duplicate_active_rule(window, cx) - })) - .on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| { - this.toggle_default_for_active_rule(window, cx) - })) - .on_action(cx.listener(|this, &RestoreDefaultContent, window, cx| { - this.restore_default_content_for_active_rule(window, cx) - })) - .size_full() - .overflow_hidden() - .font(ui_font) - .text_color(theme.colors().text) - .children(self.title_bar.clone()) - .bg(theme.colors().background) - .child( - h_flex() - .flex_1() - .when(!cfg!(target_os = "macos"), |this| { - this.border_t_1().border_color(cx.theme().colors().border) - }) - .child(self.render_rule_list(cx)) - .child(self.render_active_rule(cx)), - ), - window, - cx, - Tiling::default(), - ) - } -} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index a4771d0e08c..7ddd5215175 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5252,7 +5252,6 @@ mod tests { "recent_projects", "remote_debug", "repl", - "rules_library", "search", "settings_editor", "settings_profile_selector", From f49be143f39376c40c922d6c75898a27ed00a399 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 29 May 2026 14:18:57 +0200 Subject: [PATCH 76/93] agent: Do not render diagnostics in diff (#58052) Release Notes: - agent: Fixed an issue where diagnostics would show up in agent panel diffs --- crates/agent_ui/src/entry_view_state.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index 15bd9e89b57..a66ac0da7f7 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -10,7 +10,7 @@ use gpui::{ ScrollHandle, TextStyleRefinement, WeakEntity, Window, }; use language::language_settings::SoftWrap; -use project::{AgentId, Project}; +use project::{AgentId, Project, project_settings::DiagnosticSeverity}; use prompt_store::PromptStore; use rope::Point; use settings::Settings as _; @@ -444,7 +444,8 @@ fn create_editor_diff( cx, ); editor.set_show_gutter(false, cx); - editor.disable_inline_diagnostics(); + editor.disable_diagnostics(cx); + editor.set_max_diagnostics_severity(DiagnosticSeverity::Off, cx); editor.disable_expand_excerpt_buttons(cx); editor.set_show_vertical_scrollbar(false, cx); editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx); From 81f818aa86a96b39614eba23a95e5332a4c4ab84 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 29 May 2026 14:22:37 +0200 Subject: [PATCH 77/93] nix: Go around a linker issue on Darwin (#58070) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A --- nix/build.nix | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/nix/build.nix b/nix/build.nix index 2f283f83a4d..0375b0de5f8 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -38,6 +38,7 @@ libxfixes, libxkbcommon, libxrandr, + lld, libx11, libxcb, nodejs_22, @@ -137,6 +138,8 @@ let ] ++ lib.optionals stdenv'.hostPlatform.isLinux [ makeWrapper ] ++ lib.optionals stdenv'.hostPlatform.isDarwin [ + # Provides `ld64.lld` for clang's `-fuse-ld=lld`. + lld (cargo-bundle.overrideAttrs ( new: old: { version = "0.6.1-zed"; @@ -246,6 +249,11 @@ let }"; NIX_OUTPATH_USED_AS_RANDOM_SEED = "norebuilds"; + } + // lib.optionalAttrs stdenv'.hostPlatform.isDarwin { + # Link with lld on Darwin. nixpkgs' classic open-source ld64 fails to insert + # ARM64 branch thunks for this binary, producing `b(l) ARM64 branch out of range`. + NIX_CFLAGS_LINK = "-fuse-ld=lld"; }; # prevent nix from removing the "unused" wayland/gpu-lib rpaths From a1d2ef651426ebc66f558727694b460e823042dc Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Fri, 29 May 2026 18:54:59 +0530 Subject: [PATCH 78/93] gpui: Add item_is_above_viewport and item_is_below_viewport APIs to ListState (#58061) In prep for handling the above-viewport case in https://github.com/zed-industries/zed/pull/57632, which currently only handles below case. This PR adds `ListState::item_is_above_viewport` and `ListState::item_is_below_viewport` methods, which report whether a given list item is entirely outside the current viewport. Both return `None` when the list has not measured enough layout to answer. Release Notes: - N/A --- crates/gpui/src/elements/list.rs | 146 +++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 7f80d788925..5a729dcc5f5 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -741,6 +741,44 @@ impl ListState { pub fn viewport_bounds(&self) -> Bounds { self.0.borrow().last_layout_bounds.unwrap_or_default() } + + /// Returns whether the item is entirely above the viewport, or `None` if + /// the list has not measured enough layout to know. + pub fn item_is_above_viewport(&self, ix: usize) -> Option { + let viewport_bounds = self.viewport_bounds(); + if viewport_bounds.size.height == px(0.0) { + return None; + } + + let scroll_top = self.logical_scroll_top(); + if ix < scroll_top.item_ix { + // Rows before the logical scroll top have no item bounds, but + // their position relative to the viewport is known from scroll state. + return Some(true); + } + + let item_bounds = self.bounds_for_item(ix)?; + Some(item_bounds.bottom() <= viewport_bounds.top()) + } + + /// Returns whether the item is entirely below the viewport, or `None` if + /// the list has not measured enough layout to know. + pub fn item_is_below_viewport(&self, ix: usize) -> Option { + let viewport_bounds = self.viewport_bounds(); + if viewport_bounds.size.height == px(0.0) { + return None; + } + + let scroll_top = self.logical_scroll_top(); + if ix < scroll_top.item_ix { + // Rows before the logical scroll top have no item bounds, but + // their position relative to the viewport is known from scroll state. + return Some(false); + } + + let item_bounds = self.bounds_for_item(ix)?; + Some(item_bounds.top() >= viewport_bounds.bottom()) + } } impl StateInner { @@ -1644,6 +1682,114 @@ mod test { assert_eq!(offset.offset_in_item, px(0.)); } + struct TestListView(ListState); + impl Render for TestListView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(20.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + #[gpui::test] + fn test_item_viewport_queries_return_none_before_layout(_cx: &mut TestAppContext) { + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + assert_eq!(state.item_is_above_viewport(0), None); + assert_eq!(state.item_is_below_viewport(0), None); + } + + #[gpui::test] + fn test_item_viewport_queries_before_logical_scroll_top(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(1), Some(true)); + assert_eq!(state.item_is_below_viewport(1), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_inside_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(2), Some(false)); + assert_eq!(state.item_is_below_viewport(2), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_above_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(20.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(2), Some(true)); + assert_eq!(state.item_is_below_viewport(2), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_below_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(3), Some(false)); + assert_eq!(state.item_is_below_viewport(3), Some(true)); + } + + #[gpui::test] + fn test_item_viewport_queries_after_scroll_to_end_before_layout(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + state.scroll_to_end(); + + assert_eq!(state.logical_scroll_top().item_ix, state.item_count()); + assert_eq!(state.item_is_above_viewport(0), Some(true)); + assert_eq!(state.item_is_below_viewport(0), Some(false)); + } + #[gpui::test] fn test_measure_all_after_width_change(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); From b7b1d1a2c78baae9d51a326c9221ee2ffb60726e Mon Sep 17 00:00:00 2001 From: Lena <241371603+zelenenka@users.noreply.github.com> Date: Fri, 29 May 2026 15:28:35 +0200 Subject: [PATCH 79/93] Duplicate Bot: Reduce noise (#58074) Release Notes: - N/A --- .../github-check-new-issue-for-duplicates.py | 247 +++++++++++++++++- ...ithub-track-duplicate-bot-effectiveness.py | 38 ++- 2 files changed, 263 insertions(+), 22 deletions(-) diff --git a/script/github-check-new-issue-for-duplicates.py b/script/github-check-new-issue-for-duplicates.py index 023449bb27d..8a4eb8ec22a 100644 --- a/script/github-check-new-issue-for-duplicates.py +++ b/script/github-check-new-issue-for-duplicates.py @@ -146,11 +146,15 @@ No action needed. A maintainer will review this shortly. ] parts.append("**Possibly related open issues:**\n\n" + "\n".join(lines)) if related_closed_issues: + # state_reason is shown only for "duplicate" (the close type is otherwise + # already visible from GitHub's icon next to the issue number on render). lines = [ - f"- #{m['number']} (closed as {m['state_reason']}) — {m['explanation']}" + f"- #{m['number']}" + f"{' (closed as duplicate)' if m['state_reason'] == 'duplicate' else ''}" + f" — {m['explanation']}" for m in related_closed_issues ] - parts.append("**Recently closed, possibly related:**\n\n" + "\n".join(lines)) + parts.append("**Recently closed, possibly the same bug:**\n\n" + "\n".join(lines)) body = "\n\n".join(parts) sections.append(f"""
Additional recent context for triagers @@ -280,6 +284,12 @@ def detect_areas(anthropic_key, issue, area_labels): system_prompt = """You analyze GitHub issues to identify which area labels apply. +Decide the area from the user's stated symptom and reproduction steps. Issue bodies routinely +contain pasted log output, crash dumps, stack traces, settings files, and template headers like +"Attach Zed log file" or "Relevant Zed settings" — these are evidence about the symptom and +should not push you toward labels like "logging" or "settings" unless the bug itself is about +how that subsystem works. + Respond with ONLY a comma-separated list of matching area names. No prose, no explanation, no markdown, no preamble — just the names. @@ -500,8 +510,14 @@ def analyze_duplicates(anthropic_key, issue, magnets, search_results): return [], [], [] log("Analyzing candidates with Claude") + log(f" Candidate pool: {len(top_magnets)} magnets, {len(open_results)} open search results, " + f"{len(closed_results)} closed search results (will pass {min(len(closed_results), 5)} closed)") enrich_magnets(top_magnets) + closed_candidates_for_claude = closed_results[:5] + if closed_candidates_for_claude: + log(f" Closed candidates given to proposer: {[r['number'] for r in closed_candidates_for_claude]}") + candidates = [ {"number": m["number"], "title": m["title"], "body_preview": m["body_preview"], "state": "open", "state_reason": None, "source": "known_duplicate_magnet"} @@ -509,7 +525,7 @@ def analyze_duplicates(anthropic_key, issue, magnets, search_results): ] + [ {"number": r["number"], "title": r["title"], "body_preview": r["body_preview"], "state": r["state"], "state_reason": r["state_reason"], "source": "search_result"} - for r in open_results[:10] + closed_results[:5] + for r in open_results[:10] + closed_candidates_for_claude ] system_prompt = """You analyze GitHub issues to (a) identify duplicates among OPEN candidates @@ -548,17 +564,63 @@ Examples of things that are NOT duplicates: For OPEN duplicates (either bucket), false positives are MUCH worse than false negatives — they waste the time of both the issue author and the maintainers. When in doubt, omit. -# (b) Related closed issues — CLOSED candidates only +# (b) Closed candidates that may be the same bug — CLOSED candidates only -The goal is to give triagers extra context, NOT to claim a duplicate. The bar is lower than for -duplicates: include a closed candidate if a triager would plausibly want to see it when reviewing -the new issue. Examples worth surfacing: -- A recently fixed (state_reason "completed") issue describing the same symptom — triager may ask - the reporter to retest on the latest build. -- A cluster of similar issues closed as "not_planned" — signals a known limitation or design choice. -- A previously triaged duplicate (state_reason "duplicate") in the same code area. +The goal is NOT a "related reading" list. The goal is to surface closed issues where the +new issue is plausibly the SAME bug — a duplicate that just happens to be filed against a +closed predecessor instead of an open one. Empty is preferable to weak filler — triagers +lose trust in this section quickly if it's stretched. The same false-positives-are-worse +asymmetry as for duplicates applies here. -Include at most 5 closed candidates, prioritized by relevance. +The bar: a triager reading this should be able to act — ask the reporter to retest a fix, +point at a known design decision that already declined this request, or point at the +canonical bug this is a duplicate of. "Useful context" or "shared area" is NOT a reason +to include. + +Omit a candidate if ANY of these apply (in observed practice, almost everything does): + +1. Self-contradiction. If you find yourself writing "while focused on X rather than Y", + "although this is about A, the new issue is about B", "this issue focuses on... rather + than...", or any acknowledgment that the candidate isn't on the same topic — STOP. + You've already made the case for omitting it. + +2. Fabricated specifics. Every concrete claim about the candidate (its trigger, its scope, + its conditions) must be visible in the candidate's title or body preview. Specifics + like "when X happens", "under Y conditions", "specifically affecting Z" that aren't + supported by the candidate's actual text mean you're inventing details to fit the new + issue. Omit. + +3. Weasel phrases. Paraphrases of these all indicate you don't have a real claim: + "may indicate similar...", "could provide context for...", "shows / demonstrates recent + attention to...", "indicates the team has considered...", "demonstrates a pattern + of...", "may provide useful context...". STOP and omit. + +4. Retest by default. The "reporter may need to retest on the latest build" framing only + applies when the candidate's symptom is literally the same as the new issue's. It is + NOT a default justification for "this was a recent fix in roughly the same area." + +5. Same area / feature, different mechanism. Examples to omit: + - "ARM compile failure" alongside "ARM runtime perf" — same area, different mechanism. + - "Worktree path bug" alongside "worktree display label confusion" — same feature, + unrelated. + +6. Vague catch-all candidate. A closed issue like "Zed is slow" / "performance" / "agent + panel UX" that could be cited next to almost any new bug is filler. If you'd reuse the + same closed issue across many unrelated new issues, omit. + +7. Label or single-keyword overlap. A closed issue whose only connection is a shared + area:* label or one shared keyword is not relevant. + +Worth surfacing — strict examples: +- A recently fixed ("completed") issue with the SAME specific trigger as the new issue — + triager can ask the reporter to retest on the latest build. +- A cluster of "not_planned" closures about the EXACT same request — known design choice + the triager can point to. +- A previously triaged "duplicate" pointing at the same canonical issue, or sharing the + same specific mechanism. + +Count: typically 0 or 1. Never more than 2 unless there's an obvious cluster of identical +"not_planned" reports. 0 is a normal outcome. # Output format @@ -614,10 +676,164 @@ Return empty arrays where nothing relevant is found.""" likely = data.get("likely_duplicates", []) possible = data.get("possible_duplicates", []) closed = data.get("related_closed_issues", []) + + # Claude occasionally places a closed candidate in the duplicate buckets, or vice + # versa. Enforce that each match lives in the bucket consistent with the canonical + # state of the candidate we passed in. + candidate_states = {c["number"]: c["state"] for c in candidates} + + def filter_by_state(items, expected_state, label): + kept, wrong = [], [] + for m in items: + (kept if candidate_states.get(m["number"]) == expected_state else wrong).append(m) + if wrong: + log(f" Dropped {len(wrong)} from {label} with wrong/unknown state: {[m['number'] for m in wrong]}") + return kept + + likely = filter_by_state(likely, "open", "likely_duplicates") + possible = filter_by_state(possible, "open", "possible_duplicates") + closed = filter_by_state(closed, "closed", "related_closed_issues") + + # Avoid showing the same issue in both the user-facing alert and the triage section. + likely_numbers = {m["number"] for m in likely} + overlap = [m["number"] for m in possible if m["number"] in likely_numbers] + if overlap: + log(f" Dropped {len(overlap)} from possible_duplicates already in likely_duplicates: {overlap}") + possible = [m for m in possible if m["number"] not in likely_numbers] + log(f" Found {len(likely) + len(possible) + len(closed)} potential matches") return likely, possible, closed +CRITIQUE_SYSTEM_PROMPT = """You are evaluating ONE recently closed GitHub issue to decide whether a triager looking +at a brand-new bug report would find it useful to be told about that closed issue. + +There is no slate to fill. There is no quota. You will be shown exactly one candidate. +The default verdict is OMIT. Zero is the expected outcome for most candidates. + +A candidate is worth surfacing ONLY if the new issue is plausibly the SAME BUG as the +closed one — a duplicate that happens to be filed against a closed predecessor. Concretely, +the legitimate cases are exactly three: + +- The candidate was closed as "completed" (a fix shipped) AND the new issue has the same + specific trigger / symptom. The triager will ask the reporter to retest. +- The candidate was closed as "not_planned" AND the new issue is the EXACT same request + (a feature decision the team already declined). The triager will point at it. +- The candidate was closed as "duplicate" AND it pointed at the same canonical bug the new + issue describes, or it shares the same specific mechanism. + +"Same broad area", "similar-sounding symptom", or "recent attention to this subsystem" are +NOT reasons to include. Omit them. + +Return "omit" if ANY of the following apply (in observed practice, almost everything does): + +1. Self-contradiction. If your reasoning includes "while focused on X rather than Y", + "although this is about A, the new issue is about B", "this issue focuses on... rather + than...", or any acknowledgment the candidate is on a different topic — you've already + decided to omit. +2. Fabricated specifics. Every concrete claim about the candidate (its trigger, scope, + conditions) must be visible in the candidate's title or body preview. If you find + yourself describing the candidate using details that aren't in its text, you're + inventing details to fit the new issue. Omit. +3. Weasel phrases. Paraphrases of "may indicate similar...", "could provide context + for...", "shows / demonstrates recent attention to...", "indicates the team has + considered...", "demonstrates a pattern of...", "may provide useful context..." — + these mean you don't have a real claim. Omit. +4. Retest by default. The "reporter may need to retest on the latest build" framing only + applies when the closed issue's symptom is LITERALLY the same as the new issue's. "This + was a recent fix in roughly the same area" is not enough. +5. Same area / feature, different mechanism. Same area label but different bug, different + code path, different trigger. Omit. +6. Vague catch-all candidate. A closed issue like "Zed is slow" / "performance" / "agent + panel UX" that you could cite next to many unrelated new bugs. Omit. +7. Label or single-keyword overlap. Only connection is a shared area:* label or one shared + keyword. Omit. + +Output only valid JSON (no markdown code blocks): +{ + "verdict": "include" | "omit", + "rule_violated": null | 1 | 2 | 3 | 4 | 5 | 6 | 7, + "rationale": "one concise sentence explaining the verdict" +} + +When "verdict" is "include", "rule_violated" must be null. +When "verdict" is "omit", "rule_violated" should be the most relevant rule number, or null +if the candidate is simply too unrelated for any rule to specifically apply.""" + + +def critique_closed_candidates(anthropic_key, issue, proposed, search_results): + """Run a strict per-candidate critique pass over the proposer's closed candidates. + + For each proposed match, call Claude with only the new issue and that single candidate + (blind to the proposer's rationale) and ask for a yes/no verdict. Default is omit. + Returns the subset of `proposed` that passes critique. + """ + if not proposed: + log(" Critique: proposer surfaced 0 closed candidates; skipping") + return [] + + log(f" Critique: proposer surfaced {len(proposed)} closed candidate(s): " + f"{[m['number'] for m in proposed]}") + + results_by_number = {r["number"]: r for r in search_results} + kept = [] + for match in proposed: + number = match["number"] + candidate = results_by_number.get(number) + if candidate is None: + # Should not happen — analyze_duplicates only emits numbers from candidates it + # was given — but be defensive rather than crash the bot. + log(f" Critique: dropping #{number} — candidate context not found") + continue + + state_reason = candidate.get("state_reason") or "unknown" + user_content = f"""## New Issue #{issue['number']} +**Title:** {issue['title']} + +**Body:** +{issue['body'][:3000]} + +## Closed Candidate #{number} +**Title:** {candidate.get('title', '')} +**State reason:** {state_reason} + +**Body preview:** +{candidate.get('body_preview', '')}""" + + log(f" Critique: evaluating #{number}") + try: + response = call_claude(anthropic_key, CRITIQUE_SYSTEM_PROMPT, user_content, max_tokens=300) + except requests.RequestException as e: + # If the critique call fails, prefer omitting the candidate over posting noise. + log(f" Critique: API call failed for #{number} ({e}); omitting candidate") + continue + + fence = re.match(r"^\s*```(?:json)?\s*\n?(.*?)\n?```\s*$", response, re.DOTALL) + if fence: + response = fence.group(1) + + try: + verdict_data = json.loads(response) + except json.JSONDecodeError as e: + log(f" Critique: failed to parse verdict for #{number} ({e}); omitting candidate") + log(f" Raw response: {response}") + continue + + verdict = verdict_data.get("verdict") + rule = verdict_data.get("rule_violated") + rationale = verdict_data.get("rationale", "") + + if verdict == "include": + log(f" Critique: keeping #{number} — {rationale}") + kept.append(match) + else: + rule_str = f"rule {rule}" if rule else "no specific rule" + log(f" Critique: omitting #{number} ({rule_str}) — {rationale}") + + log(f" Critique: kept {len(kept)} of {len(proposed)} closed candidates") + return kept + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Identify potential duplicate issues") parser.add_argument("issue_number", type=int, help="Issue number to analyze") @@ -658,6 +874,13 @@ if __name__ == "__main__": anthropic_key, issue, relevant_magnets, search_results ) + # second-pass critique: prompt iteration on the proposer hit a ceiling around 30% noise. + # Re-evaluate each proposed closed candidate in isolation with a stricter prompt that + # has no slate to fill and is blind to the proposer's rationale. + related_closed_issues = critique_closed_candidates( + anthropic_key, issue, related_closed_issues, search_results + ) + # resolve close reason from our search results (the source of truth) so we don't depend # on Claude to faithfully echo it back results_by_number = {r["number"]: r for r in search_results} diff --git a/script/github-track-duplicate-bot-effectiveness.py b/script/github-track-duplicate-bot-effectiveness.py index 06dcae67cd8..02be1b9e137 100644 --- a/script/github-track-duplicate-bot-effectiveness.py +++ b/script/github-track-duplicate-bot-effectiveness.py @@ -24,6 +24,7 @@ import functools import os import re import sys +import time from datetime import datetime, timezone import requests @@ -47,6 +48,8 @@ BOT_START_DATE = "2026-02-18" NEEDS_TRIAGE_LABEL = "state:needs triage" DEFAULT_PROJECT_NUMBER = 76 VALID_CLOSED_AS_VALUES = {"duplicate", "not_planned", "completed"} +# HTTP statuses we'll retry on for GET requests +TRANSIENT_HTTP_STATUSES = {429, 500, 502, 503, 504} # Add a new tuple when you deploy a new version of the bot that you want to # keep track of (e.g. the prompt gets a rewrite or the model gets swapped). # Newest first, please. The datetime is for the deployment time (merge to main). @@ -67,10 +70,22 @@ def bot_version_for_time(date_string): def github_api_get(path, params=None): + """Fetch JSON from the GitHub REST API, retrying transient failures. Raises on non-2xx status.""" url = f"{GITHUB_API}/{path.lstrip('/')}" - response = requests.get(url, headers=GITHUB_HEADERS, params=params) - response.raise_for_status() - return response.json() + for attempt in range(3): + try: + response = requests.get(url, headers=GITHUB_HEADERS, params=params) + response.raise_for_status() + return response.json() + except requests.RequestException as e: + transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or ( + isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES + ) + if not transient or attempt == 2: + raise + wait = 2 ** attempt + print(f" Transient GitHub API error ({e}); retrying in {wait}s") + time.sleep(wait) def github_search_issues(query): @@ -161,9 +176,11 @@ def find_canonical_among(duplicate_number, candidates): if not candidates: return None + # candidate issue numbers are baked into the query body via field aliases + # (GraphQL doesn't let you parametrize alias names), so $numbers isn't needed. data = github_api_graphql( """ - query($owner: String!, $repo: String!, $numbers: [Int!]!) { + query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { PLACEHOLDER } @@ -174,7 +191,7 @@ def find_canonical_among(duplicate_number, candidates): f' nodes {{ ... on MarkedAsDuplicateEvent {{ duplicate {{ ... on Issue {{ number }} }} }} }} }} }}' for number in candidates )), - {"owner": REPO_OWNER, "repo": REPO_NAME, "numbers": list(candidates)}, + {"owner": REPO_OWNER, "repo": REPO_NAME}, partial_errors_ok=True, ) @@ -409,11 +426,10 @@ def classify_as_assist(issue, bot_comment): bot_comment_time=bot_comment["created_at"]) return - original = None - try: - original = find_canonical_among(issue["number"], suggested) - except (requests.RequestException, RuntimeError) as error: - print(f" Warning: failed to query candidate timelines: {error}") + # Let exceptions from find_canonical_among propagate — a query failure here is + # not the same as "no canonical match" and shouldn't be silently downgraded to + # a Needs review entry. Failing the workflow surfaces the problem immediately. + original = find_canonical_among(issue["number"], suggested) if original: status = "Auto-classified" @@ -483,6 +499,8 @@ def classify_open(): errors += 1 print(f" Done: added {added}, skipped {skipped}, errors {errors}") + if errors > 0: + sys.exit(1) if __name__ == "__main__": From 0bafd1938c3ba990ed303942d3a61622c1d69fb0 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Fri, 29 May 2026 10:10:02 -0400 Subject: [PATCH 80/93] Add handoff feature flag (#58024) Adds the handoff feature flag with staff disabled by default, giving the rest of the auto-compaction stack a rollout gate without changing behavior for users outside the flag. Release Notes: - N/A --- crates/feature_flags/src/flags.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index c6f9c6fc51c..f6febdc5c4e 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -35,6 +35,18 @@ impl FeatureFlag for AgentSharingFeatureFlag { } register_feature_flag!(AgentSharingFeatureFlag); +pub struct HandoffFeatureFlag; + +impl FeatureFlag for HandoffFeatureFlag { + const NAME: &'static str = "handoff"; + type Value = PresenceFlag; + + fn enabled_for_staff() -> bool { + false + } +} +register_feature_flag!(HandoffFeatureFlag); + pub struct DiffReviewFeatureFlag; impl FeatureFlag for DiffReviewFeatureFlag { From c029cc435420390d301e2fcca39b83a1d6c7bcb0 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 29 May 2026 12:00:32 -0300 Subject: [PATCH 81/93] Bump `convert_case` to v0.11.0 (#58000) Bumps the workspace `convert_case` dependency from 0.8 to 0.11 (already in the tree) so the `zed` binary includes only one copy. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- Cargo.lock | 10 +++++----- Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40236d075f3..4280c59d56f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -406,7 +406,7 @@ dependencies = [ "agent-client-protocol", "anyhow", "collections", - "convert_case 0.8.0", + "convert_case 0.11.0", "fs", "futures 0.3.32", "gpui", @@ -5783,7 +5783,7 @@ dependencies = [ "client", "clock", "collections", - "convert_case 0.8.0", + "convert_case 0.11.0", "criterion", "ctor", "dap", @@ -10147,7 +10147,7 @@ dependencies = [ "cloud_api_types", "collections", "component", - "convert_case 0.8.0", + "convert_case 0.11.0", "copilot", "copilot_chat", "copilot_ui", @@ -11365,7 +11365,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "convert_case 0.8.0", + "convert_case 0.11.0", "log", "pretty_assertions", "serde_json", @@ -19488,7 +19488,7 @@ name = "toolchain_selector" version = "0.1.0" dependencies = [ "anyhow", - "convert_case 0.8.0", + "convert_case 0.11.0", "editor", "futures 0.3.32", "fuzzy", diff --git a/Cargo.toml b/Cargo.toml index cf515b74e0a..7a8d87fca3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -559,7 +559,7 @@ clap = { version = "4.4", features = ["derive", "wrap_help"] } cocoa = "=0.26.0" cocoa-foundation = "=0.2.0" const_format = "0.2" -convert_case = "0.8.0" +convert_case = "0.11.0" core-foundation = "=0.10.0" core-foundation-sys = "0.8.6" core-video = { version = "0.5.2", features = ["metal"] } From 906bff792cb2222098ec6463d6df74bcbcdf4ccb Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Fri, 29 May 2026 20:41:25 +0530 Subject: [PATCH 82/93] agent_ui: Show permission popover when inline prompt is above the viewport (#58081) Follow up to https://github.com/zed-industries/zed/pull/57632, uses changes from https://github.com/zed-industries/zed/pull/58061 Previously the floating permission popover only appeared when the inline permission prompt was scrolled below the viewport. It now also appears when the prompt is scrolled above the viewport, with the scroll button pointing in the right direction. Release Notes: - Fixed the agent permission popover not appearing when the inline prompt was scrolled above the viewport. --- crates/agent_ui/src/conversation_view.rs | 77 ++++++++++++++++++- .../src/conversation_view/thread_view.rs | 19 ++--- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 568b94c1240..c707fc9f624 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -8113,9 +8113,17 @@ pub(crate) mod tests { async fn test_permission_row_hidden_when_inline_bounds_unavailable(cx: &mut TestAppContext) { init_test(cx); - let (_view, thread_view, _entry_ix, cx) = + let (_view, thread_view, entry_ix, cx) = setup_pending_permission_thread("perm-no-bounds", cx).await; + // Pin the scroll top to the entry so it isn't treated as above the + // viewport, forcing the unmeasured-bounds path we want to exercise. + thread_view.read_with(cx, |view, _cx| { + view.list_state.scroll_to(ListOffset { + item_ix: entry_ix, + offset_in_item: px(0.0), + }); + }); thread_view.update_in(cx, |view, window, cx| { assert!( view.render_main_agent_awaiting_permission(window, cx) @@ -8176,8 +8184,8 @@ pub(crate) mod tests { let (_view, thread_view, entry_ix, cx) = setup_pending_permission_thread("perm-scroll", cx).await; - // Start off-screen below the viewport — row visible because the item - // has bounds that do not intersect the viewport. + // Start off-screen below the viewport. The row is visible because the + // item has bounds that do not intersect the viewport. draw_thread_list_at( &thread_view, ListOffset { @@ -8221,6 +8229,69 @@ pub(crate) mod tests { }); } + #[gpui::test] + async fn test_permission_row_shown_when_inline_prompt_is_above_viewport( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (_view, thread_view, entry_ix, cx) = + setup_pending_permission_thread("perm-above", cx).await; + + let thread = thread_view.read_with(cx, |view, _cx| view.thread.clone()); + thread.update(cx, |thread, cx| { + let result = thread.handle_session_update( + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + "More content".into(), + )), + cx, + ); + assert!( + result.is_ok(), + "following assistant message should be accepted" + ); + }); + + draw_thread_list_at( + &thread_view, + ListOffset { + item_ix: entry_ix + 1, + offset_in_item: px(0.0), + }, + cx, + ); + thread_view.read_with(cx, |view, _cx| { + assert!( + entry_ix < view.list_state.logical_scroll_top().item_ix, + "The tool call entry should be above the logical scroll top" + ); + }); + thread_view.update_in(cx, |view, window, cx| { + assert!( + view.render_main_agent_awaiting_permission(window, cx) + .is_some(), + "Floating row should be visible when the inline prompt is above the viewport" + ); + }); + + // Scrolling up to the entry brings it back into view. + draw_thread_list_at( + &thread_view, + ListOffset { + item_ix: entry_ix, + offset_in_item: px(0.0), + }, + cx, + ); + thread_view.update_in(cx, |view, window, cx| { + assert!( + view.render_main_agent_awaiting_permission(window, cx) + .is_none(), + "Floating row should disappear after scrolling brings the inline prompt into view" + ); + }); + } + #[gpui::test] async fn test_permission_row_disappears_when_authorized(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index a90fef49bd9..32969d76d3d 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -3047,15 +3047,6 @@ impl ThreadView { ) } - /// Returns true when the entry has been measured and sits entirely below - /// the current viewport. - fn entry_is_below_viewport(&self, entry_ix: usize) -> bool { - let viewport_bounds = self.list_state.viewport_bounds(); - self.list_state - .bounds_for_item(entry_ix) - .is_some_and(|entry_bounds| entry_bounds.top() >= viewport_bounds.bottom()) - } - pub(crate) fn render_main_agent_awaiting_permission( &self, window: &Window, @@ -3073,9 +3064,13 @@ impl ThreadView { let thread = self.thread.read(cx); let (entry_ix, tool_call) = thread.tool_call(&tool_call_id)?; - if !self.entry_is_below_viewport(entry_ix) { + let scroll_icon = if self.list_state.item_is_above_viewport(entry_ix)? { + IconName::ArrowUp + } else if self.list_state.item_is_below_viewport(entry_ix)? { + IconName::ArrowDown + } else { return None; - } + }; let focus_handle = self.focus_handle(cx); @@ -3118,7 +3113,7 @@ impl ThreadView { Button::new("main-agent-permission-scroll-to", "Scroll") .label_size(LabelSize::Small) .end_icon( - Icon::new(IconName::ArrowDown) + Icon::new(scroll_icon) .size(IconSize::XSmall) .color(Color::Default), ) From 5fba9b0cba14e3f2eca145783cf1050b2d057c80 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 29 May 2026 09:21:53 -0600 Subject: [PATCH 83/93] Enable gain normalization on collab (#58036) This updates our WebRTC configuration to enable gain normalization in the recording flow, which should help normalize the effective volume of participants in calls. Release Notes: - Added volume equalizations to participants in collab calls --- Cargo.lock | 50 +++++++++---------- Cargo.toml | 6 +-- .../src/audio_pipeline/echo_canceller.rs | 9 ++-- .../src/livekit_client/playback.rs | 21 +++++++- 4 files changed, 54 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4280c59d56f..677ed3796ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2163,7 +2163,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.10.5", "log", "prettyplease", "proc-macro2", @@ -2183,7 +2183,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.10.5", "proc-macro2", "quote", "regex", @@ -5313,7 +5313,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6148,7 +6148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7604,7 +7604,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9065,7 +9065,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -9083,7 +9083,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.56.0", ] [[package]] @@ -9337,7 +9337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.15.5", "serde", "serde_core", ] @@ -10480,7 +10480,7 @@ dependencies = [ [[package]] name = "libwebrtc" version = "0.3.26" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "cxx", "glib", @@ -10590,7 +10590,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "livekit" version = "0.7.32" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "base64 0.22.1", "bmrng", @@ -10616,7 +10616,7 @@ dependencies = [ [[package]] name = "livekit-api" version = "0.4.14" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "base64 0.21.7", "futures-util", @@ -10643,7 +10643,7 @@ dependencies = [ [[package]] name = "livekit-protocol" version = "0.7.1" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "futures-util", "livekit-runtime", @@ -10659,7 +10659,7 @@ dependencies = [ [[package]] name = "livekit-runtime" version = "0.4.0" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "tokio", "tokio-stream", @@ -11951,7 +11951,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -14590,7 +14590,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes 1.11.1", "heck 0.5.0", - "itertools 0.11.0", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -14623,7 +14623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -14885,7 +14885,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.40", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -14922,9 +14922,9 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -16149,7 +16149,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -18771,7 +18771,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -19696,7 +19696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f" dependencies = [ "cc", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -21489,7 +21489,7 @@ dependencies = [ [[package]] name = "webrtc-sys" version = "0.3.23" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "cc", "cxx", @@ -21503,7 +21503,7 @@ dependencies = [ [[package]] name = "webrtc-sys-build" version = "0.3.13" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "anyhow", "fs2", @@ -21801,7 +21801,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7a8d87fca3b..883e0e04486 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -891,9 +891,9 @@ notify = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24c notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24cad542c28e04ced02e20325a4ec28a31d" } windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" } calloop = { git = "https://github.com/zed-industries/calloop" } -livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" } -libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" } -webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" } +livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } +libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } +webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } [profile.dev] split-debuginfo = "unpacked" diff --git a/crates/audio/src/audio_pipeline/echo_canceller.rs b/crates/audio/src/audio_pipeline/echo_canceller.rs index ec612b1b448..59f3063d9fc 100644 --- a/crates/audio/src/audio_pipeline/echo_canceller.rs +++ b/crates/audio/src/audio_pipeline/echo_canceller.rs @@ -12,9 +12,12 @@ mod real_implementation { impl Default for EchoCanceller { fn default() -> Self { - Self(Arc::new(Mutex::new(apm::AudioProcessingModule::new( - true, false, false, false, - )))) + // Sound-effect playback only feeds this APM through `process_reverse_stream` + // for AEC reference; gain/HPF/NS would be no-ops here, so we keep the + // original (echo only) configuration via the legacy flag form. + Self(Arc::new(Mutex::new( + apm::AudioProcessingModule::from_flags(true, false, false, false), + ))) } } diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index cea5b1169b0..8a72b5df40e 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -49,8 +49,27 @@ pub(crate) struct AudioStack { impl AudioStack { pub(crate) fn new(executor: BackgroundExecutor) -> Self { + // AGC2's `adaptive_digital` is what actually levels speech toward a target; + // the `gain_controller2.enabled` master switch alone leaves it off, which + // historically meant capture was effectively unleveled. Defaults match + // what Chrome/Meet ship with -- in particular `max_gain_db = 50` paired + // with `max_output_noise_level_dbfs = -50`, which lets the AGC reach + // very quiet talkers while the noise-level estimator backs off before + // boosting amplifies the noise floor. let apm = Arc::new(Mutex::new(apm::AudioProcessingModule::new( - true, true, true, true, + apm::AudioProcessingConfig { + echo_canceller_enabled: true, + gain_controller2: apm::GainController2Config { + enabled: true, + adaptive_digital: apm::AdaptiveDigitalConfig { + enabled: true, + ..Default::default() + }, + ..Default::default() + }, + high_pass_filter_enabled: true, + noise_suppression_enabled: true, + }, ))); let mixer = Arc::new(Mutex::new(audio_mixer::AudioMixer::new())); Self { From 654a864b3a1ef0b6eb1ac9a557b0377977785785 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 29 May 2026 17:27:21 +0200 Subject: [PATCH 84/93] git_ui: Do not include git commit prompt twice (#58062) Now that customisation of this prompt was moved to `AGENTS.md`, we don't want to load the customised prompt, and instead just use the default prompt, so that we don't include the same instructions twice Release Notes: - git: Improve performance when generating git commit message with LLM --- crates/git_ui/src/git_panel.rs | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index a0e7571ccdb..b690916fd2f 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -62,7 +62,7 @@ use project::{ }, project_settings::{GitPathStyle, ProjectSettings}, }; -use prompt_store::{BuiltInPrompt, PromptId, PromptStore, RULES_FILE_NAMES}; +use prompt_store::RULES_FILE_NAMES; use proto::RpcError; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore, StatusStyle, update_settings_file}; @@ -2685,20 +2685,6 @@ impl GitPanel { } } - async fn load_commit_message_prompt(cx: &mut AsyncApp) -> String { - let load = async { - let store = cx.update(|cx| PromptStore::global(cx)).await.ok()?; - store - .update(cx, |s, cx| { - s.load(PromptId::BuiltIn(BuiltInPrompt::CommitMessage), cx) - }) - .await - .ok() - }; - load.await - .unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().to_string()) - } - fn build_commit_message_prompt( prompt: &str, user_agents_md: Option<&str>, @@ -2803,7 +2789,7 @@ impl GitPanel { .and_then(|user_agents_md| user_agents_md.content().cloned()) }); - let prompt = Self::load_commit_message_prompt(&mut cx).await; + let prompt = include_str!("../src/commit_message_prompt.txt"); let subject = this.update(cx, |this, cx| { this.commit_editor From 18051ab3999e4f5604be372d2d159d45d63abc70 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 29 May 2026 17:27:28 +0200 Subject: [PATCH 85/93] agent_ui: Remove unused rule APIs (#58080) Removes unused `@rule` mentions and unused APIs from `prompt_store` Follow up to #58067 Release Notes: - N/A --- Cargo.lock | 3 - crates/acp_thread/Cargo.toml | 1 - crates/acp_thread/src/mention.rs | 34 -- crates/agent/src/thread.rs | 11 - crates/agent_ui/src/agent_panel.rs | 130 ++--- crates/agent_ui/src/conversation_view.rs | 24 +- .../src/conversation_view/thread_view.rs | 13 - crates/agent_ui/src/entry_view_state.rs | 6 - crates/agent_ui/src/inline_assistant.rs | 10 +- crates/agent_ui/src/inline_prompt_editor.rs | 10 +- crates/agent_ui/src/mention_set.rs | 33 +- crates/agent_ui/src/message_editor.rs | 25 +- .../agent_ui/src/terminal_inline_assistant.rs | 4 +- crates/agent_ui/src/thread_metadata_store.rs | 2 +- crates/agent_ui/src/ui/mention_crease.rs | 24 - crates/prompt_store/Cargo.toml | 2 - crates/prompt_store/src/prompt_store.rs | 517 +----------------- 17 files changed, 56 insertions(+), 793 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 677ed3796ed..58d81f58bd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,7 +109,6 @@ dependencies = [ "parking_lot", "portable-pty", "project", - "prompt_store", "rand 0.9.4", "sandbox", "serde", @@ -14483,7 +14482,6 @@ dependencies = [ "db", "fs", "futures 0.3.32", - "fuzzy", "gpui", "handlebars 4.5.0", "heed", @@ -14491,7 +14489,6 @@ dependencies = [ "log", "parking_lot", "paths", - "rope", "serde", "serde_json", "strum 0.27.2", diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index d115b29b1f6..c22259f94b1 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -38,7 +38,6 @@ parking_lot = { workspace = true, optional = true } image.workspace = true portable-pty.workspace = true project.workspace = true -prompt_store.workspace = true sandbox.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index cb96de34813..f2423858523 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -1,7 +1,6 @@ use agent_client_protocol::schema as acp; use anyhow::{Context as _, Result, bail}; use file_icons::FileIcons; -use prompt_store::{PromptId, UserPromptId}; use serde::{Deserialize, Serialize}; use std::{ borrow::Cow, @@ -37,10 +36,6 @@ pub enum MentionUri { id: acp::SessionId, name: String, }, - Rule { - id: PromptId, - name: String, - }, Diagnostics { #[serde(default = "default_include_errors")] include_errors: bool, @@ -205,13 +200,6 @@ impl MentionUri { id: acp::SessionId::new(thread_id), name, }) - } else if let Some(rule_id) = path.strip_prefix("/agent/rule/") { - let name = single_query_param(&url, "name")?.context("Missing rule name")?; - let rule_id = UserPromptId(rule_id.parse()?); - Ok(Self::Rule { - id: rule_id.into(), - name, - }) } else if path == "/agent/diagnostics" { let mut include_errors = default_include_errors(); let mut include_warnings = false; @@ -342,7 +330,6 @@ impl MentionUri { MentionUri::PastedImage { name } => name.clone(), MentionUri::Symbol { name, .. } => name.clone(), MentionUri::Thread { name, .. } => name.clone(), - MentionUri::Rule { name, .. } => name.clone(), MentionUri::Diagnostics { .. } => "Diagnostics".to_string(), MentionUri::TerminalSelection { line_count } => { if *line_count == 1 { @@ -443,7 +430,6 @@ impl MentionUri { .unwrap_or_else(|| IconName::Folder.path().into()), MentionUri::Symbol { .. } => IconName::Code.path().into(), MentionUri::Thread { .. } => IconName::Thread.path().into(), - MentionUri::Rule { .. } => IconName::Reader.path().into(), MentionUri::Diagnostics { .. } => IconName::Warning.path().into(), MentionUri::TerminalSelection { .. } => IconName::Terminal.path().into(), MentionUri::Selection { .. } => IconName::Reader.path().into(), @@ -526,12 +512,6 @@ impl MentionUri { url.query_pairs_mut().append_pair("name", name); url } - MentionUri::Rule { name, id } => { - let mut url = Url::parse("zed:///").unwrap(); - url.set_path(&format!("/agent/rule/{id}")); - url.query_pairs_mut().append_pair("name", name); - url - } MentionUri::Diagnostics { include_errors, include_warnings, @@ -811,20 +791,6 @@ mod tests { assert_eq!(parsed.to_uri().to_string(), thread_uri); } - #[test] - fn test_parse_rule_uri() { - let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule"; - let parsed = MentionUri::parse(rule_uri, PathStyle::local()).unwrap(); - match &parsed { - MentionUri::Rule { id, name } => { - assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52"); - assert_eq!(name, "Some rule"); - } - _ => panic!("Expected Rule variant"), - } - assert_eq!(parsed.to_uri().to_string(), rule_uri); - } - #[test] fn test_parse_skill_uri_round_trip() { let skill_uri = MentionUri::Skill { diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 5eb2c170a12..4a5efc2e1cc 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -316,17 +316,6 @@ impl UserMessage { MentionUri::Thread { .. } => { write!(&mut thread_context, "\n{}\n", content).ok(); } - MentionUri::Rule { .. } => { - write!( - &mut rules_context, - "\n{}", - MarkdownCodeBlock { - tag: "", - text: content - } - ) - .ok(); - } MentionUri::Fetch { url } => { write!(&mut fetch_context, "\nFetch: {}\n\n{}", url, content).ok(); } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 91b17a91a79..45aa0014b1a 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -78,7 +78,6 @@ use gpui::{ use language::LanguageRegistry; use language_model::LanguageModelRegistry; use project::{Project, ProjectPath, Worktree}; -use prompt_store::PromptStore; use settings::TerminalDockPosition; use settings::{NotifyWhenAgentWaiting, Settings, update_settings_file}; use skill_creator::{SkillCreatorOpenMode, is_supported_skill_url, open_skill_creator}; @@ -1049,7 +1048,6 @@ pub struct AgentPanel { fs: Arc, language_registry: Arc, thread_store: Entity, - prompt_store: Option>, connection_store: Entity, context_server_registry: Entity, configuration: Option>, @@ -1170,13 +1168,8 @@ impl AgentPanel { workspace: WeakEntity, mut cx: AsyncWindowContext, ) -> Task>> { - let prompt_store = cx.update(|_window, cx| PromptStore::global(cx)); let kvp = cx.update(|_window, cx| KeyValueStore::global(cx)).ok(); cx.spawn(async move |cx| { - let prompt_store = match prompt_store { - Ok(prompt_store) => prompt_store.await.ok(), - Err(_) => None, - }; let workspace_id = workspace .read_with(cx, |workspace, _| workspace.database_id()) .ok() @@ -1301,7 +1294,7 @@ impl AgentPanel { }; let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| Self::new(workspace, prompt_store, window, cx)); + let panel = cx.new(|cx| Self::new(workspace, window, cx)); panel.update(cx, |panel, cx| { let is_via_collab = panel.project.read(cx).is_via_collab(); @@ -1381,12 +1374,7 @@ impl AgentPanel { }) } - pub(crate) fn new( - workspace: &Workspace, - prompt_store: Option>, - _window: &mut Window, - cx: &mut Context, - ) -> Self { + pub(crate) fn new(workspace: &Workspace, _window: &mut Window, cx: &mut Context) -> Self { let fs = workspace.app_state().fs.clone(); let user_store = workspace.app_state().user_store.clone(); let project = workspace.project(); @@ -1468,7 +1456,6 @@ impl AgentPanel { project: project.clone(), fs: fs.clone(), language_registry, - prompt_store, connection_store, configuration: None, configuration_subscription: None, @@ -1547,10 +1534,6 @@ impl AgentPanel { } } - pub(crate) fn prompt_store(&self) -> &Option> { - &self.prompt_store - } - pub fn thread_store(&self) -> &Entity { &self.thread_store } @@ -4395,7 +4378,6 @@ impl AgentPanel { workspace.clone(), project, thread_store, - self.prompt_store.clone(), source, window, cx, @@ -6087,7 +6069,7 @@ impl Dismissable for TrialEndUpsell { #[cfg(any(test, feature = "test-support"))] impl AgentPanel { pub fn test_new(workspace: &Workspace, window: &mut Window, cx: &mut Context) -> Self { - Self::new(workspace, None, window, cx) + Self::new(workspace, window, cx) } /// Drops a thread's `ConversationView` from `retained_threads` without @@ -6594,7 +6576,7 @@ mod tests { // Set up workspace A: with an active thread. let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { - cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + cx.new(|cx| AgentPanel::new(workspace, window, cx)) }); panel_a.update_in(cx, |panel, window, cx| { @@ -6620,7 +6602,7 @@ mod tests { // Set up workspace B: ClaudeCode, no active thread. let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { - cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + cx.new(|cx| AgentPanel::new(workspace, window, cx)) }); panel_b.update(cx, |panel, _cx| { @@ -6723,7 +6705,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + cx.new(|cx| AgentPanel::new(workspace, window, cx)) }); panel.update_in(cx, |panel, window, cx| { @@ -6918,7 +6900,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { - cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + cx.new(|cx| AgentPanel::new(workspace, window, cx)) }); panel_a .update_in(cx, |panel, window, cx| { @@ -6995,7 +6977,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + cx.new(|cx| AgentPanel::new(workspace, window, cx)) }); panel.update_in(cx, |panel, window, cx| { @@ -7087,7 +7069,7 @@ mod tests { }); let panel = workspace.update_in(cx, |workspace, window, cx| { - cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + cx.new(|cx| AgentPanel::new(workspace, window, cx)) }); // Open a restored thread using a flaky server so the initial connect @@ -7186,7 +7168,7 @@ mod tests { .unwrap(); let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -7286,12 +7268,12 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -7666,7 +7648,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -7853,7 +7835,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -8082,7 +8064,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -8168,7 +8150,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -8258,7 +8240,7 @@ mod tests { let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(&mut cx, |workspace, window, cx| { - cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + cx.new(|cx| AgentPanel::new(workspace, window, cx)) }); (panel, cx) @@ -8305,7 +8287,7 @@ mod tests { register_test_sidebar(threads_list_active, &mut cx); let panel = workspace.update_in(&mut cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); workspace.focus_panel::(window, cx); panel @@ -8435,7 +8417,7 @@ mod tests { let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(&mut cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -8548,7 +8530,7 @@ mod tests { let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(&mut cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -9791,7 +9773,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -10383,7 +10365,7 @@ mod tests { let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(&mut cx, |workspace, window, cx| { - cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + cx.new(|cx| AgentPanel::new(workspace, window, cx)) }); // Open thread A and send a message. With empty next_prompt_updates it @@ -10652,7 +10634,7 @@ mod tests { // Set up workspace A with agent_a let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { - cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + cx.new(|cx| AgentPanel::new(workspace, window, cx)) }); panel_a.update(cx, |panel, _cx| { panel.selected_agent = agent_a.clone(); @@ -10660,7 +10642,7 @@ mod tests { // Set up workspace B with agent_b let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { - cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + cx.new(|cx| AgentPanel::new(workspace, window, cx)) }); panel_b.update(cx, |panel, _cx| { panel.selected_agent = agent_b.clone(); @@ -10731,7 +10713,7 @@ mod tests { }; let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -10788,7 +10770,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -10878,7 +10860,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -10966,7 +10948,7 @@ mod tests { workspace.update(cx, |workspace, _cx| workspace.set_random_database_id()); let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -11076,7 +11058,7 @@ mod tests { workspace.update(cx, |workspace, _cx| workspace.set_random_database_id()); let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -11182,7 +11164,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -11681,7 +11663,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + cx.new(|cx| AgentPanel::new(workspace, window, cx)) }); cx.run_until_parked(); @@ -11782,7 +11764,7 @@ mod tests { // Create the agent panel and add it to the workspace. let panel = workspace.update_in(&mut cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -11992,7 +11974,7 @@ mod tests { let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace.update_in(&mut cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -12229,7 +12211,7 @@ mod tests { // Set up panel_a with an active thread and type draft text. let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -12253,7 +12235,7 @@ mod tests { // Set up panel_b on workspace_b — starts as a fresh, empty panel. let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -12323,7 +12305,7 @@ mod tests { // Set up panel_a with draft text. let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -12347,7 +12329,7 @@ mod tests { // Set up panel_b with its OWN content — this is a non-fresh panel. let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); @@ -12391,42 +12373,4 @@ mod tests { ); }); } - - /// Regression test: NewThread must produce a connected thread even when - /// the PromptStore fails to initialize (e.g. LMDB permission error). - /// Before the fix, `NativeAgentServer::connect` propagated the - /// PromptStore error with `?`, which put every new ConversationView - /// into LoadError and made it impossible to start any native-agent - /// thread. - #[gpui::test] - async fn test_new_thread_with_prompt_store_error(cx: &mut TestAppContext) { - let (panel, mut cx) = setup_panel(cx).await; - - // NativeAgentServer::connect needs a global Fs. - let fs = FakeFs::new(cx.executor()); - cx.update(|_, cx| { - ::set_global(fs.clone(), cx); - }); - cx.run_until_parked(); - - // Dispatch NewThread, which goes through the real NativeAgentServer - // path. In tests the PromptStore LMDB open fails with - // "Permission denied"; the fix (.log_err() instead of ?) lets - // the connection succeed anyway. - panel.update_in(&mut cx, |panel, window, cx| { - panel.new_thread(&NewThread, window, cx); - }); - cx.run_until_parked(); - - panel.read_with(&cx, |panel, cx| { - assert!( - panel.active_conversation_view().is_some(), - "panel should have a conversation view after NewThread" - ); - assert!( - panel.active_agent_thread(cx).is_some(), - "panel should have an active, connected agent thread" - ); - }); - } } diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index c707fc9f624..755c9629b1f 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -42,7 +42,6 @@ use markdown::{ }; use parking_lot::{Mutex, RwLock}; use project::{AgentId, AgentServerStore, Project, ProjectEntryId, ProjectPath}; -use prompt_store::{PromptId, PromptStore}; use crate::message_editor::SessionCapabilities; use crate::{AgentThreadSource, DEFAULT_THREAD_TITLE, resolve_agent_image}; @@ -75,7 +74,6 @@ use workspace::{ path_link::sanitize_path_text, }; use zed_actions::agent::{Chat, ToggleModelSelector}; -use zed_actions::assistant::OpenRulesLibrary; use super::config_options::ConfigOptionsView; use super::entry_view_state::EntryViewState; @@ -531,7 +529,6 @@ pub struct ConversationView { workspace: WeakEntity, project: Entity, thread_store: Option>, - prompt_store: Option>, pub(crate) thread_id: ThreadId, pub(crate) root_session_id: Option, server_state: ServerState, @@ -738,7 +735,6 @@ impl ConversationView { workspace: WeakEntity, project: Entity, thread_store: Option>, - prompt_store: Option>, source: AgentThreadSource, window: &mut Window, cx: &mut Context, @@ -795,7 +791,6 @@ impl ConversationView { workspace, project: project.clone(), thread_store, - prompt_store, thread_id, root_session_id: resume_session_id.clone(), server_state: Self::initial_state( @@ -1104,7 +1099,6 @@ impl ConversationView { self.workspace.clone(), self.project.downgrade(), self.thread_store.clone(), - self.prompt_store.clone(), session_capabilities.clone(), self.agent.agent_id(), ) @@ -1273,7 +1267,6 @@ impl ConversationView { self.project.downgrade(), self.code_span_resolver.clone(), self.thread_store.clone(), - self.prompt_store.clone(), initial_content, subscriptions, window, @@ -2492,7 +2485,6 @@ impl ConversationView { workspace.clone(), project.clone(), None, - None, session_capabilities.clone(), agent_name.clone(), "", @@ -3721,7 +3713,6 @@ pub(crate) mod tests { workspace.downgrade(), project, Some(thread_store), - None, AgentThreadSource::AgentPanel, window, cx, @@ -3858,7 +3849,6 @@ pub(crate) mod tests { workspace.downgrade(), project, Some(thread_store), - None, AgentThreadSource::AgentPanel, window, cx, @@ -3940,7 +3930,6 @@ pub(crate) mod tests { workspace.downgrade(), project, Some(thread_store), - None, AgentThreadSource::AgentPanel, window, cx, @@ -4079,7 +4068,6 @@ pub(crate) mod tests { workspace.downgrade(), project.clone(), Some(thread_store), - None, AgentThreadSource::AgentPanel, window, cx, @@ -4364,7 +4352,7 @@ pub(crate) mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx); let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| crate::AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); workspace.focus_panel::(window, cx); panel @@ -4405,7 +4393,6 @@ pub(crate) mod tests { workspace.downgrade(), project.clone(), Some(thread_store), - None, AgentThreadSource::AgentPanel, window, cx, @@ -4504,7 +4491,6 @@ pub(crate) mod tests { workspace.downgrade(), project.clone(), Some(thread_store), - None, AgentThreadSource::AgentPanel, window, cx, @@ -4580,7 +4566,6 @@ pub(crate) mod tests { workspace.downgrade(), project.clone(), Some(thread_store), - None, AgentThreadSource::AgentPanel, window, cx, @@ -4648,7 +4633,6 @@ pub(crate) mod tests { workspace.downgrade(), project.clone(), Some(thread_store), - None, AgentThreadSource::AgentPanel, window, cx, @@ -4724,7 +4708,7 @@ pub(crate) mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx); let panel = workspace1.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx)); + let panel = cx.new(|cx| crate::AgentPanel::new(workspace, window, cx)); workspace.add_panel(panel.clone(), window, cx); // Open the dock and activate the agent panel so it's visible @@ -4770,7 +4754,6 @@ pub(crate) mod tests { workspace1.downgrade(), project1.clone(), Some(thread_store), - None, AgentThreadSource::AgentPanel, window, cx, @@ -4992,7 +4975,6 @@ pub(crate) mod tests { workspace.downgrade(), project, Some(thread_store), - None, AgentThreadSource::AgentPanel, window, cx, @@ -5651,7 +5633,6 @@ pub(crate) mod tests { workspace.downgrade(), project.clone(), Some(thread_store.clone()), - None, AgentThreadSource::AgentPanel, window, cx, @@ -8627,7 +8608,6 @@ pub(crate) mod tests { workspace.downgrade(), project, Some(thread_store), - None, AgentThreadSource::AgentPanel, window, cx, diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 32969d76d3d..3eec4c93ff0 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -683,7 +683,6 @@ impl ThreadView { project: WeakEntity, code_span_resolver: AgentCodeSpanResolver, thread_store: Option>, - prompt_store: Option>, initial_content: Option, mut subscriptions: Vec, window: &mut Window, @@ -703,7 +702,6 @@ impl ThreadView { workspace.clone(), project.clone(), thread_store, - prompt_store, session_capabilities.clone(), agent_id.clone(), &placeholder, @@ -10009,17 +10007,6 @@ pub(crate) fn open_link( }); } } - MentionUri::Rule { id, .. } => { - let PromptId::User { uuid } = id else { - return; - }; - window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: Some(uuid.0), - }), - cx, - ) - } MentionUri::Fetch { url } => { cx.open_url(url.as_str()); } diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index a66ac0da7f7..9d2b030638f 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -11,7 +11,6 @@ use gpui::{ }; use language::language_settings::SoftWrap; use project::{AgentId, Project, project_settings::DiagnosticSeverity}; -use prompt_store::PromptStore; use rope::Point; use settings::Settings as _; use terminal_view::TerminalView; @@ -25,7 +24,6 @@ pub struct EntryViewState { workspace: WeakEntity, project: WeakEntity, thread_store: Option>, - prompt_store: Option>, entries: Vec, session_capabilities: SharedSessionCapabilities, agent_id: AgentId, @@ -36,7 +34,6 @@ impl EntryViewState { workspace: WeakEntity, project: WeakEntity, thread_store: Option>, - prompt_store: Option>, session_capabilities: SharedSessionCapabilities, agent_id: AgentId, ) -> Self { @@ -44,7 +41,6 @@ impl EntryViewState { workspace, project, thread_store, - prompt_store, entries: Vec::new(), session_capabilities, agent_id, @@ -86,7 +82,6 @@ impl EntryViewState { self.workspace.clone(), self.project.clone(), self.thread_store.clone(), - self.prompt_store.clone(), self.session_capabilities.clone(), self.agent_id.clone(), "Edit message - @ to include context", @@ -546,7 +541,6 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store, - None, Arc::new(RwLock::new(SessionCapabilities::default())), "Test Agent".into(), ) diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index b13a9b615b6..661feabb5f6 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -43,7 +43,7 @@ use language_model::{ConfigurationError, ConfiguredModel, LanguageModelRegistry} use multi_buffer::MultiBufferRow; use parking_lot::Mutex; use project::{DisableAiSettings, Project}; -use prompt_store::{PromptBuilder, PromptStore}; +use prompt_store::PromptBuilder; use settings::{Settings, SettingsStore}; use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; @@ -228,7 +228,6 @@ impl InlineAssistant { }; let agent_panel = agent_panel.read(cx); - let prompt_store = agent_panel.prompt_store().as_ref().cloned(); let thread_store = agent_panel.thread_store().clone(); let handle_assist = @@ -240,7 +239,6 @@ impl InlineAssistant { cx.entity().downgrade(), workspace.project().downgrade(), thread_store, - prompt_store, action.prompt.clone(), window, cx, @@ -254,7 +252,6 @@ impl InlineAssistant { cx.entity().downgrade(), workspace.project().downgrade(), thread_store, - prompt_store, action.prompt.clone(), window, cx, @@ -437,7 +434,6 @@ impl InlineAssistant { workspace: WeakEntity, project: WeakEntity, thread_store: Entity, - prompt_store: Option>, initial_prompt: Option, window: &mut Window, codegen_ranges: &[Range], @@ -483,7 +479,6 @@ impl InlineAssistant { session_id, self.fs.clone(), thread_store.clone(), - prompt_store.clone(), project.clone(), workspace.clone(), window, @@ -574,7 +569,6 @@ impl InlineAssistant { workspace: WeakEntity, project: WeakEntity, thread_store: Entity, - prompt_store: Option>, initial_prompt: Option, window: &mut Window, cx: &mut App, @@ -592,7 +586,6 @@ impl InlineAssistant { workspace, project, thread_store, - prompt_store, initial_prompt, window, &codegen_ranges, @@ -1915,7 +1908,6 @@ pub mod evals { workspace.downgrade(), project.downgrade(), thread_store, - None, Some(prompt), window, cx, diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 31bdf83da16..4860b8b5765 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -17,7 +17,6 @@ use language_model::{LanguageModel, LanguageModelRegistry}; use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; use project::Project; -use prompt_store::PromptStore; use settings::Settings; use std::cmp; use std::ops::Range; @@ -1237,7 +1236,6 @@ impl PromptEditor { session_id: Uuid, fs: Arc, thread_store: Entity, - prompt_store: Option>, project: WeakEntity, workspace: WeakEntity, window: &mut Window, @@ -1276,8 +1274,7 @@ impl PromptEditor { editor }); - let mention_set = cx - .new(|_cx| MentionSet::new(project, Some(thread_store.clone()), prompt_store.clone())); + let mention_set = cx.new(|_cx| MentionSet::new(project, Some(thread_store.clone()))); let model_selector_menu_handle = PopoverMenuHandle::default(); @@ -1393,7 +1390,6 @@ impl PromptEditor { session_id: Uuid, fs: Arc, thread_store: Entity, - prompt_store: Option>, project: WeakEntity, workspace: WeakEntity, window: &mut Window, @@ -1427,8 +1423,7 @@ impl PromptEditor { editor }); - let mention_set = cx - .new(|_cx| MentionSet::new(project, Some(thread_store.clone()), prompt_store.clone())); + let mention_set = cx.new(|_cx| MentionSet::new(project, Some(thread_store.clone()))); let model_selector_menu_handle = PopoverMenuHandle::default(); @@ -1705,7 +1700,6 @@ mod tests { session_id, fs, thread_store, - None, project, workspace.downgrade(), window, diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index beecb840f08..cae882e8c9f 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -22,7 +22,6 @@ use language_model::{LanguageModelImage, LanguageModelImageExt}; use multi_buffer::MultiBufferRow; use postage::stream::Stream as _; use project::{Project, ProjectItem, ProjectPath, Worktree}; -use prompt_store::{PromptId, PromptStore}; use rope::Point; use std::{ cell::RefCell, @@ -61,21 +60,15 @@ pub struct MentionImage { pub struct MentionSet { project: WeakEntity, thread_store: Option>, - prompt_store: Option>, mentions: HashMap, crease_entities: HashMap>, } impl MentionSet { - pub fn new( - project: WeakEntity, - thread_store: Option>, - prompt_store: Option>, - ) -> Self { + pub fn new(project: WeakEntity, thread_store: Option>) -> Self { Self { project, thread_store, - prompt_store, mentions: HashMap::default(), crease_entities: HashMap::default(), } @@ -153,7 +146,6 @@ impl MentionSet { line_range, .. } => self.confirm_mention_for_symbol(abs_path, line_range, cx), - MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx), MentionUri::Skill { skill_file_path, .. } => self.confirm_mention_for_skill(skill_file_path, cx), @@ -327,7 +319,6 @@ impl MentionSet { line_range, .. } => self.confirm_mention_for_symbol(abs_path, line_range, cx), - MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx), MentionUri::Skill { skill_file_path, .. } => self.confirm_mention_for_skill(skill_file_path, cx), @@ -515,24 +506,6 @@ impl MentionSet { }) } - fn confirm_mention_for_rule( - &mut self, - id: PromptId, - cx: &mut Context, - ) -> Task> { - let Some(prompt_store) = self.prompt_store.as_ref() else { - return Task::ready(Err(anyhow!("Missing prompt store"))); - }; - let prompt = prompt_store.read(cx).load(id, cx); - cx.spawn(async move |_, _| { - let prompt = prompt.await?; - Ok(Mention::Text { - content: prompt, - tracked_buffers: Vec::new(), - }) - }) - } - pub fn confirm_mention_for_selection( &mut self, source_range: Range, @@ -773,7 +746,7 @@ mod tests { fs.insert_tree("/project", json!({"file": ""})).await; let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; let thread_store = None; - let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), thread_store, None)); + let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), thread_store)); let task = mention_set.update(cx, |mention_set, cx| { mention_set.confirm_mention_for_thread(acp::SessionId::new("thread-1"), cx) @@ -799,7 +772,7 @@ mod tests { ) .await; let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), None, None)); + let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), None)); let mention_task = mention_set.update(cx, |mention_set, cx| { let http_client = project.read(cx).client().http_client(); diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 5b6b8671413..c6e52939d60 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -33,7 +33,6 @@ use project::AgentId; use project::{ CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, ProjectPath, Worktree, }; -use prompt_store::PromptStore; use rope::Point; use settings::Settings; use std::{cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc}; @@ -453,7 +452,6 @@ impl MessageEditor { workspace: WeakEntity, project: WeakEntity, thread_store: Option>, - prompt_store: Option>, session_capabilities: SharedSessionCapabilities, agent_id: AgentId, placeholder: &str, @@ -506,8 +504,7 @@ impl MessageEditor { editor }); - let mention_set = - cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone())); + let mention_set = cx.new(|_cx| MentionSet::new(project, thread_store.clone())); let completion_provider = Rc::new(PromptCompletionProvider::new( MessageEditorCompletionDelegate { session_capabilities: session_capabilities.clone(), @@ -2475,7 +2472,6 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - None, Default::default(), "Test Agent".into(), "Test", @@ -2576,7 +2572,6 @@ mod tests { workspace_handle.clone(), project.downgrade(), thread_store.clone(), - None, session_capabilities.clone(), "Claude Agent".into(), "Test", @@ -2742,7 +2737,6 @@ mod tests { workspace_handle, project.downgrade(), thread_store.clone(), - None, session_capabilities.clone(), "Test Agent".into(), "Test", @@ -2915,7 +2909,6 @@ mod tests { workspace_handle, project.downgrade(), None, - None, session_capabilities.clone(), "Test Agent".into(), "Test", @@ -3064,7 +3057,6 @@ mod tests { workspace_handle, project.downgrade(), Some(thread_store), - None, session_capabilities.clone(), "Test Agent".into(), "Test", @@ -3556,7 +3548,6 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - None, Default::default(), "Test Agent".into(), "Test", @@ -3657,7 +3648,6 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - None, Default::default(), "Test Agent".into(), "Test", @@ -3726,7 +3716,6 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - None, Default::default(), "Test Agent".into(), "Test", @@ -3779,7 +3768,6 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - None, Default::default(), "Test Agent".into(), "Test", @@ -3836,7 +3824,6 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - None, Default::default(), "Test Agent".into(), "Test", @@ -3894,7 +3881,6 @@ mod tests { workspace.downgrade(), project.downgrade(), thread_store.clone(), - None, Default::default(), "Test Agent".into(), "Test", @@ -3956,7 +3942,6 @@ mod tests { workspace_handle, project.downgrade(), thread_store.clone(), - None, Default::default(), "Test Agent".into(), "Test", @@ -4116,7 +4101,6 @@ mod tests { workspace_handle, project.downgrade(), thread_store.clone(), - None, Default::default(), "Test Agent".into(), "Test", @@ -4236,7 +4220,6 @@ mod tests { workspace_handle, project.downgrade(), Some(thread_store.clone()), - None, Default::default(), "Test Agent".into(), "Test", @@ -4315,7 +4298,6 @@ mod tests { workspace_handle, project.downgrade(), Some(thread_store), - None, Default::default(), "Test Agent".into(), "Test", @@ -4493,7 +4475,6 @@ mod tests { workspace_handle, project.downgrade(), Some(thread_store), - None, Default::default(), "Test Agent".into(), "Test", @@ -4905,7 +4886,6 @@ mod tests { workspace_handle, project.downgrade(), Some(thread_store), - None, Default::default(), "Test Agent".into(), "Test", @@ -5160,7 +5140,6 @@ mod tests { workspace_handle, project.downgrade(), Some(thread_store), - None, Default::default(), "Test Agent".into(), "Test", @@ -5253,7 +5232,6 @@ mod tests { workspace.downgrade(), project.downgrade(), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -5402,7 +5380,6 @@ mod tests { workspace.downgrade(), project.downgrade(), None, - None, Default::default(), "Test Agent".into(), "Test", diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index c4db6a088da..0524fb2bd29 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -22,7 +22,7 @@ use language_models::provider::anthropic::telemetry::{ AnthropicCompletionType, AnthropicEventData, AnthropicEventType, report_anthropic_event, }; use project::Project; -use prompt_store::{PromptBuilder, PromptStore}; +use prompt_store::PromptBuilder; use std::sync::Arc; use terminal_view::TerminalView; use ui::prelude::*; @@ -64,7 +64,6 @@ impl TerminalInlineAssistant { workspace: WeakEntity, project: WeakEntity, thread_store: Entity, - prompt_store: Option>, initial_prompt: Option, window: &mut Window, cx: &mut App, @@ -89,7 +88,6 @@ impl TerminalInlineAssistant { session_id, self.fs.clone(), thread_store.clone(), - prompt_store.clone(), project.clone(), workspace.clone(), window, diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 80bf512147f..5b4b90e4b70 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -1888,7 +1888,7 @@ mod tests { .unwrap(); let mut vcx = VisualTestContext::from_window(multi_workspace.into(), cx); let panel = workspace_entity.update_in(&mut vcx, |workspace, window, cx| { - cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx)) + cx.new(|cx| crate::AgentPanel::new(workspace, window, cx)) }); (panel, vcx) } diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 6ce10698b5f..77cfc5512ed 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -8,7 +8,6 @@ use gpui::{ pulsating_between, }; use language::Buffer; -use prompt_store::PromptId; use rope::Point; use settings::Settings; use theme_settings::ThemeSettings; @@ -195,9 +194,6 @@ fn open_mention_uri( MentionUri::Thread { id, name } => { open_thread(workspace, id, name, window, cx); } - MentionUri::Rule { id, .. } => { - open_rule(workspace, id, window, cx); - } MentionUri::Skill { skill_file_path, .. } => { @@ -360,23 +356,3 @@ fn open_thread( } }); } - -fn open_rule( - _workspace: &mut Workspace, - id: PromptId, - window: &mut Window, - cx: &mut Context, -) { - use zed_actions::assistant::OpenRulesLibrary; - - let PromptId::User { uuid } = id else { - return; - }; - - window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: Some(uuid.0), - }), - cx, - ); -} diff --git a/crates/prompt_store/Cargo.toml b/crates/prompt_store/Cargo.toml index 8c1f296f171..bc4179e5e72 100644 --- a/crates/prompt_store/Cargo.toml +++ b/crates/prompt_store/Cargo.toml @@ -21,7 +21,6 @@ db.workspace = true fs.workspace = true futures.workspace = true -fuzzy.workspace = true gpui.workspace = true handlebars.workspace = true heed.workspace = true @@ -29,7 +28,6 @@ language.workspace = true log.workspace = true parking_lot.workspace = true paths.workspace = true -rope.workspace = true serde.workspace = true serde_json.workspace = true strum.workspace = true diff --git a/crates/prompt_store/src/prompt_store.rs b/crates/prompt_store/src/prompt_store.rs index bf9b2f981bd..df270bb0a42 100644 --- a/crates/prompt_store/src/prompt_store.rs +++ b/crates/prompt_store/src/prompt_store.rs @@ -6,24 +6,17 @@ use chrono::{DateTime, Utc}; use collections::HashMap; use futures::FutureExt as _; use futures::future::Shared; -use fuzzy::StringMatchCandidate; -use gpui::{ - App, AppContext, Context, Entity, EventEmitter, Global, ReadGlobal, SharedString, Task, -}; + +use gpui::{App, AppContext, Entity, Global, ReadGlobal, SharedString, Task}; use heed::{ Database, RoTxn, types::{SerdeBincode, SerdeJson, Str}, }; use parking_lot::RwLock; pub use prompts::*; -use rope::Rope; + use serde::{Deserialize, Serialize}; -use std::{ - cmp::Reverse, - future::Future, - path::PathBuf, - sync::{Arc, atomic::AtomicBool}, -}; +use std::{future::Future, path::PathBuf, sync::Arc}; use strum::{EnumIter, IntoEnumIterator as _}; use text::LineEnding; use util::ResultExt; @@ -122,15 +115,6 @@ impl PromptId { pub fn is_built_in(&self) -> bool { matches!(self, Self::BuiltIn { .. }) } - - pub fn can_edit(&self) -> bool { - match self { - Self::User { .. } => true, - Self::BuiltIn(builtin) => match builtin { - BuiltInPrompt::CommitMessage => true, - }, - } - } } impl From for PromptId { @@ -173,14 +157,9 @@ impl std::fmt::Display for PromptId { pub struct PromptStore { env: heed::Env, metadata_cache: RwLock, - metadata: Database, SerdeJson>, bodies: Database, Str>, } -pub struct PromptsUpdatedEvent; - -impl EventEmitter for PromptStore {} - #[derive(Default)] struct MetadataCache { metadata: Vec, @@ -220,21 +199,6 @@ impl MetadataCache { Ok(cache) } - fn insert(&mut self, metadata: PromptMetadata) { - self.metadata_by_id.insert(metadata.id, metadata.clone()); - if let Some(old_metadata) = self.metadata.iter_mut().find(|m| m.id == metadata.id) { - *old_metadata = metadata; - } else { - self.metadata.push(metadata); - } - self.sort(); - } - - fn remove(&mut self, id: PromptId) { - self.metadata.retain(|metadata| metadata.id != id); - self.metadata_by_id.remove(&id); - } - fn sort(&mut self) { self.metadata.sort_unstable_by(|a, b| { a.title @@ -275,7 +239,6 @@ impl PromptStore { Ok(PromptStore { env: db_env, metadata_cache: RwLock::new(metadata_cache), - metadata, bodies, }) }) @@ -363,219 +326,6 @@ impl PromptStore { pub fn all_prompt_metadata(&self) -> Vec { self.metadata_cache.read().metadata.clone() } - - pub fn default_prompt_metadata(&self) -> Vec { - return self - .metadata_cache - .read() - .metadata - .iter() - .filter(|metadata| metadata.default) - .cloned() - .collect::>(); - } - - pub fn delete(&self, id: PromptId, cx: &Context) -> Task> { - self.metadata_cache.write().remove(id); - - let db_connection = self.env.clone(); - let bodies = self.bodies; - let metadata = self.metadata; - - let task = cx.background_spawn(async move { - let mut txn = db_connection.write_txn()?; - - metadata.delete(&mut txn, &id)?; - bodies.delete(&mut txn, &id)?; - - if let PromptId::User { uuid } = id { - let prompt_id_v1 = PromptIdV1::from(uuid); - - if let Some(metadata_v1_db) = db_connection - .open_database::, SerdeBincode<()>>( - &txn, - Some("metadata"), - )? - { - metadata_v1_db.delete(&mut txn, &prompt_id_v1)?; - } - - if let Some(bodies_v1_db) = db_connection - .open_database::, SerdeBincode<()>>( - &txn, - Some("bodies"), - )? - { - bodies_v1_db.delete(&mut txn, &prompt_id_v1)?; - } - } - - txn.commit()?; - anyhow::Ok(()) - }); - - cx.spawn(async move |this, cx| { - task.await?; - this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok(); - anyhow::Ok(()) - }) - } - - pub fn metadata(&self, id: PromptId) -> Option { - self.metadata_cache.read().metadata_by_id.get(&id).cloned() - } - - pub fn first(&self) -> Option { - self.metadata_cache.read().metadata.first().cloned() - } - - pub fn id_for_title(&self, title: &str) -> Option { - let metadata_cache = self.metadata_cache.read(); - let metadata = metadata_cache - .metadata - .iter() - .find(|metadata| metadata.title.as_deref() == Some(title))?; - Some(metadata.id) - } - - pub fn search( - &self, - query: String, - cancellation_flag: Arc, - cx: &App, - ) -> Task> { - let cached_metadata = self.metadata_cache.read().metadata.clone(); - let executor = cx.background_executor().clone(); - cx.background_spawn(async move { - let mut matches = if query.is_empty() { - cached_metadata - } else { - let candidates = cached_metadata - .iter() - .enumerate() - .filter_map(|(ix, metadata)| { - Some(StringMatchCandidate::new(ix, metadata.title.as_ref()?)) - }) - .collect::>(); - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - 100, - &cancellation_flag, - executor, - ) - .await; - matches - .into_iter() - .map(|mat| cached_metadata[mat.candidate_id].clone()) - .collect() - }; - matches.sort_by_key(|metadata| Reverse(metadata.default)); - matches - }) - } - - pub fn save( - &self, - id: PromptId, - title: Option, - default: bool, - body: Rope, - cx: &Context, - ) -> Task> { - if !id.can_edit() { - return Task::ready(Err(anyhow!("this prompt cannot be edited"))); - } - - let body = body.to_string(); - let is_default_content = id - .as_built_in() - .is_some_and(|builtin| body.trim() == builtin.default_content().trim()); - - let metadata = if let Some(builtin) = id.as_built_in() { - PromptMetadata::builtin(builtin) - } else { - PromptMetadata { - id, - title, - default, - saved_at: Utc::now(), - } - }; - - self.metadata_cache.write().insert(metadata.clone()); - - let db_connection = self.env.clone(); - let bodies = self.bodies; - let metadata_db = self.metadata; - - let task = cx.background_spawn(async move { - let mut txn = db_connection.write_txn()?; - - if is_default_content { - metadata_db.delete(&mut txn, &id)?; - bodies.delete(&mut txn, &id)?; - } else { - metadata_db.put(&mut txn, &id, &metadata)?; - bodies.put(&mut txn, &id, &body)?; - } - - txn.commit()?; - - anyhow::Ok(()) - }); - - cx.spawn(async move |this, cx| { - task.await?; - this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok(); - anyhow::Ok(()) - }) - } - - pub fn save_metadata( - &self, - id: PromptId, - mut title: Option, - default: bool, - cx: &Context, - ) -> Task> { - let mut cache = self.metadata_cache.write(); - - if !id.can_edit() { - title = cache - .metadata_by_id - .get(&id) - .and_then(|metadata| metadata.title.clone()); - } - - let prompt_metadata = PromptMetadata { - id, - title, - default, - saved_at: Utc::now(), - }; - - cache.insert(prompt_metadata.clone()); - - let db_connection = self.env.clone(); - let metadata = self.metadata; - - let task = cx.background_spawn(async move { - let mut txn = db_connection.write_txn()?; - metadata.put(&mut txn, &id, &prompt_metadata)?; - txn.commit()?; - - anyhow::Ok(()) - }); - - cx.spawn(async move |this, cx| { - task.await?; - this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok(); - anyhow::Ok(()) - }) - } } /// Deprecated: Legacy V1 prompt ID format, used only for migrating data from old databases. Use `PromptId` instead. @@ -608,7 +358,7 @@ mod tests { use gpui::TestAppContext; #[gpui::test] - async fn test_built_in_prompt_load_save(cx: &mut TestAppContext) { + async fn test_built_in_prompt_load(cx: &mut TestAppContext) { cx.executor().allow_parking(); let temp_dir = tempfile::tempdir().unwrap(); @@ -632,265 +382,14 @@ mod tests { "Loading a built-in prompt not in DB should return default content" ); - let metadata = store.read_with(cx, |store, _| store.metadata(commit_message_id)); - assert!( - metadata.is_some(), - "Built-in prompt should always have metadata" - ); assert!( store.read_with(cx, |store, _| { store - .metadata_cache - .read() - .metadata_by_id - .contains_key(&commit_message_id) + .all_prompt_metadata() + .iter() + .any(|metadata| metadata.id == commit_message_id) }), "Built-in prompt should always be in cache" ); - - let custom_content = "Custom commit message prompt"; - store - .update(cx, |store, cx| { - store.save( - commit_message_id, - Some("Commit message".into()), - false, - Rope::from(custom_content), - cx, - ) - }) - .await - .unwrap(); - - let loaded_custom = store - .update(cx, |store, cx| store.load(commit_message_id, cx)) - .await - .unwrap(); - assert_eq!( - loaded_custom.trim(), - custom_content.trim(), - "Custom content should be loaded after saving" - ); - - assert!( - store - .read_with(cx, |store, _| store.metadata(commit_message_id)) - .is_some(), - "Built-in prompt should have metadata after customization" - ); - - store - .update(cx, |store, cx| { - store.save( - commit_message_id, - Some("Commit message".into()), - false, - Rope::from(BuiltInPrompt::CommitMessage.default_content()), - cx, - ) - }) - .await - .unwrap(); - - let metadata_after_reset = - store.read_with(cx, |store, _| store.metadata(commit_message_id)); - assert!( - metadata_after_reset.is_some(), - "Built-in prompt should still have metadata after reset" - ); - assert_eq!( - metadata_after_reset - .as_ref() - .and_then(|m| m.title.as_ref().map(|t| t.as_ref())), - Some("Commit message"), - "Built-in prompt should have default title after reset" - ); - - let loaded_after_reset = store - .update(cx, |store, cx| store.load(commit_message_id, cx)) - .await - .unwrap(); - let mut expected_content_after_reset = - BuiltInPrompt::CommitMessage.default_content().to_string(); - LineEnding::normalize(&mut expected_content_after_reset); - assert_eq!( - loaded_after_reset.trim(), - expected_content_after_reset.trim(), - "Content should be back to default after saving default content" - ); - } - - /// Test that the prompt store initializes successfully even when the database - /// contains records with incompatible/undecodable PromptId keys (e.g., from - /// a different branch that used a different serialization format). - /// - /// This is a regression test for the "fail-open" behavior: we should skip - /// bad records rather than failing the entire store initialization. - #[gpui::test] - async fn test_prompt_store_handles_incompatible_db_records(cx: &mut TestAppContext) { - cx.executor().allow_parking(); - - let temp_dir = tempfile::tempdir().unwrap(); - let db_path = temp_dir.path().join("prompts-db-with-bad-records"); - std::fs::create_dir_all(&db_path).unwrap(); - - // First, create the DB and write an incompatible record directly. - // We simulate a record written by a different branch that used - // `{"kind":"CommitMessage"}` instead of `{"kind":"BuiltIn", ...}`. - { - let db_env = unsafe { - heed::EnvOpenOptions::new() - .map_size(1024 * 1024 * 1024) - .max_dbs(4) - .open(&db_path) - .unwrap() - }; - - let mut txn = db_env.write_txn().unwrap(); - // Create the metadata.v2 database with raw bytes so we can write - // an incompatible key format. - let metadata_db: Database = db_env - .create_database(&mut txn, Some("metadata.v2")) - .unwrap(); - - // Write an incompatible PromptId key: `{"kind":"CommitMessage"}` - // This is the old/branch format that current code can't decode. - let bad_key = br#"{"kind":"CommitMessage"}"#; - let dummy_metadata = br#"{"id":{"kind":"CommitMessage"},"title":"Bad Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#; - metadata_db.put(&mut txn, bad_key, dummy_metadata).unwrap(); - - // Also write a valid record to ensure we can still read good data. - let good_key = br#"{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"}"#; - let good_metadata = br#"{"id":{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"},"title":"Good Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#; - metadata_db.put(&mut txn, good_key, good_metadata).unwrap(); - - txn.commit().unwrap(); - } - - // Now try to create a PromptStore from this DB. - // With fail-open behavior, this should succeed and skip the bad record. - // Without fail-open, this would return an error. - let store_result = cx.update(|cx| PromptStore::new(db_path, cx)).await; - - assert!( - store_result.is_ok(), - "PromptStore should initialize successfully even with incompatible DB records. \ - Got error: {:?}", - store_result.err() - ); - - let store = cx.new(|_cx| store_result.unwrap()); - - // Verify the good record was loaded. - let good_id = PromptId::User { - uuid: UserPromptId("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()), - }; - let metadata = store.read_with(cx, |store, _| store.metadata(good_id)); - assert!( - metadata.is_some(), - "Valid records should still be loaded after skipping bad ones" - ); - assert_eq!( - metadata - .as_ref() - .and_then(|m| m.title.as_ref().map(|t| t.as_ref())), - Some("Good Record"), - "Valid record should have correct title" - ); - } - - #[gpui::test] - async fn test_deleted_prompt_does_not_reappear_after_migration(cx: &mut TestAppContext) { - cx.executor().allow_parking(); - - let temp_dir = tempfile::tempdir().unwrap(); - let db_path = temp_dir.path().join("prompts-db-v1-migration"); - std::fs::create_dir_all(&db_path).unwrap(); - - let prompt_uuid: Uuid = "550e8400-e29b-41d4-a716-446655440001".parse().unwrap(); - let prompt_id_v1 = PromptIdV1(prompt_uuid); - let prompt_id_v2 = PromptId::User { - uuid: UserPromptId(prompt_uuid), - }; - - // Create V1 database with a prompt - { - let db_env = unsafe { - heed::EnvOpenOptions::new() - .map_size(1024 * 1024 * 1024) - .max_dbs(4) - .open(&db_path) - .unwrap() - }; - - let mut txn = db_env.write_txn().unwrap(); - - let metadata_v1_db: Database, SerdeBincode> = - db_env.create_database(&mut txn, Some("metadata")).unwrap(); - - let bodies_v1_db: Database, SerdeBincode> = - db_env.create_database(&mut txn, Some("bodies")).unwrap(); - - let metadata_v1 = PromptMetadataV1 { - id: prompt_id_v1.clone(), - title: Some("V1 Prompt".into()), - default: false, - saved_at: Utc::now(), - }; - - metadata_v1_db - .put(&mut txn, &prompt_id_v1, &metadata_v1) - .unwrap(); - bodies_v1_db - .put(&mut txn, &prompt_id_v1, &"V1 prompt body".to_string()) - .unwrap(); - - txn.commit().unwrap(); - } - - // Migrate V1 to V2 by creating PromptStore - let store = cx - .update(|cx| PromptStore::new(db_path.clone(), cx)) - .await - .unwrap(); - let store = cx.new(|_cx| store); - - // Verify the prompt was migrated - let metadata = store.read_with(cx, |store, _| store.metadata(prompt_id_v2)); - assert!(metadata.is_some(), "V1 prompt should be migrated to V2"); - assert_eq!( - metadata - .as_ref() - .and_then(|m| m.title.as_ref().map(|t| t.as_ref())), - Some("V1 Prompt"), - "Migrated prompt should have correct title" - ); - - // Delete the prompt - store - .update(cx, |store, cx| store.delete(prompt_id_v2, cx)) - .await - .unwrap(); - - // Verify prompt is deleted - let metadata_after_delete = store.read_with(cx, |store, _| store.metadata(prompt_id_v2)); - assert!( - metadata_after_delete.is_none(), - "Prompt should be deleted from V2" - ); - - drop(store); - - // "Restart" by creating a new PromptStore from the same path - let store_after_restart = cx.update(|cx| PromptStore::new(db_path, cx)).await.unwrap(); - let store_after_restart = cx.new(|_cx| store_after_restart); - - // Test the prompt does not reappear - let metadata_after_restart = - store_after_restart.read_with(cx, |store, _| store.metadata(prompt_id_v2)); - assert!( - metadata_after_restart.is_none(), - "Deleted prompt should NOT reappear after restart/migration" - ); } } From c30d18b10d5aa64212f75ef4c8efd5c63aa35b35 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 29 May 2026 09:58:02 -0600 Subject: [PATCH 86/93] scheduler: Add spawn_dedicated for single-threaded actors with !Send state (#57609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `scheduler::spawn_dedicated_thread` (and inherent `spawn_dedicated` methods on `PlatformScheduler` and `TestScheduler`) so single-threaded actors that own `!Send` state can run on their own OS thread and freely do blocking I/O without disturbing any other executor. ### Why A single-threaded actor that needs to do blocking syscalls is currently stuck: it can't run on the shared foreground executor (blocking would stall every other foreground session), and it can't move to the background pool because its state isn't `Send`. `spawn_dedicated` gives each such actor its own thread and its own `LocalExecutor`, while still participating in the same testable scheduler infrastructure as everything else. ### Shape - `pub fn spawn_dedicated_thread(session_id, scheduler, f) -> Task<_>` in `scheduler`. Owns the OS thread, the per-session runnable channel, and the `LocalExecutor` setup. - Inherent `spawn_dedicated` on `PlatformScheduler` (allocates its own `SessionId`, delegates to the free function). - Inherent `spawn_dedicated` on `TestScheduler` (no real thread — runs as a fresh local session driven by the test scheduler's run loop, so determinism under `many` is preserved). - Renames `Scheduler::schedule_foreground` → `schedule_local` and `scheduler::ForegroundExecutor` → `scheduler::LocalExecutor` to reflect that these are session-pinned queues rather than "the main thread" (a dedicated session runs on its own thread). GPUI's wrapper `gpui::ForegroundExecutor` and the `foreground_executor` field/method names are unchanged to keep blast radius small. - `LocalExecutor::new` now takes an explicit dispatch closure, so the routing decision (default session, dedicated thread, or something else) lives at the construction site. ### Tests - `TestScheduler` side: round-trip, `!Send` future, `Send` closure capturing shared state, inner `executor.spawn`, determinism under `many` seeds, drop-cancels-future, detached child runs after root completes. - `PlatformScheduler` side: real separate thread (blocking syscalls don't stall the test), `!Send` future output, drop-cancels-future, thread tears down after work completes, detached child outlives root. cc @as-cii Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra Co-authored-by: Conrad Irwin --- crates/channel/src/channel_store.rs | 2 +- crates/gpui/src/executor.rs | 31 ++- crates/gpui/src/platform/test/dispatcher.rs | 3 +- crates/gpui/src/platform_scheduler.rs | 293 +++++++++++++++++++- crates/remote/src/remote_client.rs | 2 +- crates/scheduler/src/executor.rs | 180 +++++++++++- crates/scheduler/src/scheduler.rs | 84 +++++- crates/scheduler/src/test_scheduler.rs | 45 ++- crates/scheduler/src/tests.rs | 231 +++++++++++++++ 9 files changed, 830 insertions(+), 41 deletions(-) diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index a1b4fb622e7..e73809e6047 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -143,7 +143,7 @@ pub enum ChannelEvent { impl EventEmitter for ChannelStore {} -enum OpenEntityHandle { +enum OpenEntityHandle { Open(WeakEntity), Loading(Shared, Arc>>>), } diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index c1afce81073..7f8d7da0d48 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -6,9 +6,7 @@ use scheduler::Instant; use scheduler::Scheduler; use std::{future::Future, marker::PhantomData, mem, pin::Pin, rc::Rc, sync::Arc, time::Duration}; -pub use scheduler::{ - FallibleTask, ForegroundExecutor as SchedulerForegroundExecutor, Priority, Task, -}; +pub use scheduler::{FallibleTask, LocalExecutor as SchedulerLocalExecutor, Priority, Task}; /// A pointer to the executor that is currently running, /// for spawning background tasks. @@ -22,7 +20,7 @@ pub struct BackgroundExecutor { /// for spawning tasks on the main thread. #[derive(Clone)] pub struct ForegroundExecutor { - inner: scheduler::ForegroundExecutor, + inner: scheduler::LocalExecutor, dispatcher: Arc, not_send: PhantomData>, } @@ -280,18 +278,29 @@ impl ForegroundExecutor { ) } else { let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone())); - let session_id = platform_scheduler.allocate_session_id(); - (platform_scheduler, session_id) + let inner = platform_scheduler.foreground_executor(); + return Self { + inner, + dispatcher, + not_send: PhantomData, + }; }; #[cfg(not(any(test, feature = "test-support")))] - let (scheduler, session_id): (Arc, _) = { + let inner = { let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone())); - let session_id = platform_scheduler.allocate_session_id(); - (platform_scheduler, session_id) + platform_scheduler.foreground_executor() }; - let inner = scheduler::ForegroundExecutor::new(session_id, scheduler); + #[cfg(any(test, feature = "test-support"))] + let inner = { + let scheduler_for_dispatch = Arc::downgrade(&scheduler); + scheduler::LocalExecutor::new(session_id, scheduler, move |runnable| { + if let Some(scheduler) = scheduler_for_dispatch.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }) + }; Self { inner, @@ -366,7 +375,7 @@ impl ForegroundExecutor { } #[doc(hidden)] - pub fn scheduler_executor(&self) -> SchedulerForegroundExecutor { + pub fn scheduler_executor(&self) -> SchedulerLocalExecutor { self.inner.clone() } } diff --git a/crates/gpui/src/platform/test/dispatcher.rs b/crates/gpui/src/platform/test/dispatcher.rs index 29aff84ff9d..ef662c6c488 100644 --- a/crates/gpui/src/platform/test/dispatcher.rs +++ b/crates/gpui/src/platform/test/dispatcher.rs @@ -139,8 +139,7 @@ impl PlatformDispatcher for TestDispatcher { } fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { - self.scheduler - .schedule_foreground(self.session_id, runnable); + self.scheduler.schedule_local(self.session_id, runnable); } fn dispatch_after(&self, _duration: Duration, _runnable: RunnableVariant) { diff --git a/crates/gpui/src/platform_scheduler.rs b/crates/gpui/src/platform_scheduler.rs index 0087c588d8d..6a3929e91bb 100644 --- a/crates/gpui/src/platform_scheduler.rs +++ b/crates/gpui/src/platform_scheduler.rs @@ -3,10 +3,14 @@ use async_task::Runnable; use chrono::{DateTime, Utc}; use futures::channel::oneshot; use scheduler::Instant; -use scheduler::{Clock, Priority, Scheduler, SessionId, TestScheduler, Timer}; +use scheduler::{ + Clock, LocalExecutor, Priority, Scheduler, SessionId, Task, TestScheduler, Timer, + spawn_dedicated_thread, +}; #[cfg(not(target_family = "wasm"))] use std::task::{Context, Poll}; use std::{ + any::Any, future::Future, pin::Pin, sync::{ @@ -35,7 +39,17 @@ impl PlatformScheduler { } } - pub fn allocate_session_id(&self) -> SessionId { + pub fn foreground_executor(self: &Arc) -> LocalExecutor { + let session_id = self.next_session_id(); + let scheduler = Arc::downgrade(self); + LocalExecutor::new(session_id, self.clone(), move |runnable| { + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }) + } + + fn next_session_id(&self) -> SessionId { SessionId::new(self.next_session_id.fetch_add(1, Ordering::SeqCst)) } } @@ -90,7 +104,7 @@ impl Scheduler for PlatformScheduler { } } - fn schedule_foreground(&self, _session_id: SessionId, runnable: Runnable) { + fn schedule_local(&self, _session_id: SessionId, runnable: Runnable) { self.dispatcher .dispatch_on_main_thread(runnable, Priority::default()); } @@ -133,6 +147,21 @@ impl Scheduler for PlatformScheduler { self.clock.clone() } + fn spawn_dedicated( + self: Arc, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task> { + let session_id = self.next_session_id(); + spawn_dedicated_thread(session_id, self, move |executor| f(executor)) + } + fn as_test(&self) -> Option<&TestScheduler> { None } @@ -152,3 +181,261 @@ impl Clock for PlatformClock { self.dispatcher.now() } } + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + use crate::{RunnableVariant, ThreadTaskTimings}; + use scheduler::BackgroundExecutor; + use std::time::Instant as StdInstant; + + // `spawn_dedicated` shouldn't touch the platform dispatcher at all; + // panicking on every method ensures the test catches it if it does. + struct SmokeDispatcher; + + impl PlatformDispatcher for SmokeDispatcher { + fn get_all_timings(&self) -> Vec { + Vec::new() + } + fn get_current_thread_timings(&self) -> ThreadTaskTimings { + ThreadTaskTimings { + thread_name: None, + thread_id: std::thread::current().id(), + timings: Vec::new(), + total_pushed: 0, + } + } + fn is_main_thread(&self) -> bool { + false + } + fn dispatch(&self, _runnable: RunnableVariant, _priority: Priority) { + panic!("SmokeDispatcher should not be asked to dispatch in this test"); + } + fn dispatch_on_main_thread(&self, _runnable: RunnableVariant, _priority: Priority) { + panic!("SmokeDispatcher does not implement a main thread"); + } + fn dispatch_after(&self, _duration: Duration, _runnable: RunnableVariant) { + panic!("SmokeDispatcher does not implement timers"); + } + fn spawn_realtime(&self, _f: Box) { + panic!("SmokeDispatcher does not implement realtime"); + } + } + + #[test] + fn spawn_dedicated_runs_on_a_real_separate_thread() { + let background = + BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher)))); + let started = StdInstant::now(); + let task = background.spawn_dedicated(|_executor| async move { + // A genuine blocking syscall on the dedicated thread. If + // `spawn_dedicated` were running the future on any shared + // executor, this would stall that executor. + let thread_id_before = std::thread::current().id(); + std::thread::sleep(Duration::from_millis(50)); + let thread_id_after = std::thread::current().id(); + assert_eq!(thread_id_before, thread_id_after); + (thread_id_before, "slept") + }); + let (dedicated_thread_id, message) = futures::executor::block_on(task); + let elapsed = started.elapsed(); + assert_eq!(message, "slept"); + assert_ne!( + dedicated_thread_id, + std::thread::current().id(), + "dedicated future ran on the test thread" + ); + assert!( + elapsed >= Duration::from_millis(40), + "expected the dedicated thread to genuinely sleep, elapsed = {:?}", + elapsed + ); + } + + #[test] + fn spawn_dedicated_returns_not_send_future_output() { + // The whole point of `spawn_dedicated` is that the future can be + // `!Send`. Constructing one with `Rc>` ensures the + // signature actually permits it. + use std::cell::RefCell; + use std::rc::Rc; + + let background = + BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher)))); + let task = background.spawn_dedicated(|_executor| async move { + let state = Rc::new(RefCell::new(0_i32)); + for _ in 0..3 { + *state.borrow_mut() += 1; + } + *state.borrow() + }); + let output = futures::executor::block_on(task); + assert_eq!(output, 3); + } + + #[test] + fn spawn_dedicated_dropping_task_cancels_future() { + use parking_lot::Mutex; + use std::sync::mpsc; + + let background = + BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher)))); + + let (started_tx, started_rx) = mpsc::channel::<()>(); + let (after_park_tx, after_park_rx) = mpsc::channel::<()>(); + let observed_post_await_write = Arc::new(Mutex::new(false)); + + let task = { + let observed_post_await_write = observed_post_await_write.clone(); + background.spawn_dedicated(move |_executor| async move { + // Announce that the future is live on the dedicated thread. + started_tx + .send(()) + .expect("started signal must be received"); + // Park forever. Dropping the `Task` must cancel us here so + // the code below this `await` never runs. + futures::future::pending::<()>().await; + *observed_post_await_write.lock() = true; + after_park_tx + .send(()) + .expect("after-park signal must be received"); + }) + }; + + // Wait until the dedicated future is actually parked at the await. + started_rx + .recv_timeout(Duration::from_secs(2)) + .expect("dedicated future failed to start"); + + // Drop the root Task: this must cancel the future. + drop(task); + + // If cancellation works, the future never advances past `pending`, + // so this recv must time out. + assert!( + after_park_rx + .recv_timeout(Duration::from_millis(100)) + .is_err(), + "dedicated future advanced past the await after its Task was dropped" + ); + assert!( + !*observed_post_await_write.lock(), + "dedicated future ran code past the cancellation point" + ); + } + + #[test] + fn spawn_dedicated_thread_tears_down_after_work_completes() { + use std::sync::mpsc; + + // Fires from `Drop` so we observe teardown of the dedicated future's + // captured state on whichever thread runs its destructor. + struct DropSignal { + tx: Option>, + } + impl Drop for DropSignal { + fn drop(&mut self) { + if let Some(tx) = self.tx.take() { + let _ = tx.send(std::thread::current().id()); + } + } + } + + let background = + BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher)))); + let (started_tx, started_rx) = mpsc::channel::(); + let (drop_tx, drop_rx) = mpsc::channel::(); + + let task = background.spawn_dedicated(move |_executor| async move { + // Captured by the future's state. When the future completes and + // its state is dropped on the dedicated thread, this guard's + // `Drop` fires and reports the thread id it ran on. + let _guard = DropSignal { tx: Some(drop_tx) }; + started_tx + .send(std::thread::current().id()) + .expect("started signal must be received"); + // Future returns immediately. The dedicated thread should then + // drop the future (firing _guard), exit the recv loop, and exit. + }); + + let dedicated_thread_id = started_rx + .recv_timeout(Duration::from_secs(2)) + .expect("dedicated future failed to start"); + assert_ne!( + dedicated_thread_id, + std::thread::current().id(), + "dedicated future ran on the test thread" + ); + + // Drive the root task to completion so its body finishes. + futures::executor::block_on(task); + + // The guard's drop runs from the dedicated thread as it tears down + // the future's captured state. If the executor/recv-loop were + // keeping the future alive past task completion, this would hang. + let drop_thread_id = drop_rx + .recv_timeout(Duration::from_secs(2)) + .expect("dedicated future's captured state was not dropped after task completion"); + assert_eq!( + drop_thread_id, dedicated_thread_id, + "dedicated future's captured state must be dropped on the dedicated thread, not elsewhere" + ); + } + + #[test] + fn spawn_dedicated_detached_child_outlives_root() { + use std::sync::mpsc; + + let background = + BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher)))); + + // `gate_rx` lets the detached child park until the test explicitly + // releases it — after we've already observed the root completing. + let (gate_tx, gate_rx) = mpsc::channel::<()>(); + let (child_done_tx, child_done_rx) = mpsc::channel::(); + + let task = background.spawn_dedicated(move |executor| async move { + executor + .spawn(async move { + // Blocking on `recv` is normally wrong inside an + // executor, but the dedicated thread is exclusive to + // this session, so blocking the only future on it is + // fine — this is the property `spawn_dedicated` is + // designed to provide. + gate_rx + .recv() + .expect("gate sender dropped before child resumed"); + child_done_tx + .send(std::thread::current().id()) + .expect("child_done receiver dropped"); + }) + .detach(); + // Root finishes here. The detached child must keep the + // dedicated thread alive until it completes. + }); + + futures::executor::block_on(task); + + // Negative assertion: the child has not finished, because the gate + // hasn't been released yet. + assert!( + child_done_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "detached child finished before being released" + ); + + // Release the gate. The detached child should now complete on the + // dedicated thread. + gate_tx.send(()).expect("gate receiver dropped"); + + let child_thread_id = child_done_rx + .recv_timeout(Duration::from_secs(2)) + .expect("detached child failed to complete after gate was released"); + assert_ne!( + child_thread_id, + std::thread::current().id(), + "detached child ran on the test thread instead of the dedicated thread" + ); + } +} diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index 85e07aee0b4..993872b179f 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -1555,7 +1555,7 @@ type ResponseChannels = Mutex, oneshot::Sender<()>)>>>>; -struct Signal { +struct Signal { tx: Mutex>>, rx: Shared>>, } diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index 2a678ec3b25..e5474a4cf03 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -1,5 +1,7 @@ use crate::{Instant, Priority, RunnableMeta, Scheduler, SessionId, Timer}; +use async_task::Runnable; use std::{ + any::Any, future::Future, marker::PhantomData, mem::ManuallyDrop, @@ -12,18 +14,39 @@ use std::{ time::Duration, }; +/// A `!Send` executor pinned to a single session. Tasks spawned on it run in +/// order on whichever thread drains the dispatch destination supplied at +/// construction time — typically the main thread for the default session, or +/// a dedicated OS thread for sessions created by `spawn_dedicated_thread`. #[derive(Clone)] -pub struct ForegroundExecutor { +pub struct LocalExecutor { session_id: SessionId, scheduler: Arc, + // Spawned tasks' schedule callbacks each hold an `Arc` clone of this + // closure, so the destination it captures stays alive as long as work + // could still land on it. + dispatch: Arc) + Send + Sync>, not_send: PhantomData>, } -impl ForegroundExecutor { - pub fn new(session_id: SessionId, scheduler: Arc) -> Self { +impl LocalExecutor { + /// Constructs a local executor that runs spawned tasks by sending their + /// runnables through `dispatch`. The `scheduler` is retained for access to + /// clocks, timers, and other scheduler-level services. + /// + /// For the common case of routing runnables through + /// `Scheduler::schedule_local`, callers pass a closure that does exactly + /// that. `spawn_dedicated_thread` instead passes a closure that sends to + /// the dedicated thread's channel. + pub fn new( + session_id: SessionId, + scheduler: Arc, + dispatch: impl Fn(Runnable) + Send + Sync + 'static, + ) -> Self { Self { session_id, scheduler, + dispatch: Arc::new(dispatch), not_send: PhantomData, } } @@ -42,16 +65,11 @@ impl ForegroundExecutor { F: Future + 'static, F::Output: 'static, { - let session_id = self.session_id; - let scheduler = Arc::downgrade(&self.scheduler); + let dispatch = self.dispatch.clone(); let location = Location::caller(); let (runnable, task) = spawn_local_with_source_location( future, - move |runnable| { - if let Some(scheduler) = scheduler.upgrade() { - scheduler.schedule_foreground(session_id, runnable); - } - }, + move |runnable| dispatch(runnable), RunnableMeta { location }, ); runnable.schedule(); @@ -110,6 +128,48 @@ impl ForegroundExecutor { pub fn now(&self) -> Instant { self.scheduler.clock().now() } + + /// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`]. + /// The closure runs on a new OS thread under `PlatformScheduler`, or on + /// the test scheduler's loop under `TestScheduler`. + /// + /// The returned `Task` represents the dedicated work: dropping it cancels + /// the dedicated closure, `.await`ing it yields the closure's return + /// value, `.detach()`ing it lets the dedicated work run independently of + /// the caller. + #[track_caller] + pub fn spawn_dedicated(&self, f: F) -> Task + where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + Sync + 'static, + { + self.scheduler + .clone() + .spawn_dedicated(box_dedicated(f)) + .downcast::() + } +} + +/// Boxes the user-supplied dedicated closure into the type-erased shape +/// expected by [`Scheduler::spawn_dedicated`]. The user's `Fut::Output` is +/// boxed as `Box` on the dedicated side and downcast +/// back to `Fut::Output` by [`Task::downcast`] in the wrapper. +fn box_dedicated( + f: F, +) -> Box< + dyn FnOnce(LocalExecutor) -> Pin> + 'static>> + + Send + + 'static, +> +where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + Sync + 'static, +{ + Box::new(move |executor| { + Box::pin(async move { Box::new(f(executor).await) as Box }) + }) } #[derive(Clone)] @@ -193,6 +253,27 @@ impl BackgroundExecutor { pub fn scheduler(&self) -> &Arc { &self.scheduler } + + /// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`]. + /// The closure runs on a new OS thread under `PlatformScheduler`, or on + /// the test scheduler's loop under `TestScheduler`. + /// + /// The returned `Task` represents the dedicated work: dropping it cancels + /// the dedicated closure, `.await`ing it yields the closure's return + /// value, `.detach()`ing it lets the dedicated work run independently of + /// the caller. + #[track_caller] + pub fn spawn_dedicated(&self, f: F) -> Task + where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + Sync + 'static, + { + self.scheduler + .clone() + .spawn_dedicated(box_dedicated(f)) + .downcast::() + } } /// Task is a primitive that allows work to happen in the background. @@ -202,16 +283,22 @@ impl BackgroundExecutor { /// If you drop a task it will be cancelled immediately. Calling [`Task::detach`] allows /// the task to continue running, but with no way to return a value. #[must_use] -#[derive(Debug)] pub struct Task(TaskState); -#[derive(Debug)] enum TaskState { /// A task that is ready to return a value Ready(Option), /// A task that is currently running. Spawned(async_task::Task), + + /// A typed view of a [`Task>`] obtained via + /// [`Task::downcast`]. The inner task drives the actual work; the + /// downcast layer just unwraps the `Box` on poll. + Downcast { + inner: Box>>, + marker: PhantomData T>, + }, } impl Task { @@ -229,6 +316,7 @@ impl Task { match &self.0 { TaskState::Ready(_) => true, TaskState::Spawned(task) => task.is_finished(), + TaskState::Downcast { inner, .. } => inner.is_ready(), } } @@ -237,6 +325,7 @@ impl Task { match self { Task(TaskState::Ready(_)) => {} Task(TaskState::Spawned(task)) => task.detach(), + Task(TaskState::Downcast { inner, .. }) => inner.detach(), } } @@ -245,10 +334,43 @@ impl Task { FallibleTask(match self.0 { TaskState::Ready(val) => FallibleTaskState::Ready(val), TaskState::Spawned(task) => FallibleTaskState::Spawned(task.fallible()), + TaskState::Downcast { inner, .. } => FallibleTaskState::Downcast { + inner: Box::new(inner.fallible()), + marker: PhantomData, + }, }) } } +impl Task> { + /// Reinterprets the boxed output as a concrete `T` via downcast on + /// completion. Used by [`LocalExecutor::spawn_dedicated`] and + /// [`BackgroundExecutor::spawn_dedicated`] to recover the user closure's + /// `Fut::Output` from the dyn-safe [`Scheduler::spawn_dedicated`]. + /// + /// Panics on poll if the inner output is not in fact a `T` -- a logic + /// error in whatever produced the inner task, since the downcast type is + /// chosen by the caller of `downcast`. + pub fn downcast(self) -> Task { + Task(TaskState::Downcast { + inner: Box::new(self), + marker: PhantomData, + }) + } +} + +impl std::fmt::Debug for Task { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + TaskState::Ready(_) => f.debug_tuple("Task::Ready").finish(), + TaskState::Spawned(task) => f.debug_tuple("Task::Spawned").field(task).finish(), + TaskState::Downcast { inner, .. } => { + f.debug_tuple("Task::Downcast").field(inner).finish() + } + } + } +} + /// A task that returns `Option` instead of panicking when cancelled. #[must_use] pub struct FallibleTask(FallibleTaskState); @@ -259,6 +381,12 @@ enum FallibleTaskState { /// A task that is currently running (wraps async_task::FallibleTask). Spawned(async_task::FallibleTask), + + /// Mirror of [`TaskState::Downcast`] for fallible tasks. + Downcast { + inner: Box>>, + marker: PhantomData T>, + }, } impl FallibleTask { @@ -272,17 +400,29 @@ impl FallibleTask { match self.0 { FallibleTaskState::Ready(_) => {} FallibleTaskState::Spawned(task) => task.detach(), + FallibleTaskState::Downcast { inner, .. } => inner.detach(), } } } -impl Future for FallibleTask { +impl Future for FallibleTask { type Output = Option; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { match unsafe { self.get_unchecked_mut() } { FallibleTask(FallibleTaskState::Ready(val)) => Poll::Ready(val.take()), FallibleTask(FallibleTaskState::Spawned(task)) => Pin::new(task).poll(cx), + FallibleTask(FallibleTaskState::Downcast { inner, .. }) => { + match Pin::new(inner.as_mut()).poll(cx) { + Poll::Ready(Some(boxed_any)) => Poll::Ready(Some( + *boxed_any + .downcast::() + .expect("FallibleTask::poll: downcast type mismatch"), + )), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } } } } @@ -294,17 +434,29 @@ impl std::fmt::Debug for FallibleTask { FallibleTaskState::Spawned(task) => { f.debug_tuple("FallibleTask::Spawned").field(task).finish() } + FallibleTaskState::Downcast { inner, .. } => f + .debug_tuple("FallibleTask::Downcast") + .field(inner) + .finish(), } } } -impl Future for Task { +impl Future for Task { type Output = T; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { match unsafe { self.get_unchecked_mut() } { Task(TaskState::Ready(val)) => Poll::Ready(val.take().unwrap()), Task(TaskState::Spawned(task)) => Pin::new(task).poll(cx), + Task(TaskState::Downcast { inner, .. }) => match Pin::new(inner.as_mut()).poll(cx) { + Poll::Ready(boxed_any) => Poll::Ready( + *boxed_any + .downcast::() + .expect("Task::poll: downcast type mismatch"), + ), + Poll::Pending => Poll::Pending, + }, } } } diff --git a/crates/scheduler/src/scheduler.rs b/crates/scheduler/src/scheduler.rs index 05d285df8d9..92125456232 100644 --- a/crates/scheduler/src/scheduler.rs +++ b/crates/scheduler/src/scheduler.rs @@ -11,11 +11,13 @@ pub use test_scheduler::*; use async_task::Runnable; use futures::channel::oneshot; use std::{ + any::Any, future::Future, panic::Location, pin::Pin, sync::Arc, task::{Context, Poll}, + thread, time::Duration, }; @@ -82,7 +84,11 @@ pub trait Scheduler: Send + Sync { timeout: Option, ) -> bool; - fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable); + /// Schedule a runnable on the local (session-pinned) queue for `session_id`. + /// Runnables scheduled here run in order on whichever thread drains the + /// session — the main thread for ordinary sessions, or a dedicated OS + /// thread for sessions created via `spawn_dedicated_thread`. + fn schedule_local(&self, session_id: SessionId, runnable: Runnable); /// Schedule a background task with the given priority. fn schedule_background_with_priority( @@ -103,11 +109,87 @@ pub trait Scheduler: Send + Sync { fn timer(&self, timeout: Duration) -> Timer; fn clock(&self) -> Arc; + /// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`]. + /// + /// `PlatformScheduler` runs the closure on a new OS thread (see + /// [`spawn_dedicated_thread`]). `TestScheduler` runs it on the test + /// scheduler's loop alongside everything else so determinism under + /// `TestScheduler::many` is preserved. + /// + /// This is the dyn-safe entry point: the closure's output is type-erased + /// as `Box` so the trait stays object-safe. + /// Callers typically reach for the type-safe wrappers on + /// [`LocalExecutor::spawn_dedicated`] and + /// [`BackgroundExecutor::spawn_dedicated`], which compose this method + /// with [`Task::downcast`] to recover the closure's concrete return type. + fn spawn_dedicated( + self: Arc, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task>; + fn as_test(&self) -> Option<&TestScheduler> { None } } +/// Spawn work on a fresh OS thread that's exclusive to the returned task and +/// anything spawned on the executor it provides. Blocking syscalls inside that +/// work don't disturb any other executor in the process. +/// +/// `f` is called on the dedicated thread with a [`LocalExecutor`] pinned +/// to it. The future `f` returns may freely be `!Send`. The returned `Task` is +/// that future's task: dropping it cancels the root, but detached children +/// keep running until they finish. The thread shuts down once the executor and +/// every task on it are gone. +/// +/// The caller is responsible for supplying a `session_id` that's distinct from +/// every other live session on `scheduler`. Concrete schedulers typically wrap +/// this in an inherent method that allocates the id from their own counter. +pub fn spawn_dedicated_thread( + session_id: SessionId, + scheduler: Arc, + f: F, +) -> Task +where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + 'static, +{ + let (runnable_sender, runnable_receiver) = flume::unbounded::>(); + let (task_sender, task_receiver) = flume::bounded::>(1); + + thread::Builder::new() + .name(format!("spawn_dedicated session {:?}", session_id)) + .spawn(move || { + let dispatch = move |runnable: Runnable| { + let _ = runnable_sender.send(runnable); + }; + let executor = LocalExecutor::new(session_id, scheduler, dispatch); + let root_task = executor.spawn(f(executor.clone())); + let _ = task_sender.send(root_task); + // After this drop, every strong reference to the runnable sender + // lives inside a spawned task or a user-held executor clone. The + // recv loop exits once all of those are gone. + drop(executor); + + while let Ok(runnable) = runnable_receiver.recv() { + runnable.run(); + } + }) + .expect("failed to spawn dedicated thread"); + + task_receiver + .recv() + .expect("dedicated thread failed to produce root task") +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct SessionId(u16); diff --git a/crates/scheduler/src/test_scheduler.rs b/crates/scheduler/src/test_scheduler.rs index 656d2f63be7..722dfb13587 100644 --- a/crates/scheduler/src/test_scheduler.rs +++ b/crates/scheduler/src/test_scheduler.rs @@ -1,6 +1,6 @@ use crate::{ - BackgroundExecutor, Clock, ForegroundExecutor, Instant, Priority, RunnableMeta, Scheduler, - SessionId, TestClock, Timer, + BackgroundExecutor, Clock, Instant, LocalExecutor, Priority, RunnableMeta, Scheduler, + SessionId, Task, TestClock, Timer, }; use async_task::Runnable; use backtrace::{Backtrace, BacktraceFrame}; @@ -10,6 +10,7 @@ use rand::{ distr::{StandardUniform, uniform::SampleRange, uniform::SampleUniform}, prelude::*, }; +use std::any::Any; use std::{ any::type_name_of_val, collections::{BTreeMap, HashSet, VecDeque}, @@ -152,18 +153,21 @@ impl TestScheduler { self.state.lock().is_main_thread } - /// Allocate a new session ID for foreground task scheduling. - /// This is used by GPUI's TestDispatcher to map dispatcher instances to sessions. pub fn allocate_session_id(&self) -> SessionId { let mut state = self.state.lock(); state.next_session_id.0 += 1; state.next_session_id } - /// Create a foreground executor for this scheduler - pub fn foreground(self: &Arc) -> ForegroundExecutor { + /// Create a local executor for this scheduler. + pub fn foreground(self: &Arc) -> LocalExecutor { let session_id = self.allocate_session_id(); - ForegroundExecutor::new(session_id, self.clone()) + let scheduler = Arc::downgrade(self); + LocalExecutor::new(session_id, self.clone(), move |runnable| { + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }) } /// Create a background executor for this scheduler @@ -585,7 +589,7 @@ impl Scheduler for TestScheduler { completed } - fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable) { + fn schedule_local(&self, session_id: SessionId, runnable: Runnable) { assert_correct_thread(&self.thread, &self.state); let mut state = self.state.lock(); let ix = if state.randomize_order { @@ -660,6 +664,31 @@ impl Scheduler for TestScheduler { self.clock.clone() } + /// In the test world, dedicated work is just a fresh local session driven + /// by the test scheduler's run loop alongside everything else. No real + /// thread is spawned, so determinism under `TestScheduler::many` is + /// preserved. + fn spawn_dedicated( + self: Arc, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task> { + let session_id = self.allocate_session_id(); + let scheduler = Arc::downgrade(&self); + let executor = LocalExecutor::new(session_id, self, move |runnable| { + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }); + executor.spawn(f(executor.clone())) + } + fn as_test(&self) -> Option<&TestScheduler> { Some(self) } diff --git a/crates/scheduler/src/tests.rs b/crates/scheduler/src/tests.rs index 8c6bc2bef41..1e29211ca87 100644 --- a/crates/scheduler/src/tests.rs +++ b/crates/scheduler/src/tests.rs @@ -728,3 +728,234 @@ fn test_background_priority_scheduling() { iterations ); } + +#[test] +fn test_spawn_dedicated_basic_round_trip() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|_executor| async { 42 }) + .await + }); + assert_eq!(result, 42); +} + +#[test] +fn test_spawn_dedicated_not_send_future() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|_executor| async move { + // `Rc>` is `!Send`. If `spawn_dedicated` required + // the returned future to be `Send`, this wouldn't compile. + let state = Rc::new(RefCell::new(0_i32)); + for _ in 0..5 { + *state.borrow_mut() += 1; + } + *state.borrow() + }) + .await + }); + assert_eq!(result, 5); +} + +#[test] +fn test_spawn_dedicated_send_closure_captures() { + use parking_lot::Mutex; + + let observed = TestScheduler::once(async |scheduler| { + let shared = Arc::new(Mutex::new(0_i32)); + let shared_for_closure = shared.clone(); + let returned = scheduler + .background() + .spawn_dedicated(move |_executor| { + // `shared_for_closure` crossed the `Send` boundary of the + // closure; we then mutate it from inside the !Send future. + let local = shared_for_closure; + async move { + *local.lock() = 7; + } + }) + .await; + let _: () = returned; + *shared.lock() + }); + assert_eq!(observed, 7); +} + +#[test] +fn test_spawn_dedicated_inner_spawn_local() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|executor| async move { + // The provided executor can spawn additional `!Send` work + // onto the same dedicated session. + let inner = Rc::new(RefCell::new(0_i32)); + let inner_for_child = inner.clone(); + let child = executor.spawn(async move { + *inner_for_child.borrow_mut() = 99; + *inner_for_child.borrow() + }); + child.await + }) + .await + }); + assert_eq!(result, 99); +} + +#[test] +fn test_spawn_dedicated_determinism_under_many() { + use parking_lot::Mutex; + + let outcomes = TestScheduler::many(if cfg!(miri) { 4 } else { 20 }, async |scheduler| { + let trace = Arc::new(Mutex::new(Vec::::new())); + + let background = scheduler.background(); + let mut tasks = Vec::new(); + for id in 0..4_u32 { + let trace = trace.clone(); + let task = background.spawn_dedicated(move |executor| async move { + for step in 0..3 { + trace.lock().push(id * 100 + step); + executor.spawn(async {}).await; + } + id + }); + tasks.push(task); + } + + let mut outputs = Vec::new(); + for task in tasks { + outputs.push(task.await); + } + + (trace.lock().clone(), outputs) + }); + + // Re-running with the same seed should produce the same trace. Run a + // second pass with identical seeds and compare to the first. + let outcomes_replay = TestScheduler::many(if cfg!(miri) { 4 } else { 20 }, async |scheduler| { + let trace = Arc::new(Mutex::new(Vec::::new())); + + let background = scheduler.background(); + let mut tasks = Vec::new(); + for id in 0..4_u32 { + let trace = trace.clone(); + let task = background.spawn_dedicated(move |executor| async move { + for step in 0..3 { + trace.lock().push(id * 100 + step); + executor.spawn(async {}).await; + } + id + }); + tasks.push(task); + } + + let mut outputs = Vec::new(); + for task in tasks { + outputs.push(task.await); + } + + (trace.lock().clone(), outputs) + }); + + assert_eq!( + outcomes, outcomes_replay, + "per-seed outcomes should be reproducible" + ); + + // Sanity: at least one seed produced a non-monotonic trace, + // demonstrating that dedicated tasks really do interleave under the + // scheduler's randomization. + let any_interleaved = outcomes.iter().any(|(trace, _)| { + trace + .windows(2) + .any(|window| window[0] / 100 != window[1] / 100) + }); + assert!( + any_interleaved, + "expected at least one seed to interleave dedicated tasks" + ); +} + +#[test] +fn test_spawn_dedicated_dropping_task_cancels_future() { + use parking_lot::Mutex; + + let counter_after = TestScheduler::once(async |scheduler| { + let counter = Arc::new(Mutex::new(0_u32)); + let (resume_tx, resume_rx) = oneshot::channel::<()>(); + + let task = { + let counter = counter.clone(); + scheduler + .background() + .spawn_dedicated(move |_executor| async move { + *counter.lock() = 1; + // Park here until the test resumes us. If the task is + // dropped before this resolves, the second assignment + // below must never happen. + let _ = resume_rx.await; + *counter.lock() = 2; + }) + }; + + // Let the dedicated future make its first observable step. + scheduler.run(); + assert_eq!(*counter.lock(), 1); + + // Cancel by dropping the root task, then unblock the parked oneshot. + // The future must not advance past the await: counter stays at 1. + drop(task); + let _ = resume_tx.send(()); + scheduler.run(); + + *counter.lock() + }); + + assert_eq!( + counter_after, 1, + "dropping the dedicated task must cancel the root future before its second write" + ); +} + +#[test] +fn test_spawn_dedicated_detached_child_runs_after_root_completes() { + use parking_lot::Mutex; + + let child_ran = TestScheduler::once(async |scheduler| { + let child_ran = Arc::new(Mutex::new(false)); + + let task = { + let child_ran = child_ran.clone(); + scheduler + .background() + .spawn_dedicated(move |executor| async move { + executor + .spawn(async move { + *child_ran.lock() = true; + }) + .detach(); + // Root returns immediately, before the child has had a + // chance to run. + }) + }; + + task.await; + + // Drain the dedicated session. The detached child must run. + scheduler.run(); + + *child_ran.lock() + }); + + assert!( + child_ran, + "detached child must complete after the root, not be cancelled with it" + ); +} + +// The production smoke test for `spawn_dedicated` lives in the `gpui` crate +// alongside `PlatformScheduler`, which is the real production implementation +// of the `Scheduler` trait. See `crates/gpui/src/platform_scheduler.rs`. From 2ea99a81f1582b24b4650a86451f4c199693ce08 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Fri, 29 May 2026 21:38:53 +0530 Subject: [PATCH 87/93] Add new area labels to track mapping (#58083) | Label | Description | |---|---| | `area:preview/csv` | Feedback for Zed's CSV support | | `area:fs` | Related to the fs crate. | | `area:scanning` | Worktree scanning related PRs. | | `area:editor/bookmarks` | Feedback for the editor bookmarks | | `area:ai/agent thread/skills` | Feedback for Zed's AI Skills feature | | `area:ai/terminal threads` | Feedback for Zed's Terminal Threads | | `area:crashes` | PR related to crashes crate. | | `area:scripts` | Changes in "script" directory | Release Notes: - N/A --- script/community-pr-track-mapping.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/script/community-pr-track-mapping.json b/script/community-pr-track-mapping.json index 3b3bfe6168d..274c65d403d 100644 --- a/script/community-pr-track-mapping.json +++ b/script/community-pr-track-mapping.json @@ -27,7 +27,7 @@ }, { "name": "Markdown Preview", - "labels": ["area:preview/markdown", "area:preview/mermaid"] + "labels": ["area:preview/markdown", "area:preview/mermaid", "area:preview/csv"] }, { "name": "NixOS", @@ -88,9 +88,11 @@ "labels": [ "area:command palette", "area:file finder", + "area:fs", "area:navigation", "area:outline", "area:project panel", + "area:scanning", "area:workspace" ] }, @@ -100,6 +102,7 @@ "area:code folding", "area:editor", "area:editor/brackets", + "area:editor/bookmarks", "area:editor/linked edits", "area:multi-buffer", "area:multi-cursor", @@ -135,6 +138,7 @@ "area:ai", "area:ai/acp", "area:ai/agent thread", + "area:ai/agent thread/skills", "area:ai/anthropic", "area:ai/assistant", "area:ai/bedrock", @@ -154,6 +158,7 @@ "area:ai/opencode", "area:ai/openrouter", "area:ai/qwen", + "area:ai/terminal threads", "area:ai/text thread" ] }, @@ -222,6 +227,7 @@ "name": "Performance & Catch-all", "labels": [ "area:cli", + "area:crashes", "area:discoverability", "area:installer-updater", "area:internationalization", @@ -236,6 +242,7 @@ "area:performance", "area:performance/memory leak", "area:release notes", + "area:scripts", "area:security & privacy", "area:security & privacy/workspace trust", "area:serialization", From 122619624d95f7d2c95d280610a4e80459eb9e86 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 29 May 2026 18:28:27 +0200 Subject: [PATCH 88/93] x_ai: Add support for specifying reasoning effort (#58078) See https://docs.x.ai/developers/model-capabilities/text/reasoning#the-reasoning_effort-parameter Closes #58056 Release Notes: - agent: Added support for specifying reasoning effort for Grok 4.3 (xAI) --- crates/language_models/src/provider/x_ai.rs | 139 +++++++++++++++++++- crates/x_ai/src/x_ai.rs | 7 + 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 623853b5214..51eb2e3b81d 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -6,10 +6,10 @@ use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt, Window}; use http_client::HttpClient; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, - env_var, + LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolSchemaFormat, RateLimiter, env_var, }; use open_ai::ResponseStreamEvent; pub use settings::XaiAvailableModel as AvailableModel; @@ -255,6 +255,75 @@ impl XAiLanguageModel { } } +fn x_ai_reasoning_efforts(model: &x_ai::Model) -> &'static [open_ai::ReasoningEffort] { + if model.supports_reasoning_effort() { + &[ + open_ai::ReasoningEffort::None, + open_ai::ReasoningEffort::Low, + open_ai::ReasoningEffort::Medium, + open_ai::ReasoningEffort::High, + ] + } else { + &[] + } +} + +fn default_thinking_reasoning_effort(model: &x_ai::Model) -> Option { + if model.supports_reasoning_effort() { + Some(open_ai::ReasoningEffort::Low) + } else { + None + } +} + +fn reasoning_effort_for_request( + request: &LanguageModelRequest, + model: &x_ai::Model, +) -> Option { + let supported_efforts = x_ai_reasoning_efforts(model); + if supported_efforts.is_empty() { + return None; + } + + if request.thinking_allowed { + request + .thinking_effort + .as_deref() + .and_then(|effort| effort.parse::().ok()) + .filter(|effort| supported_efforts.contains(effort)) + .filter(|effort| *effort != open_ai::ReasoningEffort::None) + .or_else(|| default_thinking_reasoning_effort(model)) + } else if supported_efforts.contains(&open_ai::ReasoningEffort::None) { + Some(open_ai::ReasoningEffort::None) + } else { + None + } +} + +fn supported_thinking_effort_levels(model: &x_ai::Model) -> Vec { + let default_effort = default_thinking_reasoning_effort(model); + x_ai_reasoning_efforts(model) + .iter() + .copied() + .filter_map(|effort| { + let (name, value) = match effort { + open_ai::ReasoningEffort::None => return None, + open_ai::ReasoningEffort::Minimal => ("Minimal", "minimal"), + open_ai::ReasoningEffort::Low => ("Low", "low"), + open_ai::ReasoningEffort::Medium => ("Medium", "medium"), + open_ai::ReasoningEffort::High => ("High", "high"), + open_ai::ReasoningEffort::XHigh => ("Extra High", "xhigh"), + }; + + Some(LanguageModelEffortLevel { + name: name.into(), + value: value.into(), + is_default: Some(effort) == default_effort, + }) + }) + .collect() +} + impl LanguageModel for XAiLanguageModel { fn id(&self) -> LanguageModelId { self.id.clone() @@ -291,6 +360,15 @@ impl LanguageModel for XAiLanguageModel { | LanguageModelToolChoice::None => true, } } + + fn supports_thinking(&self) -> bool { + self.model.supports_reasoning_effort() + } + + fn supported_effort_levels(&self) -> Vec { + supported_thinking_effort_levels(&self.model) + } + fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { if self.model.requires_json_schema_subset() { LanguageModelToolSchemaFormat::JsonSchemaSubset @@ -329,13 +407,14 @@ impl LanguageModel for XAiLanguageModel { LanguageModelCompletionError, >, > { + let reasoning_effort = reasoning_effort_for_request(&request, &self.model); let request = crate::provider::open_ai::into_open_ai( request, self.model.id(), self.model.supports_parallel_tool_calls(), self.model.supports_prompt_cache_key(), self.max_output_tokens(), - None, + reasoning_effort, false, ); let completions = self.stream_completion(request, cx); @@ -428,6 +507,56 @@ impl ConfigurationView { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grok_43_supports_selectable_thinking_effort_levels() { + let effort_levels = supported_thinking_effort_levels(&x_ai::Model::Grok43); + let values = effort_levels + .iter() + .map(|level| level.value.as_ref()) + .collect::>(); + + assert_eq!(values, ["low", "medium", "high"]); + assert_eq!( + effort_levels + .iter() + .find(|level| level.is_default) + .map(|level| level.value.as_ref()), + Some("low") + ); + } + + #[test] + fn grok_43_request_uses_selected_reasoning_effort() { + let request = LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some("high".to_string()), + ..Default::default() + }; + + assert_eq!( + reasoning_effort_for_request(&request, &x_ai::Model::Grok43), + Some(open_ai::ReasoningEffort::High) + ); + } + + #[test] + fn grok_43_request_uses_none_when_thinking_is_disabled() { + let request = LanguageModelRequest { + thinking_allowed: false, + ..Default::default() + }; + + assert_eq!( + reasoning_effort_for_request(&request, &x_ai::Model::Grok43), + Some(open_ai::ReasoningEffort::None) + ); + } +} + impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); diff --git a/crates/x_ai/src/x_ai.rs b/crates/x_ai/src/x_ai.rs index 7ba13d83529..6d9c558de85 100644 --- a/crates/x_ai/src/x_ai.rs +++ b/crates/x_ai/src/x_ai.rs @@ -122,4 +122,11 @@ impl Model { Self::Custom { .. } => false, } } + + pub fn supports_reasoning_effort(&self) -> bool { + match self { + Self::Grok43 => true, + Self::Grok420Reasoning | Self::Grok420NonReasoning | Self::Custom { .. } => false, + } + } } From 7f4a99aa95dd3328e227dc25c6b747d63b3adb9f Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 29 May 2026 13:49:11 -0400 Subject: [PATCH 89/93] Fix task modal fallback when LSP tasks are empty (#58090) References [FR-28](https://linear.app/zed-industries/issue/FR-28/task-modal-does-not-show-runnable-rust-test). The bug this PR aims to fix is > I can click the play button beside a rust test function, but it does not show up in the task modal. I wasn't able to reproduce it, but I suspect this was caused by the task system preferring LSP code actions by default. It checked that an LSP was queried for a task instead of checking if the queried LSP actually returned any tasks. So the fix was just adding the below if statement as a check. ```rust if !new_lsp_tasks.is_empty() { lsp_tasks .entry(source_kind) .or_insert_with(Vec::new) .append(&mut new_lsp_tasks); } ``` I also added a regression test for this Self-Review Checklist: - [x] I have reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the UI/UX checklist - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed task modal failing to show language tasks in some cases --- crates/editor/src/lsp_ext.rs | 10 ++-- crates/tasks_ui/src/modal.rs | 109 ++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/crates/editor/src/lsp_ext.rs b/crates/editor/src/lsp_ext.rs index 881c14903b2..e70f1b1202f 100644 --- a/crates/editor/src/lsp_ext.rs +++ b/crates/editor/src/lsp_ext.rs @@ -164,10 +164,12 @@ pub fn lsp_tasks( }, )); } - lsp_tasks - .entry(source_kind) - .or_insert_with(Vec::new) - .append(&mut new_lsp_tasks); + if !new_lsp_tasks.is_empty() { + lsp_tasks + .entry(source_kind) + .or_insert_with(Vec::new) + .append(&mut new_lsp_tasks); + } } } lsp_tasks.into_iter().collect() diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index 3b7edef415f..f5a416ad3c3 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -734,11 +734,14 @@ mod tests { use std::{path::PathBuf, sync::Arc}; use editor::{Editor, SelectionEffects}; - use gpui::{TestAppContext, VisualTestContext}; - use language::{Language, LanguageConfig, LanguageMatcher, Point}; + use gpui::{App, Entity, Task, TestAppContext, VisualTestContext}; + use language::{ + Buffer, ContextProvider, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, + LanguageServerName, Point, + }; use project::{ContextProviderWithTasks, FakeFs, Project}; use serde_json::json; - use task::TaskTemplates; + use task::{TaskTemplate, TaskTemplates}; use util::path; use workspace::{CloseInactiveTabsAndPanes, MultiWorkspace, OpenOptions, OpenVisible}; @@ -1033,6 +1036,80 @@ mod tests { cx.executor().run_until_parked(); } + #[gpui::test] + async fn test_empty_lsp_task_response_keeps_language_tasks_in_modal(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "main.test": "test" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new( + Language::new( + LanguageConfig { + name: "Test".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["test".to_string()], + ..LanguageMatcher::default() + }, + ..LanguageConfig::default() + }, + None, + ) + .with_context_provider(Some(Arc::new( + ContextProviderWithLspTaskSource::new(ContextProviderWithTasks::new( + TaskTemplates(vec![TaskTemplate { + label: "Run language task".to_string(), + command: "echo".to_string(), + args: vec!["language task".to_string()], + ..TaskTemplate::default() + }]), + )), + ))), + )); + let mut fake_servers = language_registry.register_fake_lsp( + "Test", + FakeLspAdapter { + name: TEST_LSP_NAME, + ..FakeLspAdapter::default() + }, + ); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + let _item = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/dir/main.test")), + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + window, + cx, + ) + }) + .await + .unwrap(); + cx.executor().run_until_parked(); + let fake_server = fake_servers + .try_recv() + .expect("fake LSP server should have started"); + use project::lsp_store::lsp_ext_command::Runnables; + fake_server + .set_request_handler::(move |_, _| async move { Ok(Vec::new()) }); + + let tasks_picker = open_spawn_tasks(&workspace, cx); + assert_eq!( + task_names(&tasks_picker, cx), + vec!["Run language task"], + "An empty LSP task response should not suppress language tasks in the modal" + ); + } + #[gpui::test] async fn test_language_task_filtering(cx: &mut TestAppContext) { init_test(cx); @@ -1238,6 +1315,32 @@ mod tests { ); } + const TEST_LSP_NAME: &str = "test-lsp"; + + struct ContextProviderWithLspTaskSource { + tasks: ContextProviderWithTasks, + } + + impl ContextProviderWithLspTaskSource { + fn new(tasks: ContextProviderWithTasks) -> Self { + Self { tasks } + } + } + + impl ContextProvider for ContextProviderWithLspTaskSource { + fn associated_tasks( + &self, + buffer: Option>, + cx: &App, + ) -> Task> { + self.tasks.associated_tasks(buffer, cx) + } + + fn lsp_task_source(&self) -> Option { + Some(LanguageServerName::new_static(TEST_LSP_NAME)) + } + } + fn emulate_task_schedule( tasks_picker: Entity>, project: &Entity, From 06826ef10f7ebd89f79ff72bb652f45ccdbbe9af Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Fri, 29 May 2026 13:55:04 -0400 Subject: [PATCH 90/93] Bump urllib3 to v2.7.0 (#58092) Self-Review Checklist: - [X] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A --- script/update_top_ranking_issues/pyproject.toml | 1 + script/update_top_ranking_issues/uv.lock | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/script/update_top_ranking_issues/pyproject.toml b/script/update_top_ranking_issues/pyproject.toml index 18d4afe9508..fcfc23e83d0 100644 --- a/script/update_top_ranking_issues/pyproject.toml +++ b/script/update_top_ranking_issues/pyproject.toml @@ -11,4 +11,5 @@ dependencies = [ "typer>=0.15.1", "types-pytz>=2025.1.0.20250204", "types-requests>=2.32.0", + "urllib3>=2.7.0", ] diff --git a/script/update_top_ranking_issues/uv.lock b/script/update_top_ranking_issues/uv.lock index 0e98447aa5b..872bae37e1f 100644 --- a/script/update_top_ranking_issues/uv.lock +++ b/script/update_top_ranking_issues/uv.lock @@ -252,6 +252,7 @@ dependencies = [ { name = "typer" }, { name = "types-pytz" }, { name = "types-requests" }, + { name = "urllib3" }, ] [package.metadata] @@ -263,13 +264,14 @@ requires-dist = [ { name = "typer", specifier = ">=0.15.1" }, { name = "types-pytz", specifier = ">=2025.1.0.20250204" }, { name = "types-requests", specifier = ">=2.32.0" }, + { name = "urllib3", specifier = ">=2.7.0" }, ] [[package]] name = "urllib3" -version = "2.2.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] From 5d3b9e467e9b789fa4422c4cf9208c497838d43b Mon Sep 17 00:00:00 2001 From: Sathwik Chirivelli <146921254+chirivelli@users.noreply.github.com> Date: Sat, 30 May 2026 01:13:26 +0530 Subject: [PATCH 91/93] git_ui: Open file diffs from git panel (#56152) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Addresses https://github.com/zed-industries/zed/discussions/33773. This changes git panel file activation so double-clicking or secondary-opening a changed file opens a dedicated full-file diff tab backed by a `SplittableEditor`. The per-file diff reuses the project diff staging and restore controls, respects the configured diff view style, and focuses an existing per-file diff tab when one is already open instead of creating duplicates. Verified with `cargo run`. Release Notes: - Improved git panel file diff opening. --------- Co-authored-by: Christopher Biscardi --- crates/git_ui/src/git_panel.rs | 73 +-- crates/git_ui/src/git_ui.rs | 1 + crates/git_ui/src/solo_diff_view.rs | 787 ++++++++++++++++++++++++++++ crates/zed/src/zed.rs | 5 + 4 files changed, 808 insertions(+), 58 deletions(-) create mode 100644 crates/git_ui/src/solo_diff_view.rs diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index b690916fd2f..8e547921842 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -5,6 +5,7 @@ use crate::commit_view::CommitView; use crate::git_panel_settings::GitPanelScrollbarAccessor; use crate::project_diff::{self, BranchDiff, Diff, ProjectDiff}; use crate::remote_output::{self, RemoteAction, SuccessMessage}; +use crate::solo_diff_view::SoloDiffView; use crate::{branch_picker, picker_prompt, render_remote_button}; use crate::{ git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, @@ -15,10 +16,7 @@ use anyhow::Context as _; use askpass::AskPassDelegate; use collections::{BTreeMap, HashMap, HashSet}; use db::kvp::KeyValueStore; -use editor::{ - Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior, - actions::ExpandAllDiffHunks, -}; +use editor::{Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior}; use editor::{EditorStyle, RewrapOptions}; use file_icons::FileIcons; use futures::StreamExt as _; @@ -85,7 +83,7 @@ use workspace::SERIALIZATION_THROTTLE_TIME; use workspace::{ Item, Workspace, dock::{DockPosition, Panel, PanelEvent}, - notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyResultExt}, + notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyTaskExt}, }; use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize}; @@ -1385,63 +1383,22 @@ impl GitPanel { }); } - fn open_file( + fn open_solo_diff( &mut self, _: &menu::SecondaryConfirm, window: &mut Window, cx: &mut Context, ) { maybe!({ - let entry = self.entries.get(self.selected_entry?)?.status_entry()?; - let active_repo = self.active_repository.as_ref()?; - let path = active_repo - .read(cx) - .repo_path_to_project_path(&entry.repo_path, cx)?; - if entry.status.is_deleted() { - return None; - } + let entry = self + .entries + .get(self.selected_entry?)? + .status_entry()? + .clone(); + let repository = self.active_repository.clone()?; - let open_task = self - .workspace - .update(cx, |workspace, cx| { - workspace.open_path_preview(path, None, false, false, true, window, cx) - }) - .ok()?; - - let workspace = self.workspace.clone(); - cx.spawn_in(window, async move |_, mut cx| { - let item = open_task - .await - .notify_workspace_async_err(workspace, &mut cx) - .ok_or_else(|| anyhow::anyhow!("Failed to open file"))?; - if let Some(active_editor) = item.downcast::() { - if let Some(diff_task) = - active_editor.update(cx, |editor, _cx| editor.wait_for_diff_to_load()) - { - diff_task.await; - } - - cx.update(|window, cx| { - active_editor.update(cx, |editor, cx| { - editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); - - let snapshot = editor.snapshot(window, cx); - editor.go_to_hunk_before_or_after_position( - &snapshot, - language::Point::new(0, 0), - Direction::Next, - true, - window, - cx, - ); - }) - }) - .log_err(); - } - - anyhow::Ok(()) - }) - .detach(); + SoloDiffView::open_or_focus(entry, repository, self.workspace.clone(), window, cx) + .detach_and_notify_err(self.workspace.clone(), window, cx); Some(()) }); @@ -5970,7 +5927,7 @@ impl GitPanel { ) .separator() .action("Open Diff", menu::Confirm.boxed_clone()) - .action("Open File", menu::SecondaryConfirm.boxed_clone()) + .action("Open Diff (File)", menu::SecondaryConfirm.boxed_clone()) .when(!is_created, |context_menu| { context_menu .separator() @@ -6249,7 +6206,7 @@ impl GitPanel { this.selected_entry = Some(ix); cx.notify(); if event.click_count() > 1 || event.modifiers().secondary() { - this.open_file(&Default::default(), window, cx) + this.open_solo_diff(&Default::default(), window, cx) } else { this.open_diff(&Default::default(), window, cx); this.focus_handle.focus(window, cx); @@ -6699,7 +6656,7 @@ impl Render for GitPanel { .on_action(cx.listener(Self::last_entry)) .on_action(cx.listener(Self::close_panel)) .on_action(cx.listener(Self::open_diff)) - .on_action(cx.listener(Self::open_file)) + .on_action(cx.listener(Self::open_solo_diff)) .on_action(cx.listener(Self::focus_changes_list)) .on_action(cx.listener(Self::focus_editor)) .on_action(cx.listener(Self::expand_commit_editor)) diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 58b1af74fb6..d19ac552067 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -45,6 +45,7 @@ pub mod picker_prompt; pub mod project_diff; pub(crate) mod remote_output; pub mod repository_selector; +pub mod solo_diff_view; pub mod stash_picker; pub mod text_diff_view; pub mod worktree_names; diff --git a/crates/git_ui/src/solo_diff_view.rs b/crates/git_ui/src/solo_diff_view.rs new file mode 100644 index 00000000000..052206c3293 --- /dev/null +++ b/crates/git_ui/src/solo_diff_view.rs @@ -0,0 +1,787 @@ +use crate::{git_panel::GitStatusEntry, git_status_icon}; +use anyhow::{Context as _, Result}; +use buffer_diff::DiffHunkSecondaryStatus; +use editor::{ + Direction, Editor, EditorEvent, EditorSettings, SplittableEditor, ToggleSplitDiff, + actions::{GoToHunk, GoToPreviousHunk}, +}; +use fs::Fs; +use git::{ + Commit, Restore, StageAndNext, StageFile, ToggleStaged, UnstageAndNext, UnstageFile, + repository::RepoPath, status::StageStatus, +}; +use gpui::{ + Action, AnyElement, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, + Focusable, IntoElement, Render, Subscription, Task, WeakEntity, Window, +}; +use language::{Buffer, HighlightedText}; +use multi_buffer::MultiBuffer; +use project::{ + Project, + git_store::{Repository, RepositoryId}, +}; +use settings::{DiffViewStyle, Settings, SettingsStore, update_settings_file}; +use std::{ + any::{Any, TypeId}, + sync::Arc, +}; +use ui::{ + Color, DiffStat, Divider, Icon, IconButton, IconButtonShape, IconName, Label, LabelCommon as _, + SharedString, Tooltip, prelude::*, vertical_divider, +}; +use util::paths::{PathExt as _, PathStyle}; +use workspace::{ + Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, + item::{ItemEvent, SaveOptions, TabContentParams}, + notifications::NotifyTaskExt, + searchable::SearchableItemHandle, +}; + +pub struct SoloDiffView { + repository: Entity, + repository_id: RepositoryId, + repo_path: RepoPath, + buffer: Entity, + editor: Entity, + workspace: WeakEntity, + _settings_subscription: Subscription, +} + +impl SoloDiffView { + pub fn open_or_focus( + entry: GitStatusEntry, + repository: Entity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let Some(workspace_entity) = workspace.upgrade() else { + return Task::ready(Err(anyhow::anyhow!("workspace was dropped"))); + }; + + let existing = workspace_entity + .read(cx) + .items_of_type::(cx) + .find(|item| item.read(cx).matches(&repository, &entry.repo_path, cx)); + if let Some(existing) = existing { + workspace_entity.update(cx, |workspace, cx| { + workspace.activate_item(&existing, true, true, window, cx); + }); + existing.focus_handle(cx).focus(window, cx); + return Task::ready(Ok(existing)); + } + + let Some(project_path) = repository + .read(cx) + .repo_path_to_project_path(&entry.repo_path, cx) + else { + return Task::ready(Err(anyhow::anyhow!( + "could not resolve repository path {:?}", + entry.repo_path + ))); + }; + + let project = workspace_entity.read(cx).project().clone(); + let repo_path = entry.repo_path; + window.spawn(cx, async move |cx| { + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + }) + .await?; + let diff = project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + }) + .await?; + + workspace_entity.update_in(cx, |workspace, window, cx| { + let workspace_handle = cx.entity(); + let view = cx.new(|cx| { + Self::new( + project, + repository, + repo_path, + buffer, + diff, + workspace_handle, + window, + cx, + ) + }); + + workspace.add_item_to_active_pane(Box::new(view.clone()), None, true, window, cx); + view + }) + }) + } + + fn new( + project: Entity, + repository: Entity, + repo_path: RepoPath, + buffer: Entity, + diff: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let repository_id = repository.read(cx).id; + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::singleton(buffer.clone(), cx); + multibuffer.add_diff(diff, cx); + multibuffer.set_all_diff_hunks_expanded(cx); + multibuffer + }); + let editor = cx.new(|cx| { + let editor = SplittableEditor::new( + EditorSettings::get_global(cx).diff_view_style, + multibuffer, + project.clone(), + workspace.clone(), + window, + cx, + ); + editor.rhs_editor().update(cx, |editor, cx| { + editor.set_should_serialize(false, cx); + let snapshot = editor.snapshot(window, cx); + editor.go_to_hunk_before_or_after_position( + &snapshot, + language::Point::new(0, 0), + Direction::Next, + true, + window, + cx, + ); + }); + editor + }); + + let mut previous_diff_view_style = EditorSettings::get_global(cx).diff_view_style; + let settings_subscription = + cx.observe_global_in::(window, move |this, window, cx| { + let diff_view_style = EditorSettings::get_global(cx).diff_view_style; + if diff_view_style != previous_diff_view_style { + this.editor.update(cx, |editor, cx| { + if editor.diff_view_style() != diff_view_style { + editor.toggle_split(&ToggleSplitDiff, window, cx); + } + }); + previous_diff_view_style = diff_view_style; + cx.notify(); + } + }); + + Self { + repository, + repository_id, + repo_path, + buffer, + editor, + workspace: workspace.downgrade(), + _settings_subscription: settings_subscription, + } + } + + fn matches(&self, repository: &Entity, repo_path: &RepoPath, cx: &App) -> bool { + self.repository_id == repository.read(cx).id && &self.repo_path == repo_path + } + + fn button_states(&self, cx: &App) -> SoloDiffButtonStates { + let editor = self.editor.read(cx).rhs_editor().read(cx); + let multibuffer = editor.buffer().read(cx); + let snapshot = multibuffer.snapshot(cx); + let prev_next = snapshot.diff_hunks().nth(1).is_some(); + let mut selection = true; + + let mut ranges = editor + .selections + .disjoint_anchor_ranges() + .collect::>(); + if !ranges.iter().any(|range| range.start != range.end) { + selection = false; + let anchor = editor.selections.newest_anchor().head(); + if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor) + && let Some(range) = snapshot + .anchor_in_buffer(excerpt_range.context.start) + .zip(snapshot.anchor_in_buffer(excerpt_range.context.end)) + .map(|(start, end)| start..end) + { + ranges = vec![range]; + } else { + ranges = Vec::new(); + } + } + + let mut stage = false; + let mut unstage = false; + for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) { + match hunk.status.secondary { + DiffHunkSecondaryStatus::HasSecondaryHunk + | DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => { + stage = true; + } + DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => { + stage = true; + unstage = true; + } + DiffHunkSecondaryStatus::NoSecondaryHunk + | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => { + unstage = true; + } + } + } + + let stage_status = self + .repository + .read(cx) + .status_for_path(&self.repo_path) + .map(|entry| entry.status.staging()) + .unwrap_or(StageStatus::Unstaged); + + SoloDiffButtonStates { + stage, + unstage, + restore: stage || unstage, + prev_next, + selection, + stage_file: stage_status.has_unstaged(), + unstage_file: stage_status.has_staged(), + } + } + + fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut App) { + self.focus_handle(cx).focus(window, cx); + let action = action.boxed_clone(); + cx.defer(move |cx| { + cx.dispatch_action(action.as_ref()); + }); + } + + fn change_file_stage(&self, stage: bool, window: &mut Window, cx: &mut Context) { + let repository = self.repository.clone(); + let repo_path = self.repo_path.clone(); + let workspace = self.workspace.clone(); + let task = cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, cx| { + if stage { + repository.stage_entries(vec![repo_path], cx) + } else { + repository.unstage_entries(vec![repo_path], cx) + } + }) + .await + .with_context(|| { + if stage { + "failed to stage file" + } else { + "failed to unstage file" + } + }) + }); + task.detach_and_notify_err(workspace, window, cx); + } +} + +impl EventEmitter for SoloDiffView {} + +impl Focusable for SoloDiffView { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl Item for SoloDiffView { + type Event = EditorEvent; + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::Diff).color(Color::Muted)) + } + + fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx)) + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } + + fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { + self.buffer + .read(cx) + .file() + .and_then(|file| { + Some( + file.full_path(cx) + .file_name()? + .to_string_lossy() + .to_string(), + ) + }) + .unwrap_or_else(|| { + self.repo_path + .as_ref() + .display(PathStyle::local()) + .into_owned() + }) + .into() + } + + fn tab_tooltip_text(&self, cx: &App) -> Option { + Some( + self.buffer + .read(cx) + .file() + .map(|file| file.full_path(cx).compact().to_string_lossy().into_owned()) + .unwrap_or_else(|| { + self.repo_path + .as_ref() + .display(PathStyle::local()) + .into_owned() + }) + .into(), + ) + } + + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("Solo Diff View Opened") + } + + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.deactivated(window, cx); + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + cx: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else { + self.editor.act_as_type(type_id, cx) + } + } + + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { + Some(Box::new(self.editor.clone())) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.editor.for_each_project_item(cx, f) + } + + fn set_nav_history( + &mut self, + nav_history: ItemNavHistory, + _: &mut Window, + cx: &mut Context, + ) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, _| { + editor.set_nav_history(Some(nav_history)); + }) + }); + } + + fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.editor.update(cx, |editor, cx| { + editor + .rhs_editor() + .update(cx, |editor, cx| editor.navigate(data, window, cx)) + }) + } + + fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { + ToolbarItemLocation::PrimaryLeft + } + + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { + self.editor.breadcrumbs(cx) + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + editor.added_to_workspace(workspace, window, cx) + }) + }); + } + + fn can_save(&self, cx: &App) -> bool { + self.editor.read(cx).rhs_editor().read(cx).can_save(cx) + } + + fn save( + &mut self, + options: SaveOptions, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.editor.save(options, project, window, cx) + } +} + +impl Render for SoloDiffView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.editor.clone() + } +} + +pub struct SoloDiffStyleToolbar { + solo_diff: Option>, +} + +pub struct SoloDiffGitToolbar { + solo_diff: Option>, +} + +impl SoloDiffStyleToolbar { + pub fn new(_: &mut Context) -> Self { + Self { solo_diff: None } + } + + fn solo_diff(&self) -> Option> { + self.solo_diff.as_ref()?.upgrade() + } + + fn set_diff_view_style( + &mut self, + diff_view_style: DiffViewStyle, + window: &mut Window, + cx: &mut Context, + ) { + let Some(solo_diff) = self.solo_diff() else { + return; + }; + let workspace = solo_diff.read(cx).workspace.clone(); + + update_settings_file(::global(cx), cx, move |settings, _| { + settings.editor.diff_view_style = Some(diff_view_style); + }); + + if let Some(workspace) = workspace.upgrade() { + let splittable_editors = { + workspace + .read(cx) + .items(cx) + .filter_map(|item| item.act_as_type(TypeId::of::(), cx)) + .filter_map(|item| item.downcast::().ok()) + .collect::>() + }; + + for editor in splittable_editors { + editor.update(cx, |editor, cx| { + if editor.diff_view_style() != diff_view_style { + editor.toggle_split(&ToggleSplitDiff, window, cx); + } + }); + } + } + + cx.notify(); + } +} + +impl EventEmitter for SoloDiffStyleToolbar {} + +impl ToolbarItemView for SoloDiffStyleToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.solo_diff = active_pane_item + .and_then(|item| item.act_as::(cx)) + .map(|entity| entity.downgrade()); + if self.solo_diff.is_some() { + ToolbarItemLocation::PrimaryLeft + } else { + ToolbarItemLocation::Hidden + } + } +} + +impl Render for SoloDiffStyleToolbar { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(solo_diff) = self.solo_diff() else { + return div(); + }; + let editor_entity = solo_diff.read(cx).editor.clone(); + let editor = editor_entity.read(cx); + let diff_view_style = editor.diff_view_style(); + let is_split_set = diff_view_style == DiffViewStyle::Split; + let split_icon = if is_split_set && !editor.is_split() { + IconName::DiffSplitAuto + } else { + IconName::DiffSplit + }; + + h_flex() + .h_8() + .items_center() + .gap_1() + .child( + IconButton::new("solo-diff-unified", IconName::DiffUnified) + .icon_size(IconSize::Small) + .toggle_state(diff_view_style == DiffViewStyle::Unified) + .tooltip(Tooltip::text("Unified")) + .on_click(cx.listener(|this, _, window, cx| { + this.set_diff_view_style(DiffViewStyle::Unified, window, cx); + })), + ) + .child( + IconButton::new("solo-diff-split", split_icon) + .icon_size(IconSize::Small) + .toggle_state(diff_view_style == DiffViewStyle::Split) + .tooltip(Tooltip::text("Split")) + .on_click(cx.listener(|this, _, window, cx| { + this.set_diff_view_style(DiffViewStyle::Split, window, cx); + })), + ) + .child(vertical_divider()) + .child(div().w_1()) + } +} + +impl SoloDiffGitToolbar { + pub fn new(_: &mut Context) -> Self { + Self { solo_diff: None } + } + + fn solo_diff(&self) -> Option> { + self.solo_diff.as_ref()?.upgrade() + } + + fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { + if let Some(solo_diff) = self.solo_diff() { + solo_diff.update(cx, |solo_diff, cx| { + solo_diff.dispatch_action(action, window, cx); + }); + } + } + + fn stage_file(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(solo_diff) = self.solo_diff() { + solo_diff.update(cx, |solo_diff, cx| { + solo_diff.change_file_stage(true, window, cx); + }); + } + } + + fn unstage_file(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(solo_diff) = self.solo_diff() { + solo_diff.update(cx, |solo_diff, cx| { + solo_diff.change_file_stage(false, window, cx); + }); + } + } +} + +impl EventEmitter for SoloDiffGitToolbar {} + +impl ToolbarItemView for SoloDiffGitToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.solo_diff = active_pane_item + .and_then(|item| item.act_as::(cx)) + .map(|entity| entity.downgrade()); + if self.solo_diff.is_some() { + ToolbarItemLocation::PrimaryRight + } else { + ToolbarItemLocation::Hidden + } + } +} + +struct SoloDiffButtonStates { + stage: bool, + unstage: bool, + restore: bool, + prev_next: bool, + selection: bool, + stage_file: bool, + unstage_file: bool, +} + +impl Render for SoloDiffGitToolbar { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(solo_diff) = self.solo_diff() else { + return div(); + }; + let focus_handle = solo_diff.focus_handle(cx); + let solo_diff = solo_diff.read(cx); + let button_states = solo_diff.button_states(cx); + let status_entry = solo_diff + .repository + .read(cx) + .status_for_path(&solo_diff.repo_path); + let status = status_entry.as_ref().map(|entry| entry.status); + let diff_stat = status_entry.and_then(|entry| entry.diff_stat); + + h_group_xl() + .my_neg_1() + .py_1() + .items_center() + .flex_wrap() + .justify_between() + .children(status.map(|status| git_status_icon(status).into_any_element())) + .children(diff_stat.map(|stat| { + DiffStat::new("solo-diff-stat", stat.added as usize, stat.deleted as usize) + .into_any_element() + })) + .child( + h_group_sm() + .when(button_states.selection, |el| { + el.child( + Button::new("stage", "Toggle Staged") + .tooltip(Tooltip::for_action_title_in( + "Toggle Staged", + &ToggleStaged, + &focus_handle, + )) + .disabled(!button_states.stage && !button_states.unstage) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&ToggleStaged, window, cx) + })), + ) + }) + .when(!button_states.selection, |el| { + el.child( + Button::new("stage", "Stage") + .tooltip(Tooltip::for_action_title_in( + "Stage and go to next hunk", + &StageAndNext, + &focus_handle, + )) + .disabled(!button_states.stage) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&StageAndNext, window, cx) + })), + ) + .child( + Button::new("unstage", "Unstage") + .tooltip(Tooltip::for_action_title_in( + "Unstage and go to next hunk", + &UnstageAndNext, + &focus_handle, + )) + .disabled(!button_states.unstage) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&UnstageAndNext, window, cx) + })), + ) + }) + .child( + Button::new("restore", "Restore") + .tooltip(Tooltip::for_action_title_in( + "Restore selected hunk", + &Restore, + &focus_handle, + )) + .disabled(!button_states.restore) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&Restore, window, cx) + })), + ), + ) + .child( + h_group_sm() + .child( + IconButton::new("up", IconName::ArrowUp) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::for_action_title_in( + "Go to previous hunk", + &GoToPreviousHunk, + &focus_handle, + )) + .disabled(!button_states.prev_next) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToPreviousHunk, window, cx) + })), + ) + .child( + IconButton::new("down", IconName::ArrowDown) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::for_action_title_in( + "Go to next hunk", + &GoToHunk, + &focus_handle, + )) + .disabled(!button_states.prev_next) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToHunk, window, cx) + })), + ), + ) + .child(vertical_divider()) + .child( + h_group_sm() + .child( + Button::new("stage-file", "Stage File") + .tooltip(Tooltip::for_action_title_in( + "Stage file", + &StageFile, + &focus_handle, + )) + .disabled(!button_states.stage_file) + .on_click( + cx.listener(|this, _, window, cx| this.stage_file(window, cx)), + ), + ) + .child( + Button::new("unstage-file", "Unstage File") + .tooltip(Tooltip::for_action_title_in( + "Unstage file", + &UnstageFile, + &focus_handle, + )) + .disabled(!button_states.unstage_file) + .on_click( + cx.listener(|this, _, window, cx| this.unstage_file(window, cx)), + ), + ) + .child(Divider::vertical()) + .child( + Button::new("commit", "Commit") + .tooltip(Tooltip::for_action_title_in( + "Commit", + &Commit, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&Commit, window, cx); + })), + ), + ) + } +} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 7ddd5215175..f9c04b7c910 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -34,6 +34,7 @@ use futures::{StreamExt, channel::mpsc, select_biased}; use git_ui::commit_view::CommitViewToolbar; use git_ui::git_panel::GitPanel; use git_ui::project_diff::{BranchDiffToolbar, ProjectDiffToolbar}; +use git_ui::solo_diff_view::{SoloDiffGitToolbar, SoloDiffStyleToolbar}; use gpui::{ Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement, @@ -1305,6 +1306,8 @@ fn initialize_pane( pane.toolbar().update(cx, |toolbar, cx| { let multibuffer_hint = cx.new(|_| MultibufferHint::new()); toolbar.add_item(multibuffer_hint, window, cx); + let solo_diff_style_toolbar = cx.new(SoloDiffStyleToolbar::new); + toolbar.add_item(solo_diff_style_toolbar, window, cx); let breadcrumbs = cx.new(|_| Breadcrumbs::new()); toolbar.add_item(breadcrumbs, window, cx); let buffer_search_bar = cx.new(|cx| { @@ -1343,6 +1346,8 @@ fn initialize_pane( toolbar.add_item(project_diff_toolbar, window, cx); let branch_diff_toolbar = cx.new(BranchDiffToolbar::new); toolbar.add_item(branch_diff_toolbar, window, cx); + let solo_diff_git_toolbar = cx.new(SoloDiffGitToolbar::new); + toolbar.add_item(solo_diff_git_toolbar, window, cx); let commit_view_toolbar = cx.new(|_| CommitViewToolbar::new()); toolbar.add_item(commit_view_toolbar, window, cx); let agent_diff_toolbar = cx.new(AgentDiffToolbar::new); From e2e7a6769e693c843c82cea2dcf65917c139cc0f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 16:29:38 -0400 Subject: [PATCH 92/93] Update dependency requests to v2.33.0 [SECURITY] (#58093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [requests](https://redirect.github.com/psf/requests) ([changelog](https://redirect.github.com/psf/requests/blob/master/HISTORY.md)) | `2.32.3` → `2.33.0` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/requests/2.33.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/requests/2.32.3/2.33.0?slim=true) | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/15138) for more information. --- ### Requests vulnerable to .netrc credentials leak via malicious URLs [CVE-2024-47081](https://nvd.nist.gov/vuln/detail/CVE-2024-47081) / [GHSA-9hjg-9r4m-mvj7](https://redirect.github.com/advisories/GHSA-9hjg-9r4m-mvj7)
More information #### Details ##### Impact Due to a URL parsing issue, Requests releases prior to 2.32.4 may leak .netrc credentials to third parties for specific maliciously-crafted URLs. ##### Workarounds For older versions of Requests, use of the .netrc file can be disabled with `trust_env=False` on your Requests Session ([docs](https://requests.readthedocs.io/en/latest/api/#requests.Session.trust_env)). ##### References [https://github.com/psf/requests/pull/6965](https://redirect.github.com/psf/requests/pull/6965) https://seclists.org/fulldisclosure/2025/Jun/2 #### Severity - CVSS Score: 5.3 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N` #### References - [https://github.com/psf/requests/security/advisories/GHSA-9hjg-9r4m-mvj7](https://redirect.github.com/psf/requests/security/advisories/GHSA-9hjg-9r4m-mvj7) - [https://nvd.nist.gov/vuln/detail/CVE-2024-47081](https://nvd.nist.gov/vuln/detail/CVE-2024-47081) - [https://github.com/psf/requests/pull/6965](https://redirect.github.com/psf/requests/pull/6965) - [https://github.com/psf/requests/commit/96ba401c1296ab1dda74a2365ef36d88f7d144ef](https://redirect.github.com/psf/requests/commit/96ba401c1296ab1dda74a2365ef36d88f7d144ef) - [https://requests.readthedocs.io/en/latest/api/#requests.Session.trust_env](https://requests.readthedocs.io/en/latest/api/#requests.Session.trust_env) - [https://seclists.org/fulldisclosure/2025/Jun/2](https://seclists.org/fulldisclosure/2025/Jun/2) - [http://seclists.org/fulldisclosure/2025/Jun/2](http://seclists.org/fulldisclosure/2025/Jun/2) - [http://www.openwall.com/lists/oss-security/2025/06/03/11](http://www.openwall.com/lists/oss-security/2025/06/03/11) - [http://www.openwall.com/lists/oss-security/2025/06/03/9](http://www.openwall.com/lists/oss-security/2025/06/03/9) - [http://www.openwall.com/lists/oss-security/2025/06/04/1](http://www.openwall.com/lists/oss-security/2025/06/04/1) - [http://www.openwall.com/lists/oss-security/2025/06/04/6](http://www.openwall.com/lists/oss-security/2025/06/04/6) - [https://github.com/advisories/GHSA-9hjg-9r4m-mvj7](https://redirect.github.com/advisories/GHSA-9hjg-9r4m-mvj7) This data is provided by the [GitHub Advisory Database](https://redirect.github.com/advisories/GHSA-9hjg-9r4m-mvj7) ([CC-BY 4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
--- ### Requests has Insecure Temp File Reuse in its extract_zipped_paths() utility function [CVE-2026-25645](https://nvd.nist.gov/vuln/detail/CVE-2026-25645) / [GHSA-gc5v-m9x4-r6x2](https://redirect.github.com/advisories/GHSA-gc5v-m9x4-r6x2)
More information #### Details ##### Impact The `requests.utils.extract_zipped_paths()` utility function uses a predictable filename when extracting files from zip archives into the system temporary directory. If the target file already exists, it is reused without validation. A local attacker with write access to the temp directory could pre-create a malicious file that would be loaded in place of the legitimate one. ##### Affected usages **Standard usage of the Requests library is not affected by this vulnerability.** Only applications that call `extract_zipped_paths()` directly are impacted. ##### Remediation Upgrade to at least Requests 2.33.0, where the library now extracts files to a non-deterministic location. If developers are unable to upgrade, they can set `TMPDIR` in their environment to a directory with restricted write access. #### Severity - CVSS Score: 4.4 / 10 (Medium) - Vector String: `CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:N/I:H/A:N` #### References - [https://github.com/psf/requests/security/advisories/GHSA-gc5v-m9x4-r6x2](https://redirect.github.com/psf/requests/security/advisories/GHSA-gc5v-m9x4-r6x2) - [https://github.com/psf/requests/commit/66d21cb07bd6255b1280291c4fafb71803cdb3b7](https://redirect.github.com/psf/requests/commit/66d21cb07bd6255b1280291c4fafb71803cdb3b7) - [https://github.com/psf/requests/releases/tag/v2.33.0](https://redirect.github.com/psf/requests/releases/tag/v2.33.0) - [https://nvd.nist.gov/vuln/detail/CVE-2026-25645](https://nvd.nist.gov/vuln/detail/CVE-2026-25645) - [https://github.com/advisories/GHSA-gc5v-m9x4-r6x2](https://redirect.github.com/advisories/GHSA-gc5v-m9x4-r6x2) This data is provided by the [GitHub Advisory Database](https://redirect.github.com/advisories/GHSA-gc5v-m9x4-r6x2) ([CC-BY 4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
--- ### Release Notes
psf/requests (requests) ### [`v2.33.0`](https://redirect.github.com/psf/requests/blob/HEAD/HISTORY.md#2330-2026-03-25) [Compare Source](https://redirect.github.com/psf/requests/compare/v2.32.5...v2.33.0) **Announcements** - 📣 Requests is adding inline types. If you have a typed code base that uses Requests, please take a look at [#​7271](https://redirect.github.com/psf/requests/issues/7271). Give it a try, and report any gaps or feedback you may have in the issue. 📣 **Security** - CVE-2026-25645 `requests.utils.extract_zipped_paths` now extracts contents to a non-deterministic location to prevent malicious file replacement. This does not affect default usage of Requests, only applications calling the utility function directly. **Improvements** - Migrated to a PEP 517 build system using setuptools. ([#​7012](https://redirect.github.com/psf/requests/issues/7012)) **Bugfixes** - Fixed an issue where an empty netrc entry could cause malformed authentication to be applied to Requests on Python 3.11+. ([#​7205](https://redirect.github.com/psf/requests/issues/7205)) **Deprecations** - Dropped support for Python 3.9 following its end of support. ([#​7196](https://redirect.github.com/psf/requests/issues/7196)) **Documentation** - Various typo fixes and doc improvements. ### [`v2.32.5`](https://redirect.github.com/psf/requests/blob/HEAD/HISTORY.md#2325-2025-08-18) [Compare Source](https://redirect.github.com/psf/requests/compare/v2.32.4...v2.32.5) **Bugfixes** - The SSLContext caching feature originally introduced in 2.32.0 has created a new class of issues in Requests that have had negative impact across a number of use cases. The Requests team has decided to revert this feature as long term maintenance of it is proving to be unsustainable in its current iteration. **Deprecations** - Added support for Python 3.14. - Dropped support for Python 3.8 following its end of support. ### [`v2.32.4`](https://redirect.github.com/psf/requests/blob/HEAD/HISTORY.md#2324-2025-06-10) [Compare Source](https://redirect.github.com/psf/requests/compare/v2.32.3...v2.32.4) **Security** - CVE-2024-47081 Fixed an issue where a maliciously crafted URL and trusted environment will retrieve credentials for the wrong hostname/machine from a netrc file. **Improvements** - Numerous documentation improvements **Deprecations** - Added support for pypy 3.11 for Linux and macOS. - Dropped support for pypy 3.9 following its end of support.
--- ### Configuration 📅 **Schedule**: (in timezone America/New_York) - Branch creation - "" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- script/update_top_ranking_issues/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/script/update_top_ranking_issues/uv.lock b/script/update_top_ranking_issues/uv.lock index 872bae37e1f..f1b3ec28515 100644 --- a/script/update_top_ranking_issues/uv.lock +++ b/script/update_top_ranking_issues/uv.lock @@ -134,7 +134,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.3" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -142,9 +142,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] From 09165c15dc5d1fea93604231eaf30ca4c25f1cd6 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sat, 30 May 2026 14:37:39 -0600 Subject: [PATCH 93/93] gpui: Support prompt_for_paths in TestPlatform (#58139) Implements the previously-`unimplemented!()` `TestPlatform::prompt_for_paths` so tests can drive the platform Open dialog deterministically. Adds `TestAppContext::simulate_path_prompt_response` and `did_prompt_for_paths`, mirroring the existing `prompt_for_new_path` test helpers (`simulate_new_path_selection`). The simulated response validates that callers don't return multiple paths when `PathPromptOptions::multiple` is false. Release Notes: - N/A --- crates/gpui/src/app/test_context.rs | 65 +++++++++++++++++++++++ crates/gpui/src/platform/test/platform.rs | 46 +++++++++++++--- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 8a6d7e3f840..9e32c5dc2d4 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -336,6 +336,20 @@ impl TestAppContext { self.test_platform.simulate_new_path_selection(select_path); } + /// Simulates responding to a `prompt_for_paths` ("Open") dialog. + pub fn simulate_path_prompt_response( + &self, + select_paths: impl FnOnce(&crate::PathPromptOptions) -> Option>, + ) { + self.test_platform + .simulate_path_prompt_response(select_paths); + } + + /// Returns true if there's a path selection dialog pending. + pub fn did_prompt_for_paths(&self) -> bool { + self.test_platform.did_prompt_for_paths() + } + /// Simulates clicking a button in an platform-level alert dialog. #[track_caller] pub fn simulate_prompt_answer(&self, button: &str) { @@ -1098,3 +1112,54 @@ impl AnyWindowHandle { .unwrap() } } + +#[cfg(test)] +mod tests { + use crate::{PathPromptOptions, TestAppContext}; + use std::path::PathBuf; + + #[gpui::test] + async fn test_simulate_path_prompt_response(cx: &mut TestAppContext) { + assert!(!cx.did_prompt_for_paths()); + + let receiver = cx.update(|cx| { + cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: true, + prompt: None, + }) + }); + assert!(cx.did_prompt_for_paths()); + + let selected = vec![PathBuf::from("/a"), PathBuf::from("/b")]; + cx.simulate_path_prompt_response({ + let selected = selected.clone(); + move |options| { + assert!(options.multiple); + Some(selected) + } + }); + assert!(!cx.did_prompt_for_paths()); + + let response = receiver.await.unwrap().unwrap(); + assert_eq!(response, Some(selected)); + } + + #[gpui::test] + async fn test_simulate_path_prompt_cancellation(cx: &mut TestAppContext) { + let receiver = cx.update(|cx| { + cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: None, + }) + }); + + cx.simulate_path_prompt_response(|_options| None); + + let response = receiver.await.unwrap().unwrap(); + assert_eq!(response, None); + } +} diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index cc8c5749bd4..b3bee3769e0 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,9 +1,10 @@ use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, - DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, - PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, - Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size, + DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform, + PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, + PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, + SourceMetadata, Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, + size, }; use anyhow::Result; use collections::VecDeque; @@ -85,6 +86,10 @@ struct TestPrompt { pub(crate) struct TestPrompts { multiple_choice: VecDeque, new_path: VecDeque<(PathBuf, oneshot::Sender>>)>, + paths: VecDeque<( + PathPromptOptions, + oneshot::Sender>>>, + )>, } impl TestPlatform { @@ -147,6 +152,33 @@ impl TestPlatform { tx.send(Ok(select_path(&path))).ok(); } + pub(crate) fn simulate_path_prompt_response( + &self, + select_paths: impl FnOnce(&PathPromptOptions) -> Option>, + ) { + let (options, tx) = self + .prompts + .borrow_mut() + .paths + .pop_front() + .expect("no pending paths prompt"); + let selection = select_paths(&options); + if let Some(paths) = &selection + && !options.multiple + && paths.len() > 1 + { + panic!( + "selected {} paths for a prompt that does not allow multiple selection", + paths.len() + ); + } + tx.send(Ok(selection)).ok(); + } + + pub(crate) fn did_prompt_for_paths(&self) -> bool { + !self.prompts.borrow().paths.is_empty() + } + #[track_caller] pub(crate) fn simulate_prompt_answer(&self, response: &str) { let prompt = self @@ -348,9 +380,11 @@ impl Platform for TestPlatform { fn prompt_for_paths( &self, - _options: crate::PathPromptOptions, + options: crate::PathPromptOptions, ) -> oneshot::Receiver>>> { - unimplemented!() + let (tx, rx) = oneshot::channel(); + self.prompts.borrow_mut().paths.push_back((options, tx)); + rx } fn prompt_for_new_path(