From d3a362c046b510e0fa2d169b307196be2d378301 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Thu, 26 Mar 2026 11:16:46 +0100 Subject: [PATCH 01/53] Do not congratulate bots for their merged PRs (#52477) I appreciate its efforts and it helps me a lot, but I do not think thanking zed-zippy in Discord is the right move to acknowledge its work. Release Notes: - N/A --- .github/workflows/congrats.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/congrats.yml b/.github/workflows/congrats.yml index 6a4111a1c5b5143ee9be067911207d5b4ca1448c..a57be7a75ad13829b096477da015ac6a43a325d7 100644 --- a/.github/workflows/congrats.yml +++ b/.github/workflows/congrats.yml @@ -29,6 +29,13 @@ jobs: } const mergedPR = prs.find(pr => pr.merged_at !== null) || prs[0]; + + if (mergedPR.user.type === "Bot") { + // They are a good bot, but not good enough to be congratulated + core.setOutput('should_congratulate', 'false'); + return; + } + const prAuthor = mergedPR.user.login; try { From ef46b31373276aa938f98d1552d33c07798a3f7b Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki <40514306+aviatesk@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:33:36 +0900 Subject: [PATCH 02/53] editor: Fix `fade_out` styling for completion labels without highlights (#45936) LSP's `CompletionItemKind` is defined by the protocol and may not have a corresponding highlight name in a language's `highlights.scm`. For example, Julia's grammar defines `@function.call` but not `@function`, so completions with `Method` kind cannot resolve a highlight. Since the mapping from `CompletionItemKind` to highlight names is an internal implementation detail that is not guaranteed to be stable, the fallback behavior should provide consistent styling regardless of grammar definitions. Bundled language extensions (e.g., Rust, TypeScript) apply `fade_out` or muted styling to the description portion of completion labels, so the fallback path and extension-provided labels should match this behavior. Previously, the description portion could fail to receive the expected `fade_out` styling in several independent cases: 1. When `runs` was empty (grammar lacks the highlight name), the `flat_map` loop never executed 2. When theme lacked a style for the `highlight_id`, early return skipped the `fade_out` logic 3. When extensions used `Literal` spans with `highlight_name: None`, `HighlightId::default()` was still added to `runs`, preventing `fade_out` from being applied The fix ensures description portions consistently receive `fade_out` styling regardless of whether the label portion can be highlighted, both for fallback completions and extension-provided labels. Closes #ISSUE Release Notes: - N/A *or* Added/Fixed/Improved ... --------- Co-authored-by: MrSubidubi --- crates/editor/src/editor.rs | 85 ++++++++++--------- .../src/extension_lsp_adapter.rs | 9 +- 2 files changed, 52 insertions(+), 42 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1984c2180d1c5434b02a1623510fc2caa30177c4..1786013a4a4d746c0580813c3e9b9962b1baa72d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -28848,49 +28848,58 @@ pub fn styled_runs_for_code_label<'a>( ..Default::default() }; + if label.runs.is_empty() { + let desc_start = label.filter_range.end; + let fade_run = + (desc_start < label.text.len()).then(|| (desc_start..label.text.len(), fade_out)); + return Either::Left(fade_run.into_iter()); + } + let mut prev_end = label.filter_range.end; - label - .runs - .iter() - .enumerate() - .flat_map(move |(ix, (range, highlight_id))| { - let style = if *highlight_id == language::HighlightId::TABSTOP_INSERT_ID { - HighlightStyle { - color: Some(local_player.cursor), - ..Default::default() - } - } else if *highlight_id == language::HighlightId::TABSTOP_REPLACE_ID { - HighlightStyle { - background_color: Some(local_player.selection), - ..Default::default() - } - } else if let Some(style) = syntax_theme.get(*highlight_id).cloned() { - style - } else { - return Default::default(); - }; - let muted_style = style.highlight(fade_out); + Either::Right( + label + .runs + .iter() + .enumerate() + .flat_map(move |(ix, (range, highlight_id))| { + let style = if *highlight_id == language::HighlightId::TABSTOP_INSERT_ID { + HighlightStyle { + color: Some(local_player.cursor), + ..Default::default() + } + } else if *highlight_id == language::HighlightId::TABSTOP_REPLACE_ID { + HighlightStyle { + background_color: Some(local_player.selection), + ..Default::default() + } + } else if let Some(style) = syntax_theme.get(*highlight_id).cloned() { + style + } else { + return Default::default(); + }; - let mut runs = SmallVec::<[(Range, HighlightStyle); 3]>::new(); - if range.start >= label.filter_range.end { - if range.start > prev_end { - runs.push((prev_end..range.start, fade_out)); + let mut runs = SmallVec::<[(Range, HighlightStyle); 3]>::new(); + let muted_style = style.highlight(fade_out); + if range.start >= label.filter_range.end { + if range.start > prev_end { + runs.push((prev_end..range.start, fade_out)); + } + runs.push((range.clone(), muted_style)); + } else if range.end <= label.filter_range.end { + runs.push((range.clone(), style)); + } else { + runs.push((range.start..label.filter_range.end, style)); + runs.push((label.filter_range.end..range.end, muted_style)); } - runs.push((range.clone(), muted_style)); - } else if range.end <= label.filter_range.end { - runs.push((range.clone(), style)); - } else { - runs.push((range.start..label.filter_range.end, style)); - runs.push((label.filter_range.end..range.end, muted_style)); - } - prev_end = cmp::max(prev_end, range.end); + prev_end = cmp::max(prev_end, range.end); - if ix + 1 == label.runs.len() && label.text.len() > prev_end { - runs.push((prev_end..label.text.len(), fade_out)); - } + if ix + 1 == label.runs.len() && label.text.len() > prev_end { + runs.push((prev_end..label.text.len(), fade_out)); + } - runs - }) + runs + }), + ) } pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator + '_ { diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 88401906fc28bb297fc2798346e110c9651b1387..13899f11c30556db189da48ed1fcb4b5d12b2f20 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -547,15 +547,16 @@ fn build_code_label( text.push_str(code_span); } extension::CodeLabelSpan::Literal(span) => { - let highlight_id = language + if let Some(highlight_id) = language .grammar() .zip(span.highlight_name.as_ref()) .and_then(|(grammar, highlight_name)| { grammar.highlight_id_for_name(highlight_name) }) - .unwrap_or_default(); - let ix = text.len(); - runs.push((ix..ix + span.text.len(), highlight_id)); + { + let ix = text.len(); + runs.push((ix..ix + span.text.len(), highlight_id)); + } text.push_str(&span.text); } } From b7c64e56b1f95cf787c0e52bd5e39f247cfb9125 Mon Sep 17 00:00:00 2001 From: Kunall Banerjee Date: Thu, 26 Mar 2026 07:44:05 -0400 Subject: [PATCH 03/53] settings_content: Fix hover descriptions for newtype wrapper settings (#51705) Happened to notice it when debugging some stuff. I initially only caught `git_hosting_providers`, but AI found another instance. | `disable_ai` | `git_hosting_providers` | |--------|--------| | image | image | Release Notes: - Fixed an issue where some settings used the wrong documentation in LSP hover documentation --- .../settings_content/src/settings_content.rs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index ea505cd2dcbd20bf5520169b808bb6848119a95a..861b6fee454edc4d18b8248b42315287a33c572c 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1133,15 +1133,15 @@ pub struct WhichKeySettingsContent { pub delay_ms: Option, } +// An ExtendingVec in the settings can only accumulate new values. +// +// This is useful for things like private files where you only want +// to allow new values to be added. +// +// Consider using a HashMap instead of this type +// (like auto_install_extensions) so that user settings files can both add +// and remove values from the set. #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -/// An ExtendingVec in the settings can only accumulate new values. -/// -/// This is useful for things like private files where you only want -/// to allow new values to be added. -/// -/// Consider using a HashMap instead of this type -/// (like auto_install_extensions) so that user settings files can both add -/// and remove values from the set. pub struct ExtendingVec(pub Vec); impl Into> for ExtendingVec { @@ -1161,10 +1161,10 @@ impl merge_from::MergeFrom for ExtendingVec { } } -/// A SaturatingBool in the settings can only ever be set to true, -/// later attempts to set it to false will be ignored. -/// -/// Used by `disable_ai`. +// A SaturatingBool in the settings can only ever be set to true, +// later attempts to set it to false will be ignored. +// +// Used by `disable_ai`. #[derive(Debug, Default, Copy, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct SaturatingBool(pub bool); From a3d72e542756d7f50e8067234dac1168a2da8acd Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 26 Mar 2026 08:52:26 -0300 Subject: [PATCH 04/53] agent_ui: Make thread generation top-down (#52440) Ever since we introduced the agent panel the way it is right now back in May 2025, we wanted to make the thread generation be top-down. Now, with the changes we're planning to launch very soon revolving around parallel agents, doing this become even more important for a better experience. Particularly because in the agent panel's new empty state, the message editor is full screen. We want to minimize as much as possible layout shift between writing your first prompt and actually submitting it. So this means that content will stream down from your first prompt and auto-scroll you if it goes beyond the viewport. To pull this off, we added a `follow_tail` feature directly to the GPUI list so that we could only call it in the thread view layer as opposed to doing it all there. https://github.com/user-attachments/assets/99961819-6a79-40e0-b482-dca68c829161 Release Notes: - Agent: Made the thread generation be top-down instead of bottom-up. Agent content now streams from the top and auto-scroll as they go beyond the viewport. --------- Co-authored-by: Richard Feldman --- crates/agent_ui/src/conversation_view.rs | 10 +- .../src/conversation_view/thread_view.rs | 565 +++++++++++------- crates/gpui/src/elements/list.rs | 261 +++++++- 3 files changed, 601 insertions(+), 235 deletions(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 740beabce22ab6eb476b8c60b281c3ebc9d9df12..f5c91cf342c69badf2915e21c17f819963416ec5 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -795,7 +795,7 @@ impl ConversationView { }); let count = thread.read(cx).entries().len(); - let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0)); + let list_state = ListState::new(0, gpui::ListAlignment::Top, px(2048.0)); entry_view_state.update(cx, |view_state, cx| { for ix in 0..count { view_state.sync_entry(ix, &thread, window, cx); @@ -1255,9 +1255,11 @@ impl ConversationView { } AcpThreadEvent::Stopped(stop_reason) => { if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { + active.update(cx, |active, cx| { active.thread_retry_status.take(); active.clear_auto_expand_tracking(); + active.list_state.set_follow_tail(false); + active.sync_generating_indicator(cx); }); } if is_subagent { @@ -1325,8 +1327,10 @@ impl ConversationView { } AcpThreadEvent::Error => { if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { + active.update(cx, |active, cx| { active.thread_retry_status.take(); + active.list_state.set_follow_tail(false); + active.sync_generating_indicator(cx); }); } if !is_subagent { diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 1ad07efd52ddcffe29bd3d50e382d85813c3c994..2778a5b4a2583a0b232f86184f33c4446bc18ea5 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -285,6 +285,7 @@ pub struct ThreadView { pub hovered_recent_history_item: Option, pub show_external_source_prompt_warning: bool, pub show_codex_windows_warning: bool, + pub generating_indicator_in_list: bool, pub history: Option>, pub _history_subscription: Option, } @@ -525,19 +526,39 @@ impl ThreadView { history, _history_subscription: history_subscription, show_codex_windows_warning, + generating_indicator_in_list: false, }; + + this.sync_generating_indicator(cx); let list_state_for_scroll = this.list_state.clone(); let thread_view = cx.entity().downgrade(); + this.list_state - .set_scroll_handler(move |_event, _window, cx| { + .set_scroll_handler(move |event, _window, cx| { let list_state = list_state_for_scroll.clone(); let thread_view = thread_view.clone(); + let is_following_tail = event.is_following_tail; // N.B. We must defer because the scroll handler is called while the // ListState's RefCell is mutably borrowed. Reading logical_scroll_top() // directly would panic from a double borrow. cx.defer(move |cx| { let scroll_top = list_state.logical_scroll_top(); let _ = thread_view.update(cx, |this, cx| { + if !is_following_tail { + let is_at_bottom = { + let current_offset = + list_state.scroll_px_offset_for_scrollbar().y.abs(); + let max_offset = list_state.max_offset_for_scrollbar().y; + current_offset >= max_offset - px(1.0) + }; + + let is_generating = + matches!(this.thread.read(cx).status(), ThreadStatus::Generating); + + if is_at_bottom && is_generating { + list_state.set_follow_tail(true); + } + } if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, _cx| { thread.set_ui_scroll_position(Some(scroll_top)); @@ -1043,7 +1064,11 @@ impl ThreadView { this.update_in(cx, |this, _window, cx| { this.set_editor_is_expanded(false, cx); })?; - let _ = this.update(cx, |this, cx| this.scroll_to_bottom(cx)); + + let _ = this.update(cx, |this, cx| { + this.list_state.set_follow_tail(true); + cx.notify(); + }); let _stop_turn = defer({ let this = this.clone(); @@ -1097,6 +1122,12 @@ impl ThreadView { thread.send(contents, cx) })?; + + let _ = this.update(cx, |this, cx| { + this.sync_generating_indicator(cx); + cx.notify(); + }); + let res = send.await; let turn_time_ms = turn_start_time.elapsed().as_millis(); drop(_stop_turn); @@ -1236,13 +1267,13 @@ impl ThreadView { ); } - // generation - pub fn cancel_generation(&mut self, cx: &mut Context) { self.thread_retry_status.take(); self.thread_error.take(); self.user_interrupted_generation = true; self._cancel_task = Some(self.thread.update(cx, |thread, cx| thread.cancel(cx))); + self.sync_generating_indicator(cx); + cx.notify(); } pub fn retry_generation(&mut self, cx: &mut Context) { @@ -1254,6 +1285,8 @@ impl ThreadView { } let task = thread.update(cx, |thread, cx| thread.retry(cx)); + self.sync_generating_indicator(cx); + cx.notify(); cx.spawn(async move |this, cx| { let result = task.await; @@ -1582,11 +1615,10 @@ impl ThreadView { } }) }; + self.message_editor.focus_handle(cx).focus(window, cx); cx.notify(); } - // tool permissions - pub fn authorize_tool_call( &mut self, session_id: acp::SessionId, @@ -1640,6 +1672,17 @@ impl ThreadView { Some(()) } + fn is_waiting_for_confirmation(entry: &AgentThreadEntry) -> bool { + if let AgentThreadEntry::ToolCall(tool_call) = entry { + matches!( + tool_call.status, + ToolCallStatus::WaitingForConfirmation { .. } + ) + } else { + false + } + } + fn handle_authorize_tool_call( &mut self, action: &AuthorizeToolCall, @@ -3207,22 +3250,98 @@ impl ThreadView { }) }; - if show_split { - let max_output_tokens = self - .as_native_thread(cx) - .and_then(|thread| thread.read(cx).model()) - .and_then(|model| model.max_output_tokens()) - .unwrap_or(0); + let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); + let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); + let input_tokens_label = + crate::text_thread_editor::humanize_token_count(usage.input_tokens); + let output_tokens_label = + crate::text_thread_editor::humanize_token_count(usage.output_tokens); + + let progress_ratio = if usage.max_tokens > 0 { + usage.used_tokens as f32 / usage.max_tokens as f32 + } else { + 0.0 + }; + let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32); + + let tooltip_separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6)); + + let (user_rules_count, first_user_rules_id, project_rules_count, project_entry_ids) = self + .as_native_thread(cx) + .map(|thread| { + let project_context = thread.read(cx).project_context().read(cx); + let user_rules_count = project_context.user_rules.len(); + let first_user_rules_id = project_context.user_rules.first().map(|r| r.uuid.0); + let project_entry_ids = project_context + .worktrees + .iter() + .filter_map(|wt| wt.rules_file.as_ref()) + .map(|rf| ProjectEntryId::from_usize(rf.project_entry_id)) + .collect::>(); + let project_rules_count = project_entry_ids.len(); + ( + user_rules_count, + first_user_rules_id, + project_rules_count, + project_entry_ids, + ) + }) + .unwrap_or_default(); + + let workspace = self.workspace.clone(); + let max_output_tokens = self + .as_native_thread(cx) + .and_then(|thread| thread.read(cx).model()) + .and_then(|model| model.max_output_tokens()) + .unwrap_or(0); + let input_max_label = crate::text_thread_editor::humanize_token_count( + usage.max_tokens.saturating_sub(max_output_tokens), + ); + let output_max_label = crate::text_thread_editor::humanize_token_count(max_output_tokens); + + let build_tooltip = { + let input_max_label = input_max_label.clone(); + let output_max_label = output_max_label.clone(); + move |_window: &mut Window, cx: &mut App| { + let percentage = percentage.clone(); + let used = used.clone(); + let max = max.clone(); + let input_tokens_label = input_tokens_label.clone(); + let output_tokens_label = output_tokens_label.clone(); + let input_max_label = input_max_label.clone(); + let output_max_label = output_max_label.clone(); + let project_entry_ids = project_entry_ids.clone(); + let workspace = workspace.clone(); + cx.new(move |_cx| TokenUsageTooltip { + percentage, + used, + max, + input_tokens: input_tokens_label, + output_tokens: output_tokens_label, + input_max: input_max_label, + output_max: output_max_label, + show_split, + separator_color: tooltip_separator_color, + user_rules_count, + first_user_rules_id, + project_rules_count, + project_entry_ids, + workspace, + }) + .into() + } + }; + + if show_split { let input = crate::text_thread_editor::humanize_token_count(usage.input_tokens); - let input_max = crate::text_thread_editor::humanize_token_count( - usage.max_tokens.saturating_sub(max_output_tokens), - ); + let input_max = input_max_label; let output = crate::text_thread_editor::humanize_token_count(usage.output_tokens); - let output_max = crate::text_thread_editor::humanize_token_count(max_output_tokens); + let output_max = output_max_label; Some( h_flex() + .id("split_token_usage") .flex_shrink_0() .gap_1() .mr_1p5() @@ -3266,39 +3385,15 @@ impl ThreadView { .color(Color::Muted), ), ) + .hoverable_tooltip(build_tooltip) .into_any_element(), ) } else { - let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); - let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); - let progress_ratio = if usage.max_tokens > 0 { - usage.used_tokens as f32 / usage.max_tokens as f32 - } else { - 0.0 - }; - let progress_color = if progress_ratio >= 0.85 { cx.theme().status().warning } else { cx.theme().colors().text_muted }; - let separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6)); - - let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32); - - let (user_rules_count, project_rules_count) = self - .as_native_thread(cx) - .map(|thread| { - let project_context = thread.read(cx).project_context().read(cx); - let user_rules = project_context.user_rules.len(); - let project_rules = project_context - .worktrees - .iter() - .filter(|wt| wt.rules_file.is_some()) - .count(); - (user_rules, project_rules) - }) - .unwrap_or((0, 0)); Some( h_flex() @@ -3315,53 +3410,7 @@ impl ThreadView { .stroke_width(px(2.)) .progress_color(progress_color), ) - .tooltip(Tooltip::element({ - move |_, cx| { - v_flex() - .min_w_40() - .child( - Label::new("Context") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child( - h_flex() - .gap_0p5() - .child(Label::new(percentage.clone())) - .child(Label::new("•").color(separator_color).mx_1()) - .child(Label::new(used.clone())) - .child(Label::new("/").color(separator_color)) - .child(Label::new(max.clone()).color(Color::Muted)), - ) - .when(user_rules_count > 0 || project_rules_count > 0, |this| { - this.child( - v_flex() - .mt_1p5() - .pt_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new("Rules") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .when(user_rules_count > 0, |this| { - this.child(Label::new(format!( - "{} user rules", - user_rules_count - ))) - }) - .when(project_rules_count > 0, |this| { - this.child(Label::new(format!( - "{} project rules", - project_rules_count - ))) - }), - ) - }) - .into_any_element() - } - })) + .hoverable_tooltip(build_tooltip) .into_any_element(), ) } @@ -3910,16 +3959,184 @@ impl ThreadView { } } +struct TokenUsageTooltip { + percentage: String, + used: String, + max: String, + input_tokens: String, + output_tokens: String, + input_max: String, + output_max: String, + show_split: bool, + separator_color: Color, + user_rules_count: usize, + first_user_rules_id: Option, + project_rules_count: usize, + project_entry_ids: Vec, + workspace: WeakEntity, +} + +impl Render for TokenUsageTooltip { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let separator_color = self.separator_color; + let percentage = self.percentage.clone(); + let used = self.used.clone(); + let max = self.max.clone(); + let input_tokens = self.input_tokens.clone(); + let output_tokens = self.output_tokens.clone(); + let input_max = self.input_max.clone(); + let output_max = self.output_max.clone(); + let show_split = self.show_split; + let user_rules_count = self.user_rules_count; + let first_user_rules_id = self.first_user_rules_id; + let project_rules_count = self.project_rules_count; + let project_entry_ids = self.project_entry_ids.clone(); + let workspace = self.workspace.clone(); + + ui::tooltip_container(cx, move |container, cx| { + container + .min_w_40() + .when(!show_split, |this| { + this.child( + Label::new("Context") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + h_flex() + .gap_0p5() + .child(Label::new(percentage.clone())) + .child(Label::new("\u{2022}").color(separator_color).mx_1()) + .child(Label::new(used.clone())) + .child(Label::new("/").color(separator_color)) + .child(Label::new(max.clone()).color(Color::Muted)), + ) + }) + .when(show_split, |this| { + this.child( + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_0p5() + .child(Label::new("Input:").color(Color::Muted).mr_0p5()) + .child(Label::new(input_tokens)) + .child(Label::new("/").color(separator_color)) + .child(Label::new(input_max).color(Color::Muted)), + ) + .child( + h_flex() + .gap_0p5() + .child(Label::new("Output:").color(Color::Muted).mr_0p5()) + .child(Label::new(output_tokens)) + .child(Label::new("/").color(separator_color)) + .child(Label::new(output_max).color(Color::Muted)), + ), + ) + }) + .when( + user_rules_count > 0 || project_rules_count > 0, + move |this| { + this.child( + v_flex() + .mt_1p5() + .pt_1p5() + .pb_0p5() + .gap_0p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new("Rules") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + v_flex() + .mx_neg_1() + .when(user_rules_count > 0, move |this| { + this.child( + Button::new( + "open-user-rules", + format!("{} user rules", user_rules_count), + ) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(OpenRulesLibrary { + prompt_to_select: first_user_rules_id, + }), + cx, + ); + }), + ) + }) + .when(project_rules_count > 0, move |this| { + let workspace = workspace.clone(); + let project_entry_ids = project_entry_ids.clone(); + this.child( + Button::new( + "open-project-rules", + format!( + "{} project rules", + project_rules_count + ), + ) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .on_click(move |_, window, cx| { + let _ = + workspace.update(cx, |workspace, cx| { + let project = + workspace.project().read(cx); + let paths = project_entry_ids + .iter() + .flat_map(|id| { + project.path_for_entry(*id, cx) + }) + .collect::>(); + for path in paths { + workspace + .open_path( + path, None, true, window, + cx, + ) + .detach_and_log_err(cx); + } + }); + }), + ) + }), + ), + ) + }, + ) + }) + } +} + impl ThreadView { pub(crate) fn render_entries(&mut self, cx: &mut Context) -> List { list( self.list_state.clone(), cx.processor(|this, index: usize, window, cx| { let entries = this.thread.read(cx).entries(); - let Some(entry) = entries.get(index) else { - return Empty.into_any(); - }; - this.render_entry(index, entries.len(), entry, window, cx) + if let Some(entry) = entries.get(index) { + this.render_entry(index, entries.len(), entry, window, cx) + } else if this.generating_indicator_in_list { + let confirmation = entries + .last() + .is_some_and(|entry| Self::is_waiting_for_confirmation(entry)); + this.render_generating(confirmation, cx).into_any_element() + } else { + Empty.into_any() + } }), ) .with_sizing_behavior(gpui::ListSizingBehavior::Auto) @@ -3959,12 +4176,6 @@ impl ThreadView { let editor_focus = editor.focus_handle(cx).is_focused(window); let focus_border = cx.theme().colors().border_focused; - let rules_item = if entry_ix == 0 { - self.render_rules_item(cx) - } else { - None - }; - let has_checkpoint_button = message .checkpoint .as_ref() @@ -3983,10 +4194,6 @@ impl ThreadView { .map(|this| { if is_first_indented { this.pt_0p5() - } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { - this.pt(rems_from_px(18.)) - } else if rules_item.is_some() { - this.pt_3() } else { this.pt_2() } @@ -3995,7 +4202,6 @@ impl ThreadView { .px_2() .gap_1p5() .w_full() - .children(rules_item) .when(is_editable && has_checkpoint_button, |this| { this.children(message.id.clone().map(|message_id| { h_flex() @@ -4250,6 +4456,8 @@ impl ThreadView { primary }; + let thread = self.thread.clone(); + let primary = if is_indented { let line_top = if is_first_indented { rems_from_px(-12.0) @@ -4277,28 +4485,16 @@ impl ThreadView { primary }; - let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry { - matches!( - tool_call.status, - ToolCallStatus::WaitingForConfirmation { .. } - ) - } else { - false - }; + let needs_confirmation = Self::is_waiting_for_confirmation(entry); - let thread = self.thread.clone(); let comments_editor = self.thread_feedback.comments_editor.clone(); let primary = if entry_ix + 1 == total_entries { v_flex() .w_full() .child(primary) - .map(|this| { - if needs_confirmation { - this.child(self.render_generating(true, cx)) - } else { - this.child(self.render_thread_controls(&thread, cx)) - } + .when(!needs_confirmation, |this| { + this.child(self.render_thread_controls(&thread, cx)) }) .when_some(comments_editor, |this, editor| { this.child(Self::render_feedback_feedback_editor(editor, cx)) @@ -4382,7 +4578,7 @@ impl ThreadView { ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); if is_generating { - return self.render_generating(false, cx).into_any_element(); + return Empty.into_any_element(); } let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) @@ -4582,13 +4778,12 @@ impl ThreadView { }); cx.notify(); } else { - self.scroll_to_bottom(cx); + self.scroll_to_end(cx); } } - pub fn scroll_to_bottom(&mut self, cx: &mut Context) { - let entry_count = self.thread.read(cx).entries().len(); - self.list_state.reset(entry_count); + pub fn scroll_to_end(&mut self, cx: &mut Context) { + self.list_state.scroll_to_end(); cx.notify(); } @@ -4669,6 +4864,21 @@ impl ThreadView { }) } + /// Ensures the list item count includes (or excludes) an extra item for the generating indicator + pub(crate) fn sync_generating_indicator(&mut self, cx: &App) { + let is_generating = matches!(self.thread.read(cx).status(), ThreadStatus::Generating); + + if is_generating && !self.generating_indicator_in_list { + let entries_count = self.thread.read(cx).entries().len(); + self.list_state.splice(entries_count..entries_count, 1); + self.generating_indicator_in_list = true; + } else if !is_generating && self.generating_indicator_in_list { + let entries_count = self.thread.read(cx).entries().len(); + self.list_state.splice(entries_count..entries_count + 1, 0); + self.generating_indicator_in_list = false; + } + } + fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement { let show_stats = AgentSettings::get_global(cx).show_turn_stats; let elapsed_label = show_stats @@ -4952,7 +5162,7 @@ impl ThreadView { let entity = entity.clone(); move |_, cx| { entity.update(cx, |this, cx| { - this.scroll_to_bottom(cx); + this.scroll_to_end(cx); }); } }) @@ -7423,113 +7633,6 @@ impl ThreadView { } } - fn render_rules_item(&self, cx: &Context) -> Option { - let project_context = self - .as_native_thread(cx)? - .read(cx) - .project_context() - .read(cx); - - let user_rules_text = if project_context.user_rules.is_empty() { - None - } else if project_context.user_rules.len() == 1 { - let user_rules = &project_context.user_rules[0]; - - match user_rules.title.as_ref() { - Some(title) => Some(format!("Using \"{title}\" user rule")), - None => Some("Using user rule".into()), - } - } else { - Some(format!( - "Using {} user rules", - project_context.user_rules.len() - )) - }; - - let first_user_rules_id = project_context - .user_rules - .first() - .map(|user_rules| user_rules.uuid.0); - - let rules_files = project_context - .worktrees - .iter() - .filter_map(|worktree| worktree.rules_file.as_ref()) - .collect::>(); - - let rules_file_text = match rules_files.as_slice() { - &[] => None, - &[rules_file] => Some(format!( - "Using project {:?} file", - rules_file.path_in_worktree - )), - rules_files => Some(format!("Using {} project rules files", rules_files.len())), - }; - - if user_rules_text.is_none() && rules_file_text.is_none() { - return None; - } - - let has_both = user_rules_text.is_some() && rules_file_text.is_some(); - - Some( - h_flex() - .px_2p5() - .child( - Icon::new(IconName::Attach) - .size(IconSize::XSmall) - .color(Color::Disabled), - ) - .when_some(user_rules_text, |parent, user_rules_text| { - parent.child( - h_flex() - .id("user-rules") - .ml_1() - .mr_1p5() - .child( - Label::new(user_rules_text) - .size(LabelSize::XSmall) - .color(Color::Muted) - .truncate(), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View User Rules")) - .on_click(move |_event, window, cx| { - window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: first_user_rules_id, - }), - cx, - ) - }), - ) - }) - .when(has_both, |this| { - this.child( - Label::new("•") - .size(LabelSize::XSmall) - .color(Color::Disabled), - ) - }) - .when_some(rules_file_text, |parent, rules_file_text| { - parent.child( - h_flex() - .id("project-rules") - .ml_1p5() - .child( - Label::new(rules_file_text) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View Project Rules")) - .on_click(cx.listener(Self::handle_open_rules)), - ) - }) - .into_any(), - ) - } - fn tool_card_header_bg(&self, cx: &Context) -> Hsla { cx.theme() .colors() diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 578900085334baf27ab90ae77748fb7fd362e8ad..ed441e3b40534690d02b31109e719c60dd5802e0 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -72,6 +72,7 @@ struct StateInner { scrollbar_drag_start_height: Option, measuring_behavior: ListMeasuringBehavior, pending_scroll: Option, + follow_tail: bool, } /// Keeps track of a fractional scroll position within an item for restoration @@ -102,6 +103,9 @@ pub struct ListScrollEvent { /// Whether the list has been scrolled. pub is_scrolled: bool, + + /// Whether the list is currently in follow-tail mode (auto-scrolling to end). + pub is_following_tail: bool, } /// The sizing behavior to apply during layout. @@ -236,6 +240,7 @@ impl ListState { scrollbar_drag_start_height: None, measuring_behavior: ListMeasuringBehavior::default(), pending_scroll: None, + follow_tail: false, }))); this.splice(0..0, item_count); this @@ -394,6 +399,34 @@ impl ListState { }); } + /// Scroll the list to the very end (past the last item). + /// + /// Unlike [`scroll_to_reveal_item`], this uses the total item count as the + /// anchor, so the list's layout pass will walk backwards from the end and + /// always show the bottom of the last item — even when that item is still + /// growing (e.g. during streaming). + pub fn scroll_to_end(&self) { + let state = &mut *self.0.borrow_mut(); + let item_count = state.items.summary().count; + state.logical_scroll_top = Some(ListOffset { + item_ix: item_count, + offset_in_item: px(0.), + }); + } + + /// Set whether the list should automatically follow the tail (auto-scroll to the end). + pub fn set_follow_tail(&self, follow: bool) { + self.0.borrow_mut().follow_tail = follow; + if follow { + self.scroll_to_end(); + } + } + + /// Returns whether the list is currently in follow-tail mode (auto-scrolling to the end). + pub fn is_following_tail(&self) -> bool { + self.0.borrow().follow_tail + } + /// Scroll the list to the given offset pub fn scroll_to(&self, mut scroll_top: ListOffset) { let state = &mut *self.0.borrow_mut(); @@ -559,7 +592,6 @@ impl StateInner { if self.reset { return; } - let padding = self.last_padding.unwrap_or_default(); let scroll_max = (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.)); @@ -581,6 +613,10 @@ impl StateInner { }); } + if self.follow_tail && delta.y > px(0.) { + self.follow_tail = false; + } + if let Some(handler) = self.scroll_handler.as_mut() { let visible_range = Self::visible_range(&self.items, height, scroll_top); handler( @@ -588,6 +624,7 @@ impl StateInner { visible_range, count: self.items.summary().count, is_scrolled: self.logical_scroll_top.is_some(), + is_following_tail: self.follow_tail, }, window, cx, @@ -677,6 +714,15 @@ impl StateInner { let mut rendered_height = padding.top; let mut max_item_width = px(0.); let mut scroll_top = self.logical_scroll_top(); + + if self.follow_tail { + scroll_top = ListOffset { + item_ix: self.items.summary().count, + offset_in_item: px(0.), + }; + self.logical_scroll_top = Some(scroll_top); + } + let mut rendered_focused_item = false; let available_item_space = size( @@ -958,6 +1004,8 @@ impl StateInner { content_height - self.scrollbar_drag_start_height.unwrap_or(content_height); let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max); + self.follow_tail = false; + if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { self.logical_scroll_top = None; } else { @@ -1457,6 +1505,217 @@ mod test { assert_eq!(offset.offset_in_item, px(20.)); } + #[gpui::test] + fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items, each 50px tall → 500px total content, 200px viewport. + // With follow-tail on, the list should always show the bottom. + let item_height = Rc::new(Cell::new(50usize)); + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |_, _, _| { + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + state.set_follow_tail(true); + + // First paint — items are 50px, total 500px, viewport 200px. + // Follow-tail should anchor to the end. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + // The scroll should be at the bottom: the last visible items fill the + // 200px viewport from the end of 500px of content (offset 300px). + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + + // Simulate items growing (e.g. streaming content makes each item taller). + // 10 items × 80px = 800px total. + item_height.set(80); + state.remeasure(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // After growth, follow-tail should have re-anchored to the new end. + // 800px total − 200px viewport = 600px offset → item 7 at offset 40px, + // but follow-tail anchors to item_count (10), and layout walks back to + // fill 200px, landing at item 7 (7 × 80 = 560, 800 − 560 = 240 > 200, + // so item 8: 8 × 80 = 640, 800 − 640 = 160 < 200 → keeps walking → + // item 7: offset = 800 − 200 = 600, item_ix = 600/80 = 7, remainder 40). + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 7); + assert_eq!(offset.offset_in_item, px(40.)); + assert!(state.is_following_tail()); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + state.set_follow_tail(true); + + // Paint with follow-tail — scroll anchored to the bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + assert!(state.is_following_tail()); + + // Simulate the user scrolling up. + // This should disengage follow-tail. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(100.))), + ..Default::default() + }); + + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the user scrolls toward the start" + ); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_tail(true); + + // Paint with follow-tail — scroll anchored to the bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + // Simulate the scrollbar moving the viewport to the middle. + // `set_offset_from_scrollbar` accepts a positive distance from the start. + state.set_offset_from_scrollbar(point(px(0.), px(150.))); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the scrollbar manually repositions the list" + ); + + // A subsequent draw should preserve the user's manual position instead + // of snapping back to the end. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + } + + #[gpui::test] + fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + // Scroll to the middle of the list (item 3). + state.scroll_to(gpui::ListOffset { + item_ix: 3, + offset_in_item: px(0.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(!state.is_following_tail()); + + // Enable follow-tail — this should immediately snap the scroll anchor + // to the end, like the user just sent a prompt. + state.set_follow_tail(true); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // After paint, scroll should be at the bottom. + // 500px total − 200px viewport = 300px offset → item 6, offset 0. + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + } + #[gpui::test] fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); From 5d0934b443e9eb20d6b604b52a29dd0b1edda536 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Thu, 26 Mar 2026 11:53:16 +0000 Subject: [PATCH 05/53] workspace: Show file path in bottom bar (#52381) Context: if the toolbar and tab bar are both disabled, the current filename is not visible. This adds it to the bottom bar, similar to vim. Behind a setting, disabled by default Release Notes: - N/A or Added/Fixed/Improved ... --- assets/settings/default.json | 2 + crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/workspace.rs | 4 ++ crates/settings_ui/src/page_data.rs | 24 +++++++- crates/workspace/src/active_file_name.rs | 69 ++++++++++++++++++++++ crates/workspace/src/workspace.rs | 1 + crates/workspace/src/workspace_settings.rs | 2 + crates/zed/src/zed.rs | 2 + 8 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 crates/workspace/src/active_file_name.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 9ea3285f90885d1ab2c33717b802ac6e8ebbfe3d..7bfb1f2cdb68856d66073e8629d9921602d806d8 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1617,6 +1617,8 @@ "status_bar": { // Whether to show the status bar. "experimental.show": true, + // Whether to show the name of the active file in the status bar. + "show_active_file": false, // Whether to show the active language button in the status bar. "active_language_button": true, // Whether to show the cursor position button in the status bar. diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 414fef665a8e6841bc43242bd2f0a05147eaea1d..2d52fee639f50b26ec115a69660a90492e7e85ef 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -769,6 +769,7 @@ impl VsCodeSettings { fn status_bar_settings_content(&self) -> Option { skip_default(StatusBarSettingsContent { show: self.read_bool("workbench.statusBar.visible"), + show_active_file: None, active_language_button: None, cursor_position_button: None, line_endings_button: None, diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 7134f7db6e058bbbdd53e72196ae6727c628d339..ef00a44790fd10b8c56278362a2f552a40f52cbb 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -434,6 +434,10 @@ pub struct StatusBarSettingsContent { /// Default: true #[serde(rename = "experimental.show")] pub show: Option, + /// Whether to show the name of the active file in the status bar. + /// + /// Default: false + pub show_active_file: Option, /// Whether to display the active language button in the status bar. /// /// Default: true diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 5fa1679532aa9ad82801e78a929a8bfd59509818..593564c7013fa8a0fb3e6a9f49ff0d14fbe9584f 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -3327,7 +3327,7 @@ fn search_and_files_page() -> SettingsPage { } fn window_and_layout_page() -> SettingsPage { - fn status_bar_section() -> [SettingsPageItem; 9] { + fn status_bar_section() -> [SettingsPageItem; 10] { [ SettingsPageItem::SectionHeader("Status Bar"), SettingsPageItem::SettingItem(SettingItem { @@ -3472,6 +3472,28 @@ fn window_and_layout_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Active File Name", + description: "Show the name of the active file in the status bar.", + field: Box::new(SettingField { + json_path: Some("status_bar.show_active_file"), + pick: |settings_content| { + settings_content + .status_bar + .as_ref()? + .show_active_file + .as_ref() + }, + write: |settings_content, value| { + settings_content + .status_bar + .get_or_insert_default() + .show_active_file = value; + }, + }), + metadata: None, + files: USER, + }), ] } diff --git a/crates/workspace/src/active_file_name.rs b/crates/workspace/src/active_file_name.rs new file mode 100644 index 0000000000000000000000000000000000000000..f35312d529423c4dc81bb71dc585c99169afdd39 --- /dev/null +++ b/crates/workspace/src/active_file_name.rs @@ -0,0 +1,69 @@ +use gpui::{ + Context, Empty, EventEmitter, IntoElement, ParentElement, Render, SharedString, Window, +}; +use settings::Settings; +use ui::{Button, Tooltip, prelude::*}; +use util::paths::PathStyle; + +use crate::{StatusItemView, item::ItemHandle, workspace_settings::StatusBarSettings}; + +pub struct ActiveFileName { + project_path: Option, + full_path: Option, +} + +impl ActiveFileName { + pub fn new() -> Self { + Self { + project_path: None, + full_path: None, + } + } +} + +impl Render for ActiveFileName { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !StatusBarSettings::get_global(cx).show_active_file { + return Empty.into_any_element(); + } + + let Some(project_path) = self.project_path.clone() else { + return Empty.into_any_element(); + }; + + let tooltip_text = self + .full_path + .clone() + .unwrap_or_else(|| project_path.clone()); + + div() + .child( + Button::new("active-file-name-button", project_path) + .label_size(LabelSize::Small) + .tooltip(Tooltip::text(tooltip_text)), + ) + .into_any_element() + } +} + +impl EventEmitter for ActiveFileName {} + +impl StatusItemView for ActiveFileName { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(item) = active_pane_item { + self.project_path = item + .project_path(cx) + .map(|path| path.path.display(PathStyle::local()).into_owned().into()); + self.full_path = item.tab_tooltip_text(cx); + } else { + self.project_path = None; + self.full_path = None; + } + cx.notify(); + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 37cac09863b2251a7c8dc259d3fb1fc68c00c07e..fcb46039921f94e9a4a8b717f62ec9f709955f40 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,3 +1,4 @@ +pub mod active_file_name; pub mod dock; pub mod history_manager; pub mod invalid_item_view; diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 5575af3d7cf07fd7afd22ddbb78a620bab775714..d78b233229800b571ccc37f87719d09125f1c4c3 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -132,6 +132,7 @@ impl Settings for TabBarSettings { #[derive(Deserialize, RegisterSetting)] pub struct StatusBarSettings { pub show: bool, + pub show_active_file: bool, pub active_language_button: bool, pub cursor_position_button: bool, pub line_endings_button: bool, @@ -143,6 +144,7 @@ impl Settings for StatusBarSettings { let status_bar = content.status_bar.clone().unwrap(); StatusBarSettings { show: status_bar.show.unwrap(), + show_active_file: status_bar.show_active_file.unwrap(), active_language_button: status_bar.active_language_button.unwrap(), cursor_position_button: status_bar.cursor_position_button.unwrap(), line_endings_button: status_bar.line_endings_button.unwrap(), diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 1a51d08540a95381e4494ae724806967dd8ed1ec..d8438eb1b85aaa5191a178adc6b61865ebd94590 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -478,6 +478,7 @@ pub fn initialize_workspace( let search_button = cx.new(|_| search::search_status_button::SearchButton::new()); let diagnostic_summary = cx.new(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx)); + let active_file_name = cx.new(|_| workspace::active_file_name::ActiveFileName::new()); let activity_indicator = activity_indicator::ActivityIndicator::new( workspace, workspace.project().read(cx).languages().clone(), @@ -510,6 +511,7 @@ pub fn initialize_workspace( status_bar.add_left_item(search_button, window, cx); status_bar.add_left_item(lsp_button, window, cx); status_bar.add_left_item(diagnostic_summary, window, cx); + status_bar.add_left_item(active_file_name, window, cx); status_bar.add_left_item(activity_indicator, window, cx); status_bar.add_right_item(edit_prediction_ui, window, cx); status_bar.add_right_item(active_buffer_encoding, window, cx); From ec6c4ed00bc50edefa18c95b256bc8f8c8486711 Mon Sep 17 00:00:00 2001 From: Ted Robertson <10043369+tredondo@users.noreply.github.com> Date: Thu, 26 Mar 2026 05:37:18 -0700 Subject: [PATCH 06/53] docs: Update keybindings in `webstorm.md` (#49583) - add Linux/Windows keybindings - use consistent spacing around the `+` in key combos Release Notes: - I don't have access to a Mac, so I haven't verified any of the macOS keybindings. I suspect some may be out of date. Please verify. - All other migration guides should be updated with Linux/Windows shortcuts, and should use consistent spacing around the `+`: - https://zed.dev/docs/migrate/vs-code#differences-in-keybindings - https://zed.dev/docs/migrate/intellij#differences-in-keybindings - https://zed.dev/docs/migrate/pycharm#differences-in-keybindings - https://zed.dev/docs/migrate/rustrover#differences-in-keybindings --------- Co-authored-by: MrSubidubi --- crates/docs_preprocessor/src/main.rs | 106 ++++++++++++++++++--- docs/README.md | 8 ++ docs/src/migrate/webstorm.md | 133 +++++++++++++-------------- 3 files changed, 163 insertions(+), 84 deletions(-) diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index 43efbeea0b0310cf70cd9bdb560b1b0d2b0c14ef..fc1bc404244a4896e7d13fbb0e9c81674438568f 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -22,8 +22,45 @@ static KEYMAP_WINDOWS: LazyLock = LazyLock::new(|| { load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap") }); +static KEYMAP_JETBRAINS_MACOS: LazyLock = LazyLock::new(|| { + load_keymap("keymaps/macos/jetbrains.json").expect("Failed to load JetBrains macOS keymap") +}); + +static KEYMAP_JETBRAINS_LINUX: LazyLock = LazyLock::new(|| { + load_keymap("keymaps/linux/jetbrains.json").expect("Failed to load JetBrains Linux keymap") +}); + static ALL_ACTIONS: LazyLock = LazyLock::new(load_all_actions); +#[derive(Clone, Copy)] +#[allow(dead_code)] +enum Os { + MacOs, + Linux, + Windows, +} + +#[derive(Clone, Copy)] +enum KeymapOverlay { + JetBrains, +} + +impl KeymapOverlay { + fn parse(name: &str) -> Option { + match name { + "jetbrains" => Some(Self::JetBrains), + _ => None, + } + } + + fn keymap(self, os: Os) -> &'static KeymapFile { + match (self, os) { + (Self::JetBrains, Os::MacOs) => &KEYMAP_JETBRAINS_MACOS, + (Self::JetBrains, Os::Linux | Os::Windows) => &KEYMAP_JETBRAINS_LINUX, + } + } +} + const FRONT_MATTER_COMMENT: &str = ""; fn main() -> Result<()> { @@ -64,6 +101,9 @@ enum PreprocessorError { snippet: String, error: String, }, + UnknownKeymapOverlay { + overlay_name: String, + }, } impl PreprocessorError { @@ -125,6 +165,13 @@ impl std::fmt::Display for PreprocessorError { snippet ) } + PreprocessorError::UnknownKeymapOverlay { overlay_name } => { + write!( + f, + "Unknown keymap overlay: '{}'. Supported overlays: jetbrains", + overlay_name + ) + } } } } @@ -205,20 +252,39 @@ fn format_binding(binding: String) -> String { } fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#kb (.*?)\}").unwrap(); + let regex = Regex::new(r"\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex .replace_all(&chapter.content, |caps: ®ex::Captures| { - let action = caps[1].trim(); + let overlay_name = caps.get(1).map(|m| m.as_str()); + let action = caps[2].trim(); + if is_missing_action(action) { errors.insert(PreprocessorError::new_for_not_found_action( action.to_string(), )); return String::new(); } - let macos_binding = find_binding("macos", action).unwrap_or_default(); - let linux_binding = find_binding("linux", action).unwrap_or_default(); + + let overlay = if let Some(name) = overlay_name { + let Some(overlay) = KeymapOverlay::parse(name) else { + errors.insert(PreprocessorError::UnknownKeymapOverlay { + overlay_name: name.to_string(), + }); + return String::new(); + }; + Some(overlay) + } else { + None + }; + + let macos_binding = + find_binding_with_overlay(Os::MacOs, action, overlay) + .unwrap_or_default(); + let linux_binding = + find_binding_with_overlay(Os::Linux, action, overlay) + .unwrap_or_default(); if macos_binding.is_empty() && linux_binding.is_empty() { return "
No default binding
".to_string(); @@ -227,7 +293,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet{formatted_macos_binding}|{formatted_linux_binding}") + format!("{formatted_macos_binding}|{formatted_linux_binding}") }) .into_owned() }); @@ -270,15 +336,8 @@ fn is_missing_action(name: &str) -> bool { actions_available() && find_action_by_name(name).is_none() } -fn find_binding(os: &str, action: &str) -> Option { - let keymap = match os { - "macos" => &KEYMAP_MACOS, - "linux" | "freebsd" => &KEYMAP_LINUX, - "windows" => &KEYMAP_WINDOWS, - _ => unreachable!("Not a valid OS: {}", os), - }; - - // Find the binding in reverse order, as the last binding takes precedence. +// Find the binding in reverse order, as the last binding takes precedence. +fn find_binding_in_keymap(keymap: &KeymapFile, action: &str) -> Option { keymap.sections().rev().find_map(|section| { section.bindings().rev().find_map(|(keystroke, a)| { if name_for_action(a.to_string()) == action { @@ -290,6 +349,25 @@ fn find_binding(os: &str, action: &str) -> Option { }) } +fn find_binding(os: Os, action: &str) -> Option { + let keymap = match os { + Os::MacOs => &KEYMAP_MACOS, + Os::Linux => &KEYMAP_LINUX, + Os::Windows => &KEYMAP_WINDOWS, + }; + find_binding_in_keymap(keymap, action) +} + +fn find_binding_with_overlay( + os: Os, + action: &str, + overlay: Option, +) -> Option { + overlay + .and_then(|overlay| find_binding_in_keymap(overlay.keymap(os), action)) + .or_else(|| find_binding(os, action)) +} + fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet) { let settings_schema = SettingsStore::json_schema(&Default::default()); let settings_validator = jsonschema::validator_for(&settings_schema) diff --git a/docs/README.md b/docs/README.md index a0f9bbd5c628f41d291880239ca555ea7ec0e3ea..f03f008223ba1102585c34f3b98bf93a985c1284 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,14 @@ This will output a code element like: `Cmd + , | Ctrl + ,`. We then By using the action name, we can ensure that the keybinding is always up-to-date rather than hardcoding the keybinding. +#### Keymap Overlays + +`{#kb:keymap_name scope::Action}` - e.g., `{#kb:jetbrains editor::GoToDefinition}`. + +This resolves the keybinding from a keymap overlay (e.g., JetBrains) first, falling back to the default keymap if the overlay doesn't define a binding for that action. This is useful for sections where the documentation expects a special base keymap to be configured. + +Supported overlays: `jetbrains`. + ### Actions `{#action scope::Action}` - e.g., `{#action zed::OpenSettings}`. diff --git a/docs/src/migrate/webstorm.md b/docs/src/migrate/webstorm.md index 72916b04c5579785d2f099f1fd2b09d7ffb11acf..eb41f5c245cdc33a9a78320997b546bee8e14f15 100644 --- a/docs/src/migrate/webstorm.md +++ b/docs/src/migrate/webstorm.md @@ -37,11 +37,11 @@ This opens the current directory in Zed. If you're coming from WebStorm, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime: -1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +1. Open Settings with {#kb zed::OpenSettings} 2. Search for `Base Keymap` 3. Select `JetBrains` -This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action. +This maps familiar shortcuts like {#kb:jetbrains project_symbols::Toggle} for Go to Class and {#kb:jetbrains command_palette::Toggle} for Find Action. ## Set Up Editor Preferences @@ -63,7 +63,7 @@ Zed also supports per-project settings. Create a `.zed/settings.json` file in yo ## Open or Create a Project -After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike WebStorm, there's no project configuration wizard, no framework selection dialog, and no project structure setup required. +After setup, use {#kb:jetbrains file_finder::Toggle} to open a folder. This becomes your workspace in Zed. Unlike WebStorm, there's no project configuration wizard, no framework selection dialog, and no project structure setup required. To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project. For new projects, you'd typically run `npm init`, `pnpm create`, or your framework's CLI tool first, then open the resulting folder in Zed. @@ -72,60 +72,53 @@ You can also launch Zed from the terminal inside any folder with: Once inside a project: -- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like WebStorm's "Recent Files") -- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like WebStorm's "Search Everywhere") -- Use `Cmd+O` to search for symbols (like WebStorm's "Go to Symbol") +- Use {#kb:jetbrains file_finder::Toggle} to jump between files quickly (like WebStorm's "Recent Files") +- Use {#kb:jetbrains command_palette::Toggle} to open the Command Palette (like WebStorm's "Search Everywhere") +- Use {#kb:jetbrains project_symbols::Toggle} to search for symbols (like WebStorm's "Go to Symbol") -Open buffers appear as tabs across the top. The Project Panel shows your file tree and Git status. Toggle it with `Cmd+1` (just like WebStorm's Project tool window). +Open buffers appear as tabs across the top. The Project Panel shows your file tree and Git status. Toggle it with {#kb:jetbrains project_panel::ToggleFocus} (just like WebStorm's Project tool window). ## Differences in Keybindings -If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to WebStorm. - -### Common Shared Keybindings - -| Action | Shortcut | -| ----------------------------- | ----------------------- | -| Search Everywhere | `Shift Shift` | -| Find Action / Command Palette | `Cmd + Shift + A` | -| Go to File | `Cmd + Shift + O` | -| Go to Symbol | `Cmd + O` | -| Recent Files | `Cmd + E` | -| Go to Definition | `Cmd + B` | -| Find Usages | `Alt + F7` | -| Rename Symbol | `Shift + F6` | -| Reformat Code | `Cmd + Alt + L` | -| Toggle Project Panel | `Cmd + 1` | -| Toggle Terminal | `Alt + F12` | -| Duplicate Line | `Cmd + D` | -| Delete Line | `Cmd + Backspace` | -| Move Line Up/Down | `Shift + Alt + Up/Down` | -| Expand/Shrink Selection | `Alt + Up/Down` | -| Comment Line | `Cmd + /` | -| Go Back / Forward | `Cmd + [` / `Cmd + ]` | -| Toggle Breakpoint | `Ctrl + F8` | - -### Different Keybindings (WebStorm → Zed) - -| Action | WebStorm | Zed (JetBrains keymap) | -| ---------------------- | ----------- | ------------------------ | -| File Structure | `Cmd + F12` | `Cmd + F12` (outline) | -| Navigate to Next Error | `F2` | `F2` | -| Run | `Ctrl + R` | `Ctrl + Alt + R` (tasks) | -| Debug | `Ctrl + D` | `Alt + Shift + F9` | -| Stop | `Cmd + F2` | `Ctrl + F2` | +If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference of common actions and their keybindings with the JetBrains keymap active. + +### Common Keybindings + +| Action | Zed Keybinding | +| ---------------------- | ----------------------------------------------- | +| Command Palette | {#kb:jetbrains command_palette::Toggle} | +| Go to File | {#kb:jetbrains file_finder::Toggle} | +| Go to Symbol | {#kb:jetbrains project_symbols::Toggle} | +| File Outline | {#kb:jetbrains outline::Toggle} | +| Go to Definition | {#kb:jetbrains editor::GoToDefinition} | +| Find Usages | {#kb:jetbrains editor::FindAllReferences} | +| Rename Symbol | {#kb:jetbrains editor::Rename} | +| Reformat Code | {#kb:jetbrains editor::Format} | +| Toggle Project Panel | {#kb:jetbrains project_panel::ToggleFocus} | +| Toggle Terminal | {#kb:jetbrains terminal_panel::Toggle} | +| Duplicate Line | {#kb:jetbrains editor::DuplicateSelection} | +| Delete Line | {#kb:jetbrains editor::DeleteLine} | +| Move Line Up | {#kb:jetbrains editor::MoveLineUp} | +| Move Line Down | {#kb:jetbrains editor::MoveLineDown} | +| Expand Selection | {#kb:jetbrains editor::SelectLargerSyntaxNode} | +| Shrink Selection | {#kb:jetbrains editor::SelectSmallerSyntaxNode} | +| Comment Line | {#kb:jetbrains editor::ToggleComments} | +| Go Back | {#kb:jetbrains pane::GoBack} | +| Go Forward | {#kb:jetbrains pane::GoForward} | +| Toggle Breakpoint | {#kb:jetbrains editor::ToggleBreakpoint} | +| Navigate to Next Error | {#kb:jetbrains editor::GoToDiagnostic} | ### Unique to Zed -| Action | Shortcut | Notes | -| ----------------- | -------------------------- | ------------------------------ | -| Toggle Right Dock | `Cmd + R` | Assistant panel, notifications | -| Split Panes | `Cmd + K`, then arrow keys | Create splits in any direction | +| Action | Keybinding | Notes | +| ----------------- | -------------------------------- | ------------------------------------------------------------- | +| Toggle Right Dock | {#kb workspace::ToggleRightDock} | Assistant panel, notifications | +| Split Pane Right | {#kb pane::SplitRight} | Use other arrow keys to create splits in different directions | ### How to Customize Keybindings -- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) -- Run `Zed: Open Keymap Editor` +- Open the Command Palette ({#kb:jetbrains command_palette::Toggle}) +- Run `zed: open keymap` This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. @@ -143,9 +136,9 @@ WebStorm's index enables features like finding all usages across your entire cod **How to adapt:** -- Search symbols across the project with `Cmd+O` (powered by the TypeScript language server) -- Find files by name with `Cmd+Shift+O` -- Use `Cmd+Shift+F` for text search—it stays fast even in large monorepos +- Search symbols across the project with {#kb:jetbrains project_symbols::Toggle} (powered by the TypeScript language server) +- Find files by name with {#kb:jetbrains file_finder::Toggle} +- Use {#kb pane::DeploySearch} for text search—it stays fast even in large monorepos - Run `tsc --noEmit` or `eslint .` from the terminal when you need deeper project-wide analysis ### LSP vs. Native Language Intelligence @@ -169,10 +162,10 @@ Where you might notice differences: **How to adapt:** -- Use `Alt+Enter` for available code actions—the list will vary by language server +- Use {#kb:jetbrains editor::ToggleCodeActions} for available code actions—the list will vary by language server - Ensure your `tsconfig.json` is properly configured so the language server understands your project structure - Use Prettier for consistent formatting (it's enabled by default for JS/TS) -- For code inspection similar to WebStorm's "Inspect Code," check the Diagnostics panel (`Cmd+6`)—ESLint and TypeScript together catch many of the same issues +- For code inspection similar to WebStorm's "Inspect Code," check the Diagnostics panel ({#kb:jetbrains diagnostics::Deploy})—ESLint and TypeScript together catch many of the same issues ### No Project Model @@ -212,8 +205,8 @@ What this means in practice: ] ``` -- Use `Ctrl+Alt+R` to run tasks quickly -- Lean on your terminal (`Alt+F12`) for anything tasks don't cover +- Use {#kb:jetbrains task::Spawn} to run tasks quickly +- Lean on your terminal ({#kb:jetbrains terminal_panel::Toggle}) for anything tasks don't cover ### No Framework Integration @@ -223,8 +216,8 @@ Zed has none of this built-in. The TypeScript language server sees your code as **How to adapt:** -- Use grep and file search liberally. `Cmd+Shift+F` with a regex can find component definitions, route configurations, or API endpoints. -- Rely on your language server's "find references" (`Alt+F7`) for navigation—it works, just without framework context +- Use grep and file search liberally. {#kb pane::DeploySearch} with a regex can find component definitions, route configurations, or API endpoints. +- Rely on your language server's "find references" ({#kb:jetbrains editor::FindAllReferences}) for navigation—it works, just without framework context - Consider using framework-specific CLI tools (`ng`, `next`, `vite`) from Zed's terminal - For React, JSX/TSX syntax and TypeScript types still provide good intelligence @@ -232,16 +225,16 @@ Zed has none of this built-in. The TypeScript language server sees your code as ### Tool Windows vs. Docks -WebStorm organizes auxiliary views into numbered tool windows (Project = 1, npm = Alt+F11, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks": +WebStorm organizes auxiliary views into numbered tool windows. Zed uses a similar concept called "docks": -| WebStorm Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) | -| -------------------- | -------------- | --------------------------- | -| Project (1) | Project Panel | `Cmd + 1` | -| Git (9 or Cmd+0) | Git Panel | `Cmd + 0` | -| Terminal (Alt+F12) | Terminal Panel | `Alt + F12` | -| Structure (7) | Outline Panel | `Cmd + 7` | -| Problems (6) | Diagnostics | `Cmd + 6` | -| Debug (5) | Debug Panel | `Cmd + 5` | +| WebStorm Tool Window | Zed Equivalent | Zed Keybinding | +| -------------------- | -------------- | ------------------------------------------ | +| Project | Project Panel | {#kb:jetbrains project_panel::ToggleFocus} | +| Git | Git Panel | {#kb:jetbrains git_panel::ToggleFocus} | +| Terminal | Terminal Panel | {#kb:jetbrains terminal_panel::Toggle} | +| Structure | Outline Panel | {#kb:jetbrains outline_panel::ToggleFocus} | +| Problems | Diagnostics | {#kb:jetbrains diagnostics::Deploy} | +| Debug | Debug Panel | {#kb:jetbrains debug_panel::ToggleFocus} | Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings. @@ -252,10 +245,10 @@ Note that there's no dedicated npm tool window in Zed. Use the terminal or defin Both WebStorm and Zed offer integrated debugging for JavaScript and TypeScript: - Zed uses `vscode-js-debug` (the same debug adapter that VS Code uses) -- Set breakpoints with `Ctrl+F8` -- Start debugging with `Alt+Shift+F9` or press `F4` and select a debug target -- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out) -- Continue execution with `F9` +- Set breakpoints with {#kb:jetbrains editor::ToggleBreakpoint} +- Start debugging with {#kb:jetbrains debugger::Start} +- Step through code with {#kb:jetbrains debugger::StepInto} (step into), {#kb:jetbrains debugger::StepOver} (step over), {#kb:jetbrains debugger::StepOut} (step out) +- Continue execution with {#kb:jetbrains debugger::Continue} Zed can debug: @@ -359,7 +352,7 @@ If you're used to AI assistants in WebStorm (like GitHub Copilot, JetBrains AI A ### Configuring GitHub Copilot -1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +1. Open Settings with {#kb zed::OpenSettings} 2. Navigate to **AI → Edit Predictions** 3. Click **Configure** next to "Configure Providers" 4. Under **GitHub Copilot**, click **Sign in to GitHub** From 15d8660748b508b3525d3403e5d172f1a557bfa5 Mon Sep 17 00:00:00 2001 From: Jose Garcia <47431411+ruxwez@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:37:35 +0000 Subject: [PATCH 07/53] collab_ui: Fix "lost session" visual bug in Collab Panel (#52486) ## Context This PR fixes a UX issue ("visual bug") in the collaboration panel documented in issue [#51800](https://github.com/zed-industries/zed/issues/51800), where users who had already signed in were still seeing the "Sign In" screen after restarting the editor. As I mentioned in my response there ([Link to comment #4132366441](https://github.com/zed-industries/zed/issues/51800#issuecomment-4132366441)), I have investigated the problem thoroughly and found that the session is not actually lost. What I discovered is that in Zed, only "staff" users automatically connect to the collaboration servers when opening the editor (by design, this logic is in `crates/client/src/client.rs` starting at line `962`). Therefore, regular users keep their saved session and `Authenticated` status, but since they don't automatically connect upon startup, the UI didn't detect this correctly. It erroneously showed the GitHub account request and the "Sign in to enable collaboration" text, giving the false impression that the user had been logged out. ### Screenshots Before (Bug) image After (Fix) image **Note:** This PR specifically addresses the visual issue in the **Collab Panel**. Similar behaviors might exist in other parts of the editor, but this change focuses on correcting the collaboration interface. This current PR: 1. Improves the `render_signed_out` function in `crates/collab_ui/src/collab_panel.rs`. 2. Simplifies the connection check using `self.client.user_id().is_some()`, which is more robust against volatile network states and perfectly covers connection transitions. 3. During rendering, it detects existing credentials and shows the correct message "Connect" / "Connecting...", replacing the GitHub icon with the appropriate network icon (`SignalHigh`). ## How to Review - Review the cleaner and simplified code in `crates/collab_ui/src/collab_panel.rs:render_signed_out`. - Verify that instead of verbose validations on the `Status` enum, simply checking the user ID correctly captures any subsequent subtype, properly differentiating between account authorization and a simple network reconnection. ## 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 an issue (#51800) in the Collab Panel where the UI appeared to log users out. Implemented improvements to properly differentiate between "Sign In" and "Connect," avoiding false authentication prompts when users are already logged in but not automatically connected to the servers. --------- Co-authored-by: Danilo Leal --- crates/collab_ui/src/collab_panel.rs | 77 ++++++++++++++++------------ 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 3c32e76fea6dcd64b2a8b74c565544954af28c44..392e2340f14c5b633bcd9a0a8128d9423aed6a22 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2340,46 +2340,57 @@ impl CollabPanel { fn render_signed_out(&mut self, cx: &mut Context) -> Div { let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more."; - let is_signing_in = self.client.status().borrow().is_signing_in(); - let button_label = if is_signing_in { - "Signing in…" + + // Two distinct "not connected" states: + // - Authenticated (has credentials): user just needs to connect. + // - Unauthenticated (no credentials): user needs to sign in via GitHub. + let is_authenticated = self.client.user_id().is_some(); + let status = *self.client.status().borrow(); + let is_busy = status.is_signing_in(); + + let (button_id, button_label, button_icon) = if is_authenticated { + ( + "connect", + if is_busy { "Connecting…" } else { "Connect" }, + IconName::Public, + ) } else { - "Sign in" + ( + "sign_in", + if is_busy { + "Signing in…" + } else { + "Sign In with GitHub" + }, + IconName::Github, + ) }; v_flex() - .gap_6() .p_4() + .gap_4() + .size_full() + .text_center() + .justify_center() .child(Label::new(collab_blurb)) .child( - v_flex() - .gap_2() - .child( - Button::new("sign_in", button_label) - .start_icon(Icon::new(IconName::Github).color(Color::Muted)) - .style(ButtonStyle::Filled) - .full_width() - .disabled(is_signing_in) - .on_click(cx.listener(|this, _, window, cx| { - let client = this.client.clone(); - let workspace = this.workspace.clone(); - cx.spawn_in(window, async move |_, mut cx| { - client - .connect(true, &mut cx) - .await - .into_response() - .notify_workspace_async_err(workspace, &mut cx); - }) - .detach() - })), - ) - .child( - v_flex().w_full().items_center().child( - Label::new("Sign in to enable collaboration.") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ), + Button::new(button_id, button_label) + .full_width() + .start_icon(Icon::new(button_icon).color(Color::Muted)) + .style(ButtonStyle::Outlined) + .disabled(is_busy) + .on_click(cx.listener(|this, _, window, cx| { + let client = this.client.clone(); + let workspace = this.workspace.clone(); + cx.spawn_in(window, async move |_, mut cx| { + client + .connect(true, &mut cx) + .await + .into_response() + .notify_workspace_async_err(workspace, &mut cx); + }) + .detach() + })), ) } From 12bdc208e5298a6aaa1803b07bf6f63599cb6add Mon Sep 17 00:00:00 2001 From: Erik Funder Carstensen Date: Thu, 26 Mar 2026 14:48:18 +0100 Subject: [PATCH 08/53] zed_agent: Pick rules file in order described in docs (#52495) ## Context This makes zed-agent prioritize rules files in the same order as is described in the docs. My order of experience was - saw in my zed agent thread `Using project "CLAUDE.md" file. - went to settings to see if I can make it use `AGENTS.md` instead. - went to [the docs](https://zed.dev/docs/ai/rules) where it specifies that AGENTS.md is be picked over CLAUDE.md. - went to source to see what went wrong ## How to Review I'm changing the order of filenames in an array - the only two places where the order matters is when picking which rules file to use. The last place it's used with an `.iter().any()`. ## 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 - If you want the behavior tested I can, but I think it's equally hard keeping docs and tests and docs and this codepath in sync. - [x] Performance impact has been considered and is acceptable Release Notes: -Fixed agent rules files are prioritized as described in docs --- crates/prompt_store/src/prompts.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/prompt_store/src/prompts.rs b/crates/prompt_store/src/prompts.rs index 6a845bb8dd394f8a1ff26a8a0e130156a2a158bd..b0052947c44445be37f99e99cf723d5aa53c5008 100644 --- a/crates/prompt_store/src/prompts.rs +++ b/crates/prompt_store/src/prompts.rs @@ -26,9 +26,9 @@ pub const RULES_FILE_NAMES: &[&str] = &[ ".windsurfrules", ".clinerules", ".github/copilot-instructions.md", - "CLAUDE.md", "AGENT.md", "AGENTS.md", + "CLAUDE.md", "GEMINI.md", ]; From dd0d87f4eec9470a8bdd219a13d83c1a097e7139 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 26 Mar 2026 14:53:49 +0100 Subject: [PATCH 09/53] eval: Improve `StreamingEditFileTool` performance (#52428) ## Context | Eval | Score | |------|-------| | eval_delete_function | 1.00 | | eval_extract_handle_command_output | 0.96 | | eval_translate_doc_comments | 0.96 | Porting the rest of the evals is still a todo. ## 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: Ben Brandt --- .../possible-09.diff | 20 +++++++++++++++++++ .../src/tools/evals/streaming_edit_file.rs | 2 ++ .../src/tools/streaming_edit_file_tool.rs | 2 ++ crates/language_model/src/tool_schema.rs | 7 ++++++- 4 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff diff --git a/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff new file mode 100644 index 0000000000000000000000000000000000000000..6bc45657b3d6bf23b4542deb4f6016472a0e89b9 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff @@ -0,0 +1,20 @@ +@@ -5,7 +5,7 @@ + use futures::AsyncWriteExt; + use gpui::SharedString; + use serde::{Deserialize, Serialize}; +-use std::process::Stdio; ++use std::process::{Output, Stdio}; + use std::{ops::Range, path::Path}; + use text::Rope; + use time::OffsetDateTime; +@@ -94,6 +94,10 @@ + + let output = child.output().await.context("reading git blame output")?; + ++ handle_command_output(output) ++} ++ ++fn handle_command_output(output: Output) -> Result { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let trimmed = stderr.trim(); diff --git a/crates/agent/src/tools/evals/streaming_edit_file.rs b/crates/agent/src/tools/evals/streaming_edit_file.rs index 5ab931915e4789e2dd9f6fb7c1da19be6da59de2..6a55517037e54ae4166cd22427201d9325ef0f76 100644 --- a/crates/agent/src/tools/evals/streaming_edit_file.rs +++ b/crates/agent/src/tools/evals/streaming_edit_file.rs @@ -808,6 +808,8 @@ fn eval_extract_handle_command_output() { include_str!("fixtures/extract_handle_command_output/possible-05.diff"), include_str!("fixtures/extract_handle_command_output/possible-06.diff"), include_str!("fixtures/extract_handle_command_output/possible-07.diff"), + include_str!("fixtures/extract_handle_command_output/possible-08.diff"), + include_str!("fixtures/extract_handle_command_output/possible-09.diff"), ]; eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || { diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index ea89d6fef77bf02e50a7e1599254cac897ed074f..df99b4d65a62e3bb12239ef58d9ad49416554209 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -111,6 +111,8 @@ pub enum StreamingEditFileMode { } /// A single edit operation that replaces old text with new text +/// Properly escape all text fields as valid JSON strings. +/// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct Edit { /// The exact text to find in the file. This will be matched using fuzzy matching diff --git a/crates/language_model/src/tool_schema.rs b/crates/language_model/src/tool_schema.rs index f9402c28dc316f9ccdacc58afaa0eebd6699f92d..6fbb3761b43ea04924aaa23373920c41a14c74e3 100644 --- a/crates/language_model/src/tool_schema.rs +++ b/crates/language_model/src/tool_schema.rs @@ -17,7 +17,12 @@ pub enum LanguageModelToolSchemaFormat { pub fn root_schema_for(format: LanguageModelToolSchemaFormat) -> Schema { let mut generator = match format { - LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07().into_generator(), + LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07() + .with(|settings| { + settings.meta_schema = None; + settings.inline_subschemas = true; + }) + .into_generator(), LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3() .with(|settings| { settings.meta_schema = None; From 260280d3e74a916ff3939bcb31122e885a1cd566 Mon Sep 17 00:00:00 2001 From: Gaauwe Rombouts Date: Thu, 26 Mar 2026 17:21:41 +0100 Subject: [PATCH 10/53] docs: Improve image display aspect ratio (#52511) ## Context Updates the image heights to auto on the docs pages, so that they don't get squishy and keep their correct aspect ratio. ## 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 --- docs/src/development/glossary.md | 6 +++--- docs/src/performance.md | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/src/development/glossary.md b/docs/src/development/glossary.md index ed3b9fdde00a605ec04e3efc25271b57691a45af..1f6b07840b8c70a86c587c45e7b617b0266144e1 100644 --- a/docs/src/development/glossary.md +++ b/docs/src/development/glossary.md @@ -84,16 +84,16 @@ h_flex() - `Panel`: An `Entity` implementing the `Panel` trait. Panels can be placed in a `Dock`. In the image below: `ProjectPanel` is in the left dock, `DebugPanel` is in the bottom dock, and `AgentPanel` is in the right dock. `Editor` does not implement `Panel`. - `Dock`: A UI element similar to a `Pane` that can be opened and hidden. Up to three docks can be open at once: left, right, and bottom. A dock contains one or more `Panel`s, not `Pane`s. -Screenshot for the Pane and Dock features +Screenshot for the Pane and Dock features - `Project`: One or more `Worktree`s - `Worktree`: Represents either local or remote files. -Screenshot for the Worktree feature +Screenshot for the Worktree feature - [Multibuffer](https://zed.dev/docs/multibuffers): A list of Editors, a multi-buffer allows editing multiple files simultaneously. A multi-buffer opens when an operation in Zed returns multiple locations, examples: _search_ or _go to definition_. See project search in the image below. -Screenshot for the MultiBuffer feature +Screenshot for the MultiBuffer feature ## Editor diff --git a/docs/src/performance.md b/docs/src/performance.md index e974d63f8816b68d30a1c06d7cbbc083f8564327..e52ea9c684de0e2b9d39efe2741dfe0728bc7641 100644 --- a/docs/src/performance.md +++ b/docs/src/performance.md @@ -15,7 +15,7 @@ See [samply](https://github.com/mstange/samply)'s README on how to install and r The profile.json does not contain any symbols. Firefox profiler can add the local symbols to the profile for for. To do that hit the upload local profile button in the top right corner. -image +image # In depth CPU profiling (Tracing) @@ -52,10 +52,12 @@ Download the profiler: ## Usage Open the profiler (tracy-profiler), you should see zed in the list of `Discovered clients` click it. -image + +image To find functions that take a long time follow this image: -image + +image # Task/Async profiling From be6cd3e5f70438f36b5a483f30cd84977e78073e Mon Sep 17 00:00:00 2001 From: Josh Robson Chase Date: Thu, 26 Mar 2026 12:31:18 -0400 Subject: [PATCH 11/53] helix: Fix insert line above/below with selection (#46492) Fix Helix `o`/`O` behavior when a selection is active. This updates `InsertLineAbove` and `InsertLineBelow` to use the selection bounds correctly for Helix selections, including line selections whose end is represented at column 0 of the following line. It also adds Helix select-mode keybindings for `o` and `O`, and adds tests covering both line selections and selections created via `v`. Closes #43210 Release Notes: - helix: Fixed insert line above/below behavior when a full line is selected --------- Co-authored-by: dino --- assets/keymaps/vim.json | 2 + crates/vim/src/helix.rs | 85 ++++++++++++++++++++++++++++++++++++++++ crates/vim/src/normal.rs | 34 ++++++++++------ crates/vim/src/vim.rs | 4 +- 4 files changed, 110 insertions(+), 15 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 5da15a1c1f304743c55e87ecc208fd6adbdc7cc2..ae0a0dd0f1ef3ba99814b39db6ec3932d0ef3730 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -337,6 +337,8 @@ "shift-j": "vim::JoinLines", "i": "vim::InsertBefore", "a": "vim::InsertAfter", + "o": "vim::InsertLineBelow", + "shift-o": "vim::InsertLineAbove", "p": "vim::Paste", "u": "vim::Undo", "r": "vim::PushReplace", diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 56241275b5d8fa6de3645c6d00361b29dc49d259..c1e766c03a897facb3c7acf76b3ef7811e6910a8 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -1898,6 +1898,91 @@ mod test { ); } + #[gpui::test] + async fn test_helix_insert_before_after_select_lines(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + "line one\nline ˇtwo\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("2 x"); + cx.assert_state( + "line one\n«line two\nline three\nˇ»line four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("o"); + cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert); + + cx.set_state( + "line one\nline ˇtwo\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("2 x"); + cx.assert_state( + "line one\n«line two\nline three\nˇ»line four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("line one\nˇ\nline two\nline three\nline four", Mode::Insert); + } + + #[gpui::test] + async fn test_helix_insert_before_after_helix_select(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Test new line in selection direction + cx.set_state( + "ˇline one\nline two\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v j j"); + cx.assert_state( + "«line one\nline two\nlˇ»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("o"); + cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert); + + cx.set_state( + "line one\nline two\nˇline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v k k"); + cx.assert_state( + "«ˇline one\nline two\nl»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("ˇ\nline one\nline two\nline three\nline four", Mode::Insert); + + // Test new line in opposite selection direction + cx.set_state( + "ˇline one\nline two\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v j j"); + cx.assert_state( + "«line one\nline two\nlˇ»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("ˇ\nline one\nline two\nline three\nline four", Mode::Insert); + + cx.set_state( + "line one\nline two\nˇline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v k k"); + cx.assert_state( + "«ˇline one\nline two\nl»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("o"); + cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert); + } + #[gpui::test] async fn test_helix_select_mode_motion(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 6763c5cddb8bf2cda6aa4fa0988ff6be67119d3c..118805586118e36269a1f0c1d1d619058133da30 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -731,10 +731,10 @@ impl Vim { .collect::>(); editor.edit_with_autoindent(edits, cx); editor.change_selections(Default::default(), window, cx, |s| { - s.move_cursors_with(&mut |map, cursor, _| { - let previous_line = map.start_of_relative_buffer_row(cursor, -1); + s.move_with(&mut |map, selection| { + let previous_line = map.start_of_relative_buffer_row(selection.start, -1); let insert_point = motion::end_of_line(map, false, previous_line, 1); - (insert_point, SelectionGoal::None) + selection.collapse_to(insert_point, SelectionGoal::None) }); }); }); @@ -750,14 +750,19 @@ impl Vim { self.start_recording(cx); self.switch_mode(Mode::Insert, false, window, cx); self.update_editor(cx, |_, editor, cx| { - let text_layout_details = editor.text_layout_details(window, cx); editor.transact(window, cx, |editor, window, cx| { let selections = editor.selections.all::(&editor.display_snapshot(cx)); let snapshot = editor.buffer().read(cx).snapshot(cx); let selection_end_rows: BTreeSet = selections .into_iter() - .map(|selection| selection.end.row) + .map(|selection| { + if !selection.is_empty() && selection.end.column == 0 { + selection.end.row.saturating_sub(1) + } else { + selection.end.row + } + }) .collect(); let edits = selection_end_rows .into_iter() @@ -772,14 +777,17 @@ impl Vim { }) .collect::>(); editor.change_selections(Default::default(), window, cx, |s| { - s.maybe_move_cursors_with(&mut |map, cursor, goal| { - Motion::CurrentLine.move_point( - map, - cursor, - goal, - None, - &text_layout_details, - ) + s.move_with(&mut |map, selection| { + let current_line = if !selection.is_empty() && selection.end.column() == 0 { + // If this is an insert after a selection to the end of the line, the + // cursor needs to be bumped back, because it'll be at the start of the + // *next* line. + map.start_of_relative_buffer_row(selection.end, -1) + } else { + selection.end + }; + let insert_point = motion::end_of_line(map, false, current_line, 1); + selection.collapse_to(insert_point, SelectionGoal::None) }); }); editor.edit_with_autoindent(edits, cx); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 1c2416dcdb4b9a4a06970c66aded0816faf21cd0..11cf59f590823068088308a74354badf3bacfbd1 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1210,7 +1210,7 @@ impl Vim { return; } - if !mode.is_visual() && last_mode.is_visual() { + if !mode.is_visual() && last_mode.is_visual() && !last_mode.is_helix() { self.create_visual_marks(last_mode, window, cx); } @@ -1277,7 +1277,7 @@ impl Vim { } s.move_with(&mut |map, selection| { - if last_mode.is_visual() && !mode.is_visual() { + if last_mode.is_visual() && !last_mode.is_helix() && !mode.is_visual() { let mut point = selection.head(); if !selection.reversed && !selection.is_empty() { point = movement::left(map, selection.head()); From 2d62837877e14fa369a9ae598d49ac011a8fe6b4 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:37:28 -0300 Subject: [PATCH 12/53] sidebar: Ensure the projects menu is dismissed (#52494) --- crates/recent_projects/src/sidebar_recent_projects.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index bef88557b12aa076658799ff0c08518c68b6e729..72006cf6b769d23e4d2e4d535d33b61c605bad8c 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -403,8 +403,8 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { Some( v_flex() - .flex_1() .p_1p5() + .flex_1() .gap_1() .border_t_1() .border_color(cx.theme().colors().border_variant) @@ -414,9 +414,10 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { }; Button::new("open_local_folder", "Add Local Project") .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx)) - .on_click(move |_, window, cx| { + .on_click(cx.listener(move |_, _, window, cx| { + cx.emit(DismissEvent); window.dispatch_action(open_action.boxed_clone(), cx) - }) + })) }) .into_any(), ) From 2a3fcb2ce4a47b87b103d6a5760777f7d03a11ce Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:24:20 -0300 Subject: [PATCH 13/53] collab_panel: Add ability to favorite a channel (#52378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds the ability to favorite a channel in the collab panel. Note that favorited channels: - appear at the very top of the panel - also appear in their normal place in the tree - are not stored in settings but rather in the local key-value store Screenshot 2026-03-25 at 1  11@2x Release Notes: - Collab: Added the ability to favorite channels in the collab panel. --- assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 1 + assets/keymaps/default-windows.json | 1 + crates/collab_ui/src/collab_panel.rs | 322 ++++++++++++++++++++------- 4 files changed, 243 insertions(+), 82 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 412bec85625412089b2435e46573c1cf40c50b4f..617d7a6d0662264858ac3066d40481135dab9ae6 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1077,6 +1077,7 @@ "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", "alt-enter": "collab_panel::OpenSelectedChannelNotes", + "shift-enter": "collab_panel::ToggleSelectedChannelFavorite", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 5741c5a9af5517533c214f0f77050aa2faf1a669..d3dda49c9a52a8c9b52dfddc04ae573f2fa4cf28 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1138,6 +1138,7 @@ "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", "alt-enter": "collab_panel::OpenSelectedChannelNotes", + "shift-enter": "collab_panel::ToggleSelectedChannelFavorite", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index d94cbfdac16b5a86c380c158fae9f467abd5d202..e665d26aaf0c90d6c2fa4ee66284687c843fcd62 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1082,6 +1082,7 @@ "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", "alt-enter": "collab_panel::OpenSelectedChannelNotes", + "shift-enter": "collab_panel::ToggleSelectedChannelFavorite", }, }, { diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 392e2340f14c5b633bcd9a0a8128d9423aed6a22..74e7a7c82b2123bfca8d4fc4a9e8f02463e3f7d3 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -61,6 +61,8 @@ actions!( /// /// Use `collab::OpenChannelNotes` to open the channel notes for the current call. OpenSelectedChannelNotes, + /// Toggles whether the selected channel is in the Favorites section. + ToggleSelectedChannelFavorite, /// Starts moving a channel to a new location. StartMoveChannel, /// Moves the selected item to the current location. @@ -256,6 +258,7 @@ pub struct CollabPanel { subscriptions: Vec, collapsed_sections: Vec
, collapsed_channels: Vec, + favorite_channels: Vec, filter_active_channels: bool, workspace: WeakEntity, } @@ -263,11 +266,14 @@ pub struct CollabPanel { #[derive(Serialize, Deserialize)] struct SerializedCollabPanel { collapsed_channels: Option>, + #[serde(default)] + favorite_channels: Option>, } #[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)] enum Section { ActiveCall, + FavoriteChannels, Channels, ChannelInvites, ContactRequests, @@ -387,6 +393,7 @@ impl CollabPanel { match_candidates: Vec::default(), collapsed_sections: vec![Section::Offline], collapsed_channels: Vec::default(), + favorite_channels: Vec::default(), filter_active_channels: false, workspace: workspace.weak_handle(), client: workspace.app_state().client.clone(), @@ -460,7 +467,13 @@ impl CollabPanel { panel.update(cx, |panel, cx| { panel.collapsed_channels = serialized_panel .collapsed_channels - .unwrap_or_else(Vec::new) + .unwrap_or_default() + .iter() + .map(|cid| ChannelId(*cid)) + .collect(); + panel.favorite_channels = serialized_panel + .favorite_channels + .unwrap_or_default() .iter() .map(|cid| ChannelId(*cid)) .collect(); @@ -493,12 +506,22 @@ impl CollabPanel { } else { Some(self.collapsed_channels.iter().map(|id| id.0).collect()) }; + + let favorite_channels = if self.favorite_channels.is_empty() { + None + } else { + Some(self.favorite_channels.iter().map(|id| id.0).collect()) + }; + let kvp = KeyValueStore::global(cx); self.pending_serialization = cx.background_spawn( async move { kvp.write_kvp( serialization_key, - serde_json::to_string(&SerializedCollabPanel { collapsed_channels })?, + serde_json::to_string(&SerializedCollabPanel { + collapsed_channels, + favorite_channels, + })?, ) .await?; anyhow::Ok(()) @@ -512,10 +535,8 @@ impl CollabPanel { } fn update_entries(&mut self, select_same_item: bool, cx: &mut Context) { - let channel_store = self.channel_store.read(cx); - let user_store = self.user_store.read(cx); let query = self.filter_editor.read(cx).text(cx); - let fg_executor = cx.foreground_executor(); + let fg_executor = cx.foreground_executor().clone(); let executor = cx.background_executor().clone(); let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned()); @@ -541,7 +562,7 @@ impl CollabPanel { } // Populate the active user. - if let Some(user) = user_store.current_user() { + if let Some(user) = self.user_store.read(cx).current_user() { self.match_candidates.clear(); self.match_candidates .push(StringMatchCandidate::new(0, &user.github_login)); @@ -662,6 +683,62 @@ impl CollabPanel { let mut request_entries = Vec::new(); + let previous_len = self.favorite_channels.len(); + self.favorite_channels + .retain(|id| self.channel_store.read(cx).channel_for_id(*id).is_some()); + if self.favorite_channels.len() != previous_len { + self.serialize(cx); + } + + let channel_store = self.channel_store.read(cx); + let user_store = self.user_store.read(cx); + + if !self.favorite_channels.is_empty() { + let favorite_channels: Vec<_> = self + .favorite_channels + .iter() + .filter_map(|id| channel_store.channel_for_id(*id)) + .collect(); + + self.match_candidates.clear(); + self.match_candidates.extend( + favorite_channels + .iter() + .enumerate() + .map(|(ix, channel)| StringMatchCandidate::new(ix, &channel.name)), + ); + + let matches = fg_executor.block_on(match_strings( + &self.match_candidates, + &query, + true, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + + if !matches.is_empty() || query.is_empty() { + self.entries + .push(ListEntry::Header(Section::FavoriteChannels)); + + let matches_by_candidate: HashMap = + matches.iter().map(|mat| (mat.candidate_id, mat)).collect(); + + for (ix, channel) in favorite_channels.iter().enumerate() { + if !query.is_empty() && !matches_by_candidate.contains_key(&ix) { + continue; + } + self.entries.push(ListEntry::Channel { + channel: (*channel).clone(), + depth: 0, + has_children: false, + string_match: matches_by_candidate.get(&ix).cloned().cloned(), + }); + } + } + } + self.entries.push(ListEntry::Header(Section::Channels)); if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() { @@ -1359,6 +1436,18 @@ impl CollabPanel { window.handler_for(&this, move |this, _, cx| { this.copy_channel_notes_link(channel_id, cx) }), + ) + .separator() + .entry( + if self.is_channel_favorited(channel_id) { + "Remove from Favorites" + } else { + "Add to Favorites" + }, + None, + window.handler_for(&this, move |this, _window, cx| { + this.toggle_favorite_channel(channel_id, cx) + }), ); let mut has_destructive_actions = false; @@ -1608,7 +1697,8 @@ impl CollabPanel { Section::ActiveCall => Self::leave_call(window, cx), Section::Channels => self.new_root_channel(window, cx), Section::Contacts => self.toggle_contact_finder(window, cx), - Section::ContactRequests + Section::FavoriteChannels + | Section::ContactRequests | Section::Online | Section::Offline | Section::ChannelInvites => { @@ -1838,6 +1928,24 @@ impl CollabPanel { self.collapsed_channels.binary_search(&channel_id).is_ok() } + fn toggle_favorite_channel(&mut self, channel_id: ChannelId, cx: &mut Context) { + match self.favorite_channels.binary_search(&channel_id) { + Ok(ix) => { + self.favorite_channels.remove(ix); + } + Err(ix) => { + self.favorite_channels.insert(ix, channel_id); + } + }; + self.serialize(cx); + self.update_entries(true, cx); + cx.notify(); + } + + fn is_channel_favorited(&self, channel_id: ChannelId) -> bool { + self.favorite_channels.binary_search(&channel_id).is_ok() + } + fn leave_call(window: &mut Window, cx: &mut App) { ActiveCall::global(cx) .update(cx, |call, cx| call.hang_up(cx)) @@ -1954,6 +2062,17 @@ impl CollabPanel { } } + fn toggle_selected_channel_favorite( + &mut self, + _: &ToggleSelectedChannelFavorite, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(channel) = self.selected_channel() { + self.toggle_favorite_channel(channel.id, cx); + } + } + fn set_channel_visibility( &mut self, channel_id: ChannelId, @@ -2589,6 +2708,7 @@ impl CollabPanel { SharedString::from("Current Call") } } + Section::FavoriteChannels => SharedString::from("Favorites"), Section::ContactRequests => SharedString::from("Requests"), Section::Contacts => SharedString::from("Contacts"), Section::Channels => SharedString::from("Channels"), @@ -2606,6 +2726,7 @@ impl CollabPanel { }), Section::Contacts => Some( IconButton::new("add-contact", IconName::Plus) + .icon_size(IconSize::Small) .on_click( cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)), ) @@ -2619,9 +2740,6 @@ impl CollabPanel { IconButton::new("filter-active-channels", IconName::ListFilter) .icon_size(IconSize::Small) .toggle_state(self.filter_active_channels) - .when(!self.filter_active_channels, |button| { - button.visible_on_hover("section-header") - }) .on_click(cx.listener(|this, _, _window, cx| { this.filter_active_channels = !this.filter_active_channels; this.update_entries(true, cx); @@ -2634,10 +2752,11 @@ impl CollabPanel { ) .child( IconButton::new("add-channel", IconName::Plus) + .icon_size(IconSize::Small) .on_click(cx.listener(|this, _, window, cx| { this.new_root_channel(window, cx) })) - .tooltip(Tooltip::text("Create a channel")), + .tooltip(Tooltip::text("Create Channel")), ) .into_any_element(), ) @@ -2646,7 +2765,11 @@ impl CollabPanel { }; let can_collapse = match section { - Section::ActiveCall | Section::Channels | Section::Contacts => false, + Section::ActiveCall + | Section::Channels + | Section::Contacts + | Section::FavoriteChannels => false, + Section::ChannelInvites | Section::ContactRequests | Section::Online @@ -2932,11 +3055,17 @@ impl CollabPanel { .unwrap_or(px(240.)); let root_id = channel.root_id(); - div() - .h_6() + let is_favorited = self.is_channel_favorited(channel_id); + let (favorite_icon, favorite_color, favorite_tooltip) = if is_favorited { + (IconName::StarFilled, Color::Accent, "Remove from Favorites") + } else { + (IconName::Star, Color::Muted, "Add to Favorites") + }; + + h_flex() .id(channel_id.0 as usize) .group("") - .flex() + .h_6() .w_full() .when(!channel.is_root_channel(), |el| { el.on_drag(channel.clone(), move |channel, _, _, cx| { @@ -2966,6 +3095,7 @@ impl CollabPanel { .child( ListItem::new(channel_id.0 as usize) // Add one level of depth for the disclosure arrow. + .height(px(26.)) .indent_level(depth + 1) .indent_step_size(px(20.)) .toggle_state(is_selected || is_active) @@ -2991,78 +3121,105 @@ impl CollabPanel { ) }, )) - .start_slot( - div() - .relative() - .child( - Icon::new(if is_public { - IconName::Public - } else { - IconName::Hash - }) - .size(IconSize::Small) - .color(Color::Muted), - ) - .children(has_notes_notification.then(|| { - div() - .w_1p5() - .absolute() - .right(px(-1.)) - .top(px(-1.)) - .child(Indicator::dot().color(Color::Info)) - })), - ) .child( h_flex() - .id(channel_id.0 as usize) - .child(match string_match { - None => Label::new(channel.name.clone()).into_any_element(), - Some(string_match) => HighlightedLabel::new( - channel.name.clone(), - string_match.positions.clone(), - ) - .into_any_element(), - }) - .children(face_pile.map(|face_pile| face_pile.p_1())), + .id(format!("inside-{}", channel_id.0)) + .w_full() + .gap_1() + .child( + div() + .relative() + .child( + Icon::new(if is_public { + IconName::Public + } else { + IconName::Hash + }) + .size(IconSize::Small) + .color(Color::Muted), + ) + .children(has_notes_notification.then(|| { + div() + .w_1p5() + .absolute() + .right(px(-1.)) + .top(px(-1.)) + .child(Indicator::dot().color(Color::Info)) + })), + ) + .child( + h_flex() + .id(channel_id.0 as usize) + .child(match string_match { + None => Label::new(channel.name.clone()).into_any_element(), + Some(string_match) => HighlightedLabel::new( + channel.name.clone(), + string_match.positions.clone(), + ) + .into_any_element(), + }) + .children(face_pile.map(|face_pile| face_pile.p_1())), + ) + .tooltip({ + let channel_store = self.channel_store.clone(); + move |_window, cx| { + cx.new(|_| JoinChannelTooltip { + channel_store: channel_store.clone(), + channel_id, + has_notes_notification, + }) + .into() + } + }), ), ) .child( - h_flex().absolute().right(rems(0.)).h_full().child( - h_flex() - .h_full() - .bg(cx.theme().colors().background) - .rounded_l_sm() - .gap_1() - .px_1() - .child( - IconButton::new("channel_notes", IconName::Reader) - .style(ButtonStyle::Filled) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(if has_notes_notification { - Color::Default - } else { - Color::Muted - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_channel_notes(channel_id, window, cx) - })) - .tooltip(Tooltip::text("Open channel notes")), - ) - .visible_on_hover(""), - ), - ) - .tooltip({ - let channel_store = self.channel_store.clone(); - move |_window, cx| { - cx.new(|_| JoinChannelTooltip { - channel_store: channel_store.clone(), - channel_id, - has_notes_notification, + h_flex() + .absolute() + .right_0() + .visible_on_hover("") + .h_full() + .pl_1() + .pr_1p5() + .gap_0p5() + .bg(cx.theme().colors().background.opacity(0.5)) + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("channel_favorite", favorite_icon) + .icon_size(IconSize::Small) + .icon_color(favorite_color) + .on_click(cx.listener(move |this, _, _window, cx| { + this.toggle_favorite_channel(channel_id, cx) + })) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + favorite_tooltip, + &ToggleSelectedChannelFavorite, + &focus_handle, + cx, + ) + }) }) - .into() - } - }) + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("channel_notes", IconName::Reader) + .icon_size(IconSize::Small) + .when(!has_notes_notification, |this| { + this.icon_color(Color::Muted) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_channel_notes(channel_id, window, cx) + })) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + "Open Channel Notes", + &OpenSelectedChannelNotes, + &focus_handle, + cx, + ) + }) + }), + ) } fn render_channel_editor( @@ -3161,6 +3318,7 @@ impl Render for CollabPanel { .on_action(cx.listener(CollabPanel::show_inline_context_menu)) .on_action(cx.listener(CollabPanel::rename_selected_channel)) .on_action(cx.listener(CollabPanel::open_selected_channel_notes)) + .on_action(cx.listener(CollabPanel::toggle_selected_channel_favorite)) .on_action(cx.listener(CollabPanel::collapse_selected_channel)) .on_action(cx.listener(CollabPanel::expand_selected_channel)) .on_action(cx.listener(CollabPanel::start_move_selected_channel)) @@ -3382,7 +3540,7 @@ impl Render for JoinChannelTooltip { .channel_participants(self.channel_id); container - .child(Label::new("Join channel")) + .child(Label::new("Join Channel")) .children(participants.iter().map(|participant| { h_flex() .gap_2() From cd05f190546de4f3206cbce59f2f368986c81b3c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:36:04 +0000 Subject: [PATCH 14/53] Pin dependencies (#52522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/github-script](https://redirect.github.com/actions/github-script) | action | pinDigest | → `f28e40c` | | [actions/setup-python](https://redirect.github.com/actions/setup-python) | action | pinDigest | → `a26af69` | | [namespacelabs/nscloud-cache-action](https://redirect.github.com/namespacelabs/nscloud-cache-action) | action | pinDigest | → `a90bb5d` | | [taiki-e/install-action](https://redirect.github.com/taiki-e/install-action) | action | pinDigest | → `921e2c9` | | [taiki-e/install-action](https://redirect.github.com/taiki-e/install-action) | action | pinDigest | → `b4f2d5c` | | [withastro/automation](https://redirect.github.com/withastro/automation) | action | pinDigest | → `a5bd0c5` | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/15138) for more information. --- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, 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. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] 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> Co-authored-by: Marshall Bowers --- .github/actions/run_tests/action.yml | 2 +- .github/actions/run_tests_windows/action.yml | 2 +- .github/workflows/autofix_pr.yml | 2 +- .github/workflows/background_agent_mvp.yml | 2 +- .../community_champion_auto_labeler.yml | 2 +- .github/workflows/compare_perf.yml | 2 +- .github/workflows/congrats.yml | 4 +-- .github/workflows/deploy_collab.yml | 6 ++-- .github/workflows/extension_bump.yml | 6 ++-- .github/workflows/extension_tests.yml | 6 ++-- .../workflows/extension_workflow_rollout.yml | 4 +-- .github/workflows/publish_extension_cli.yml | 4 +-- .github/workflows/release.yml | 14 ++++----- .github/workflows/release_nightly.yml | 4 +-- .github/workflows/run_agent_evals.yml | 2 +- .github/workflows/run_bundling.yml | 4 +-- .github/workflows/run_cron_unit_evals.yml | 4 +-- .github/workflows/run_tests.yml | 30 +++++++++---------- .github/workflows/run_unit_evals.yml | 4 +-- .../xtask/src/tasks/workflows/compare_perf.rs | 6 +++- .../src/tasks/workflows/extension_bump.rs | 13 ++++++-- .../workflows/extension_workflow_rollout.rs | 2 +- tooling/xtask/src/tasks/workflows/steps.rs | 30 +++++++++++++++---- 23 files changed, 93 insertions(+), 62 deletions(-) diff --git a/.github/actions/run_tests/action.yml b/.github/actions/run_tests/action.yml index a071aba3a87dcf8e8f48f740115cfddf48b9f805..610c334a65c3a3817ab0ee2bb7356a923643092b 100644 --- a/.github/actions/run_tests/action.yml +++ b/.github/actions/run_tests/action.yml @@ -5,7 +5,7 @@ runs: using: "composite" steps: - name: Install nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c # nextest - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/actions/run_tests_windows/action.yml b/.github/actions/run_tests_windows/action.yml index 307b73f363b7d5fd7a3c9e5082c4f17d622ec165..3752cbb50d538459ea58d2219e591d1abbda6247 100644 --- a/.github/actions/run_tests_windows/action.yml +++ b/.github/actions/run_tests_windows/action.yml @@ -12,7 +12,7 @@ runs: steps: - name: Install test runner working-directory: ${{ inputs.working-directory }} - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c # nextest - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml index 36a459c94b9ea2e35b683bb957d33db362bee262..f055c078cf4f814e342697e311ad5660f68f4624 100644 --- a/.github/workflows/autofix_pr.yml +++ b/.github/workflows/autofix_pr.yml @@ -31,7 +31,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/background_agent_mvp.yml b/.github/workflows/background_agent_mvp.yml index f8c654a293c26e50ccd5194742d7a6977009fb48..2f048d572df6fb45368c6d7aece574e83c9e7949 100644 --- a/.github/workflows/background_agent_mvp.yml +++ b/.github/workflows/background_agent_mvp.yml @@ -50,7 +50,7 @@ jobs: "${HOME}/.local/bin/droid" --version - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" diff --git a/.github/workflows/community_champion_auto_labeler.yml b/.github/workflows/community_champion_auto_labeler.yml index fa44afc16dcaee4c1e1176b9344aed476ac6d8e5..82a9e274d64725b0e55c6ced46ca64ac3890e35e 100644 --- a/.github/workflows/community_champion_auto_labeler.yml +++ b/.github/workflows/community_champion_auto_labeler.yml @@ -12,7 +12,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: Check if author is a community champion and apply label - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: COMMUNITY_CHAMPIONS: | 0x2CA diff --git a/.github/workflows/compare_perf.yml b/.github/workflows/compare_perf.yml index f6c4253573364269b5b28ee9773a3885381ddfe2..2b2154ce9bd14c85d0f0d10e95c4065a458006a1 100644 --- a/.github/workflows/compare_perf.yml +++ b/.github/workflows/compare_perf.yml @@ -33,7 +33,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: compare_perf::run_perf::install_hyperfine - uses: taiki-e/install-action@hyperfine + uses: taiki-e/install-action@b4f2d5cb8597b15997c8ede873eb6185efc5f0ad - name: steps::git_checkout run: git fetch origin "$REF_NAME" && git checkout "$REF_NAME" env: diff --git a/.github/workflows/congrats.yml b/.github/workflows/congrats.yml index a57be7a75ad13829b096477da015ac6a43a325d7..4866b3c33bc6bab9f9d20ac1701b7d6535b356ee 100644 --- a/.github/workflows/congrats.yml +++ b/.github/workflows/congrats.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Get PR info and check if author is external id: check - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ secrets.CONGRATSBOT_GITHUB_TOKEN }} script: | @@ -57,7 +57,7 @@ jobs: congrats: needs: check-author if: needs.check-author.outputs.should_congratulate == 'true' - uses: withastro/automation/.github/workflows/congratsbot.yml@main + uses: withastro/automation/.github/workflows/congratsbot.yml@a5bd0c5748c4d56e687cdd558064f9ee8adfb1f2 # main with: EMOJIS: 🎉,🎊,🧑‍🚀,🥳,🙌,🚀,🦀,🔥,🚢 secrets: diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 9ba1ee7d8be38b1fd3b3147c679afde03b98dcd7..5a3eff186814128ebb3973642040d9228f0e87fd 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -26,7 +26,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -57,7 +57,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -66,7 +66,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: deploy_collab::tests::run_collab_tests diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 72bc340a814e340ef8e716e3db4cb156aee40e8f..b4cbac4ec8c0ab37ebad73eb96c2ee074ca969a6 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -84,7 +84,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -187,7 +187,7 @@ jobs: CURRENT_VERSION: ${{ needs.check_version_changed.outputs.current_version }} WORKING_DIR: ${{ inputs.working-directory }} - name: extension_bump::create_version_tag - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: script: |- github.rest.git.createRef({ @@ -239,7 +239,7 @@ jobs: env: COMMITTER_TOKEN: ${{ steps.generate-token.outputs.token }} - name: extension_bump::enable_automerge_if_staff - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 066e1ab1739a0fabb0d6ce8f0f7f4832cfbdc228..622f4c8f1034b4ec0c7625a361ecdb6fb84d9429 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -77,7 +77,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -97,7 +97,7 @@ jobs: env: PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }} - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: extension_tests::run_nextest run: 'cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n ''s|host: ||p'')"' env: @@ -131,7 +131,7 @@ jobs: wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" -O "$GITHUB_WORKSPACE/zed-extension" chmod +x "$GITHUB_WORKSPACE/zed-extension" - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index 4dfaf708f738ef5b5fe8d8687d80690af040eba9..5bb315a730d8f25f6e1eccbbe5e1734e1cda6d99 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -57,7 +57,7 @@ jobs: PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }} - id: list-repos name: extension_workflow_rollout::fetch_extension_repos::get_repositories - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: script: | const repos = await github.paginate(github.rest.repos.listForOrg, { @@ -81,7 +81,7 @@ jobs: return filteredRepos; result-encoding: json - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/publish_extension_cli.yml b/.github/workflows/publish_extension_cli.yml index e7ba9075db8e552b5050e5e65fef9aeac872a776..17248cea11307d4604b05d5160212a4f38e2874a 100644 --- a/.github/workflows/publish_extension_cli.yml +++ b/.github/workflows/publish_extension_cli.yml @@ -18,7 +18,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -48,7 +48,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec01217c6acb7ab9a4afd5b65aa1f98a9740aab1..b651e7046bc7d603a7a829ce1b59fcf0468bdd3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -31,7 +31,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 300 - name: steps::setup_sccache @@ -66,7 +66,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -79,7 +79,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache @@ -159,7 +159,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -191,7 +191,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -257,7 +257,7 @@ jobs: env: ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }} - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index a60ae34c27a0f955d7b068187e88c0a463329a86..30d0e1fbf9c7955d1216e2e3d7ac51a9a51f4416 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -410,7 +410,7 @@ jobs: with: clean: false - name: steps::cache_nix_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: nix - name: nix_build::build_nix::install_nix @@ -444,7 +444,7 @@ jobs: with: clean: false - name: steps::cache_nix_store_macos - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: path: ~/nix-cache - name: nix_build::build_nix::install_nix diff --git a/.github/workflows/run_agent_evals.yml b/.github/workflows/run_agent_evals.yml index 218d84e7afa39c2333fcd65bc05c5dc07bf2db8c..83fd91b037fd982a25845b10aaff561b42af5fc5 100644 --- a/.github/workflows/run_agent_evals.yml +++ b/.github/workflows/run_agent_evals.yml @@ -28,7 +28,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index bc16c2ee9c4f72969a42d04745ba3953d8462469..71b2e4d5fa0b386334bb8acab8e732f1c7d0ad93 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -278,7 +278,7 @@ jobs: with: clean: false - name: steps::cache_nix_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: nix - name: nix_build::build_nix::install_nix @@ -310,7 +310,7 @@ jobs: with: clean: false - name: steps::cache_nix_store_macos - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: path: ~/nix-cache - name: nix_build::build_nix::install_nix diff --git a/.github/workflows/run_cron_unit_evals.yml b/.github/workflows/run_cron_unit_evals.yml index 46ed2e380afe7618aa835d5e122955504283ee97..7bb7f79473eb4dae170eb18edd454b7ae35d13e8 100644 --- a/.github/workflows/run_cron_unit_evals.yml +++ b/.github/workflows/run_cron_unit_evals.yml @@ -29,7 +29,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -38,7 +38,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 746941b08c8d6e67148af0651f41cc651a13b2eb..9f335a76beab036d97fe5555cd049ea46b4f87f0 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -128,7 +128,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -212,7 +212,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -247,7 +247,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -278,7 +278,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -356,7 +356,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -369,7 +369,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache @@ -411,7 +411,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -420,7 +420,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 300 - name: steps::setup_sccache @@ -453,7 +453,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -501,7 +501,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -542,7 +542,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -580,7 +580,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -619,7 +619,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -661,7 +661,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -689,7 +689,7 @@ jobs: env: ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }} - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/run_unit_evals.yml b/.github/workflows/run_unit_evals.yml index 670a6e6b0fc19940b598221e439a68b656c7ca0f..1bf75188832668f40a24c4d3452940bf05fcd3fd 100644 --- a/.github/workflows/run_unit_evals.yml +++ b/.github/workflows/run_unit_evals.yml @@ -32,7 +32,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -41,7 +41,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache diff --git a/tooling/xtask/src/tasks/workflows/compare_perf.rs b/tooling/xtask/src/tasks/workflows/compare_perf.rs index 74a1fbdc389e2b0dacdf579d9ee96a0366eb5c01..39f17b8d148bd6022913fdf5097368690cbd0fd0 100644 --- a/tooling/xtask/src/tasks/workflows/compare_perf.rs +++ b/tooling/xtask/src/tasks/workflows/compare_perf.rs @@ -42,7 +42,11 @@ pub fn run_perf( } fn install_hyperfine() -> Step { - named::uses("taiki-e", "install-action", "hyperfine") + named::uses( + "taiki-e", + "install-action", + "b4f2d5cb8597b15997c8ede873eb6185efc5f0ad", // hyperfine + ) } fn compare_runs(head: &WorkflowInput, base: &WorkflowInput) -> Step { diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index a69856ed3333810dcada4b8a8ac5b6cadee12e23..a1c2abc169f4348fd04a529c5a5b10b412464c9b 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -145,7 +145,12 @@ fn create_version_label( } fn create_version_tag(tag: &StepOutput, generated_token: StepOutput) -> Step { - named::uses("actions", "github-script", "v7").with( + named::uses( + "actions", + "github-script", + "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7 + ) + .with( Input::default() .add( "script", @@ -413,7 +418,11 @@ fn enable_automerge_if_staff( pull_request_number: StepOutput, generated_token: StepOutput, ) -> Step { - named::uses("actions", "github-script", "v7") + named::uses( + "actions", + "github-script", + "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7 + ) .add_with(("github-token", generated_token.to_string())) .add_with(( "script", diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs index 418b7f9e4617ad0ca42b666b7eb4d7d9614895a7..3a5d14603f97b43aacb581aaf3b970bac31b701f 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -50,7 +50,7 @@ pub(crate) fn extension_workflow_rollout() -> Workflow { fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOutput, JobOutput) { fn get_repositories(filter_repos_input: &WorkflowInput) -> (Step, StepOutput) { - let step = named::uses("actions", "github-script", "v7") + let step = named::uses("actions", "github-script", "f28e40c7f34bde8b3046d885e986cb6290c5673b") .id("list-repos") .add_with(( "script", diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 1be6a779f33bfb411ccdd5ac4d979b07dc283e50..ebdd9b30538eb389a267a1c2fdb1822eec1d3a54 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -177,7 +177,11 @@ pub fn cargo_fmt() -> Step { } pub fn cargo_install_nextest() -> Step { - named::uses("taiki-e", "install-action", "nextest") + named::uses( + "taiki-e", + "install-action", + "921e2c9f7148d7ba14cd819f417db338f63e733c", // nextest + ) } pub fn setup_cargo_config(platform: Platform) -> Step { @@ -230,9 +234,13 @@ pub fn install_rustup_target(target: &str) -> Step { } pub fn cache_rust_dependencies_namespace() -> Step { - named::uses("namespacelabs", "nscloud-cache-action", "v1") - .add_with(("cache", "rust")) - .add_with(("path", "~/.rustup")) + named::uses( + "namespacelabs", + "nscloud-cache-action", + "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1 + ) + .add_with(("cache", "rust")) + .add_with(("path", "~/.rustup")) } pub fn setup_sccache(platform: Platform) -> Step { @@ -259,14 +267,24 @@ pub fn show_sccache_stats(platform: Platform) -> Step { } pub fn cache_nix_dependencies_namespace() -> Step { - named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "nix")) + named::uses( + "namespacelabs", + "nscloud-cache-action", + "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1 + ) + .add_with(("cache", "nix")) } pub fn cache_nix_store_macos() -> Step { // On macOS, `/nix` is on a read-only root filesystem so nscloud's `cache: nix` // cannot mount or symlink there. Instead we cache a user-writable directory and // use nix-store --import/--export in separate steps to transfer store paths. - named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("path", "~/nix-cache")) + named::uses( + "namespacelabs", + "nscloud-cache-action", + "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1 + ) + .add_with(("path", "~/nix-cache")) } pub fn setup_linux() -> Step { From 086bece3f11a91679b046d9139d398c54059a0b2 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 26 Mar 2026 13:18:39 -0700 Subject: [PATCH 15/53] Avoid flicker in flexible width agent panel's size when resizing workspace (#52519) This improves the rendering of flexible-width panels so that they do not lag behind by one frame when tracking workspace size changes. I've also simplified the code for panel size management in the workspace. Release Notes: - N/A --- crates/workspace/src/dock.rs | 77 ++---------- crates/workspace/src/workspace.rs | 197 ++++++++++++++++-------------- 2 files changed, 113 insertions(+), 161 deletions(-) diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 131c02e9c885b66ddf32ed6d2a0dfb01d2764a49..e0870503b7c64bb23218d897bc6b4828d315c8b8 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -776,17 +776,9 @@ impl Dock { } } - pub fn panel_size(&self, panel: &dyn PanelHandle, window: &Window, cx: &App) -> Option { - self.panel_entries - .iter() - .find(|entry| entry.panel.panel_id() == panel.panel_id()) - .map(|entry| self.resolved_panel_size(entry, window, cx)) - } - - pub fn active_panel_size(&self, window: &Window, cx: &App) -> Option { + pub fn active_panel_size(&self) -> Option { if self.is_open { - self.active_panel_entry() - .map(|entry| self.resolved_panel_size(entry, window, cx)) + self.active_panel_entry().map(|entry| entry.size_state) } else { None } @@ -947,28 +939,6 @@ impl Dock { } } - fn resolved_panel_size(&self, entry: &PanelEntry, window: &Window, cx: &App) -> Pixels { - if self.position.axis() == Axis::Horizontal - && entry.panel.supports_flexible_size(window, cx) - { - if let Some(workspace) = self.workspace.upgrade() { - let workspace = workspace.read(cx); - return resolve_panel_size( - entry.size_state, - entry.panel.as_ref(), - self.position, - workspace, - window, - cx, - ); - } - } - entry - .size_state - .size - .unwrap_or_else(|| entry.panel.default_size(window, cx)) - } - pub(crate) fn load_persisted_size_state( workspace: &Workspace, panel_key: &'static str, @@ -988,41 +958,10 @@ impl Dock { } } -pub(crate) fn resolve_panel_size( - size_state: PanelSizeState, - panel: &dyn PanelHandle, - position: DockPosition, - workspace: &Workspace, - window: &Window, - cx: &App, -) -> Pixels { - if position.axis() == Axis::Horizontal && panel.supports_flexible_size(window, cx) { - let ratio = size_state - .flexible_size_ratio - .or_else(|| workspace.default_flexible_dock_ratio(position)); - - if let Some(ratio) = ratio { - return workspace - .flexible_dock_size(position, ratio, window, cx) - .unwrap_or_else(|| { - size_state - .size - .unwrap_or_else(|| panel.default_size(window, cx)) - }); - } - } - - size_state - .size - .unwrap_or_else(|| panel.default_size(window, cx)) -} - impl Render for Dock { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let dispatch_context = Self::dispatch_context(); if let Some(entry) = self.visible_entry() { - let size = self.resolved_panel_size(entry, window, cx); - let position = self.position; let create_resize_handle = || { let handle = div() @@ -1091,8 +1030,10 @@ impl Render for Dock { .border_color(cx.theme().colors().border) .overflow_hidden() .map(|this| match self.position().axis() { - Axis::Horizontal => this.w(size).h_full().flex_row(), - Axis::Vertical => this.h(size).w_full().flex_col(), + // Width and height are always set on the workspace wrapper in + // render_dock, so fill whatever space the wrapper provides. + Axis::Horizontal => this.w_full().h_full().flex_row(), + Axis::Vertical => this.h_full().w_full().flex_col(), }) .map(|this| match self.position() { DockPosition::Left => this.border_r_1(), @@ -1102,8 +1043,8 @@ impl Render for Dock { .child( div() .map(|this| match self.position().axis() { - Axis::Horizontal => this.min_w(size).h_full(), - Axis::Vertical => this.min_h(size).w_full(), + Axis::Horizontal => this.w_full().h_full(), + Axis::Vertical => this.h_full().w_full(), }) .child( entry diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index fcb46039921f94e9a4a8b717f62ec9f709955f40..1d7c71c1ea4d66c65155a6491b7cf8a526256d82 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -2208,30 +2208,29 @@ impl Workspace { did_set } - pub fn flexible_dock_size( - &self, - position: DockPosition, - ratio: f32, - window: &Window, - cx: &App, - ) -> Option { - if position.axis() != Axis::Horizontal { - return None; + fn dock_size(&self, dock: &Dock, window: &Window, cx: &App) -> Option { + let panel = dock.active_panel()?; + let size_state = dock + .stored_panel_size_state(panel.as_ref()) + .unwrap_or_default(); + let position = dock.position(); + + if position.axis() == Axis::Horizontal + && panel.supports_flexible_size(window, cx) + && let Some(ratio) = size_state + .flexible_size_ratio + .or_else(|| self.default_flexible_dock_ratio(position)) + && let Some(available_width) = + self.available_width_for_horizontal_dock(position, window, cx) + { + return Some((available_width * ratio.clamp(0.0, 1.0)).max(RESIZE_HANDLE_SIZE)); } - let available_width = self.available_width_for_horizontal_dock(position, window, cx)?; - Some((available_width * ratio.clamp(0.0, 1.0)).max(RESIZE_HANDLE_SIZE)) - } - - pub fn resolved_dock_panel_size( - &self, - dock: &Dock, - panel: &dyn PanelHandle, - window: &Window, - cx: &App, - ) -> Pixels { - let size_state = dock.stored_panel_size_state(panel).unwrap_or_default(); - dock::resolve_panel_size(size_state, panel, dock.position(), self, window, cx) + Some( + size_state + .size + .unwrap_or_else(|| panel.default_size(window, cx)), + ) } pub fn flexible_dock_ratio_for_size( @@ -4908,10 +4907,7 @@ impl Workspace { if let Some(dock_entity) = active_dock { let dock = dock_entity.read(cx); - let Some(panel_size) = dock - .active_panel() - .map(|panel| self.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) - else { + let Some(panel_size) = self.dock_size(&dock, window, cx) else { return; }; match dock.position() { @@ -7274,14 +7270,46 @@ impl Workspace { leader_border_for_pane(follower_states, &pane, window, cx) }); - Some( - div() - .flex() - .flex_none() - .overflow_hidden() - .child(dock.clone()) - .children(leader_border), - ) + let mut container = div() + .flex() + .overflow_hidden() + .flex_none() + .child(dock.clone()) + .children(leader_border); + + // Apply sizing only when the dock is open. When closed the dock is still + // included in the element tree so its focus handle remains mounted — without + // this, toggle_panel_focus cannot focus the panel when the dock is closed. + let dock = dock.read(cx); + if let Some(panel) = dock.visible_panel() { + let size_state = dock.stored_panel_size_state(panel.as_ref()); + if position.axis() == Axis::Horizontal { + if let Some(ratio) = size_state + .and_then(|state| state.flexible_size_ratio) + .or_else(|| self.default_flexible_dock_ratio(position)) + && panel.supports_flexible_size(window, cx) + { + let ratio = ratio.clamp(0.001, 0.999); + let grow = ratio / (1.0 - ratio); + let style = container.style(); + style.flex_grow = Some(grow); + style.flex_shrink = Some(1.0); + style.flex_basis = Some(relative(0.).into()); + } else { + let size = size_state + .and_then(|state| state.size) + .unwrap_or_else(|| panel.default_size(window, cx)); + container = container.w(size); + } + } else { + let size = size_state + .and_then(|state| state.size) + .unwrap_or_else(|| panel.default_size(window, cx)); + container = container.h(size); + } + } + + Some(container) } pub fn for_window(window: &Window, cx: &App) -> Option> { @@ -7351,18 +7379,17 @@ impl Workspace { } } - fn adjust_dock_size_by_px( + fn resize_dock( &mut self, - panel_size: Pixels, dock_pos: DockPosition, - px: Pixels, + new_size: Pixels, window: &mut Window, cx: &mut Context, ) { match dock_pos { - DockPosition::Left => self.resize_left_dock(panel_size + px, window, cx), - DockPosition::Right => self.resize_right_dock(panel_size + px, window, cx), - DockPosition::Bottom => self.resize_bottom_dock(panel_size + px, window, cx), + DockPosition::Left => self.resize_left_dock(new_size, window, cx), + DockPosition::Right => self.resize_right_dock(new_size, window, cx), + DockPosition::Bottom => self.resize_bottom_dock(new_size, window, cx), } } @@ -7806,14 +7833,10 @@ fn adjust_active_dock_size_by_px( return; }; let dock = active_dock.read(cx); - let Some(panel_size) = dock - .active_panel() - .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) - else { + let Some(panel_size) = workspace.dock_size(&dock, window, cx) else { return; }; - let dock_pos = dock.position(); - workspace.adjust_dock_size_by_px(panel_size, dock_pos, px, window, cx); + workspace.resize_dock(dock.position(), panel_size + px, window, cx); } fn adjust_open_docks_size_by_px( @@ -7828,22 +7851,18 @@ fn adjust_open_docks_size_by_px( .filter_map(|dock_entity| { let dock = dock_entity.read(cx); if dock.is_open() { - let panel_size = dock.active_panel().map(|panel| { - workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx) - })?; let dock_pos = dock.position(); - Some((panel_size, dock_pos, px)) + let panel_size = workspace.dock_size(&dock, window, cx)?; + Some((dock_pos, panel_size + px)) } else { None } }) .collect::>(); - docks - .into_iter() - .for_each(|(panel_size, dock_pos, offset)| { - workspace.adjust_dock_size_by_px(panel_size, dock_pos, offset, window, cx); - }); + for (position, new_size) in docks { + workspace.resize_dock(position, new_size, window, cx); + } } impl Focusable for Workspace { @@ -12286,11 +12305,8 @@ mod tests { let dock = workspace.right_dock().read(cx); let workspace_width = workspace.bounds.size.width; - let initial_width = dock - .active_panel() - .map(|panel| { - workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx) - }) + let initial_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should have an initial width"); assert_eq!(initial_width, workspace_width / 2.); @@ -12298,11 +12314,8 @@ mod tests { workspace.resize_right_dock(px(300.), window, cx); let dock = workspace.right_dock().read(cx); - let resized_width = dock - .active_panel() - .map(|panel| { - workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx) - }) + let resized_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should keep its resized width"); assert_eq!(resized_width, px(300.)); @@ -12322,9 +12335,8 @@ mod tests { workspace.toggle_dock(DockPosition::Right, window, cx); let dock = workspace.right_dock().read(cx); - let reopened_width = dock - .active_panel() - .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + let reopened_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should restore when reopened"); assert_eq!(reopened_width, resized_width); @@ -12351,9 +12363,8 @@ mod tests { ); let dock = workspace.right_dock().read(cx); - let split_width = dock - .active_panel() - .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + let split_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should keep its user-resized proportion"); assert_eq!(split_width, px(300.)); @@ -12361,9 +12372,8 @@ mod tests { workspace.bounds.size.width = px(1600.); let dock = workspace.right_dock().read(cx); - let resized_window_width = dock - .active_panel() - .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + let resized_window_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should preserve proportional size on window resize"); assert_eq!( @@ -12533,9 +12543,8 @@ mod tests { workspace.toggle_dock(DockPosition::Left, window, cx); let left_dock = workspace.left_dock().read(cx); - let left_width = left_dock - .active_panel() - .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx)) + let left_width = workspace + .dock_size(&left_dock, window, cx) .expect("left dock should have an active panel"); assert_eq!( @@ -12557,9 +12566,8 @@ mod tests { ); let left_dock = workspace.left_dock().read(cx); - let left_width = left_dock - .active_panel() - .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx)) + let left_width = workspace + .dock_size(&left_dock, window, cx) .expect("left dock should still have an active panel after vertical split"); assert_eq!( @@ -12578,15 +12586,13 @@ mod tests { workspace.toggle_dock(DockPosition::Right, window, cx); let right_dock = workspace.right_dock().read(cx); - let right_width = right_dock - .active_panel() - .map(|p| workspace.resolved_dock_panel_size(&right_dock, p.as_ref(), window, cx)) + let right_width = workspace + .dock_size(&right_dock, window, cx) .expect("right dock should have an active panel"); let left_dock = workspace.left_dock().read(cx); - let left_width = left_dock - .active_panel() - .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx)) + let left_width = workspace + .dock_size(&left_dock, window, cx) .expect("left dock should still have an active panel"); let available_width = workspace.bounds.size.width - right_width; @@ -12650,8 +12656,8 @@ mod tests { panel_1.panel_id() ); assert_eq!( - left_dock.read(cx).active_panel_size(window, cx).unwrap(), - px(300.) + workspace.dock_size(&left_dock.read(cx), window, cx), + Some(px(300.)) ); workspace.resize_left_dock(px(1337.), window, cx); @@ -12684,7 +12690,12 @@ mod tests { panel_1.panel_id() ); assert_eq!( - right_dock.read(cx).active_panel_size(window, cx).unwrap(), + right_dock + .read(cx) + .active_panel_size() + .unwrap() + .size + .unwrap(), px(1337.) ); @@ -12722,8 +12733,8 @@ mod tests { panel_1.panel_id() ); assert_eq!( - left_dock.read(cx).active_panel_size(window, cx).unwrap(), - px(1337.) + workspace.dock_size(&left_dock.read(cx), window, cx), + Some(px(1337.)) ); // And the right dock should be closed as it no longer has any panels. assert!(!workspace.right_dock().read(cx).is_open()); @@ -12739,8 +12750,8 @@ mod tests { // since the panel orientation changed from vertical to horizontal. let bottom_dock = workspace.bottom_dock(); assert_eq!( - bottom_dock.read(cx).active_panel_size(window, cx).unwrap(), - px(300.), + workspace.dock_size(&bottom_dock.read(cx), window, cx), + Some(px(300.)) ); // Close bottom dock and move panel_1 back to the left. bottom_dock.update(cx, |bottom_dock, cx| { From d3f5fc8466444c21332bfd70ba709d92c1903c88 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:23:03 -0300 Subject: [PATCH 16/53] agent_ui: Display an activity bar for subagents waiting for permission (#52460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/issues/52346 Given the parallel nature of subagents calls, it's possible that there is a subagent way out of view that's waiting for the user to give permissions. Right now, it's kind of hard to know this and you may think something wrong is happening given the thread generation isn't making any progress. This PR adds an "activity bar" to the thread view that displays subagents on a "waiting for confirmation" status. We display the subagent's summary label as well as allow clicking on it to quickly scrolling to that subagent. Screenshot 2026-03-25 at 10  09@2x Release Notes: - Agent: Improved the experience of interacting with subagents waiting for confirmation. --- crates/agent_ui/src/conversation_view.rs | 14 ++ .../src/conversation_view/thread_view.rs | 130 +++++++++++++++++- 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index f5c91cf342c69badf2915e21c17f819963416ec5..a3c87c8d66031f553bcd4cb8dc82c681a0b79c94 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -237,6 +237,20 @@ impl Conversation { )) } + pub fn subagents_awaiting_permission(&self, cx: &App) -> Vec<(acp::SessionId, usize)> { + self.permission_requests + .iter() + .filter_map(|(session_id, tool_call_ids)| { + let thread = self.threads.get(session_id)?; + if thread.read(cx).parent_session_id().is_some() && !tool_call_ids.is_empty() { + Some((session_id.clone(), tool_call_ids.len())) + } else { + None + } + }) + .collect() + } + pub fn authorize_pending_tool_call( &mut self, session_id: &acp::SessionId, diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 2778a5b4a2583a0b232f86184f33c4446bc18ea5..0c2ecf4bbefdbc2eb0431c0d7c094dc9f5b2155b 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -2155,7 +2155,14 @@ impl ThreadView { let plan = thread.plan(); let queue_is_empty = !self.has_queued_messages(); - if changed_buffers.is_empty() && plan.is_empty() && queue_is_empty { + let subagents_awaiting_permission = self.render_subagents_awaiting_permission(cx); + let has_subagents_awaiting = subagents_awaiting_permission.is_some(); + + if changed_buffers.is_empty() + && plan.is_empty() + && queue_is_empty + && !has_subagents_awaiting + { return None; } @@ -2183,6 +2190,14 @@ impl ThreadView { blur_radius: px(2.), spread_radius: px(0.), }]) + .when_some(subagents_awaiting_permission, |this, element| { + this.child(element) + }) + .when( + has_subagents_awaiting + && (!plan.is_empty() || !changed_buffers.is_empty() || !queue_is_empty), + |this| this.child(Divider::horizontal().color(DividerColor::Border)), + ) .when(!plan.is_empty(), |this| { this.child(self.render_plan_summary(plan, window, cx)) .when(plan_expanded, |parent| { @@ -2442,6 +2457,119 @@ impl ThreadView { ) } + fn render_subagents_awaiting_permission(&self, cx: &Context) -> Option { + let awaiting = self.conversation.read(cx).subagents_awaiting_permission(cx); + + if awaiting.is_empty() { + return None; + } + + let thread = self.thread.read(cx); + let entries = thread.entries(); + let mut subagent_items: Vec<(SharedString, usize)> = Vec::new(); + + for (session_id, _) in &awaiting { + for (entry_ix, entry) in entries.iter().enumerate() { + if let AgentThreadEntry::ToolCall(tool_call) = entry { + if let Some(info) = &tool_call.subagent_session_info { + if &info.session_id == session_id { + let subagent_summary: SharedString = { + let summary_text = tool_call.label.read(cx).source().to_string(); + if !summary_text.is_empty() { + summary_text.into() + } else { + "Subagent".into() + } + }; + subagent_items.push((subagent_summary, entry_ix)); + break; + } + } + } + } + } + + if subagent_items.is_empty() { + return None; + } + + let item_count = subagent_items.len(); + + Some( + v_flex() + .child( + h_flex() + .py_1() + .px_2() + .w_full() + .gap_1() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Label::new("Subagents Awaiting Permission:") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Label::new(item_count.to_string()).size(LabelSize::Small)), + ) + .child( + v_flex().children(subagent_items.into_iter().enumerate().map( + |(ix, (label, entry_ix))| { + let is_last = ix == item_count - 1; + let group = format!("group-{}", entry_ix); + + h_flex() + .cursor_pointer() + .id(format!("subagent-permission-{}", entry_ix)) + .group(&group) + .p_1() + .pl_2() + .min_w_0() + .w_full() + .gap_1() + .justify_between() + .bg(cx.theme().colors().editor_background) + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .when(!is_last, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Circle) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + .child( + Label::new(label) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + .child( + div().visible_on_hover(&group).child( + Label::new("Scroll to Subagent") + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.list_state.scroll_to(ListOffset { + item_ix: entry_ix, + offset_in_item: px(0.0), + }); + cx.notify(); + })) + }, + )), + ) + .into_any(), + ) + } + fn render_message_queue_summary( &self, _window: &mut Window, From 73226701c022acdac3dc7b20a39717705da6614b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:23:19 -0300 Subject: [PATCH 17/53] agent_ui: Move fully complete plan to the thread view (#52462) When a plan generate by the plan tool fully completes, there's no need for that to be in the activity bar anymore. It's complete and in the next turn, the agent may come up with another plan and the cycle restarts. So, this PR moves a fully complete plan to the thread view, so that it stays as part of a given turn: image The way this PR does this is by adding a new entry to `AgentThreadEntry` and snapshotting the completed plan so we can display it properly in the thread. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 37 +++++++- crates/agent/src/agent.rs | 3 + .../src/conversation_view/thread_view.rs | 84 ++++++++++++++++++- crates/agent_ui/src/entry_view_state.rs | 18 ++-- 4 files changed, 130 insertions(+), 12 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index df59c67bb4576e34f76539df34147fb4606bb9f3..f33732f1e0f3623df5ce6833356f3547c5781adb 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -160,6 +160,7 @@ pub enum AgentThreadEntry { UserMessage(UserMessage), AssistantMessage(AssistantMessage), ToolCall(ToolCall), + CompletedPlan(Vec), } impl AgentThreadEntry { @@ -168,6 +169,7 @@ impl AgentThreadEntry { Self::UserMessage(message) => message.indented, Self::AssistantMessage(message) => message.indented, Self::ToolCall(_) => false, + Self::CompletedPlan(_) => false, } } @@ -176,6 +178,14 @@ impl AgentThreadEntry { Self::UserMessage(message) => message.to_markdown(cx), Self::AssistantMessage(message) => message.to_markdown(cx), Self::ToolCall(tool_call) => tool_call.to_markdown(cx), + Self::CompletedPlan(entries) => { + let mut md = String::from("## Plan\n\n"); + for entry in entries { + let source = entry.content.read(cx).source().to_string(); + md.push_str(&format!("- [x] {}\n", source)); + } + md + } } } @@ -1298,7 +1308,9 @@ impl AcpThread { status: ToolCallStatus::WaitingForConfirmation { .. }, .. }) => return true, - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } false @@ -1320,7 +1332,9 @@ impl AcpThread { ) if call.diffs().next().is_some() => { return true; } - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } @@ -1337,7 +1351,9 @@ impl AcpThread { }) => { return true; } - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } @@ -1348,7 +1364,9 @@ impl AcpThread { for entry in self.entries.iter().rev() { match entry { AgentThreadEntry::UserMessage(..) => return false, - AgentThreadEntry::AssistantMessage(..) => continue, + AgentThreadEntry::AssistantMessage(..) | AgentThreadEntry::CompletedPlan(..) => { + continue; + } AgentThreadEntry::ToolCall(..) => return true, } } @@ -2065,6 +2083,13 @@ impl AcpThread { cx.notify(); } + pub fn snapshot_completed_plan(&mut self, cx: &mut Context) { + if !self.plan.is_empty() && self.plan.stats().pending == 0 { + let completed_entries = std::mem::take(&mut self.plan.entries); + self.push_entry(AgentThreadEntry::CompletedPlan(completed_entries), cx); + } + } + fn clear_completed_plan_entries(&mut self, cx: &mut Context) { self.plan .entries @@ -2223,6 +2248,10 @@ impl AcpThread { this.mark_pending_tools_as_canceled(); } + if !canceled { + this.snapshot_completed_plan(cx); + } + // Handle refusal - distinguish between user prompt and tool call refusals if let acp::StopReason::Refusal = r.stop_reason { this.had_error = true; diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index f36d8c0497430c27c7cafd99445c8baad18406f5..b7aa9d1e311016f572928993e049798c2b5e3bb2 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -942,6 +942,9 @@ impl NativeAgent { NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx) }) .await?; + acp_thread.update(cx, |thread, cx| { + thread.snapshot_completed_plan(cx); + }); Ok(acp_thread) }) } diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 0c2ecf4bbefdbc2eb0431c0d7c094dc9f5b2155b..4ebe196e7ca7de9c6341925676423bdc4a8d8d38 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1,7 +1,10 @@ -use crate::{DEFAULT_THREAD_TITLE, SelectPermissionGranularity}; +use crate::{ + DEFAULT_THREAD_TITLE, SelectPermissionGranularity, + agent_configuration::configure_context_server_modal::default_markdown_style, +}; use std::cell::RefCell; -use acp_thread::ContentBlock; +use acp_thread::{ContentBlock, PlanEntry}; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; @@ -2789,6 +2792,76 @@ impl ThreadView { .into_any_element() } + fn render_completed_plan( + &self, + entries: &[PlanEntry], + window: &Window, + cx: &Context, + ) -> AnyElement { + v_flex() + .px_5() + .py_1p5() + .w_full() + .child( + v_flex() + .w_full() + .rounded_md() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .child( + h_flex() + .px_2() + .py_1() + .gap_1() + .bg(self.tool_card_header_bg(cx)) + .border_b_1() + .border_color(self.tool_card_border_color(cx)) + .child( + Label::new("Completed Plan") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(format!( + "— {} {}", + entries.len(), + if entries.len() == 1 { "step" } else { "steps" } + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + v_flex().children(entries.iter().enumerate().map(|(index, entry)| { + h_flex() + .py_1() + .px_2() + .gap_1p5() + .when(index < entries.len() - 1, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + Icon::new(IconName::TodoComplete) + .size(IconSize::Small) + .color(Color::Success), + ) + .child( + div() + .max_w_full() + .overflow_x_hidden() + .text_xs() + .text_color(cx.theme().colors().text_muted) + .child(MarkdownElement::new( + entry.content.clone(), + default_markdown_style(window, cx), + )), + ) + })), + ), + ) + .into_any() + } + fn render_edits_summary( &self, changed_buffers: &BTreeMap, Entity>, @@ -4546,6 +4619,9 @@ impl ThreadView { cx, ) .into_any(), + AgentThreadEntry::CompletedPlan(entries) => { + self.render_completed_plan(entries, window, cx) + } }; let is_subagent_output = self.is_subagent() @@ -5411,7 +5487,9 @@ impl ThreadView { return false; } } - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index ef5e8a9812e8266566f027365e4b270177aab71c..dfa76e3716f0b938e8ff53e0799c12dd1a657a88 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -235,6 +235,11 @@ impl EntryViewState { }; entry.sync(message); } + AgentThreadEntry::CompletedPlan(_) => { + if !matches!(self.entries.get(index), Some(Entry::CompletedPlan)) { + self.set_entry(index, Entry::CompletedPlan); + } + } }; } @@ -253,7 +258,9 @@ impl EntryViewState { pub fn agent_ui_font_size_changed(&mut self, cx: &mut App) { for entry in self.entries.iter() { match entry { - Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {} + Entry::UserMessage { .. } + | Entry::AssistantMessage { .. } + | Entry::CompletedPlan => {} Entry::ToolCall(ToolCallEntry { content }) => { for view in content.values() { if let Ok(diff_editor) = view.clone().downcast::() { @@ -320,6 +327,7 @@ pub enum Entry { UserMessage(Entity), AssistantMessage(AssistantMessageEntry), ToolCall(ToolCallEntry), + CompletedPlan, } impl Entry { @@ -327,14 +335,14 @@ impl Entry { match self { Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)), Self::AssistantMessage(message) => Some(message.focus_handle.clone()), - Self::ToolCall(_) => None, + Self::ToolCall(_) | Self::CompletedPlan => None, } } pub fn message_editor(&self) -> Option<&Entity> { match self { Self::UserMessage(editor) => Some(editor), - Self::AssistantMessage(_) | Self::ToolCall(_) => None, + Self::AssistantMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None, } } @@ -361,7 +369,7 @@ impl Entry { ) -> Option { match self { Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix), - Self::UserMessage(_) | Self::ToolCall(_) => None, + Self::UserMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None, } } @@ -376,7 +384,7 @@ impl Entry { pub fn has_content(&self) -> bool { match self { Self::ToolCall(ToolCallEntry { content }) => !content.is_empty(), - Self::UserMessage(_) | Self::AssistantMessage(_) => false, + Self::UserMessage(_) | Self::AssistantMessage(_) | Self::CompletedPlan => false, } } } From 1625f98fb063faac06fdbdbb14a1b9d904e3a7db Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:25:58 -0300 Subject: [PATCH 18/53] collab_panel: Fix favorite channels not surviving startup (#52541) Follow up to https://github.com/zed-industries/zed/pull/52378 This PR fixes a little race condition that was happening where we were running the favorite channel pruning function faster than the channels could load, leading to favorite channels not surviving the app restarting. The fix is to make the pruning happen only when the number of channels is bigger than 0, which means the list from the server has already been loaded. Release Notes: - N/A _(No release notes yet because this feature hasn't reached the wider public)_ --- crates/collab_ui/src/collab_panel.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 74e7a7c82b2123bfca8d4fc4a9e8f02463e3f7d3..4e3e1ec1bfac253f7d9dae3b01fdc9a17b9acd34 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -683,11 +683,13 @@ impl CollabPanel { let mut request_entries = Vec::new(); - let previous_len = self.favorite_channels.len(); - self.favorite_channels - .retain(|id| self.channel_store.read(cx).channel_for_id(*id).is_some()); - if self.favorite_channels.len() != previous_len { - self.serialize(cx); + if self.channel_store.read(cx).channel_count() > 0 { + let previous_len = self.favorite_channels.len(); + self.favorite_channels + .retain(|id| self.channel_store.read(cx).channel_for_id(*id).is_some()); + if self.favorite_channels.len() != previous_len { + self.serialize(cx); + } } let channel_store = self.channel_store.read(cx); From d77aba3ee721e4b93c9deb937739eed3b602df45 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Thu, 26 Mar 2026 17:11:14 -0700 Subject: [PATCH 19/53] Group threads by canonical path lists (#52524) ## Context With the new sidebar, we are having some bugs around multi-root projects combined with git work trees that can cause threads to be visible in the agent panel but not have an entry in the sidebar. ## How to Review This PR takes a step towards resolving these issue by adding a `ProjectGroupBuilder` which is responsible for gathering the set of projects groups from the open workspaces and then helping to discover threads and map them into this set. ## 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: Mikayla Maki Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Mikayla Maki Co-authored-by: Max Brunsfeld --- crates/sidebar/src/project_group_builder.rs | 330 +++++++++++ crates/sidebar/src/sidebar.rs | 573 ++++++++++---------- 2 files changed, 621 insertions(+), 282 deletions(-) create mode 100644 crates/sidebar/src/project_group_builder.rs diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs new file mode 100644 index 0000000000000000000000000000000000000000..d03190e028082e086b30956933780090c1be07e5 --- /dev/null +++ b/crates/sidebar/src/project_group_builder.rs @@ -0,0 +1,330 @@ +//! The sidebar groups threads by a canonical path list. +//! +//! Threads have a path list associated with them, but this is the absolute path +//! of whatever worktrees they were associated with. In the sidebar, we want to +//! group all threads by their main worktree, and then we add a worktree chip to +//! the sidebar entry when that thread is in another worktree. +//! +//! This module is provides the functions and structures necessary to do this +//! lookup and mapping. + +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, + sync::Arc, +}; + +use gpui::{App, Entity}; +use ui::SharedString; +use workspace::{MultiWorkspace, PathList, Workspace}; + +/// Identifies a project group by a set of paths the workspaces in this group +/// have. +/// +/// Paths are mapped to their main worktree path first so we can group +/// workspaces by main repos. +#[derive(PartialEq, Eq, Hash, Clone)] +pub struct ProjectGroupName { + path_list: PathList, +} + +impl ProjectGroupName { + pub fn display_name(&self) -> SharedString { + let mut names = Vec::with_capacity(self.path_list.paths().len()); + for abs_path in self.path_list.paths() { + if let Some(name) = abs_path.file_name() { + names.push(name.to_string_lossy().to_string()); + } + } + if names.is_empty() { + // TODO: Can we do something better in this case? + "Empty Workspace".into() + } else { + names.join(", ").into() + } + } + + pub fn path_list(&self) -> &PathList { + &self.path_list + } +} + +#[derive(Default)] +pub struct ProjectGroup { + pub workspaces: Vec>, + /// Root paths of all open workspaces in this group. Used to skip + /// redundant thread-store queries for linked worktrees that already + /// have an open workspace. + covered_paths: HashSet>, +} + +impl ProjectGroup { + fn add_workspace(&mut self, workspace: &Entity, cx: &App) { + if !self.workspaces.contains(workspace) { + self.workspaces.push(workspace.clone()); + } + for path in workspace.read(cx).root_paths(cx) { + self.covered_paths.insert(path); + } + } + + pub fn first_workspace(&self) -> &Entity { + self.workspaces + .first() + .expect("groups always have at least one workspace") + } +} + +pub struct ProjectGroupBuilder { + /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path + directory_mappings: HashMap, + project_group_names: Vec, + project_groups: Vec, +} + +impl ProjectGroupBuilder { + fn new() -> Self { + Self { + directory_mappings: HashMap::new(), + project_group_names: Vec::new(), + project_groups: Vec::new(), + } + } + + pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self { + let mut builder = Self::new(); + + // First pass: collect all directory mappings from every workspace + // so we know how to canonicalize any path (including linked + // worktree paths discovered by the main repo's workspace). + for workspace in mw.workspaces() { + builder.add_workspace_mappings(workspace.read(cx), cx); + } + + // Second pass: group each workspace using canonical paths derived + // from the full set of mappings. + for workspace in mw.workspaces() { + let group_name = builder.canonical_workspace_paths(workspace, cx); + builder + .project_group_entry(&group_name) + .add_workspace(workspace, cx); + } + builder + } + + fn project_group_entry(&mut self, name: &ProjectGroupName) -> &mut ProjectGroup { + match self.project_group_names.iter().position(|n| n == name) { + Some(idx) => &mut self.project_groups[idx], + None => { + let idx = self.project_group_names.len(); + self.project_group_names.push(name.clone()); + self.project_groups.push(ProjectGroup::default()); + &mut self.project_groups[idx] + } + } + } + + fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) { + let old = self + .directory_mappings + .insert(PathBuf::from(work_directory), PathBuf::from(original_repo)); + if let Some(old) = old { + debug_assert_eq!( + &old, original_repo, + "all worktrees should map to the same main worktree" + ); + } + } + + pub fn add_workspace_mappings(&mut self, workspace: &Workspace, cx: &App) { + for repo in workspace.project().read(cx).repositories(cx).values() { + let snapshot = repo.read(cx).snapshot(); + + self.add_mapping( + &snapshot.work_directory_abs_path, + &snapshot.original_repo_abs_path, + ); + + for worktree in snapshot.linked_worktrees.iter() { + self.add_mapping(&worktree.path, &snapshot.original_repo_abs_path); + } + } + } + + /// Derives the canonical group name for a workspace by canonicalizing + /// each of its root paths using the builder's directory mappings. + fn canonical_workspace_paths( + &self, + workspace: &Entity, + cx: &App, + ) -> ProjectGroupName { + let paths: Vec<_> = workspace + .read(cx) + .root_paths(cx) + .iter() + .map(|p| self.canonicalize_path(p).to_path_buf()) + .collect(); + ProjectGroupName { + path_list: PathList::new(&paths), + } + } + + pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path { + self.directory_mappings + .get(path) + .map(AsRef::as_ref) + .unwrap_or(path) + } + + /// Whether the given group should load threads for a linked worktree at + /// `worktree_path`. Returns `false` if the worktree already has an open + /// workspace in the group (its threads are loaded via the workspace loop) + /// or if the worktree's canonical path list doesn't match `group_path_list`. + pub fn group_owns_worktree( + &self, + group: &ProjectGroup, + group_path_list: &PathList, + worktree_path: &Path, + ) -> bool { + let worktree_arc: Arc = Arc::from(worktree_path); + if group.covered_paths.contains(&worktree_arc) { + return false; + } + let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path])); + canonical == *group_path_list + } + + fn canonicalize_path_list(&self, path_list: &PathList) -> PathList { + let paths: Vec<_> = path_list + .paths() + .iter() + .map(|p| self.canonicalize_path(p).to_path_buf()) + .collect(); + PathList::new(&paths) + } + + pub fn groups(&self) -> impl Iterator { + self.project_group_names + .iter() + .zip(self.project_groups.iter()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use fs::FakeFs; + use gpui::TestAppContext; + use settings::SettingsStore; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + }); + } + + async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt/feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "abc".into(), + }); + }) + .expect("git state should be set"); + fs + } + + #[gpui::test] + async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) { + init_test(cx); + let fs = create_fs_with_main_and_worktree(cx).await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + + multi_workspace.read_with(cx, |mw, cx| { + let mut canonicalizer = ProjectGroupBuilder::new(); + for workspace in mw.workspaces() { + canonicalizer.add_workspace_mappings(workspace.read(cx), cx); + } + + // The main repo path should canonicalize to itself. + assert_eq!( + canonicalizer.canonicalize_path(Path::new("/project")), + Path::new("/project"), + ); + + // An unknown path returns None. + assert_eq!( + canonicalizer.canonicalize_path(Path::new("/something/else")), + Path::new("/something/else"), + ); + }); + } + + #[gpui::test] + async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) { + init_test(cx); + let fs = create_fs_with_main_and_worktree(cx).await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Open the worktree checkout as its own project. + let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + + multi_workspace.read_with(cx, |mw, cx| { + let mut canonicalizer = ProjectGroupBuilder::new(); + for workspace in mw.workspaces() { + canonicalizer.add_workspace_mappings(workspace.read(cx), cx); + } + + // The worktree checkout path should canonicalize to the main repo. + assert_eq!( + canonicalizer.canonicalize_path(Path::new("/wt/feature-a")), + Path::new("/project"), + ); + }); + } +} diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 501b55a73260f0d453775fc245868669c35ab406..123ca7a6bec8af78f25a0c3bbac5767ced38b55f 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -19,16 +19,14 @@ use gpui::{ use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, }; -use project::{AgentId, Event as ProjectEvent, linked_worktree_short_name}; +use project::{Event as ProjectEvent, linked_worktree_short_name}; use recent_projects::sidebar_recent_projects::SidebarRecentProjects; use ui::utils::platform_title_bar_height; use settings::Settings as _; use std::collections::{HashMap, HashSet}; use std::mem; -use std::path::Path; use std::rc::Rc; -use std::sync::Arc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding, @@ -47,6 +45,10 @@ use zed_actions::editor::{MoveDown, MoveUp}; use zed_actions::agents_sidebar::FocusSidebarFilter; +use crate::project_group_builder::ProjectGroupBuilder; + +mod project_group_builder; + gpui::actions!( agents_sidebar, [ @@ -118,6 +120,24 @@ struct ThreadEntry { diff_stats: DiffStats, } +impl ThreadEntry { + /// Updates this thread entry with active thread information. + /// + /// The existing [`ThreadEntry`] was likely deserialized from the database + /// but if we have a correspond thread already loaded we want to apply the + /// live information. + fn apply_active_info(&mut self, info: &ActiveThreadInfo) { + self.session_info.title = Some(info.title.clone()); + self.status = info.status; + self.icon = info.icon; + self.icon_from_external_svg = info.icon_from_external_svg.clone(); + self.is_live = true; + self.is_background = info.is_background; + self.is_title_generating = info.is_title_generating; + self.diff_stats = info.diff_stats; + } +} + #[derive(Clone)] enum ListEntry { ProjectHeader { @@ -209,21 +229,6 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } -fn workspace_label_from_path_list(path_list: &PathList) -> SharedString { - let mut names = Vec::with_capacity(path_list.paths().len()); - for abs_path in path_list.paths() { - if let Some(name) = abs_path.file_name() { - names.push(name.to_string_lossy().to_string()); - } - } - if names.is_empty() { - // TODO: Can we do something better in this case? - "Empty Workspace".into() - } else { - names.join(", ").into() - } -} - /// The sidebar re-derives its entire entry list from scratch on every /// change via `update_entries` → `rebuild_contents`. Avoid adding /// incremental or inter-event coordination state — if something can @@ -542,8 +547,21 @@ impl Sidebar { result } - /// When modifying this thread, aim for a single forward pass over workspaces - /// and threads plus an O(T log T) sort. Avoid adding extra scans over the data. + /// Rebuilds the sidebar contents from current workspace and thread state. + /// + /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git + /// repository, then populates thread entries from the metadata store and + /// merges live thread info from active agent panels. + /// + /// Aim for a single forward pass over workspaces and threads plus an + /// O(T log T) sort. Avoid adding extra scans over the data. + /// + /// Properties: + /// + /// - Should always show every workspace in the multiworkspace + /// - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown + /// - Should always show every thread, associated with each workspace in the multiworkspace + /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread. fn rebuild_contents(&mut self, cx: &App) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; @@ -552,7 +570,6 @@ impl Sidebar { let workspaces = mw.workspaces().to_vec(); let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); - // Build a lookup for agent icons from the first workspace's AgentServerStore. let agent_server_store = workspaces .first() .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone()); @@ -607,118 +624,62 @@ impl Sidebar { let mut current_session_ids: HashSet = HashSet::new(); let mut project_header_indices: Vec = Vec::new(); - // Identify absorbed workspaces in a single pass. A workspace is - // "absorbed" when it points at a git worktree checkout whose main - // repo is open as another workspace — its threads appear under the - // main repo's header instead of getting their own. - let mut main_repo_workspace: HashMap, usize> = HashMap::new(); - let mut absorbed: HashMap = HashMap::new(); - let mut pending: HashMap, Vec<(usize, SharedString, Arc)>> = HashMap::new(); - let mut absorbed_workspace_by_path: HashMap, usize> = HashMap::new(); - let workspace_indices_by_path: HashMap, Vec> = workspaces - .iter() - .enumerate() - .flat_map(|(index, workspace)| { - let paths = workspace_path_list(workspace, cx).paths().to_vec(); - paths - .into_iter() - .map(move |path| (Arc::from(path.as_path()), index)) - }) - .fold(HashMap::new(), |mut map, (path, index)| { - map.entry(path).or_default().push(index); - map - }); - - for (i, workspace) in workspaces.iter().enumerate() { - for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.is_main_worktree() { - main_repo_workspace - .entry(snapshot.work_directory_abs_path.clone()) - .or_insert(i); - - for git_worktree in snapshot.linked_worktrees() { - let worktree_path: Arc = Arc::from(git_worktree.path.as_path()); - if let Some(worktree_indices) = - workspace_indices_by_path.get(worktree_path.as_ref()) - { - for &worktree_idx in worktree_indices { - if worktree_idx == i { - continue; - } - - let worktree_name = linked_worktree_short_name( - &snapshot.original_repo_abs_path, - &git_worktree.path, - ) - .unwrap_or_default(); - absorbed.insert(worktree_idx, (i, worktree_name.clone())); - absorbed_workspace_by_path - .insert(worktree_path.clone(), worktree_idx); - } - } - } - - if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) { - for (ws_idx, name, ws_path) in waiting { - absorbed.insert(ws_idx, (i, name)); - absorbed_workspace_by_path.insert(ws_path, ws_idx); - } - } - } else { - let name: SharedString = snapshot - .work_directory_abs_path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - .into(); - if let Some(&main_idx) = - main_repo_workspace.get(&snapshot.original_repo_abs_path) - { - absorbed.insert(i, (main_idx, name)); - absorbed_workspace_by_path - .insert(snapshot.work_directory_abs_path.clone(), i); - } else { - pending - .entry(snapshot.original_repo_abs_path.clone()) - .or_default() - .push((i, name, snapshot.work_directory_abs_path.clone())); - } - } - } - } + // Use ProjectGroupBuilder to canonically group workspaces by their + // main git repository. This replaces the manual absorbed-workspace + // detection that was here before. + let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx); let has_open_projects = workspaces .iter() .any(|ws| !workspace_path_list(ws, cx).paths().is_empty()); - let active_ws_index = active_workspace - .as_ref() - .and_then(|active| workspaces.iter().position(|ws| ws == active)); - - for (ws_index, workspace) in workspaces.iter().enumerate() { - if absorbed.contains_key(&ws_index) { - continue; + let resolve_agent = |row: &ThreadMetadata| -> (Agent, IconName, Option) { + match &row.agent_id { + None => (Agent::NativeAgent, IconName::ZedAgent, None), + Some(id) => { + let custom_icon = agent_server_store + .as_ref() + .and_then(|store| store.read(cx).agent_icon(id)); + ( + Agent::Custom { id: id.clone() }, + IconName::Terminal, + custom_icon, + ) + } } + }; - let path_list = workspace_path_list(workspace, cx); + for (group_name, group) in project_groups.groups() { + let path_list = group_name.path_list().clone(); if path_list.paths().is_empty() { continue; } - let label = workspace_label_from_path_list(&path_list); + let label = group_name.display_name(); let is_collapsed = self.collapsed_groups.contains(&path_list); let should_load_threads = !is_collapsed || !query.is_empty(); - let is_active = active_ws_index.is_some_and(|active_idx| { - active_idx == ws_index - || absorbed - .get(&active_idx) - .is_some_and(|(main_idx, _)| *main_idx == ws_index) - }); - - let mut live_infos: Vec<_> = all_thread_infos_for_workspace(workspace, cx).collect(); + let is_active = active_workspace + .as_ref() + .is_some_and(|active| group.workspaces.contains(active)); + + // Pick a representative workspace for the group: prefer the active + // workspace if it belongs to this group, otherwise use the first. + // + // This is the workspace that will be activated by the project group + // header. + let representative_workspace = active_workspace + .as_ref() + .filter(|_| is_active) + .unwrap_or_else(|| group.first_workspace()); + + // Collect live thread infos from all workspaces in this group. + let live_infos: Vec<_> = group + .workspaces + .iter() + .flat_map(|ws| all_thread_infos_for_workspace(ws, cx)) + .collect(); let mut threads: Vec = Vec::new(); let mut has_running_threads = false; @@ -726,138 +687,124 @@ impl Sidebar { if should_load_threads { let mut seen_session_ids: HashSet = HashSet::new(); - - // Read threads from the store cache for this workspace's path list. let thread_store = SidebarThreadMetadataStore::global(cx); - let workspace_rows: Vec<_> = - thread_store.read(cx).entries_for_path(&path_list).collect(); - for row in workspace_rows { - seen_session_ids.insert(row.session_id.clone()); - let (agent, icon, icon_from_external_svg) = match &row.agent_id { - None => (Agent::NativeAgent, IconName::ZedAgent, None), - Some(id) => { - let custom_icon = agent_server_store - .as_ref() - .and_then(|store| store.read(cx).agent_icon(&id)); - ( - Agent::Custom { id: id.clone() }, - IconName::Terminal, - custom_icon, - ) - } - }; - threads.push(ThreadEntry { - agent, - session_info: acp_thread::AgentSessionInfo { - session_id: row.session_id.clone(), - work_dirs: None, - title: Some(row.title.clone()), - updated_at: Some(row.updated_at), - created_at: row.created_at, - meta: None, - }, - icon, - icon_from_external_svg, - status: AgentThreadStatus::default(), - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }); - } - // Load threads from linked git worktrees of this workspace's repos. - { - let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc)> = - Vec::new(); - for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.is_linked_worktree() { - continue; - } + // Load threads from each workspace in the group. + for workspace in &group.workspaces { + let ws_path_list = workspace_path_list(workspace, cx); + + // Determine if this workspace covers a git worktree (its + // path canonicalizes to the main repo, not itself). If so, + // threads from it get a worktree chip in the sidebar. + let worktree_info: Option<(SharedString, SharedString)> = + ws_path_list.paths().first().and_then(|path| { + let canonical = project_groups.canonicalize_path(path); + if canonical != path.as_path() { + let name = + linked_worktree_short_name(canonical, path).unwrap_or_default(); + let full_path: SharedString = path.display().to_string().into(); + Some((name, full_path)) + } else { + None + } + }); - let main_worktree_path = snapshot.original_repo_abs_path.clone(); - - for git_worktree in snapshot.linked_worktrees() { - let worktree_name = - linked_worktree_short_name(&main_worktree_path, &git_worktree.path) - .unwrap_or_default(); - linked_worktree_queries.push(( - PathList::new(std::slice::from_ref(&git_worktree.path)), - worktree_name, - Arc::from(git_worktree.path.as_path()), - )); + let workspace_threads: Vec<_> = thread_store + .read(cx) + .entries_for_path(&ws_path_list) + .collect(); + for thread in workspace_threads { + if !seen_session_ids.insert(thread.session_id.clone()) { + continue; } + let (agent, icon, icon_from_external_svg) = resolve_agent(&thread); + threads.push(ThreadEntry { + agent, + session_info: acp_thread::AgentSessionInfo { + session_id: thread.session_id.clone(), + work_dirs: None, + title: Some(thread.title.clone()), + updated_at: Some(thread.updated_at), + created_at: thread.created_at, + meta: None, + }, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()), + worktree_full_path: worktree_info + .as_ref() + .map(|(_, path)| path.clone()), + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }); } + } - for (worktree_path_list, worktree_name, worktree_path) in - &linked_worktree_queries - { - let target_workspace = match absorbed_workspace_by_path - .get(worktree_path.as_ref()) - { - Some(&idx) => { - live_infos - .extend(all_thread_infos_for_workspace(&workspaces[idx], cx)); - ThreadEntryWorkspace::Open(workspaces[idx].clone()) - } - None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()), - }; + // Load threads from linked git worktrees that don't have an + // open workspace in this group. Only include worktrees that + // belong to this group (not shared with another group). + let linked_worktree_path_lists = group + .workspaces + .iter() + .flat_map(|ws| root_repository_snapshots(ws, cx)) + .filter(|snapshot| !snapshot.is_linked_worktree()) + .flat_map(|snapshot| { + snapshot + .linked_worktrees() + .iter() + .filter(|wt| { + project_groups.group_owns_worktree(group, &path_list, &wt.path) + }) + .map(|wt| PathList::new(std::slice::from_ref(&wt.path))) + .collect::>() + }); - let worktree_rows: Vec<_> = thread_store - .read(cx) - .entries_for_path(worktree_path_list) - .collect(); - for row in worktree_rows { - if !seen_session_ids.insert(row.session_id.clone()) { - continue; - } - let (agent, icon, icon_from_external_svg) = match &row.agent_id { - None => (Agent::NativeAgent, IconName::ZedAgent, None), - Some(name) => { - let custom_icon = - agent_server_store.as_ref().and_then(|store| { - store.read(cx).agent_icon(&AgentId(name.clone().into())) - }); - ( - Agent::Custom { - id: AgentId::new(name.clone()), - }, - IconName::Terminal, - custom_icon, - ) - } - }; - threads.push(ThreadEntry { - agent, - session_info: acp_thread::AgentSessionInfo { - session_id: row.session_id.clone(), - work_dirs: None, - title: Some(row.title.clone()), - updated_at: Some(row.updated_at), - created_at: row.created_at, - meta: None, - }, - icon, - icon_from_external_svg, - status: AgentThreadStatus::default(), - workspace: target_workspace.clone(), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: Some(worktree_name.clone()), - worktree_full_path: Some( - worktree_path.display().to_string().into(), - ), - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }); + for worktree_path_list in linked_worktree_path_lists { + for row in thread_store.read(cx).entries_for_path(&worktree_path_list) { + if !seen_session_ids.insert(row.session_id.clone()) { + continue; } + let worktree_info = row.folder_paths.paths().first().and_then(|path| { + let canonical = project_groups.canonicalize_path(path); + if canonical != path.as_path() { + let name = + linked_worktree_short_name(canonical, path).unwrap_or_default(); + let full_path: SharedString = path.display().to_string().into(); + Some((name, full_path)) + } else { + None + } + }); + let (agent, icon, icon_from_external_svg) = resolve_agent(&row); + threads.push(ThreadEntry { + agent, + session_info: acp_thread::AgentSessionInfo { + session_id: row.session_id.clone(), + work_dirs: None, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, + meta: None, + }, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace: ThreadEntryWorkspace::Closed(row.folder_paths.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()), + worktree_full_path: worktree_info.map(|(_, path)| path), + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }); } } @@ -878,19 +825,12 @@ impl Sidebar { // Merge live info into threads and update notification state // in a single pass. for thread in &mut threads { - let session_id = &thread.session_info.session_id; - - if let Some(info) = live_info_by_session.get(session_id) { - thread.session_info.title = Some(info.title.clone()); - thread.status = info.status; - thread.icon = info.icon; - thread.icon_from_external_svg = info.icon_from_external_svg.clone(); - thread.is_live = true; - thread.is_background = info.is_background; - thread.is_title_generating = info.is_title_generating; - thread.diff_stats = info.diff_stats; + if let Some(info) = live_info_by_session.get(&thread.session_info.session_id) { + thread.apply_active_info(info); } + let session_id = &thread.session_info.session_id; + let is_thread_workspace_active = match &thread.workspace { ThreadEntryWorkspace::Open(thread_workspace) => active_workspace .as_ref() @@ -916,7 +856,7 @@ impl Sidebar { b_time.cmp(&a_time) }); } else { - for info in &live_infos { + for info in live_infos { if info.status == AgentThreadStatus::Running { has_running_threads = true; } @@ -964,7 +904,7 @@ impl Sidebar { entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, - workspace: workspace.clone(), + workspace: representative_workspace.clone(), highlight_positions: workspace_highlight_positions, has_running_threads, waiting_thread_count, @@ -988,7 +928,7 @@ impl Sidebar { entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, - workspace: workspace.clone(), + workspace: representative_workspace.clone(), highlight_positions: Vec::new(), has_running_threads, waiting_thread_count, @@ -1002,7 +942,7 @@ impl Sidebar { if show_new_thread_entry { entries.push(ListEntry::NewThread { path_list: path_list.clone(), - workspace: workspace.clone(), + workspace: representative_workspace.clone(), is_active_draft: is_draft_for_workspace, }); } @@ -1611,7 +1551,7 @@ impl Sidebar { true, &path_list, &label, - &workspace, + workspace, &highlight_positions, *has_running_threads, *waiting_thread_count, @@ -3018,9 +2958,7 @@ impl Sidebar { bar.child(toggle_button).child(action_buttons) } } -} -impl Sidebar { fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context) { match &self.view { SidebarView::ThreadList => self.show_archive(window, cx), @@ -3211,24 +3149,8 @@ fn all_thread_infos_for_workspace( workspace: &Entity, cx: &App, ) -> impl Iterator { - enum ThreadInfoIterator> { - Empty, - Threads(T), - } - - impl> Iterator for ThreadInfoIterator { - type Item = ActiveThreadInfo; - - fn next(&mut self) -> Option { - match self { - ThreadInfoIterator::Empty => None, - ThreadInfoIterator::Threads(threads) => threads.next(), - } - } - } - let Some(agent_panel) = workspace.read(cx).panel::(cx) else { - return ThreadInfoIterator::Empty; + return None.into_iter().flatten(); }; let agent_panel = agent_panel.read(cx); @@ -3274,7 +3196,7 @@ fn all_thread_infos_for_workspace( } }); - ThreadInfoIterator::Threads(threads) + Some(threads).into_iter().flatten() } #[cfg(test)] @@ -5833,10 +5755,9 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ - "v [wt-feature-a]", - " Thread A", - "v [wt-feature-b]", - " Thread B", + "v [project]", + " Thread A {wt-feature-a}", + " Thread B {wt-feature-b}", ] ); @@ -7139,4 +7060,92 @@ mod tests { entries_after ); } + + #[gpui::test] + async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) { + // When a multi-root workspace (e.g. [/other, /project]) shares a + // repo with a single-root workspace (e.g. [/project]), linked + // worktree threads from the shared repo should only appear under + // the dedicated group [project], not under [other, project]. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/other", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + project_only + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let multi_root = + project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await; + multi_root + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(project_only.clone(), window, cx) + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(multi_root.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Worktree Thread {wt-feature-a}", + "v [other, project]", + " [+ New Thread]", + ] + ); + } } From 6bc34ff44f9931a77e5e82cff87dc2aa266a41a4 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 26 Mar 2026 21:39:48 -0600 Subject: [PATCH 20/53] Remove PR size notifications (#52373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These are noisy, and in general I'd prefer people to focus on the quality of the resulting system; not the size of the diff. (Which may require deliberately making changes larger) ## Context ## How to Review ## Self-Review Checklist - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A --- .github/pull_request_template.md | 21 +-- .github/workflows/pr-size-check.yml | 109 ---------------- .github/workflows/pr-size-label.yml | 195 ---------------------------- 3 files changed, 3 insertions(+), 322 deletions(-) delete mode 100644 .github/workflows/pr-size-check.yml delete mode 100644 .github/workflows/pr-size-label.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b8b7939813f9cc72da88e75653b6f2933403a239..a56793ad6222e5788621f6c8a430205e9ad848d7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,28 +1,13 @@ -## Context +Self-Review Checklist: - - -## How to Review - - - -## Self-Review Checklist - - - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable +Closes #ISSUE + Release Notes: - N/A or Added/Fixed/Improved ... diff --git a/.github/workflows/pr-size-check.yml b/.github/workflows/pr-size-check.yml deleted file mode 100644 index 6cbed314e012c66da16fd016dd9b3cdcf9788149..0000000000000000000000000000000000000000 --- a/.github/workflows/pr-size-check.yml +++ /dev/null @@ -1,109 +0,0 @@ -# PR Size Check — Compute -# -# Calculates PR size and saves the result as an artifact. A companion -# workflow (pr-size-label.yml) picks up the artifact via workflow_run -# and applies labels + comments with write permissions. -# -# This two-workflow split is required because fork PRs receive a -# read-only GITHUB_TOKEN. The compute step needs no write access; -# the label/comment step runs via workflow_run on the base repo with -# full write permissions. -# -# Security note: This workflow only reads PR file data via the JS API -# and writes a JSON artifact. No untrusted input is interpolated into -# shell commands. - -name: PR Size Check - -on: - pull_request: - types: [opened, synchronize] - -permissions: - contents: read - pull-requests: read - -jobs: - compute-size: - if: github.repository_owner == 'zed-industries' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Calculate PR size - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const fs = require('fs'); - - const { data: files } = await github.rest.pulls.listFiles({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - per_page: 300, - }); - - // Sum additions + deletions, excluding generated/lock files - const IGNORED_PATTERNS = [ - /\.lock$/, - /^Cargo\.lock$/, - /pnpm-lock\.yaml$/, - /\.generated\./, - /\/fixtures\//, - /\/snapshots\//, - ]; - - let totalChanges = 0; - for (const file of files) { - const ignored = IGNORED_PATTERNS.some(p => p.test(file.filename)); - if (!ignored) { - totalChanges += file.additions + file.deletions; - } - } - - // Assign size bracket - const SIZE_BRACKETS = [ - ['Size S', 0, 100, '0e8a16'], - ['Size M', 100, 400, 'fbca04'], - ['Size L', 400, 800, 'e99695'], - ['Size XL', 800, Infinity, 'b60205'], - ]; - - let sizeLabel = 'Size S'; - let labelColor = '0e8a16'; - for (const [label, min, max, color] of SIZE_BRACKETS) { - if (totalChanges >= min && totalChanges < max) { - sizeLabel = label; - labelColor = color; - break; - } - } - - // Check if the author wrote content in the "How to Review" section. - const rawBody = context.payload.pull_request.body || ''; - const howToReview = rawBody.match(/## How to Review\s*\n([\s\S]*?)(?=\n## |$)/i); - const hasReviewGuidance = howToReview - ? howToReview[1].replace(//g, '').trim().length > 0 - : false; - - const result = { - pr_number: context.issue.number, - total_changes: totalChanges, - size_label: sizeLabel, - label_color: labelColor, - has_review_guidance: hasReviewGuidance, - }; - - console.log(`PR #${result.pr_number}: ${totalChanges} LOC, ${sizeLabel}`); - - fs.mkdirSync('pr-size', { recursive: true }); - fs.writeFileSync('pr-size/result.json', JSON.stringify(result)); - - - name: Upload size result - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: pr-size-result - path: pr-size/ - retention-days: 1 -defaults: - run: - shell: bash -euxo pipefail {0} diff --git a/.github/workflows/pr-size-label.yml b/.github/workflows/pr-size-label.yml deleted file mode 100644 index 599daf122aac728c469acd45da865e1079c07fb6..0000000000000000000000000000000000000000 --- a/.github/workflows/pr-size-label.yml +++ /dev/null @@ -1,195 +0,0 @@ -# PR Size Check — Label & Comment -# -# Triggered by workflow_run after pr-size-check.yml completes. -# Downloads the size result artifact and applies labels + comments. -# -# This runs on the base repo with full GITHUB_TOKEN write access, -# so it works for both same-repo and fork PRs. -# -# Security note: The artifact is treated as untrusted data — only -# structured JSON fields (PR number, size label, color, boolean) are -# read. No artifact content is executed or interpolated into shell. - -name: PR Size Label - -on: - workflow_run: - workflows: ["PR Size Check"] - types: [completed] - -jobs: - apply-labels: - if: > - github.repository_owner == 'zed-industries' && - github.event.workflow_run.conclusion == 'success' - permissions: - contents: read - pull-requests: write - issues: write - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Download size result artifact - id: download - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const fs = require('fs'); - const path = require('path'); - - const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: context.payload.workflow_run.id, - }); - - const match = allArtifacts.data.artifacts.find(a => a.name === 'pr-size-result'); - if (!match) { - console.log('No pr-size-result artifact found, skipping'); - core.setOutput('found', 'false'); - return; - } - - const download = await github.rest.actions.downloadArtifact({ - owner: context.repo.owner, - repo: context.repo.repo, - artifact_id: match.id, - archive_format: 'zip', - }); - - const temp = path.join(process.env.RUNNER_TEMP, 'pr-size'); - fs.mkdirSync(temp, { recursive: true }); - fs.writeFileSync(path.join(temp, 'result.zip'), Buffer.from(download.data)); - core.setOutput('found', 'true'); - - - name: Unzip artifact - if: steps.download.outputs.found == 'true' - env: - ARTIFACT_DIR: ${{ runner.temp }}/pr-size - run: unzip "$ARTIFACT_DIR/result.zip" -d "$ARTIFACT_DIR" - - - name: Apply labels and comment - if: steps.download.outputs.found == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const fs = require('fs'); - const path = require('path'); - - const temp = path.join(process.env.RUNNER_TEMP, 'pr-size'); - const resultPath = path.join(temp, 'result.json'); - if (!fs.existsSync(resultPath)) { - console.log('No result.json found, skipping'); - return; - } - - const result = JSON.parse(fs.readFileSync(resultPath, 'utf8')); - - // Validate artifact data (treat as untrusted) - const prNumber = Number(result.pr_number); - const totalChanges = Number(result.total_changes); - const sizeLabel = String(result.size_label); - const labelColor = String(result.label_color); - const hasReviewGuidance = Boolean(result.has_review_guidance); - - if (!prNumber || !sizeLabel.startsWith('Size ')) { - core.setFailed(`Invalid artifact data: pr=${prNumber}, label=${sizeLabel}`); - return; - } - - console.log(`PR #${prNumber}: ${totalChanges} LOC, ${sizeLabel}`); - - // --- Size label (idempotent) --- - const existingLabels = (await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - })).data.map(l => l.name); - - const existingSizeLabels = existingLabels.filter(l => l.startsWith('Size ')); - const alreadyCorrect = existingSizeLabels.length === 1 && existingSizeLabels[0] === sizeLabel; - - if (!alreadyCorrect) { - for (const label of existingSizeLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - name: label, - }); - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: sizeLabel, - color: labelColor, - }); - } catch (e) { - if (e.status !== 422) throw e; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: [sizeLabel], - }); - } - - // --- Large PR handling (400+ LOC) --- - if (totalChanges >= 400) { - if (!existingLabels.includes('large-pr')) { - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: 'large-pr', - color: 'e99695', - }); - } catch (e) { - if (e.status !== 422) throw e; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: ['large-pr'], - }); - } - - // Comment once with guidance - const MARKER = ''; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - }); - - const alreadyCommented = comments.some(c => c.body.includes(MARKER)); - if (!alreadyCommented) { - let body = `${MARKER}\n`; - body += `### :straight_ruler: PR Size: **${totalChanges} lines changed** (${sizeLabel})\n\n`; - body += `Please note: this PR exceeds the 400 LOC soft limit.\n`; - body += `- Consider **splitting** into separate PRs if the changes are separable\n`; - body += `- Ensure the PR description includes a **guided tour** in the "How to Review" section so reviewers know where to start\n`; - - if (hasReviewGuidance) { - body += `\n:white_check_mark: "How to Review" section appears to include guidance — thank you!\n`; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: body, - }); - } - } - - console.log(`PR #${prNumber}: labeled ${sizeLabel}, done`); -defaults: - run: - shell: bash -euxo pipefail {0} From 4f9f088fc1f7d81eb65864d6fa02b486b4755352 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Fri, 27 Mar 2026 00:29:10 -0400 Subject: [PATCH 21/53] Improve wording in collab channels filter tooltip (#52531) I heard a comment that `active` didn't really explain what this filter was for, and I've had the same feeling. I think `occupied` makes it more clear. ## 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 --- crates/collab_ui/src/collab_panel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 4e3e1ec1bfac253f7d9dae3b01fdc9a17b9acd34..ae5d4f0b13f26f5c0d06af4d46eb4355bda1f4dc 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2749,7 +2749,7 @@ impl CollabPanel { .tooltip(Tooltip::text(if self.filter_active_channels { "Show All Channels" } else { - "Show Active Channels" + "Show Occupied Channels" })), ) .child( From bd5aa7753ecf4728e2f9501b49e1574663f5fe84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Soares?= <37777652+Dnreikronos@users.noreply.github.com> Date: Fri, 27 Mar 2026 01:47:48 -0300 Subject: [PATCH 22/53] agent_ui: Fix message editor not expanding after sending a message (#52545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context Tracks the previous `v2_empty_state` so `set_mode()` only fires on actual state transitions, not every render frame. Closes #52424 ## Demo ### Before: https://github.com/user-attachments/assets/76b61861-cebc-44ce-b483-596eeed19bb1 ### After: https://github.com/user-attachments/assets/9da9f3bc-2fc0-4182-8712-4f42d108650b ## How to review 1. `crates/agent_ui/src/conversation_view/thread_view.rs` — adds a `was_v2_empty_state` field to gate the `set_mode()` call in `render_message_editor()` so it only runs on transitions 2. `crates/agent_ui/src/agent_panel.rs` — test verifying that manually setting the editor to Full mode survives a render cycle without being reset back to AutoHeight ## Self-review checklist - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed agent chat input box not expanding after sending a message (#52424) --------- Co-authored-by: Danilo Leal --- crates/agent_ui/src/conversation_view.rs | 6 +++ .../src/conversation_view/thread_view.rs | 47 +++++++++---------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index a3c87c8d66031f553bcd4cb8dc82c681a0b79c94..544af06eaf8dae68fcdaa293b1e4b9e940c31baa 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -1229,6 +1229,9 @@ impl ConversationView { .and_then(|entry| entry.focus_handle(cx))], ); }); + active.update(cx, |active, cx| { + active.sync_editor_mode_for_empty_state(cx); + }); } } AcpThreadEvent::EntryUpdated(index) => { @@ -1248,6 +1251,9 @@ impl ConversationView { let list_state = active.read(cx).list_state.clone(); entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone())); list_state.splice(range.clone(), 0); + active.update(cx, |active, cx| { + active.sync_editor_mode_for_empty_state(cx); + }); } } AcpThreadEvent::SubagentSpawned(session_id) => self.load_subagent_session( diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 4ebe196e7ca7de9c6341925676423bdc4a8d8d38..b6708647868214d3ca02a2952ce718defe6ab557 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -533,6 +533,7 @@ impl ThreadView { }; this.sync_generating_indicator(cx); + this.sync_editor_mode_for_empty_state(cx); let list_state_for_scroll = this.list_state.clone(); let thread_view = cx.entity().downgrade(); @@ -3125,31 +3126,6 @@ impl ThreadView { (IconName::Maximize, "Expand Message Editor") }; - if v2_empty_state { - self.message_editor.update(cx, |editor, cx| { - editor.set_mode( - EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sizing_behavior: SizingBehavior::Default, - }, - cx, - ); - }); - } else { - self.message_editor.update(cx, |editor, cx| { - editor.set_mode( - EditorMode::AutoHeight { - min_lines: AgentSettings::get_global(cx).message_editor_min_lines, - max_lines: Some( - AgentSettings::get_global(cx).set_message_editor_max_lines(), - ), - }, - cx, - ); - }); - } - v_flex() .on_action(cx.listener(Self::expand_message_editor)) .p_2() @@ -5068,6 +5044,27 @@ impl ThreadView { }) } + pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context) { + let has_messages = self.list_state.item_count() > 0; + let v2_empty_state = cx.has_flag::() && !has_messages; + + let mode = if v2_empty_state { + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::Default, + } + } else { + EditorMode::AutoHeight { + min_lines: AgentSettings::get_global(cx).message_editor_min_lines, + max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()), + } + }; + self.message_editor.update(cx, |editor, cx| { + editor.set_mode(mode, cx); + }); + } + /// Ensures the list item count includes (or excludes) an extra item for the generating indicator pub(crate) fn sync_generating_indicator(&mut self, cx: &App) { let is_generating = matches!(self.thread.read(cx).status(), ThreadStatus::Generating); From 2f762eee4bebed1210680c0ffbd2d242016ca297 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 Mar 2026 00:14:52 -0700 Subject: [PATCH 23/53] Avoid killing Zed when terminating terminal process before process group is set by shell (#52542) Release Notes: - Fixed a bug where killing a terminal process in the agent panel would sometimes kill Zed itself. --- crates/terminal/src/pty_info.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/terminal/src/pty_info.rs b/crates/terminal/src/pty_info.rs index 2663095c52f386cfd9528f1c96fa32a39abd9a59..7b6676760ca61c1cfde22601d0c0eb0b9641b42a 100644 --- a/crates/terminal/src/pty_info.rs +++ b/crates/terminal/src/pty_info.rs @@ -36,11 +36,19 @@ impl ProcessIdGetter { } fn pid(&self) -> Option { + // Negative pid means error. + // Zero pid means no foreground process group is set on the PTY yet. + // Avoid killing the current process by returning a zero pid. let pid = unsafe { libc::tcgetpgrp(self.handle) }; - if pid < 0 { + if pid > 0 { + return Some(Pid::from_u32(pid as u32)); + } + + if self.fallback_pid > 0 { return Some(Pid::from_u32(self.fallback_pid)); } - Some(Pid::from_u32(pid as u32)) + + None } } From 5197cb4da9a7861eecbaeef9c0f019f46ff6d913 Mon Sep 17 00:00:00 2001 From: Alan P John Date: Fri, 27 Mar 2026 12:49:49 +0530 Subject: [PATCH 24/53] gpui: Fix emoji rendering in SVG preview (#51569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #50483 ## Findings As reported in the original issue, emojis in SVG preview were not rendering consistently with the editor. The SVG renderer uses `usvg`/`resvg` for parsing and rendering SVG files. The first problem was that emoji fonts were not rendering at all, which was fixed by enabling the `raster_images` on `resvg`. Beyond that it was observed that the default font fallback mechanism in `usvg` searches through the font database alphabetically without prioritizing emoji fonts. This caused emojis to sometimes render in non-emoji fonts that happened to contain glyph mappings for those characters. For example, on Linux systems with the default `uvsg::FontResolver::default_fallback_selector()`: - The character ✅ would fall back to `FreeSerif` (monochrome) - Instead of `Noto Color Emoji` (full color) Log output showed the inconsistent behavior: ``` WARN [usvg::text] Fallback from FreeSans to Noto Color Emoji. WARN [usvg::text] Fallback from FreeSans to FreeSerif. WARN [usvg::text] Fallback from FreeSans to Noto Color Emoji. ``` Image This created a jarring inconsistency where the same emoji character would render differently in: - The editor (correct, using platform emoji fonts) - SVG preview (incorrect, using arbitrary fallback fonts) ## Solution If the specified font in SVG is available on the system, we should show that. If not, we should fallback to what editors show today for that emoji. This PR implements emoji-aware font fallback that: 1. **Enabled `raster_images` build feature** to render emojis in SVG. 2. **Detects emoji characters** using Unicode emoji properties (via `\p{Emoji}` regex pattern), consistent with how we check for emoji in the Editor as well. 3. **Preserves user-specified fonts** by only intervening when the default font resolver would use a non-emoji font for emoji characters ### Font Family Selection I avoided completely reusing/rebuilding the logic for emoji font selection used by the editor as `uvsg` internally does quite a bit of the job and it felt like overcomplicating the solution. Instead using hard coded platform specific font family names. The hardcoded emoji font families are sourced from Zed's existing platform-specific text rendering systems: - **macOS**: `Apple Color Emoji`, `.AppleColorEmojiUI` Source: https://github.com/zed-industries/zed/blob/db622edc8b26bd138c91027a02792a84c083acbf/crates/gpui_macos/src/text_system.rs#L353-L359 - **Linux/FreeBSD**: `Noto Color Emoji`, `Emoji One` Source: https://github.com/zed-industries/zed/blob/db622edc8b26bd138c91027a02792a84c083acbf/crates/gpui_wgpu/src/cosmic_text_system.rs#L642-L646 - **Windows**: `Segoe UI Emoji`, `Segoe UI Symbol` Source: Standard Windows emoji font stack These match the fonts the editor uses for emoji rendering on each platform. To break down further into the similarity and differences in the emoji font resolution: **Similarities:** - Both now use the regex based emoji detection logic - Both prioritize the same platform-specific emoji font families - Both support color emoji rendering **Differences:** - **Editor**: Uses platform-native text shaping (CoreText on macOS, DirectWrite on Windows, cosmic-text on Linux) which handles fallback automatically - **SVG**: Uses custom fallback selector that explicitly queries emoji fonts first, then falls back to default usvg behavior ## Testing - Added unit tests for `is_emoji_character` in `util` crate - Tested emoji detection for various Unicode characters - [ ] Verified platform-specific font lists compile correctly (Only linux done) - [ ] Manual testing with SVG files containing emojis on all platforms (Only linux done) Release Notes: - Fixed SVG preview to render emojis consistently with the editor by prioritizing platform-specific color emoji fonts --------- Signed-off-by: Alan P John Co-authored-by: Smit Barmase --- Cargo.lock | 5 ++ crates/gpui/Cargo.toml | 9 +- crates/gpui/src/svg_renderer.rs | 152 +++++++++++++++++++++++++++++++- 3 files changed, 162 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 33645135abda30a991f7645338fa84bd1618d574..9da2e0144da52421bb2e2d044899d2c881ce3ad4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7652,6 +7652,7 @@ dependencies = [ "rand 0.9.2", "raw-window-handle", "refineable", + "regex", "reqwest_client", "resvg", "scheduler", @@ -7667,6 +7668,7 @@ dependencies = [ "sum_tree", "taffy", "thiserror 2.0.17", + "ttf-parser 0.25.1", "unicode-segmentation", "url", "usvg", @@ -14609,12 +14611,15 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ + "gif", + "image-webp", "log", "pico-args", "rgb", "svgtypes", "tiny-skia", "usvg", + "zune-jpeg", ] [[package]] diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 9eb2de936c2e1db1d80cc3627db5594152e7223e..cb4a48f63103118aafe78398d4842634c976ef9d 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -70,14 +70,17 @@ chrono.workspace = true profiling.workspace = true rand.workspace = true raw-window-handle = "0.6" +regex.workspace = true refineable.workspace = true scheduler.workspace = true resvg = { version = "0.45.0", default-features = false, features = [ "text", "system-fonts", "memmap-fonts", + "raster-images" ] } usvg = { version = "0.45.0", default-features = false } +ttf-parser = "0.25" util_macros.workspace = true schemars.workspace = true seahash = "4.1" @@ -145,12 +148,12 @@ backtrace.workspace = true collections = { workspace = true, features = ["test-support"] } env_logger.workspace = true gpui_platform = { workspace = true, features = ["font-kit"] } +gpui_util = { workspace = true } lyon = { version = "1.0", features = ["extra"] } +proptest = { workspace = true } rand.workspace = true scheduler = { workspace = true, features = ["test-support"] } -unicode-segmentation.workspace = true -gpui_util = { workspace = true } -proptest = { workspace = true } +unicode-segmentation = { workspace = true } [target.'cfg(not(target_family = "wasm"))'.dev-dependencies] http_client = { workspace = true, features = ["test-support"] } diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs index f82530f8d10fab074dd5e116114cf028a8a19cfe..217555e3b0e295d06e375e19d013e0b520118e0b 100644 --- a/crates/gpui/src/svg_renderer.rs +++ b/crates/gpui/src/svg_renderer.rs @@ -10,6 +10,73 @@ use std::{ sync::{Arc, LazyLock}, }; +#[cfg(target_os = "macos")] +const EMOJI_FONT_FAMILIES: &[&str] = &["Apple Color Emoji", ".AppleColorEmojiUI"]; + +#[cfg(target_os = "windows")] +const EMOJI_FONT_FAMILIES: &[&str] = &["Segoe UI Emoji", "Segoe UI Symbol"]; + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +const EMOJI_FONT_FAMILIES: &[&str] = &[ + "Noto Color Emoji", + "Emoji One", + "Twitter Color Emoji", + "JoyPixels", +]; + +#[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd", +)))] +const EMOJI_FONT_FAMILIES: &[&str] = &[]; + +fn is_emoji_presentation(c: char) -> bool { + static EMOJI_PRESENTATION_REGEX: LazyLock = + LazyLock::new(|| regex::Regex::new("\\p{Emoji_Presentation}").unwrap()); + let mut buf = [0u8; 4]; + EMOJI_PRESENTATION_REGEX.is_match(c.encode_utf8(&mut buf)) +} + +fn font_has_char(db: &usvg::fontdb::Database, id: usvg::fontdb::ID, ch: char) -> bool { + db.with_face_data(id, |font_data, face_index| { + ttf_parser::Face::parse(font_data, face_index) + .ok() + .and_then(|face| face.glyph_index(ch)) + .is_some() + }) + .unwrap_or(false) +} + +fn select_emoji_font( + ch: char, + fonts: &[usvg::fontdb::ID], + db: &usvg::fontdb::Database, + families: &[&str], +) -> Option { + for family_name in families { + let query = usvg::fontdb::Query { + families: &[usvg::fontdb::Family::Name(family_name)], + weight: usvg::fontdb::Weight(400), + stretch: usvg::fontdb::Stretch::Normal, + style: usvg::fontdb::Style::Normal, + }; + + let Some(id) = db.query(&query) else { + continue; + }; + + if fonts.contains(&id) || !font_has_char(db, id, ch) { + continue; + } + + return Some(id); + } + + None +} + /// When rendering SVGs, we render them at twice the size to get a higher-quality result. pub const SMOOTH_SVG_SCALE_FACTOR: f32 = 2.; @@ -52,10 +119,23 @@ impl SvgRenderer { default_font_resolver(font, db) }, ); + let default_fallback_selection = usvg::FontResolver::default_fallback_selector(); + let fallback_selection = Box::new( + move |ch: char, fonts: &[usvg::fontdb::ID], db: &mut Arc| { + if is_emoji_presentation(ch) { + if let Some(id) = select_emoji_font(ch, fonts, db.as_ref(), EMOJI_FONT_FAMILIES) + { + return Some(id); + } + } + + default_fallback_selection(ch, fonts, db) + }, + ); let options = usvg::Options { font_resolver: usvg::FontResolver { select_font: font_resolver, - select_fallback: usvg::FontResolver::default_fallback_selector(), + select_fallback: fallback_selection, }, ..Default::default() }; @@ -148,3 +228,73 @@ impl SvgRenderer { Ok(pixmap) } } + +#[cfg(test)] +mod tests { + use super::*; + + const IBM_PLEX_REGULAR: &[u8] = + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"); + const LILEX_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"); + + #[test] + fn test_is_emoji_presentation() { + let cases = [ + ("a", false), + ("Z", false), + ("1", false), + ("#", false), + ("*", false), + ("漢", false), + ("中", false), + ("カ", false), + ("©", false), + ("♥", false), + ("😀", true), + ("✅", true), + ("🇺🇸", true), + // SVG fallback is not cluster-aware yet + ("©️", false), + ("♥️", false), + ("1️⃣", false), + ]; + for (s, expected) in cases { + assert_eq!( + is_emoji_presentation(s.chars().next().unwrap()), + expected, + "for char {:?}", + s + ); + } + } + + #[test] + fn test_select_emoji_font_skips_family_without_glyph() { + let mut db = usvg::fontdb::Database::new(); + + db.load_font_data(IBM_PLEX_REGULAR.to_vec()); + db.load_font_data(LILEX_REGULAR.to_vec()); + + let ibm_plex_sans = db + .query(&usvg::fontdb::Query { + families: &[usvg::fontdb::Family::Name("IBM Plex Sans")], + weight: usvg::fontdb::Weight(400), + stretch: usvg::fontdb::Stretch::Normal, + style: usvg::fontdb::Style::Normal, + }) + .unwrap(); + let lilex = db + .query(&usvg::fontdb::Query { + families: &[usvg::fontdb::Family::Name("Lilex")], + weight: usvg::fontdb::Weight(400), + stretch: usvg::fontdb::Stretch::Normal, + style: usvg::fontdb::Style::Normal, + }) + .unwrap(); + let selected = select_emoji_font('│', &[], &db, &["IBM Plex Sans", "Lilex"]).unwrap(); + + assert_eq!(selected, lilex); + assert!(!font_has_char(&db, ibm_plex_sans, '│')); + assert!(font_has_char(&db, selected, '│')); + } +} From 80b86cef24f485bdb5b6a0db78ba3ce57667a35b Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Fri, 27 Mar 2026 12:55:12 +0530 Subject: [PATCH 25/53] terminal: Fix terminal not closing after non-zero shell exit (#52520) Closes https://github.com/zed-industries/zed/issues/38901 Supersedes https://github.com/zed-industries/zed/pull/39082 This PR fixes an issue where the terminal tab would stay open after the user exits a shell that has a non-zero exit code (e.g. running `false` then `exit`, or pressing Ctrl-C followed by Ctrl-D). We now track whether any keyboard input was sent to distinguish user-initiated exits from shell spawn failures. Release Notes: - Fixed terminal tab not closing when the shell exits with a non-zero code. Co-authored-by: Glenn Miao --- crates/terminal/src/terminal.rs | 80 ++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 3e7a0c77d265b9eea4c2ab90caa4f0818340fdd8..859a331bfb9febd238b8053bd6d46dc59de8e858 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -417,6 +417,7 @@ impl TerminalBuilder { window_id, }, child_exited: None, + keyboard_input_sent: false, event_loop_task: Task::ready(Ok(())), background_executor: background_executor.clone(), path_style, @@ -650,6 +651,7 @@ impl TerminalBuilder { window_id, }, child_exited: None, + keyboard_input_sent: false, event_loop_task: Task::ready(Ok(())), background_executor, path_style, @@ -876,6 +878,7 @@ pub struct Terminal { template: CopyTemplate, activation_script: Vec, child_exited: Option, + keyboard_input_sent: bool, event_loop_task: Task>, background_executor: BackgroundExecutor, path_style: PathStyle, @@ -1462,6 +1465,7 @@ impl Terminal { .push_back(InternalEvent::Scroll(AlacScroll::Bottom)); self.events.push_back(InternalEvent::SetSelection(None)); + self.keyboard_input_sent = true; let input = input.into(); #[cfg(any(test, feature = "test-support"))] self.input_log.push(input.to_vec()); @@ -2245,7 +2249,17 @@ impl Terminal { let task = match &mut self.task { Some(task) => task, None => { - if self.child_exited.is_none_or(|e| e.code() == Some(0)) { + // For interactive shells (no task), we need to differentiate: + // 1. User-initiated exits (typed "exit", Ctrl+D, etc.) - always close, + // even if the shell exits with a non-zero code (e.g. after `false`). + // 2. Shell spawn failures (bad $SHELL) - don't close, so the user sees + // the error. Spawn failures never receive keyboard input. + let should_close = if self.keyboard_input_sent { + true + } else { + self.child_exited.is_none_or(|e| e.code() == Some(0)) + }; + if should_close { cx.emit(Event::CloseTerminal); } return; @@ -2560,7 +2574,7 @@ mod tests { use smol::channel::Receiver; use task::{Shell, ShellBuilder}; - #[cfg(target_os = "macos")] + #[cfg(not(target_os = "windows"))] fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); @@ -2795,6 +2809,68 @@ mod tests { ); } + #[cfg(not(target_os = "windows"))] + #[gpui::test(iterations = 10)] + async fn test_terminal_closes_after_nonzero_exit(cx: &mut TestAppContext) { + init_test(cx); + + cx.executor().allow_parking(); + + let builder = cx + .update(|cx| { + TerminalBuilder::new( + None, + None, + task::Shell::System, + HashMap::default(), + CursorShape::default(), + AlternateScroll::On, + None, + vec![], + 0, + false, + 0, + None, + cx, + Vec::new(), + PathStyle::local(), + ) + }) + .await + .unwrap(); + let terminal = cx.new(|cx| builder.subscribe(cx)); + + let (event_tx, event_rx) = smol::channel::unbounded::(); + cx.update(|cx| { + cx.subscribe(&terminal, move |_, e, _| { + event_tx.send_blocking(e.clone()).unwrap(); + }) + }) + .detach(); + + let first_event = event_rx.recv().await.expect("No wakeup event received"); + + terminal.update(cx, |terminal, _| { + terminal.input(b"false\r".to_vec()); + }); + cx.executor().timer(Duration::from_millis(500)).await; + terminal.update(cx, |terminal, _| { + terminal.input(b"exit\r".to_vec()); + }); + + let mut all_events = vec![first_event]; + while let Ok(new_event) = event_rx.recv().await { + all_events.push(new_event.clone()); + if new_event == Event::CloseTerminal { + break; + } + } + assert!( + all_events.contains(&Event::CloseTerminal), + "Shell exiting after `false && exit` should close terminal, but got events: {all_events:?}", + ); + } + #[gpui::test(iterations = 10)] async fn test_terminal_no_exit_on_spawn_failure(cx: &mut TestAppContext) { cx.executor().allow_parking(); From 354bc35974c0584725a9b5b87ca6d3ac840a7c8b Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 27 Mar 2026 09:17:30 +0100 Subject: [PATCH 26/53] Cut `fs` dependency from `theme` (#52482) Trying to clean up the deps here for potential use of the ui crate in web Release Notes: - N/A or Added/Fixed/Improved ... --- Cargo.lock | 6 +-- crates/editor/src/editor.rs | 4 +- crates/extension_cli/src/main.rs | 2 +- crates/theme/Cargo.toml | 7 +-- crates/theme/src/registry.rs | 43 ++++--------------- crates/theme/src/theme.rs | 19 +++----- crates/theme_extension/src/theme_extension.rs | 18 ++++---- crates/ui/Cargo.toml | 2 +- crates/ui/src/components/keybinding.rs | 5 ++- crates/ui/src/components/scrollbar.rs | 2 +- crates/ui/src/utils.rs | 22 ++++++++++ crates/util/src/util.rs | 22 ---------- crates/zed/src/main.rs | 25 ++++++++--- crates/zed/src/zed.rs | 26 +++++------ 14 files changed, 92 insertions(+), 111 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9da2e0144da52421bb2e2d044899d2c881ce3ad4..c183dcadf823de0017b321216616341e595c9865 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17571,9 +17571,8 @@ dependencies = [ "anyhow", "collections", "derive_more", - "fs", - "futures 0.3.31", "gpui", + "gpui_util", "log", "palette", "parking_lot", @@ -17585,7 +17584,6 @@ dependencies = [ "settings", "strum 0.27.2", "thiserror 2.0.17", - "util", "uuid", ] @@ -18771,6 +18769,7 @@ dependencies = [ "documented", "gpui", "gpui_macros", + "gpui_util", "icons", "itertools 0.14.0", "menu", @@ -18782,7 +18781,6 @@ dependencies = [ "strum 0.27.2", "theme", "ui_macros", - "util", "windows 0.61.3", ] diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1786013a4a4d746c0580813c3e9b9962b1baa72d..3c35175002fe17995db478f01eed06585c0ebe88 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -9950,7 +9950,7 @@ impl Editor { }) .when(!is_platform_style_mac, |parent| { parent.child( - Key::new(util::capitalize(keystroke.key()), Some(Color::Default)) + Key::new(ui::utils::capitalize(keystroke.key()), Some(Color::Default)) .size(Some(IconSize::XSmall.rems().into())), ) }) @@ -9978,7 +9978,7 @@ impl Editor { ))) .into_any() } else { - Key::new(util::capitalize(keystroke.key()), Some(color)) + Key::new(ui::utils::capitalize(keystroke.key()), Some(color)) .size(Some(IconSize::XSmall.rems().into())) .into_any_element() } diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index 4d290992f318dc8fec78dad0e40d347d4826ed65..4d16cd4f1bba1df53c621bad2c15c01a1db4a533 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -413,7 +413,7 @@ async fn test_themes( ) -> Result<()> { for relative_theme_path in &manifest.themes { let theme_path = extension_path.join(relative_theme_path); - let theme_family = theme::read_user_theme(&theme_path, fs.clone()).await?; + let theme_family = theme::deserialize_user_theme(&fs.load_bytes(&theme_path).await?)?; log::info!("loaded theme family {}", theme_family.name); for theme in &theme_family.themes { diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index ef193c500d461201e8746ad3ec0f33b01e423b18..b1c689bc7c451b92e9fff86cbacebc60c9a31b58 100644 --- a/crates/theme/Cargo.toml +++ b/crates/theme/Cargo.toml @@ -10,7 +10,7 @@ workspace = true [features] default = [] -test-support = ["gpui/test-support", "fs/test-support", "settings/test-support"] +test-support = ["gpui/test-support", "settings/test-support"] [lib] path = "src/theme.rs" @@ -20,9 +20,8 @@ doctest = false anyhow.workspace = true collections.workspace = true derive_more.workspace = true -fs.workspace = true -futures.workspace = true gpui.workspace = true +gpui_util.workspace = true log.workspace = true palette = { workspace = true, default-features = false, features = ["std"] } parking_lot.workspace = true @@ -34,10 +33,8 @@ serde_json_lenient.workspace = true settings.workspace = true strum.workspace = true thiserror.workspace = true -util.workspace = true uuid.workspace = true [dev-dependencies] -fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } diff --git a/crates/theme/src/registry.rs b/crates/theme/src/registry.rs index c362b62704257fefde125e81ca1c056490263b0b..5b8e47439cd1baf47172bd17f3fae2ca56635b29 100644 --- a/crates/theme/src/registry.rs +++ b/crates/theme/src/registry.rs @@ -4,17 +4,15 @@ use std::{fmt::Debug, path::Path}; use anyhow::{Context as _, Result}; use collections::HashMap; use derive_more::{Deref, DerefMut}; -use fs::Fs; -use futures::StreamExt; use gpui::{App, AssetSource, Global, SharedString}; +use gpui_util::ResultExt; use parking_lot::RwLock; use thiserror::Error; -use util::ResultExt; use crate::{ Appearance, AppearanceContent, ChevronIcons, DEFAULT_ICON_THEME_NAME, DirectoryIcons, - IconDefinition, IconTheme, Theme, ThemeFamily, ThemeFamilyContent, default_icon_theme, - read_icon_theme, read_user_theme, refine_theme_family, + IconDefinition, IconTheme, IconThemeFamilyContent, Theme, ThemeFamily, ThemeFamilyContent, + default_icon_theme, deserialize_user_theme, refine_theme_family, }; /// The metadata for a theme. @@ -208,29 +206,9 @@ impl ThemeRegistry { } } - /// Loads the user themes from the specified directory and adds them to the registry. - pub async fn load_user_themes(&self, themes_path: &Path, fs: Arc) -> Result<()> { - let mut theme_paths = fs - .read_dir(themes_path) - .await - .with_context(|| format!("reading themes from {themes_path:?}"))?; - - while let Some(theme_path) = theme_paths.next().await { - let Some(theme_path) = theme_path.log_err() else { - continue; - }; - - self.load_user_theme(&theme_path, fs.clone()) - .await - .log_err(); - } - - Ok(()) - } - - /// Loads the user theme from the specified path and adds it to the registry. - pub async fn load_user_theme(&self, theme_path: &Path, fs: Arc) -> Result<()> { - let theme = read_user_theme(theme_path, fs).await?; + /// Loads the user theme from the specified data and adds it to the registry. + pub fn load_user_theme(&self, bytes: &[u8]) -> Result<()> { + let theme = deserialize_user_theme(bytes)?; self.insert_user_theme_families([theme]); @@ -273,18 +251,15 @@ impl ThemeRegistry { .retain(|name, _| !icon_themes_to_remove.contains(name)) } - /// Loads the icon theme from the specified path and adds it to the registry. + /// Loads the icon theme from the icon theme family and adds it to the registry. /// /// The `icons_root_dir` parameter indicates the root directory from which /// the relative paths to icons in the theme should be resolved against. - pub async fn load_icon_theme( + pub fn load_icon_theme( &self, - icon_theme_path: &Path, + icon_theme_family: IconThemeFamilyContent, icons_root_dir: &Path, - fs: Arc, ) -> Result<()> { - let icon_theme_family = read_icon_theme(icon_theme_path, fs).await?; - let resolve_icon_path = |path: SharedString| { icons_root_dir .join(path.as_ref()) diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 3449e039a9e3f4135a0f8471b8346f6b6e6b9fcc..8134461dfa9dd0aac1ae685a5be9861fb78ba4a1 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -19,7 +19,6 @@ mod schema; mod settings; mod styles; -use std::path::Path; use std::sync::Arc; use ::settings::DEFAULT_DARK_THEME; @@ -28,7 +27,6 @@ use ::settings::Settings; use ::settings::SettingsStore; use anyhow::Result; use fallback_themes::apply_status_color_defaults; -use fs::Fs; use gpui::BorrowAppContext; use gpui::Global; use gpui::{ @@ -405,10 +403,9 @@ impl Theme { } } -/// Asynchronously reads the user theme from the specified path. -pub async fn read_user_theme(theme_path: &Path, fs: Arc) -> Result { - let bytes = fs.load_bytes(theme_path).await?; - let theme_family: ThemeFamilyContent = serde_json_lenient::from_slice(&bytes)?; +/// Deserializes a user theme from the given bytes. +pub fn deserialize_user_theme(bytes: &[u8]) -> Result { + let theme_family: ThemeFamilyContent = serde_json_lenient::from_slice(bytes)?; for theme in &theme_family.themes { if theme @@ -427,13 +424,9 @@ pub async fn read_user_theme(theme_path: &Path, fs: Arc) -> Result, -) -> Result { - let bytes = fs.load_bytes(icon_theme_path).await?; - let icon_theme_family: IconThemeFamilyContent = serde_json_lenient::from_slice(&bytes)?; +/// Deserializes a icon theme from the given bytes. +pub fn deserialize_icon_theme(bytes: &[u8]) -> Result { + let icon_theme_family: IconThemeFamilyContent = serde_json_lenient::from_slice(bytes)?; Ok(icon_theme_family) } diff --git a/crates/theme_extension/src/theme_extension.rs b/crates/theme_extension/src/theme_extension.rs index 10df2349c86decbadaa010778a95d04af36a6aab..7dcb43aa74c5520399e7d3a37f30ed3f6b74d410 100644 --- a/crates/theme_extension/src/theme_extension.rs +++ b/crates/theme_extension/src/theme_extension.rs @@ -5,7 +5,7 @@ use anyhow::Result; use extension::{ExtensionHostProxy, ExtensionThemeProxy}; use fs::Fs; use gpui::{App, BackgroundExecutor, SharedString, Task}; -use theme::{GlobalTheme, ThemeRegistry}; +use theme::{GlobalTheme, ThemeRegistry, deserialize_icon_theme}; pub fn init( extension_host_proxy: Arc, @@ -30,7 +30,7 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { fn list_theme_names(&self, theme_path: PathBuf, fs: Arc) -> Task>> { self.executor.spawn(async move { - let themes = theme::read_user_theme(&theme_path, fs).await?; + let themes = theme::deserialize_user_theme(&fs.load_bytes(&theme_path).await?)?; Ok(themes.themes.into_iter().map(|theme| theme.name).collect()) }) } @@ -41,8 +41,9 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { fn load_user_theme(&self, theme_path: PathBuf, fs: Arc) -> Task> { let theme_registry = self.theme_registry.clone(); - self.executor - .spawn(async move { theme_registry.load_user_theme(&theme_path, fs).await }) + self.executor.spawn(async move { + theme_registry.load_user_theme(&fs.load_bytes(&theme_path).await?) + }) } fn reload_current_theme(&self, cx: &mut App) { @@ -55,7 +56,8 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { fs: Arc, ) -> Task>> { self.executor.spawn(async move { - let icon_theme_family = theme::read_icon_theme(&icon_theme_path, fs).await?; + let icon_theme_family = + theme::deserialize_icon_theme(&fs.load_bytes(&icon_theme_path).await?)?; Ok(icon_theme_family .themes .into_iter() @@ -76,9 +78,9 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { ) -> Task> { let theme_registry = self.theme_registry.clone(); self.executor.spawn(async move { - theme_registry - .load_icon_theme(&icon_theme_path, &icons_root_dir, fs) - .await + let icon_theme_family = + deserialize_icon_theme(&fs.load_bytes(&icon_theme_path).await?)?; + theme_registry.load_icon_theme(icon_theme_family, &icons_root_dir) }) } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 5eb58bf1da1f25cc273a9fc5d7c08b920d3471e9..6ea1b6d26f700c9c44a8dda5e510d0505d7e7db8 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -29,7 +29,7 @@ story = { workspace = true, optional = true } strum.workspace = true theme.workspace = true ui_macros.workspace = true -util.workspace = true +gpui_util.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index e22669995db416a3ec6884a79860e76610dd7d03..6c7efa4e49ee93fd13407c03cf383ff3385bacc7 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -1,6 +1,7 @@ use std::rc::Rc; use crate::PlatformStyle; +use crate::utils::capitalize; use crate::{Icon, IconName, IconSize, h_flex, prelude::*}; use gpui::{ Action, AnyElement, App, FocusHandle, Global, IntoElement, KeybindingKeystroke, Keystroke, @@ -142,7 +143,7 @@ fn render_key( match key_icon { Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(), None => { - let key = util::capitalize(key); + let key = capitalize(key); Key::new(&key, color).size(size).into_any_element() } } @@ -546,7 +547,7 @@ fn keystroke_text( let key = match key { "pageup" => "PageUp", "pagedown" => "PageDown", - key => &util::capitalize(key), + key => &capitalize(key), }; text.push_str(key); } diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index d0c720d5081d3ab7ad700df798b931933e03db28..795c5174fb42d3caeec3052d3c636b0408ac7ed6 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -15,10 +15,10 @@ use gpui::{ UniformListScrollHandle, Window, ease_in_out, prelude::FluentBuilder as _, px, quad, relative, size, }; +use gpui_util::ResultExt; use settings::SettingsStore; use smallvec::SmallVec; use theme::ActiveTheme as _; -use util::ResultExt; use std::ops::Range; diff --git a/crates/ui/src/utils.rs b/crates/ui/src/utils.rs index 2f2a148e1985d026371c96297eb92cc4ec079a3b..d88bf4a45e0b54536b6f5ca5ad4ae7c7fe936937 100644 --- a/crates/ui/src/utils.rs +++ b/crates/ui/src/utils.rs @@ -34,3 +34,25 @@ pub fn reveal_in_file_manager_label(is_remote: bool) -> &'static str { "Reveal in File Manager" } } + +/// Capitalizes the first character of a string. +/// +/// This function takes a string slice as input and returns a new `String` with the first character +/// capitalized. +/// +/// # Examples +/// +/// ``` +/// use ui::utils::capitalize; +/// +/// assert_eq!(capitalize("hello"), "Hello"); +/// assert_eq!(capitalize("WORLD"), "WORLD"); +/// assert_eq!(capitalize(""), ""); +/// ``` +pub fn capitalize(str: &str) -> String { + let mut chars = str.chars(); + match chars.next() { + None => String::new(), + Some(first_char) => first_char.to_uppercase().collect::() + chars.as_str(), + } +} diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 4f129ef6d529aff0991b86882e5e60b6ad837d5c..bd8ab4e2d4d99864c5e0dc228410904f3338d7c6 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -686,28 +686,6 @@ impl PartialOrd for NumericPrefixWithSuffix<'_> { } } -/// Capitalizes the first character of a string. -/// -/// This function takes a string slice as input and returns a new `String` with the first character -/// capitalized. -/// -/// # Examples -/// -/// ``` -/// use util::capitalize; -/// -/// assert_eq!(capitalize("hello"), "Hello"); -/// assert_eq!(capitalize("WORLD"), "WORLD"); -/// assert_eq!(capitalize(""), ""); -/// ``` -pub fn capitalize(str: &str) -> String { - let mut chars = str.chars(); - match chars.next() { - None => String::new(), - Some(first_char) => first_char.to_uppercase().collect::() + chars.as_str(), - } -} - fn emoji_regex() -> &'static Regex { static EMOJI_REGEX: LazyLock = LazyLock::new(|| Regex::new("(\\p{Emoji}|\u{200D})").unwrap()); diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 0a55953931ff4527851f9c9e7d6ac5f451eea0fd..18f7e98f001a83fbdd527f98ee8f4b22c7e91bcc 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1781,7 +1781,23 @@ fn load_user_themes_in_background(fs: Arc, cx: &mut App) { })?; } } - theme_registry.load_user_themes(themes_dir, fs).await?; + + let mut theme_paths = fs + .read_dir(themes_dir) + .await + .with_context(|| format!("reading themes from {themes_dir:?}"))?; + + while let Some(theme_path) = theme_paths.next().await { + let Some(theme_path) = theme_path.log_err() else { + continue; + }; + let Some(bytes) = fs.load_bytes(&theme_path).await.log_err() else { + continue; + }; + + theme_registry.load_user_theme(&bytes).log_err(); + } + cx.update(GlobalTheme::reload_theme); anyhow::Ok(()) } @@ -1801,11 +1817,8 @@ fn watch_themes(fs: Arc, cx: &mut App) { for event in paths { if fs.metadata(&event.path).await.ok().flatten().is_some() { let theme_registry = cx.update(|cx| ThemeRegistry::global(cx)); - if theme_registry - .load_user_theme(&event.path, fs.clone()) - .await - .log_err() - .is_some() + if let Some(bytes) = fs.load_bytes(&event.path).await.log_err() + && theme_registry.load_user_theme(&bytes).log_err().is_some() { cx.update(GlobalTheme::reload_theme); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index d8438eb1b85aaa5191a178adc6b61865ebd94590..e4ccedcf4143d194406307e84b2ff03ef375609f 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -77,7 +77,10 @@ use std::{ sync::atomic::{self, AtomicBool}, }; use terminal_view::terminal_panel::{self, TerminalPanel}; -use theme::{ActiveTheme, GlobalTheme, SystemAppearance, ThemeRegistry, ThemeSettings}; +use theme::{ + ActiveTheme, GlobalTheme, SystemAppearance, ThemeRegistry, ThemeSettings, + deserialize_icon_theme, +}; use ui::{PopoverMenuHandle, prelude::*}; use util::markdown::MarkdownString; use util::rel_path::RelPath; @@ -2221,24 +2224,23 @@ pub(crate) fn eager_load_active_theme_and_icon_theme(fs: Arc, cx: &mut A let reload_tasks = &reload_tasks; let fs = fs.clone(); - scope.spawn(async { + scope.spawn(async move { match load_target { LoadTarget::Theme(theme_path) => { - if theme_registry - .load_user_theme(&theme_path, fs) - .await - .log_err() - .is_some() + if let Some(bytes) = fs.load_bytes(&theme_path).await.log_err() + && theme_registry.load_user_theme(&bytes).log_err().is_some() { reload_tasks.lock().push(ReloadTarget::Theme); } } LoadTarget::IconTheme((icon_theme_path, icons_root_path)) => { - if theme_registry - .load_icon_theme(&icon_theme_path, &icons_root_path, fs) - .await - .log_err() - .is_some() + if let Some(bytes) = fs.load_bytes(&icon_theme_path).await.log_err() + && let Some(icon_theme_family) = + deserialize_icon_theme(&bytes).log_err() + && theme_registry + .load_icon_theme(icon_theme_family, &icons_root_path) + .log_err() + .is_some() { reload_tasks.lock().push(ReloadTarget::IconTheme); } From 79347e8ca539252e6295442d7b46926512085be1 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 27 Mar 2026 11:10:57 +0100 Subject: [PATCH 27/53] Restore language query watcher in dev builds (#52543) The watcher had been broken for some time, but became even more broken after the recent move of the queries. This PR restores the reloading behavior for debug builds so that languages are reloaded once a scheme file is changed. Release Notes: - N/A --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- crates/edit_prediction_cli/Cargo.toml | 2 +- crates/languages/src/lib.rs | 6 +----- crates/settings/Cargo.toml | 2 +- crates/zed/src/main.rs | 6 ++---- 6 files changed, 12 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c183dcadf823de0017b321216616341e595c9865..e6967770a5e1a6e09a2bd7a4f7e77a5307f9bfeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14873,9 +14873,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.7.2" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -14884,9 +14884,9 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.7.2" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" dependencies = [ "proc-macro2", "quote", @@ -14897,9 +14897,9 @@ dependencies = [ [[package]] name = "rust-embed-utils" -version = "8.7.2" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ "globset", "sha2", diff --git a/Cargo.toml b/Cargo.toml index e9993d821888a2107427026f742aaca0cec220bb..922a88056c81dc23eaf187e8d4a70dd856611d0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -680,7 +680,7 @@ rsa = "0.9.6" runtimelib = { version = "1.4.0", default-features = false, features = [ "async-dispatcher-runtime", "aws-lc-rs" ] } -rust-embed = { version = "8.4", features = ["include-exclude"] } +rust-embed = { version = "8.11", features = ["include-exclude"] } rustc-hash = "2.1.0" rustls = { version = "0.23.26" } rustls-platform-verifier = "0.5.0" diff --git a/crates/edit_prediction_cli/Cargo.toml b/crates/edit_prediction_cli/Cargo.toml index 1c8985d1480c3746a71cad2c8394b89b59069597..83a78641bc2b14a9ea92cc0eae674135444ac691 100644 --- a/crates/edit_prediction_cli/Cargo.toml +++ b/crates/edit_prediction_cli/Cargo.toml @@ -65,7 +65,7 @@ rand.workspace = true similar = "2.7.0" flate2 = "1.1.8" toml.workspace = true -rust-embed = { workspace = true, features = ["debug-embed"] } +rust-embed.workspace = true gaoya = "0.2.0" # Wasmtime is included as a dependency in order to enable the same diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 3a84ee7c283007d3f40ef8a557981a9490f07c28..9a0524dffd238b566931a4a612edd91b1e6361c3 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -362,7 +362,7 @@ fn register_language( Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), - queries: load_queries(name), + queries: grammars::load_queries(name), context_provider: context.clone(), toolchain_provider: toolchain.clone(), manifest_name: manifest_name.clone(), @@ -384,7 +384,3 @@ fn load_config(name: &str) -> LanguageConfig { let grammars_loaded = cfg!(any(feature = "load-grammars", test)); grammars::load_config_for_feature(name, grammars_loaded) } - -fn load_queries(name: &str) -> LanguageQueries { - grammars::load_queries(name) -} diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index 27e8182d37ba1c67700d3a41dbdfc1c4ce27e4d6..a0d75e5b76fd4a0066ff606585088f61a23d19a1 100644 --- a/crates/settings/Cargo.toml +++ b/crates/settings/Cargo.toml @@ -27,7 +27,7 @@ log.workspace = true migrator.workspace = true paths.workspace = true release_channel.workspace = true -rust-embed = { workspace = true, features = ["debug-embed"] } +rust-embed.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 18f7e98f001a83fbdd527f98ee8f4b22c7e91bcc..60d696d05f0136cacc3c5d71ad9885b6acf3630d 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -811,6 +811,7 @@ fn main() { let fs = app_state.fs.clone(); load_user_themes_in_background(fs.clone(), cx); watch_themes(fs.clone(), cx); + #[cfg(debug_assertions)] watch_languages(fs.clone(), app_state.languages.clone(), cx); let menus = app_menus(cx); @@ -1834,7 +1835,7 @@ fn watch_languages(fs: Arc, languages: Arc, cx: &m use std::time::Duration; cx.background_spawn(async move { - let languages_src = Path::new("crates/languages/src"); + let languages_src = Path::new("crates/grammars/src"); let Some(languages_src) = fs.canonicalize(languages_src).await.log_err() else { return; }; @@ -1864,9 +1865,6 @@ fn watch_languages(fs: Arc, languages: Arc, cx: &m .detach(); } -#[cfg(not(debug_assertions))] -fn watch_languages(_fs: Arc, _languages: Arc, _cx: &mut App) {} - fn dump_all_gpui_actions() { #[derive(Debug, serde::Serialize)] struct ActionDef { From ab71d1a2974d125377fbd215766c5ad50e860c59 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 27 Mar 2026 11:11:50 +0100 Subject: [PATCH 28/53] agent_ui: Delete metadata for empty released threads (#52563) Keep sidebar metadata only for threads with entries. Important for ACP agents especially that won't persist the thread. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A Co-authored-by: Bennet Bo Fenner --- crates/agent_ui/src/thread_metadata_store.rs | 118 ++++++++++++++++++- 1 file changed, 116 insertions(+), 2 deletions(-) diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 03b3f645c5e0eecd6d71bd2f69c545d7a7d23522..9a99ca9fcd5766e041fa50206d6a536ac4a97854 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -325,8 +325,14 @@ impl SidebarThreadMetadataStore { let weak_store = weak_store.clone(); move |thread, cx| { weak_store - .update(cx, |store, _cx| { - store.session_subscriptions.remove(thread.session_id()); + .update(cx, |store, cx| { + let session_id = thread.session_id().clone(); + store.session_subscriptions.remove(&session_id); + if thread.entries().is_empty() { + // Empty threads can be unloaded without ever being + // durably persisted by the underlying agent. + store.delete(session_id, cx); + } }) .ok(); } @@ -998,6 +1004,114 @@ mod tests { assert_eq!(list[0].session_id.0.as_ref(), "existing-session"); } + #[gpui::test] + async fn test_empty_thread_metadata_deleted_when_thread_released(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None::<&Path>, cx).await; + let connection = Rc::new(StubAgentConnection::new()); + + let thread = cx + .update(|cx| { + connection + .clone() + .new_session(project.clone(), PathList::default(), cx) + }) + .await + .unwrap(); + let session_id = cx.read(|cx| thread.read(cx).session_id().clone()); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_title("Draft Thread".into(), cx).detach(); + }); + }); + cx.run_until_parked(); + + let metadata_ids = cx.update(|cx| { + SidebarThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>() + }); + assert_eq!(metadata_ids, vec![session_id]); + + drop(thread); + cx.update(|_| {}); + cx.run_until_parked(); + cx.run_until_parked(); + + let metadata_ids = cx.update(|cx| { + SidebarThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>() + }); + assert!( + metadata_ids.is_empty(), + "expected empty draft thread metadata to be deleted on release" + ); + } + + #[gpui::test] + async fn test_nonempty_thread_metadata_preserved_when_thread_released(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None::<&Path>, cx).await; + let connection = Rc::new(StubAgentConnection::new()); + + let thread = cx + .update(|cx| { + connection + .clone() + .new_session(project.clone(), PathList::default(), cx) + }) + .await + .unwrap(); + let session_id = cx.read(|cx| thread.read(cx).session_id().clone()); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.push_user_content_block(None, "Hello".into(), cx); + }); + }); + cx.run_until_parked(); + + let metadata_ids = cx.update(|cx| { + SidebarThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>() + }); + assert_eq!(metadata_ids, vec![session_id.clone()]); + + drop(thread); + cx.update(|_| {}); + cx.run_until_parked(); + + let metadata_ids = cx.update(|cx| { + SidebarThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>() + }); + assert_eq!(metadata_ids, vec![session_id]); + } + #[gpui::test] async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) { cx.update(|cx| { From a92283111b1e7ee1a1381c2307cddf8387347c8c Mon Sep 17 00:00:00 2001 From: Martin Pool Date: Fri, 27 Mar 2026 04:20:21 -0600 Subject: [PATCH 29/53] Don't preallocate 600MB for GPUI profiler (#45197) Previously, the GPUI profiler allocates one CircularBuffer per thread, and `CircularBuffer` always preallocates space for N entries. As a result it allocates ~20MB/thread, and on my machine about 33 threads are created at startup for a total of 600MB used. In this PR I change it to use a VecDeque that can gradually grow up to 20MB as data is written. At least in my experiments it seems that this caps overall usage at about 21MB perhaps because only one thread writes very much usage data. Since this is fixed overhead for everyone running Zed it seems like a worthwhile gain. This also folds duplicated code across platforms into the common gpui profiler. Before: Image After: image I got here from #35780 but I don't think this is tree-size related, it seems to be fixed overhead. Release Notes: - Improved: Significantly less memory used to record internal profiling information. --------- Co-authored-by: MrSubidubi Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Cargo.lock | 1 - crates/gpui/Cargo.toml | 1 - crates/gpui/src/profiler.rs | 65 +++++++++++++++-------- crates/gpui_linux/src/linux/dispatcher.rs | 22 +------- crates/gpui_macos/src/dispatcher.rs | 49 +++-------------- crates/gpui_windows/src/dispatcher.rs | 22 +------- 6 files changed, 55 insertions(+), 105 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6967770a5e1a6e09a2bd7a4f7e77a5307f9bfeb..dd421f8059245978ce45c0821c38e6105532b22b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7605,7 +7605,6 @@ dependencies = [ "block", "cbindgen", "chrono", - "circular-buffer", "cocoa 0.26.0", "cocoa-foundation 0.2.0", "collections", diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index cb4a48f63103118aafe78398d4842634c976ef9d..915f0fc03e2cc5beaf40c810654724295c41cde8 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -98,7 +98,6 @@ gpui_util.workspace = true waker-fn = "1.2.0" lyon = "1.0" pin-project = "1.1.10" -circular-buffer.workspace = true spin = "0.10.0" pollster.workspace = true url.workspace = true diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index dc6e9a6600f5c172050fd30cfed181ac7ed81ec4..1405b4d04964f5497bb4d7f865d6c4405507b43d 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -1,9 +1,8 @@ use scheduler::Instant; use std::{ cell::LazyCell, - collections::HashMap, - hash::Hasher, - hash::{DefaultHasher, Hash}, + collections::{HashMap, VecDeque}, + hash::{DefaultHasher, Hash, Hasher}, sync::Arc, thread::ThreadId, }; @@ -45,7 +44,6 @@ impl ThreadTaskTimings { let timings = &timings.timings; let mut vec = Vec::with_capacity(timings.len()); - let (s1, s2) = timings.as_slices(); vec.extend_from_slice(s1); vec.extend_from_slice(s2); @@ -243,11 +241,14 @@ impl ProfilingCollector { } } -// Allow 20mb of task timing entries -const MAX_TASK_TIMINGS: usize = (20 * 1024 * 1024) / core::mem::size_of::(); +// Allow 16MiB of task timing entries. +// VecDeque grows by doubling its capacity when full, so keep this a power of 2 to avoid wasting +// memory. +const MAX_TASK_TIMINGS: usize = (16 * 1024 * 1024) / core::mem::size_of::(); #[doc(hidden)] -pub type TaskTimings = circular_buffer::CircularBuffer; +pub(crate) type TaskTimings = VecDeque; + #[doc(hidden)] pub type GuardedTaskTimings = spin::Mutex; @@ -287,7 +288,7 @@ thread_local! { pub struct ThreadTimings { pub thread_name: Option, pub thread_id: ThreadId, - pub timings: Box, + pub timings: TaskTimings, pub total_pushed: u64, } @@ -296,10 +297,38 @@ impl ThreadTimings { ThreadTimings { thread_name, thread_id, - timings: TaskTimings::boxed(), + timings: TaskTimings::new(), total_pushed: 0, } } + + /// If this task is the same as the last task, update the end time of the last task. + /// + /// Otherwise, add the new task timing to the list. + pub fn add_task_timing(&mut self, timing: TaskTiming) { + if let Some(last_timing) = self.timings.back_mut() + && last_timing.location == timing.location + && last_timing.start == timing.start + { + last_timing.end = timing.end; + } else { + while self.timings.len() + 1 > MAX_TASK_TIMINGS { + // This should only ever pop one element because it matches the insertion below. + self.timings.pop_front(); + } + self.timings.push_back(timing); + self.total_pushed += 1; + } + } + + pub fn get_thread_task_timings(&self) -> ThreadTaskTimings { + ThreadTaskTimings { + thread_name: self.thread_name.clone(), + thread_id: self.thread_id, + timings: self.timings.iter().cloned().collect(), + total_pushed: self.total_pushed, + } + } } impl Drop for ThreadTimings { @@ -318,19 +347,13 @@ impl Drop for ThreadTimings { } #[doc(hidden)] -#[allow(dead_code)] // Used by Linux and Windows dispatchers, not macOS pub fn add_task_timing(timing: TaskTiming) { THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - - if let Some(last_timing) = timings.timings.back_mut() { - if last_timing.location == timing.location && last_timing.start == timing.start { - last_timing.end = timing.end; - return; - } - } - - timings.timings.push_back(timing); - timings.total_pushed += 1; + timings.lock().add_task_timing(timing); }); } + +#[doc(hidden)] +pub fn get_current_thread_task_timings() -> ThreadTaskTimings { + THREAD_TIMINGS.with(|timings| timings.lock().get_thread_task_timings()) +} diff --git a/crates/gpui_linux/src/linux/dispatcher.rs b/crates/gpui_linux/src/linux/dispatcher.rs index a72276cc7658a399505fa62bd2d5fe7b41e43e14..22df5799ddf9c77bfdbc7b09accbea117de6d130 100644 --- a/crates/gpui_linux/src/linux/dispatcher.rs +++ b/crates/gpui_linux/src/linux/dispatcher.rs @@ -13,7 +13,7 @@ use std::{ use gpui::{ GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueReceiver, - PriorityQueueSender, RunnableVariant, THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, profiler, + PriorityQueueSender, RunnableVariant, TaskTiming, ThreadTaskTimings, profiler, }; struct TimerAfter { @@ -135,25 +135,7 @@ impl PlatformDispatcher for LinuxDispatcher { } fn get_current_thread_timings(&self) -> gpui::ThreadTaskTimings { - THREAD_TIMINGS.with(|timings| { - let timings = timings.lock(); - let thread_name = timings.thread_name.clone(); - let total_pushed = timings.total_pushed; - let timings = &timings.timings; - - let mut vec = Vec::with_capacity(timings.len()); - - let (s1, s2) = timings.as_slices(); - vec.extend_from_slice(s1); - vec.extend_from_slice(s2); - - gpui::ThreadTaskTimings { - thread_name, - thread_id: std::thread::current().id(), - timings: vec, - total_pushed, - } - }) + gpui::profiler::get_current_thread_task_timings() } fn is_main_thread(&self) -> bool { diff --git a/crates/gpui_macos/src/dispatcher.rs b/crates/gpui_macos/src/dispatcher.rs index dd6f546f68b88efe6babc13e2d923d634eff5825..f4b80ec7cbaf6deeebad1f7b6448463c9e132afe 100644 --- a/crates/gpui_macos/src/dispatcher.rs +++ b/crates/gpui_macos/src/dispatcher.rs @@ -1,7 +1,7 @@ use dispatch2::{DispatchQueue, DispatchQueueGlobalPriority, DispatchTime, GlobalQueueIdentifier}; use gpui::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RunnableMeta, RunnableVariant, - THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RunnableMeta, RunnableVariant, TaskTiming, + ThreadTaskTimings, add_task_timing, }; use mach2::{ kern_return::KERN_SUCCESS, @@ -42,25 +42,7 @@ impl PlatformDispatcher for MacDispatcher { } fn get_current_thread_timings(&self) -> ThreadTaskTimings { - THREAD_TIMINGS.with(|timings| { - let timings = timings.lock(); - let thread_name = timings.thread_name.clone(); - let total_pushed = timings.total_pushed; - let timings = &timings.timings; - - let mut vec = Vec::with_capacity(timings.len()); - - let (s1, s2) = timings.as_slices(); - vec.extend_from_slice(s1); - vec.extend_from_slice(s2); - - ThreadTaskTimings { - thread_name, - thread_id: std::thread::current().id(), - timings: vec, - total_pushed, - } - }) + gpui::profiler::get_current_thread_task_timings() } fn is_main_thread(&self) -> bool { @@ -204,33 +186,16 @@ extern "C" fn trampoline(context: *mut c_void) { let location = runnable.metadata().location; let start = Instant::now(); - let timing = TaskTiming { + let mut timing = TaskTiming { location, start, end: None, }; - THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - let timings = &mut timings.timings; - if let Some(last_timing) = timings.iter_mut().rev().next() { - if last_timing.location == timing.location { - return; - } - } - - timings.push_back(timing); - }); + add_task_timing(timing); runnable.run(); - let end = Instant::now(); - THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - let timings = &mut timings.timings; - let Some(last_timing) = timings.iter_mut().rev().next() else { - return; - }; - last_timing.end = Some(end); - }); + timing.end = Some(Instant::now()); + add_task_timing(timing); } diff --git a/crates/gpui_windows/src/dispatcher.rs b/crates/gpui_windows/src/dispatcher.rs index a5cfd9dc10d9afcce9580565943c28cb83dc9dab..60b9898cef3076fa64898ebcb7223616150bf01b 100644 --- a/crates/gpui_windows/src/dispatcher.rs +++ b/crates/gpui_windows/src/dispatcher.rs @@ -24,7 +24,7 @@ use windows::{ use crate::{HWND, SafeHwnd, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD}; use gpui::{ GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueSender, RunnableVariant, - THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, TimerResolutionGuard, + TaskTiming, ThreadTaskTimings, TimerResolutionGuard, }; pub(crate) struct WindowsDispatcher { @@ -106,25 +106,7 @@ impl PlatformDispatcher for WindowsDispatcher { } fn get_current_thread_timings(&self) -> gpui::ThreadTaskTimings { - THREAD_TIMINGS.with(|timings| { - let timings = timings.lock(); - let thread_name = timings.thread_name.clone(); - let total_pushed = timings.total_pushed; - let timings = &timings.timings; - - let mut vec = Vec::with_capacity(timings.len()); - - let (s1, s2) = timings.as_slices(); - vec.extend_from_slice(s1); - vec.extend_from_slice(s2); - - gpui::ThreadTaskTimings { - thread_name, - thread_id: std::thread::current().id(), - timings: vec, - total_pushed, - } - }) + gpui::profiler::get_current_thread_task_timings() } fn is_main_thread(&self) -> bool { From 3b66f9076e56318993b2ad158f339e7ce14244de Mon Sep 17 00:00:00 2001 From: Neel Date: Fri, 27 Mar 2026 10:26:52 +0000 Subject: [PATCH 30/53] client: Persist last used organization (#52505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Persists last used organization through restart. Opted to do this via `kvp` instead of `settings.json` since the value could change often, and we would have to persist an ID rather than a friendly name. Closes CLO-568. ## How to Review ## Self-Review Checklist - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A --- Cargo.lock | 1 + crates/client/Cargo.toml | 1 + crates/client/src/user.rs | 33 +++++++++++++++++++++++++++++---- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd421f8059245978ce45c0821c38e6105532b22b..d24b5173abb50429a36c8be32af3b688b47e6b88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2972,6 +2972,7 @@ dependencies = [ "cloud_llm_client", "collections", "credentials_provider", + "db", "derive_more", "feature_flags", "fs", diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index d9ef55056049e387d931bc9fe59e0327b4ce1637..1edbb3399e4332e2ebd23f812c66697bda72d587 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -25,6 +25,7 @@ cloud_api_client.workspace = true cloud_llm_client.workspace = true collections.workspace = true credentials_provider.workspace = true +db.workspace = true derive_more.workspace = true feature_flags.workspace = true fs.workspace = true diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index df0c00d86636b6b4a138161a151e20a1c50a688d..e9b9acf68573ef5a05d642c09ed96a4d8aa23580 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -9,6 +9,7 @@ use cloud_llm_client::{ EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, UsageLimit, }; use collections::{HashMap, HashSet, hash_map::Entry}; +use db::kvp::KeyValueStore; use derive_more::Deref; use feature_flags::FeatureFlagAppExt; use futures::{Future, StreamExt, channel::mpsc}; @@ -25,6 +26,8 @@ use std::{ use text::ReplicaId; use util::{ResultExt, TryFutureExt as _}; +const CURRENT_ORGANIZATION_ID_KEY: &str = "current_organization_id"; + pub type UserId = u64; #[derive( @@ -706,9 +709,16 @@ impl UserStore { .is_some_and(|current| current.id == organization.id); if !is_same_organization { + let organization_id = organization.id.0.to_string(); self.current_organization.replace(organization); cx.emit(Event::OrganizationChanged); cx.notify(); + + let kvp = KeyValueStore::global(cx); + db::write_and_log(cx, move || async move { + kvp.write_kvp(CURRENT_ORGANIZATION_ID_KEY.into(), organization_id) + .await + }); } } @@ -816,14 +826,29 @@ impl UserStore { } self.organizations = response.organizations.into_iter().map(Arc::new).collect(); - self.current_organization = response - .default_organization_id - .and_then(|default_organization_id| { + let persisted_org_id = KeyValueStore::global(cx) + .read_kvp(CURRENT_ORGANIZATION_ID_KEY) + .log_err() + .flatten() + .map(|id| OrganizationId(Arc::from(id))); + + self.current_organization = persisted_org_id + .and_then(|persisted_id| { self.organizations .iter() - .find(|organization| organization.id == default_organization_id) + .find(|org| org.id == persisted_id) .cloned() }) + .or_else(|| { + response + .default_organization_id + .and_then(|default_organization_id| { + self.organizations + .iter() + .find(|organization| organization.id == default_organization_id) + .cloned() + }) + }) .or_else(|| self.organizations.first().cloned()); self.plans_by_organization = response .plans_by_organization From a2f1703e288d88b4ac8ab95f4afe6999be59d2c0 Mon Sep 17 00:00:00 2001 From: Ben Vollrath <116082598+Ben-Vollrath@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:44:46 +0100 Subject: [PATCH 32/53] editor: Autoscroll to initial selection on select all matches edit (#49232) Fix the way selections are built in `Editor::select_all_matches` in order to guarantee that the original selection, where the user's cursor is located, is the last selection provided to `MutableSelectionsCollection::select_ranges` as the editor will attempt to scroll to the last selection when the user starts editing. This way, we ensure that the user stays in the same location from which the `editor: select all matches` action was triggered when they start editing. Closes #32894 Release Notes: - Fixed an issue where editing selections after `editor: select all matches` would scroll to the last match --------- Co-authored-by: dino --- crates/editor/src/editor.rs | 41 ++++++++++++--------- crates/editor/src/editor_tests.rs | 60 ++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3c35175002fe17995db478f01eed06585c0ebe88..8379191e14c69593430133499eded32da94f8c62 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -16119,11 +16119,8 @@ impl Editor { }; let mut new_selections = Vec::new(); - - let reversed = self - .selections - .oldest::(&display_map) - .reversed; + let initial_selection = self.selections.oldest::(&display_map); + let reversed = initial_selection.reversed; let buffer = display_map.buffer_snapshot(); let query_matches = select_next_state .query @@ -16137,21 +16134,33 @@ impl Editor { MultiBufferOffset(query_match.start())..MultiBufferOffset(query_match.end()) }; - if !select_next_state.wordwise - || (!buffer.is_inside_word(offset_range.start, None) - && !buffer.is_inside_word(offset_range.end, None)) - { - new_selections.push(offset_range.start..offset_range.end); - } - } + let is_partial_word_match = select_next_state.wordwise + && (buffer.is_inside_word(offset_range.start, None) + || buffer.is_inside_word(offset_range.end, None)); - select_next_state.done = true; + let is_initial_selection = MultiBufferOffset(query_match.start()) + == initial_selection.start + && MultiBufferOffset(query_match.end()) == initial_selection.end; - if new_selections.is_empty() { - log::error!("bug: new_selections is empty in select_all_matches"); - return Ok(()); + if !is_partial_word_match && !is_initial_selection { + new_selections.push(offset_range); + } } + // Ensure that the initial range is the last selection, as + // `MutableSelectionsCollection::select_ranges` makes the last selection + // the newest selection, which the editor then relies on as the primary + // cursor for scroll targeting. Without this, the last match would then + // be automatically focused when the user started editing the selected + // matches. + let initial_directed_range = if reversed { + initial_selection.end..initial_selection.start + } else { + initial_selection.start..initial_selection.end + }; + new_selections.push(initial_directed_range); + + select_next_state.done = true; self.unfold_ranges(&new_selections, false, false, cx); self.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges(new_selections) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index f285d130be5be071b75161c114da53ca9c55d301..79f470d42496c275f3dd729aa9d21cf7cd84c168 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -9968,7 +9968,6 @@ async fn test_select_all_matches_does_not_scroll(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let large_body_1 = "\nd".repeat(200); let large_body_2 = "\ne".repeat(200); @@ -9981,17 +9980,62 @@ async fn test_select_all_matches_does_not_scroll(cx: &mut TestAppContext) { scroll_position }); - cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) + cx.update_editor(|editor, window, cx| editor.select_all_matches(&SelectAllMatches, window, cx)) .unwrap(); cx.assert_editor_state(&format!( "«ˇa»bc\n«ˇa»bc{large_body_1} «ˇa»bc{large_body_2}\nef«ˇa»bc\n«ˇa»bc" )); - let scroll_position_after_selection = - cx.update_editor(|editor, _, cx| editor.scroll_position(cx)); - assert_eq!( - initial_scroll_position, scroll_position_after_selection, - "Scroll position should not change after selecting all matches" - ); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after selecting all matches" + ) + }); + + // Simulate typing while the selections are active, as that is where the + // editor would attempt to actually scroll to the newest selection, which + // should have been set as the original selection to avoid scrolling to the + // last match. + cx.simulate_keystroke("x"); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after editing all matches" + ) + }); + + cx.set_state(&format!( + "abc\nabc{large_body_1} «aˇ»bc{large_body_2}\nefabc\nabc" + )); + let initial_scroll_position = cx.update_editor(|editor, _, cx| { + let scroll_position = editor.scroll_position(cx); + assert!(scroll_position.y > 0.0, "Initial selection is between two large bodies and should have the editor scrolled to it"); + scroll_position + }); + + cx.update_editor(|editor, window, cx| editor.select_all_matches(&SelectAllMatches, window, cx)) + .unwrap(); + cx.assert_editor_state(&format!( + "«aˇ»bc\n«aˇ»bc{large_body_1} «aˇ»bc{large_body_2}\nef«aˇ»bc\n«aˇ»bc" + )); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after selecting all matches" + ) + }); + + cx.simulate_keystroke("x"); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after editing all matches" + ) + }); } #[gpui::test] From 69286d6929f59c8974d4a622b7327e606325d7bd Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:01:01 -0400 Subject: [PATCH 33/53] git: Forbid deleting current git worktree or branch from picker (#52327) ## Context This just makes the UI enforce some git cli rules more clearly and prevents some unexpected behavior. ## 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/git_ui/src/branch_picker.rs | 39 ++++++++++++------ crates/git_ui/src/worktree_picker.rs | 61 ++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 26 deletions(-) diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index d9adc079bf470e121c99b2d13c2ef25ffea7a68f..ce1504dd60f0ea1a18c6cb827ee14a8f5b411695 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -486,6 +486,10 @@ impl BranchListDelegate { let is_remote; let result = match &entry { Entry::Branch { branch, .. } => { + if branch.is_head { + return Ok(()); + } + is_remote = branch.is_remote(); repo.update(cx, |repo, _| { repo.delete_branch(is_remote, branch.name().to_string()) @@ -1151,20 +1155,29 @@ impl PickerDelegate for BranchListDelegate { let delete_and_select_btns = h_flex() .gap_1() - .child( - Button::new("delete-branch", "Delete") - .key_binding( - KeyBinding::for_action_in( - &branch_picker::DeleteBranch, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), + .when( + !selected_entry + .and_then(|entry| entry.as_branch()) + .is_some_and(|branch| branch.is_head), + |this| { + this.child( + Button::new("delete-branch", "Delete") + .key_binding( + KeyBinding::for_action_in( + &branch_picker::DeleteBranch, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action( + branch_picker::DeleteBranch.boxed_clone(), + cx, + ); + }), ) - .on_click(|_, window, cx| { - window - .dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx); - }), + }, ) .child( Button::new("select_branch", "Select") diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 2cbba9347f973aa084413b03af7314f30d22fc99..d7488cc1bddb7e9d2825b8d21d3dc6c4c4fdde5a 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -19,7 +19,7 @@ use remote_connection::{RemoteConnectionModal, connect}; use settings::Settings; use std::{path::PathBuf, sync::Arc}; use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; -use util::ResultExt; +use util::{ResultExt, debug_panic}; use workspace::{ModalView, MultiWorkspace, Workspace, notifications::DetachAndPromptErr}; use crate::git_panel::show_error_toast; @@ -115,6 +115,7 @@ impl WorktreeList { this.picker.update(cx, |picker, cx| { picker.delegate.all_worktrees = Some(all_worktrees); picker.delegate.default_branch = default_branch; + picker.delegate.refresh_forbidden_deletion_path(cx); picker.refresh(window, cx); }) })?; @@ -261,6 +262,7 @@ pub struct WorktreeListDelegate { modifiers: Modifiers, focus_handle: FocusHandle, default_branch: Option, + forbidden_deletion_path: Option, } impl WorktreeListDelegate { @@ -280,6 +282,7 @@ impl WorktreeListDelegate { modifiers: Default::default(), focus_handle: cx.focus_handle(), default_branch: None, + forbidden_deletion_path: None, } } @@ -452,7 +455,7 @@ impl WorktreeListDelegate { let Some(entry) = self.matches.get(idx).cloned() else { return; }; - if entry.is_new { + if entry.is_new || self.forbidden_deletion_path.as_ref() == Some(&entry.worktree.path) { return; } let Some(repo) = self.repo.clone() else { @@ -486,6 +489,7 @@ impl WorktreeListDelegate { if let Some(all_worktrees) = &mut picker.delegate.all_worktrees { all_worktrees.retain(|w| w.path != path); } + picker.delegate.refresh_forbidden_deletion_path(cx); if picker.delegate.matches.is_empty() { picker.delegate.selected_index = 0; } else if picker.delegate.selected_index >= picker.delegate.matches.len() { @@ -498,6 +502,29 @@ impl WorktreeListDelegate { }) .detach(); } + + fn refresh_forbidden_deletion_path(&mut self, cx: &App) { + let Some(workspace) = self.workspace.upgrade() else { + debug_panic!("Workspace should always be available or else the picker would be closed"); + self.forbidden_deletion_path = None; + return; + }; + + let visible_worktree_paths = workspace.read_with(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .collect::>() + }); + + self.forbidden_deletion_path = if visible_worktree_paths.len() == 1 { + visible_worktree_paths.into_iter().next() + } else { + None + }; + } } async fn open_remote_worktree( @@ -771,6 +798,9 @@ impl PickerDelegate for WorktreeListDelegate { let focus_handle = self.focus_handle.clone(); + let can_delete = + !entry.is_new && self.forbidden_deletion_path.as_ref() != Some(&entry.worktree.path); + let delete_button = |entry_ix: usize| { IconButton::new(("delete-worktree", entry_ix), IconName::Trash) .icon_size(IconSize::Small) @@ -839,7 +869,7 @@ impl PickerDelegate for WorktreeListDelegate { } })), ) - .when(!entry.is_new, |this| { + .when(can_delete, |this| { if selected { this.end_slot(delete_button(ix)) } else { @@ -857,6 +887,9 @@ impl PickerDelegate for WorktreeListDelegate { let focus_handle = self.focus_handle.clone(); let selected_entry = self.matches.get(self.selected_index); let is_creating = selected_entry.is_some_and(|entry| entry.is_new); + let can_delete = selected_entry.is_some_and(|entry| { + !entry.is_new && self.forbidden_deletion_path.as_ref() != Some(&entry.worktree.path) + }); let footer_container = h_flex() .w_full() @@ -904,16 +937,18 @@ impl PickerDelegate for WorktreeListDelegate { } else { Some( footer_container - .child( - Button::new("delete-worktree", "Delete") - .key_binding( - KeyBinding::for_action_in(&DeleteWorktree, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(DeleteWorktree.boxed_clone(), cx) - }), - ) + .when(can_delete, |this| { + this.child( + Button::new("delete-worktree", "Delete") + .key_binding( + KeyBinding::for_action_in(&DeleteWorktree, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(DeleteWorktree.boxed_clone(), cx) + }), + ) + }) .child( Button::new("open-in-new-window", "Open in New Window") .key_binding( From c90bf6e6b7c0c792b91dd7eeb7d725cf74c1b234 Mon Sep 17 00:00:00 2001 From: Jakub Charvat Date: Fri, 27 Mar 2026 14:06:21 +0100 Subject: [PATCH 34/53] Fix unformatted error contexts (#52568) When debugging a remote SSH connection, I came across an unformatted format string in the output log. I changed the raw `.context(fmt)` call to a `.with_context(|| format!(fmt))`. I ran a quick sweep through the codebase to identify and fix two other instances of the same issue. 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/extension_host/src/headless_host.rs | 2 +- crates/language_models/src/provider/bedrock.rs | 2 +- crates/project/src/lsp_command.rs | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index 0aff06fdddcf5c075bd669528b5c52137f745863..7c30228257dbaa037fbc772be822a1000adfdfef 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -281,7 +281,7 @@ impl HeadlessExtensionStore { fs.rename(&tmp_path, &path, RenameOptions::default()) .await - .context("Failed to rename {tmp_path:?} to {path:?}")?; + .with_context(|| format!("Failed to rename {tmp_path:?} to {path:?}"))?; Self::load_extension(this, extension, cx).await }) diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 734e97ee335c4106fced9d334d31b5ed5b86d407..f53f145dbd387aa948b977d854ba77f1cbe49ded 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -344,7 +344,7 @@ impl State { .ok_or(AuthenticateError::CredentialsNotFound)?; let credentials_str = String::from_utf8(credentials_bytes) - .context("invalid {PROVIDER_NAME} credentials")?; + .with_context(|| format!("invalid {PROVIDER_NAME} credentials"))?; let credentials: BedrockCredentials = serde_json::from_str(&credentials_str).context("failed to parse credentials")?; diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 59baaa156e64c744d8906d294f7f3978280a1839..d4a4f9b04968413c51607f71047752a9b779b79a 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -3215,8 +3215,9 @@ impl InlayHints { Some(((uri, range), server_id)) => Some(( LanguageServerId(server_id as usize), lsp::Location { - uri: lsp::Uri::from_str(&uri) - .context("invalid uri in hint part {part:?}")?, + uri: lsp::Uri::from_str(&uri).with_context(|| { + format!("invalid uri in hint part {uri:?}") + })?, range: lsp::Range::new( point_to_lsp(PointUtf16::new( range.start.row, From 93e641166df7290b900bfea6799ba570ec26f2b2 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:41:25 +0100 Subject: [PATCH 35/53] theme: Split out theme_settings crate (#52569) Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A --- Cargo.lock | 89 +- Cargo.toml | 2 + crates/acp_tools/Cargo.toml | 2 +- crates/acp_tools/src/acp_tools.rs | 2 +- .../disable_cursor_blinking/before.rs | 6 +- .../disable_cursor_blinking/before.rs | 6 +- crates/agent_ui/Cargo.toml | 1 + .../add_llm_provider_modal.rs | 2 +- .../configure_context_server_modal.rs | 2 +- crates/agent_ui/src/agent_diff.rs | 4 +- crates/agent_ui/src/agent_panel.rs | 24 +- crates/agent_ui/src/agent_registry_ui.rs | 2 +- crates/agent_ui/src/completion_provider.rs | 2 +- crates/agent_ui/src/conversation_view.rs | 4 +- crates/agent_ui/src/entry_view_state.rs | 4 +- crates/agent_ui/src/inline_prompt_editor.rs | 2 +- crates/agent_ui/src/mention_set.rs | 2 +- crates/agent_ui/src/message_editor.rs | 2 +- crates/agent_ui/src/test_support.rs | 2 +- crates/agent_ui/src/text_thread_editor.rs | 8 +- crates/agent_ui/src/thread_history.rs | 2 +- crates/agent_ui/src/ui/agent_notification.rs | 3 +- crates/agent_ui/src/ui/mention_crease.rs | 2 +- crates/collab/Cargo.toml | 1 + .../integration/randomized_test_helpers.rs | 2 +- .../collab/tests/integration/test_server.rs | 4 +- crates/collab_ui/Cargo.toml | 1 + crates/collab_ui/src/collab_panel.rs | 3 +- .../incoming_call_notification.rs | 2 +- .../project_shared_notification.rs | 2 +- crates/command_palette/Cargo.toml | 1 + crates/command_palette/src/command_palette.rs | 2 +- crates/component_preview/Cargo.toml | 1 + .../examples/component_preview.rs | 4 +- crates/copilot/Cargo.toml | 1 + .../src/copilot_edit_prediction_delegate.rs | 2 +- crates/debugger_ui/Cargo.toml | 1 + .../src/session/running/console.rs | 3 +- .../src/session/running/memory_view.rs | 2 +- crates/debugger_ui/src/tests.rs | 2 +- crates/diagnostics/Cargo.toml | 1 + crates/diagnostics/src/diagnostic_renderer.rs | 2 +- crates/diagnostics/src/diagnostics_tests.rs | 2 +- crates/edit_prediction_ui/Cargo.toml | 2 +- .../src/rate_prediction_modal.rs | 2 +- crates/editor/Cargo.toml | 1 + crates/editor/benches/editor_render.rs | 2 +- crates/editor/src/bracket_colorization.rs | 2 +- crates/editor/src/display_map.rs | 2 +- crates/editor/src/display_map/block_map.rs | 2 +- crates/editor/src/display_map/fold_map.rs | 3 +- crates/editor/src/display_map/inlay_map.rs | 2 +- crates/editor/src/display_map/wrap_map.rs | 2 +- crates/editor/src/editor.rs | 22 +- crates/editor/src/editor_tests.rs | 2 +- crates/editor/src/element.rs | 5 +- crates/editor/src/git/blame.rs | 2 +- crates/editor/src/hover_popover.rs | 2 +- crates/editor/src/inlays/inlay_hints.rs | 2 +- crates/editor/src/items.rs | 4 +- crates/editor/src/movement.rs | 2 +- crates/editor/src/semantic_tokens.rs | 4 +- crates/editor/src/signature_help.rs | 2 +- crates/editor/src/split.rs | 2 +- crates/extension_cli/Cargo.toml | 2 +- crates/extension_cli/src/main.rs | 3 +- crates/extension_host/Cargo.toml | 1 + .../src/extension_store_test.rs | 2 +- crates/extensions_ui/Cargo.toml | 2 +- crates/extensions_ui/src/extensions_ui.rs | 2 +- crates/file_finder/Cargo.toml | 1 + crates/file_finder/src/file_finder_tests.rs | 2 +- crates/git_graph/Cargo.toml | 1 + crates/git_graph/src/git_graph.rs | 5 +- crates/git_ui/Cargo.toml | 1 + crates/git_ui/src/blame_ui.rs | 2 +- crates/git_ui/src/branch_picker.rs | 2 +- crates/git_ui/src/commit_tooltip.rs | 2 +- crates/git_ui/src/file_diff_view.rs | 2 +- crates/git_ui/src/git_panel.rs | 4 +- crates/git_ui/src/project_diff.rs | 2 +- crates/git_ui/src/stash_picker.rs | 2 +- crates/git_ui/src/text_diff_view.rs | 2 +- crates/image_viewer/Cargo.toml | 2 +- crates/image_viewer/src/image_viewer.rs | 2 +- crates/inspector_ui/Cargo.toml | 2 +- crates/inspector_ui/src/inspector.rs | 2 +- crates/keymap_editor/Cargo.toml | 1 + crates/keymap_editor/src/keymap_editor.rs | 2 +- .../src/ui_components/keystroke_input.rs | 2 +- crates/language/Cargo.toml | 1 + crates/language/src/buffer_tests.rs | 2 +- crates/language_tools/Cargo.toml | 3 +- .../language_tools/src/lsp_log_view_tests.rs | 2 +- crates/markdown/Cargo.toml | 1 + crates/markdown/examples/markdown.rs | 2 +- crates/markdown/examples/markdown_as_child.rs | 2 +- crates/markdown/src/html/html_rendering.rs | 2 +- crates/markdown/src/markdown.rs | 4 +- crates/markdown/src/mermaid.rs | 2 +- crates/markdown_preview/Cargo.toml | 2 +- .../src/markdown_preview_view.rs | 2 +- crates/miniprofiler_ui/Cargo.toml | 2 +- crates/miniprofiler_ui/src/miniprofiler_ui.rs | 2 +- crates/onboarding/Cargo.toml | 1 + crates/onboarding/src/basics_page.rs | 12 +- crates/open_path_prompt/Cargo.toml | 1 + .../src/open_path_prompt_tests.rs | 2 +- crates/outline/Cargo.toml | 1 + crates/outline/src/outline.rs | 3 +- crates/outline_panel/Cargo.toml | 1 + crates/outline_panel/src/outline_panel.rs | 5 +- crates/picker/Cargo.toml | 1 + crates/picker/src/picker.rs | 4 +- crates/platform_title_bar/Cargo.toml | 1 + .../src/system_window_tabs.rs | 2 +- crates/project_panel/Cargo.toml | 1 + crates/project_panel/src/project_panel.rs | 2 +- .../project_panel/src/project_panel_tests.rs | 4 +- crates/project_symbols/Cargo.toml | 1 + crates/project_symbols/src/project_symbols.rs | 5 +- crates/remote_connection/Cargo.toml | 2 +- .../src/remote_connection.rs | 2 +- crates/remote_server/Cargo.toml | 1 + .../remote_server/src/remote_editing_tests.rs | 2 +- crates/repl/Cargo.toml | 1 + crates/repl/src/notebook/cell.rs | 2 +- crates/repl/src/outputs.rs | 2 +- crates/repl/src/outputs/plain.rs | 4 +- crates/repl/src/outputs/table.rs | 2 +- crates/rules_library/Cargo.toml | 2 +- crates/rules_library/src/rules_library.rs | 4 +- crates/schema_generator/Cargo.toml | 1 + crates/schema_generator/src/main.rs | 3 +- crates/search/Cargo.toml | 1 + crates/search/src/buffer_search.rs | 2 +- crates/search/src/project_search.rs | 2 +- crates/search/src/search_bar.rs | 2 +- crates/settings_profile_selector/Cargo.toml | 1 + .../src/settings_profile_selector.rs | 4 +- crates/settings_ui/Cargo.toml | 1 + .../settings_ui/src/components/input_field.rs | 2 +- crates/settings_ui/src/page_data.rs | 15 +- .../src/pages/tool_permissions_setup.rs | 2 +- crates/settings_ui/src/settings_ui.rs | 12 +- crates/sidebar/Cargo.toml | 1 + crates/sidebar/src/project_group_builder.rs | 2 +- crates/sidebar/src/sidebar.rs | 4 +- crates/storybook/Cargo.toml | 1 + crates/storybook/src/storybook.rs | 10 +- crates/tab_switcher/Cargo.toml | 1 + crates/tab_switcher/src/tab_switcher_tests.rs | 2 +- crates/terminal/Cargo.toml | 1 + crates/terminal/src/terminal.rs | 2 +- crates/terminal/src/terminal_settings.rs | 2 +- crates/terminal_view/Cargo.toml | 1 + crates/terminal_view/src/terminal_element.rs | 7 +- crates/terminal_view/src/terminal_panel.rs | 2 +- .../src/terminal_path_like_target.rs | 2 +- crates/terminal_view/src/terminal_view.rs | 2 +- crates/theme/Cargo.toml | 6 +- crates/theme/src/fallback_themes.rs | 6 +- crates/theme/src/icon_theme.rs | 2 +- crates/theme/src/registry.rs | 60 +- crates/theme/src/schema.rs | 838 +---------------- crates/theme/src/styles/accents.rs | 22 - crates/theme/src/styles/players.rs | 38 +- crates/theme/src/theme.rs | 350 ++------ crates/theme_extension/Cargo.toml | 1 + crates/theme_extension/src/theme_extension.rs | 12 +- crates/theme_importer/Cargo.toml | 1 + crates/theme_importer/src/vscode/converter.rs | 2 +- crates/theme_selector/Cargo.toml | 1 + .../theme_selector/src/icon_theme_selector.rs | 8 +- crates/theme_selector/src/theme_selector.rs | 12 +- crates/theme_settings/Cargo.toml | 37 + crates/theme_settings/LICENSE-GPL | 1 + crates/theme_settings/src/schema.rs | 850 ++++++++++++++++++ .../{theme => theme_settings}/src/settings.rs | 73 +- crates/theme_settings/src/theme_settings.rs | 386 ++++++++ crates/ui/Cargo.toml | 1 + crates/ui/src/components/context_menu.rs | 2 +- crates/ui/src/components/label/label_like.rs | 12 +- crates/ui/src/components/list/list_header.rs | 4 +- crates/ui/src/components/tooltip.rs | 2 +- crates/ui/src/styles/spacing.rs | 2 +- crates/ui/src/styles/typography.rs | 3 +- crates/ui_macros/src/dynamic_spacing.rs | 12 +- crates/ui_prompt/Cargo.toml | 2 +- crates/ui_prompt/src/ui_prompt.rs | 2 +- crates/vim/Cargo.toml | 1 + crates/vim/src/state.rs | 2 +- crates/vim/src/test/vim_test_context.rs | 2 +- crates/vim/src/vim.rs | 2 +- crates/which_key/Cargo.toml | 2 +- crates/which_key/src/which_key_modal.rs | 2 +- crates/workspace/Cargo.toml | 1 + crates/workspace/src/multi_workspace.rs | 4 +- crates/workspace/src/notifications.rs | 2 +- crates/workspace/src/pane.rs | 4 +- crates/workspace/src/tasks.rs | 2 +- crates/workspace/src/workspace.rs | 33 +- crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 11 +- crates/zed/src/visual_test_runner.rs | 4 +- crates/zed/src/zed.rs | 46 +- crates/zed/src/zed/migrate.rs | 2 +- crates/zed/src/zed/telemetry_log.rs | 2 +- crates/zed/src/zed/visual_tests.rs | 2 +- 209 files changed, 1820 insertions(+), 1577 deletions(-) create mode 100644 crates/theme_settings/Cargo.toml create mode 120000 crates/theme_settings/LICENSE-GPL create mode 100644 crates/theme_settings/src/schema.rs rename crates/{theme => theme_settings}/src/settings.rs (91%) create mode 100644 crates/theme_settings/src/theme_settings.rs diff --git a/Cargo.lock b/Cargo.lock index d24b5173abb50429a36c8be32af3b688b47e6b88..5b30819007b3688ee50e92d4f7c1a6e63ec9b44b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,7 +59,7 @@ dependencies = [ "serde", "serde_json", "settings", - "theme", + "theme_settings", "ui", "util", "workspace", @@ -406,6 +406,7 @@ dependencies = [ "terminal_view", "text", "theme", + "theme_settings", "time", "time_format", "tree-sitter-md", @@ -3258,6 +3259,7 @@ dependencies = [ "telemetry_events", "text", "theme", + "theme_settings", "time", "tokio", "toml 0.8.23", @@ -3303,6 +3305,7 @@ dependencies = [ "smallvec", "telemetry", "theme", + "theme_settings", "time", "time_format", "title_bar", @@ -3375,6 +3378,7 @@ dependencies = [ "settings", "telemetry", "theme", + "theme_settings", "time", "ui", "util", @@ -3427,6 +3431,7 @@ dependencies = [ "session", "settings", "theme", + "theme_settings", "ui", "ui_input", "uuid", @@ -3624,6 +3629,7 @@ dependencies = [ "settings", "sum_tree", "theme", + "theme_settings", "util", "workspace", "zlog", @@ -4694,6 +4700,7 @@ dependencies = [ "terminal_view", "text", "theme", + "theme_settings", "tree-sitter", "tree-sitter-go", "tree-sitter-json", @@ -4881,6 +4888,7 @@ dependencies = [ "settings", "text", "theme", + "theme_settings", "ui", "unindent", "util", @@ -5397,6 +5405,7 @@ dependencies = [ "telemetry", "text", "theme", + "theme_settings", "time", "ui", "util", @@ -5465,6 +5474,7 @@ dependencies = [ "telemetry", "text", "theme", + "theme_settings", "time", "tracing", "tree-sitter-bash", @@ -6044,7 +6054,7 @@ dependencies = [ "settings_content", "snippet_provider", "task", - "theme", + "theme_settings", "tokio", "toml 0.8.23", "tree-sitter", @@ -6093,6 +6103,7 @@ dependencies = [ "tempfile", "theme", "theme_extension", + "theme_settings", "toml 0.8.23", "tracing", "url", @@ -6131,7 +6142,7 @@ dependencies = [ "smallvec", "strum 0.27.2", "telemetry", - "theme", + "theme_settings", "ui", "util", "vim_mode_setting", @@ -6288,6 +6299,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "ui", "util", "workspace", @@ -7303,6 +7315,7 @@ dependencies = [ "settings", "smallvec", "theme", + "theme_settings", "time", "ui", "workspace", @@ -7378,6 +7391,7 @@ dependencies = [ "strum 0.27.2", "telemetry", "theme", + "theme_settings", "time", "time_format", "tracing", @@ -8729,7 +8743,7 @@ dependencies = [ "project", "serde", "settings", - "theme", + "theme_settings", "ui", "util", "workspace", @@ -8845,7 +8859,7 @@ dependencies = [ "project", "serde_json", "serde_json_lenient", - "theme", + "theme_settings", "ui", "util", "util_macros", @@ -9288,6 +9302,7 @@ dependencies = [ "telemetry", "tempfile", "theme", + "theme_settings", "tree-sitter-json", "tree-sitter-rust", "ui", @@ -9398,6 +9413,7 @@ dependencies = [ "task", "text", "theme", + "theme_settings", "toml 0.8.23", "tracing", "tree-sitter", @@ -9611,6 +9627,7 @@ dependencies = [ "sysinfo 0.37.2", "telemetry", "theme", + "theme_settings", "tree-sitter", "ui", "util", @@ -10289,6 +10306,7 @@ dependencies = [ "stacksafe", "sum_tree", "theme", + "theme_settings", "ui", "util", ] @@ -10305,7 +10323,7 @@ dependencies = [ "markdown", "settings", "tempfile", - "theme", + "theme_settings", "ui", "urlencoding", "util", @@ -10665,7 +10683,7 @@ dependencies = [ "rpc", "serde_json", "smol", - "theme", + "theme_settings", "util", "workspace", "zed_actions", @@ -11556,6 +11574,7 @@ dependencies = [ "settings", "telemetry", "theme", + "theme_settings", "ui", "util", "vim_mode_setting", @@ -11661,6 +11680,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "ui", "util", "workspace", @@ -11839,6 +11859,7 @@ dependencies = [ "settings", "smol", "theme", + "theme_settings", "ui", "util", "workspace", @@ -11871,6 +11892,7 @@ dependencies = [ "smallvec", "smol", "theme", + "theme_settings", "ui", "util", "workspace", @@ -12736,6 +12758,7 @@ dependencies = [ "serde", "settings", "theme", + "theme_settings", "ui", "ui_input", "workspace", @@ -12844,6 +12867,7 @@ dependencies = [ "settings", "smallvec", "theme", + "theme_settings", "ui", "windows 0.61.3", "workspace", @@ -13342,6 +13366,7 @@ dependencies = [ "telemetry", "tempfile", "theme", + "theme_settings", "ui", "util", "workspace", @@ -13368,6 +13393,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "util", "workspace", ] @@ -14360,7 +14386,7 @@ dependencies = [ "remote", "semver", "settings", - "theme", + "theme_settings", "ui", "ui_input", "workspace", @@ -14428,6 +14454,7 @@ dependencies = [ "sysinfo 0.37.2", "task", "theme", + "theme_settings", "thiserror 2.0.17", "toml 0.8.23", "unindent", @@ -14498,6 +14525,7 @@ dependencies = [ "terminal", "terminal_view", "theme", + "theme_settings", "tree-sitter-md", "tree-sitter-python", "tree-sitter-typescript", @@ -14837,7 +14865,7 @@ dependencies = [ "rope", "serde", "settings", - "theme", + "theme_settings", "ui", "ui_input", "util", @@ -15251,6 +15279,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", ] [[package]] @@ -15474,6 +15503,7 @@ dependencies = [ "settings", "smol", "theme", + "theme_settings", "tracing", "ui", "unindent", @@ -15818,6 +15848,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "ui", "workspace", "zed_actions", @@ -15866,6 +15897,7 @@ dependencies = [ "strum 0.27.2", "telemetry", "theme", + "theme_settings", "title_bar", "ui", "util", @@ -16001,6 +16033,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "ui", "util", "vim_mode_setting", @@ -16656,6 +16689,7 @@ dependencies = [ "story", "strum 0.27.2", "theme", + "theme_settings", "title_bar", "ui", ] @@ -17306,6 +17340,7 @@ dependencies = [ "settings", "smol", "theme", + "theme_settings", "ui", "util", "workspace", @@ -17490,6 +17525,7 @@ dependencies = [ "sysinfo 0.37.2", "task", "theme", + "theme_settings", "thiserror 2.0.17", "url", "urlencoding", @@ -17537,6 +17573,7 @@ dependencies = [ "task", "terminal", "theme", + "theme_settings", "ui", "util", "workspace", @@ -17572,8 +17609,6 @@ dependencies = [ "collections", "derive_more", "gpui", - "gpui_util", - "log", "palette", "parking_lot", "refineable", @@ -17581,7 +17616,6 @@ dependencies = [ "serde", "serde_json", "serde_json_lenient", - "settings", "strum 0.27.2", "thiserror 2.0.17", "uuid", @@ -17596,6 +17630,7 @@ dependencies = [ "fs", "gpui", "theme", + "theme_settings", ] [[package]] @@ -17615,6 +17650,7 @@ dependencies = [ "simplelog", "strum 0.27.2", "theme", + "theme_settings", "vscode_theme", ] @@ -17631,12 +17667,33 @@ dependencies = [ "settings", "telemetry", "theme", + "theme_settings", "ui", "util", "workspace", "zed_actions", ] +[[package]] +name = "theme_settings" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "gpui", + "gpui_util", + "log", + "palette", + "refineable", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "settings", + "theme", + "uuid", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -18780,6 +18837,7 @@ dependencies = [ "story", "strum 0.27.2", "theme", + "theme_settings", "ui_macros", "windows 0.61.3", ] @@ -18811,7 +18869,7 @@ dependencies = [ "markdown", "menu", "settings", - "theme", + "theme_settings", "ui", "workspace", ] @@ -19210,6 +19268,7 @@ dependencies = [ "task", "text", "theme", + "theme_settings", "tokio", "ui", "util", @@ -20337,7 +20396,7 @@ dependencies = [ "gpui", "serde", "settings", - "theme", + "theme_settings", "ui", "util", "workspace", @@ -21556,6 +21615,7 @@ dependencies = [ "telemetry", "tempfile", "theme", + "theme_settings", "ui", "util", "uuid", @@ -22117,6 +22177,7 @@ dependencies = [ "theme", "theme_extension", "theme_selector", + "theme_settings", "time", "time_format", "title_bar", diff --git a/Cargo.toml b/Cargo.toml index 922a88056c81dc23eaf187e8d4a70dd856611d0a..57466b06e99198f60cd2d62f375e56cef0fe006a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -198,6 +198,7 @@ members = [ "crates/text", "crates/theme", "crates/theme_extension", + "crates/theme_settings", "crates/theme_importer", "crates/theme_selector", "crates/time_format", @@ -445,6 +446,7 @@ terminal_view = { path = "crates/terminal_view" } text = { path = "crates/text" } theme = { path = "crates/theme" } theme_extension = { path = "crates/theme_extension" } +theme_settings = { path = "crates/theme_settings" } theme_selector = { path = "crates/theme_selector" } time_format = { path = "crates/time_format" } platform_title_bar = { path = "crates/platform_title_bar" } diff --git a/crates/acp_tools/Cargo.toml b/crates/acp_tools/Cargo.toml index 0720c4b6685ecf7fa20d8cacd2b61baa765c961c..8f14b1f93b32c6df521ea13ebf3f0f73e7ed755c 100644 --- a/crates/acp_tools/Cargo.toml +++ b/crates/acp_tools/Cargo.toml @@ -23,7 +23,7 @@ project.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index 78c873c3a1a12c1f24a2c64e96ce1d1801bc4eb9..52a9d03f893d0b82bf6395b4c96bc9ebe14d3afe 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -16,7 +16,7 @@ use language::LanguageRegistry; use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; use project::{AgentId, Project}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{CopyButton, Tooltip, WithScrollbar, prelude::*}; use util::ResultExt as _; use workspace::{ diff --git a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs index 6f9ad092d428389f0d83383060010446dd2c2dff..198ab45b13faef814e5964892e02e4c9d60de5b0 100644 --- a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs +++ b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs @@ -7837,7 +7837,7 @@ impl Editor { h_flex() .px_0p5() .when(is_platform_style_mac, |parent| parent.gap_0p5()) - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .text_size(TextSize::XSmall.rems(cx)) .child(h_flex().children(ui::render_modifiers( &accept_keystroke.modifiers, @@ -8149,7 +8149,7 @@ impl Editor { .px_2() .child( h_flex() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .when(is_platform_style_mac, |parent| parent.gap_1()) .child(h_flex().children(ui::render_modifiers( &accept_keystroke.modifiers, @@ -8258,7 +8258,7 @@ impl Editor { .gap_2() .pr_1() .overflow_x_hidden() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .child(left) .child(preview), ) diff --git a/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/before.rs b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/before.rs index 607daa8ce3a129e0f4bc53a00d1a62f479da3932..bdf160d8ffe2c605a9e995d6efe7227dce34eaab 100644 --- a/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/before.rs +++ b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/before.rs @@ -7837,7 +7837,7 @@ impl Editor { h_flex() .px_0p5() .when(is_platform_style_mac, |parent| parent.gap_0p5()) - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .text_size(TextSize::XSmall.rems(cx)) .child(h_flex().children(ui::render_modifiers( &accept_keystroke.modifiers, @@ -8149,7 +8149,7 @@ impl Editor { .px_2() .child( h_flex() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .when(is_platform_style_mac, |parent| parent.gap_1()) .child(h_flex().children(ui::render_modifiers( &accept_keystroke.modifiers, @@ -8258,7 +8258,7 @@ impl Editor { .gap_2() .pr_1() .overflow_x_hidden() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .child(left) .child(preview), ) diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 245a5f5472b47db5660216d6d9147b89d865e61f..0d95aa9bc8d69e1197b35ebb7268ba0020aea3af 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -101,6 +101,7 @@ terminal.workspace = true terminal_view.workspace = true text.workspace = true theme.workspace = true +theme_settings.workspace = true time.workspace = true time_format.workspace = true ui.workspace = true diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 334aaf4026527938144cf12e25c9a7a23d5c28ac..4e3dd63b0337f9be54b550f4f4a6a5ca2e7cdd42 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -813,7 +813,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); language_model::init_settings(cx); editor::init(cx); diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index e550d59c0ccb4deab40f6fcbc39dae124e3c08db..9c44288e1cd23cd3bb0d6876f086c3f0e89dc4c7 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -22,7 +22,7 @@ use project::{ use serde::Deserialize; use settings::{Settings as _, update_settings_file}; use std::sync::Arc; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, WithScrollbar, prelude::*, diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 44b706bbe705ea9368c79fb774bd171f6220c70b..541199028b1becade3b9891114a89e69152fcb02 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1806,7 +1806,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); prompt_store::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); language_model::init_settings(cx); }); @@ -1963,7 +1963,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); prompt_store::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); language_model::init_settings(cx); workspace::register_project_item::(cx); }); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index ff819b3730bdaf8dc89d5c40e5fdad04b3342496..67ddd5a09c14c36f06f2dd19abd486d7c346892e 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -76,7 +76,7 @@ use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize, @@ -1624,17 +1624,17 @@ impl AgentPanel { let agent_buffer_font_size = ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta; - let _ = settings - .theme - .agent_ui_font_size - .insert(f32::from(theme::clamp_font_size(agent_ui_font_size)).into()); + let _ = settings.theme.agent_ui_font_size.insert( + f32::from(theme_settings::clamp_font_size(agent_ui_font_size)).into(), + ); let _ = settings.theme.agent_buffer_font_size.insert( - f32::from(theme::clamp_font_size(agent_buffer_font_size)).into(), + f32::from(theme_settings::clamp_font_size(agent_buffer_font_size)) + .into(), ); }); } else { - theme::adjust_agent_ui_font_size(cx, |size| size + delta); - theme::adjust_agent_buffer_font_size(cx, |size| size + delta); + theme_settings::adjust_agent_ui_font_size(cx, |size| size + delta); + theme_settings::adjust_agent_buffer_font_size(cx, |size| size + delta); } } WhichFontSize::BufferFont => { @@ -1658,14 +1658,14 @@ impl AgentPanel { settings.theme.agent_buffer_font_size = None; }); } else { - theme::reset_agent_ui_font_size(cx); - theme::reset_agent_buffer_font_size(cx); + theme_settings::reset_agent_ui_font_size(cx); + theme_settings::reset_agent_buffer_font_size(cx); } } pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context) { - theme::reset_agent_ui_font_size(cx); - theme::reset_agent_buffer_font_size(cx); + theme_settings::reset_agent_ui_font_size(cx); + theme_settings::reset_agent_buffer_font_size(cx); } pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context) { diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs index 6e8f9ddee30b1a72c1c5daee32fda24042ff7df7..78b4e3a5a3965c72b96d4ec201139b1d8e510fb2 100644 --- a/crates/agent_ui/src/agent_registry_ui.rs +++ b/crates/agent_ui/src/agent_registry_ui.rs @@ -12,7 +12,7 @@ use gpui::{ use project::agent_server_store::{AllAgentServersSettings, CustomAgentServerSettings}; use project::{AgentRegistryStore, RegistryAgent}; use settings::{Settings, SettingsStore, update_settings_file}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ButtonStyle, ScrollableHandle, ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar, prelude::*, diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 5597aec7a66edd8561caa4ac53f672d5bd2a33ab..b6be6502b152847822a79bc8c486195345c0a195 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -2577,7 +2577,7 @@ mod tests { let app_state = cx.update(|cx| { let state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); state }); diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 544af06eaf8dae68fcdaa293b1e4b9e940c31baa..3fabb528315f8f32c03d358c13d123e5bb299fd7 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -49,7 +49,7 @@ use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; use terminal_view::terminal_panel::TerminalPanel; use text::Anchor; -use theme::AgentFontSize; +use theme_settings::AgentFontSize; use ui::{ Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, DecoratedIcon, DiffStat, Disclosure, Divider, DividerColor, IconDecoration, IconDecorationKind, @@ -4257,7 +4257,7 @@ pub(crate) mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); SidebarThreadMetadataStore::init_global(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); agent_panel::init(cx); release_channel::init(semver::Version::new(0, 0, 0), cx); diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index dfa76e3716f0b938e8ff53e0799c12dd1a657a88..eeaf8f6935a2294d8d9a1fe71b8d8acd62ee43a2 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -16,7 +16,7 @@ use prompt_store::PromptStore; use rope::Point; use settings::Settings as _; use terminal_view::TerminalView; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{Context, TextSize}; use workspace::Workspace; @@ -594,7 +594,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); }); } diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 43e6b1ad393a8ca1d568bc8dc8df6b0fa9d977db..5d168d410476b1a367042d886715c2d57d50477e 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -24,7 +24,7 @@ use std::cmp; use std::ops::Range; use std::rc::Rc; use std::sync::Arc; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::utils::WithRemSize; use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*}; use uuid::Uuid; diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 97adcb6e39092a892c5e56429b9d446f5ee0be68..b8e16de99f13d9eb6925e5618ccca81c742f8d12 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -667,7 +667,7 @@ mod tests { let settings_store = cx.update(SettingsStore::test); cx.set_global(settings_store); cx.update(|cx| { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(Version::new(0, 0, 0), cx); prompt_store::init(cx); }); diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index a82b5c26fe003e01a58358cf75f6a00e7a983b40..44a816f894f791f8b9f3b4753deef7028fae20ab 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -33,7 +33,7 @@ use prompt_store::PromptStore; use rope::Point; use settings::Settings; use std::{fmt::Write, ops::Range, rc::Rc, sync::Arc}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ContextMenu, Disclosure, ElevationIndex, prelude::*}; use util::paths::PathStyle; use util::{ResultExt, debug_panic}; diff --git a/crates/agent_ui/src/test_support.rs b/crates/agent_ui/src/test_support.rs index 375d54263780f8c464904b4c56ab9aeb490a9193..94502485b1f3a2bb6a6d88ccd897de56c5a566f5 100644 --- a/crates/agent_ui/src/test_support.rs +++ b/crates/agent_ui/src/test_support.rs @@ -73,7 +73,7 @@ pub fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); release_channel::init("0.0.0".parse().unwrap(), cx); agent_panel::init(cx); diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 14aec38e481768b5482a2cbf67df3d59304a915c..180a31edde29b7ef78ee263a437458abd5affafc 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -1030,7 +1030,11 @@ impl TextThreadEditor { h_flex() .items_center() .gap_1() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font( + theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(), + ) .text_size(TextSize::XSmall.rems(cx)) .text_color(colors.text_muted) .child("Press") @@ -3440,7 +3444,7 @@ mod tests { LanguageModelRegistry::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); } #[gpui::test] diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs index d2aa38e13ceae1d088e8b078ce741c42f4c31206..7b7a3e60211896bf717fb3dfb2670d92b7409281 100644 --- a/crates/agent_ui/src/thread_history.rs +++ b/crates/agent_ui/src/thread_history.rs @@ -232,7 +232,7 @@ mod tests { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/agent_ui/src/ui/agent_notification.rs b/crates/agent_ui/src/ui/agent_notification.rs index 371523f129869786f13d1a220747f4d0d944d1e5..18a4161f1df99988177462059870234f81e48b5c 100644 --- a/crates/agent_ui/src/ui/agent_notification.rs +++ b/crates/agent_ui/src/ui/agent_notification.rs @@ -5,7 +5,6 @@ use gpui::{ }; use release_channel::ReleaseChannel; use std::rc::Rc; -use theme; use ui::{Render, prelude::*}; pub struct AgentNotification { @@ -87,7 +86,7 @@ impl AgentNotification { impl Render for AgentNotification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let line_height = window.line_height(); let bg = cx.theme().colors().elevated_surface_background; diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index b70b77e6ca603aba8fd55706918ffb3543e2a734..91200684d7ca1891578bb70fd6db65b2885aed93 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -9,7 +9,7 @@ use gpui::{ use prompt_store::PromptId; use rope::Point; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ButtonLike, TintColor, Tooltip, prelude::*}; use workspace::{OpenOptions, Workspace}; diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 447c2da08e054c9964f3813ac569964173ded5c3..41f1ba2c14c6c09bcbf6861674a845b3954aa733 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -130,6 +130,7 @@ settings = { workspace = true, features = ["test-support"] } smol.workspace = true sqlx = { version = "0.8", features = ["sqlite"] } task.workspace = true +theme_settings = { workspace = true, features = ["test-support"] } theme.workspace = true unindent.workspace = true diff --git a/crates/collab/tests/integration/randomized_test_helpers.rs b/crates/collab/tests/integration/randomized_test_helpers.rs index a6772019768ba19e2a92843a1e33b256f0eb8b0c..0a2555929a959fe04735ad7cb03595eea56c5cf5 100644 --- a/crates/collab/tests/integration/randomized_test_helpers.rs +++ b/crates/collab/tests/integration/randomized_test_helpers.rs @@ -191,7 +191,7 @@ pub async fn run_randomized_test( let settings = cx.remove_global::(); cx.clear_globals(); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); drop(client); }); executor.run_until_parked(); diff --git a/crates/collab/tests/integration/test_server.rs b/crates/collab/tests/integration/test_server.rs index 7472bd01173eca007eb762bf6e7920c55489ae7d..cca48bea973f178000d24bddcbb73252c5657b53 100644 --- a/crates/collab/tests/integration/test_server.rs +++ b/crates/collab/tests/integration/test_server.rs @@ -173,7 +173,7 @@ impl TestServer { } let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); }); @@ -341,7 +341,7 @@ impl TestServer { let os_keymap = "keymaps/default-macos.json"; cx.update(|cx| { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); Project::init(&client, cx); client::init(&client, cx); editor::init(cx); diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 498f3f0bd76e002797389a279a17849448e6e873..efcba05456955e308e5a00e938bf3092d894efeb 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -54,6 +54,7 @@ settings.workspace = true smallvec.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true time.workspace = true time_format.workspace = true title_bar.workspace = true diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index ae5d4f0b13f26f5c0d06af4d46eb4355bda1f4dc..328a97ce3296aefbabc284b91da62530b0106359 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -29,7 +29,8 @@ use serde::{Deserialize, Serialize}; use settings::Settings; use smallvec::SmallVec; use std::{mem, sync::Arc}; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; +use theme_settings::ThemeSettings; use ui::{ Avatar, AvatarAvailabilityIndicator, ContextMenu, CopyButton, Facepile, HighlightedLabel, IconButtonShape, Indicator, ListHeader, ListItem, Tab, Tooltip, prelude::*, tooltip_container, diff --git a/crates/collab_ui/src/notifications/incoming_call_notification.rs b/crates/collab_ui/src/notifications/incoming_call_notification.rs index 164b91395a8853c330e2f7842b5676fff0916e63..71940794f4180e18d54a8b2ff258d37642c1e83b 100644 --- a/crates/collab_ui/src/notifications/incoming_call_notification.rs +++ b/crates/collab_ui/src/notifications/incoming_call_notification.rs @@ -111,7 +111,7 @@ impl IncomingCallNotification { impl Render for IncomingCallNotification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); div().size_full().font(ui_font).child( CollabNotification::new( diff --git a/crates/collab_ui/src/notifications/project_shared_notification.rs b/crates/collab_ui/src/notifications/project_shared_notification.rs index 165e46458438850f872794d057c17faee86775e2..3c231c5397af23656cc914e71269bdfff52d4af1 100644 --- a/crates/collab_ui/src/notifications/project_shared_notification.rs +++ b/crates/collab_ui/src/notifications/project_shared_notification.rs @@ -120,7 +120,7 @@ impl ProjectSharedNotification { impl Render for ProjectSharedNotification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let no_worktree_root_names = self.worktree_root_names.is_empty(); let punctuation = if no_worktree_root_names { "" } else { ":" }; diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml index 96be6cb9ee2b767bc14503cbae7e2de6838e6724..df9da6f67e5c2c2e7d91b2ece0245c352e4190b7 100644 --- a/crates/command_palette/Cargo.toml +++ b/crates/command_palette/Cargo.toml @@ -49,3 +49,4 @@ menu.workspace = true project = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true \ No newline at end of file diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 579946f30d88db379f6649fd65b13d7d291e19de..90ed7d0d3518aa4f6d49bb4cc18cbf3c275ce7c5 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -931,7 +931,7 @@ mod tests { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let app_state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); menu::init(); go_to_line::init(cx); diff --git a/crates/component_preview/Cargo.toml b/crates/component_preview/Cargo.toml index 3bfbdcf2979ebca34a80c9d8703813c40a20387b..4a3cde33631e2da7839d93267de0afe94a7a62c7 100644 --- a/crates/component_preview/Cargo.toml +++ b/crates/component_preview/Cargo.toml @@ -33,6 +33,7 @@ reqwest_client.workspace = true session.workspace = true settings.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true ui_input.workspace = true uuid.workspace = true diff --git a/crates/component_preview/examples/component_preview.rs b/crates/component_preview/examples/component_preview.rs index 99222a9ffd47222eb11375b2277bd7ee4e6c7a94..463eb976b667012f58186e385f719b27c4cd2702 100644 --- a/crates/component_preview/examples/component_preview.rs +++ b/crates/component_preview/examples/component_preview.rs @@ -39,7 +39,7 @@ fn main() { ::set_global(fs.clone(), cx); settings::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); let languages = Arc::new(LanguageRegistry::new(cx.background_executor().clone())); let client = Client::production(cx); @@ -81,7 +81,7 @@ fn main() { { move |window, cx| { let app_state = app_state; - theme::setup_ui_font(window, cx); + theme_settings::setup_ui_font(window, cx); let project = Project::local( app_state.client.clone(), diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index d625c998b034a249cb3f498ae1fdd4e0e179a4cc..4d2ffde10c783d4fdbbad29b1fcd497cdfc30ced 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -68,3 +68,4 @@ settings = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } zlog.workspace = true +theme_settings.workspace = true diff --git a/crates/copilot/src/copilot_edit_prediction_delegate.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs index 2d5b387479f380f66519a07468a88929d1c5cc55..6f69bc6bc7bea4ec31aa59262a4abc5640999a2e 100644 --- a/crates/copilot/src/copilot_edit_prediction_delegate.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -1120,7 +1120,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { store.update_user_settings(cx, |settings| f(&mut settings.project.all_languages)); }); diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml index f95712b05129b7f86699f658c4c2c3effbd7d216..ba98df3e3764f773d490b76d6a5912bab5e4adbe 100644 --- a/crates/debugger_ui/Cargo.toml +++ b/crates/debugger_ui/Cargo.toml @@ -67,6 +67,7 @@ tasks_ui.workspace = true terminal_view.workspace = true text.workspace = true theme.workspace = true +theme_settings.workspace = true tree-sitter-json.workspace = true tree-sitter.workspace = true ui.workspace = true diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index e33efd2c4904fe83cbbffb9ae57aadfbfc6d5470..c488e88d74e7f282bd0424a2213e08e2c9bec15f 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -26,7 +26,8 @@ use project::{ use settings::Settings; use std::fmt::Write; use std::{ops::Range, rc::Rc, usize}; -use theme::{Theme, ThemeSettings}; +use theme::Theme; +use theme_settings::ThemeSettings; use ui::{ContextMenu, Divider, PopoverMenu, SplitButton, Tooltip, prelude::*}; use util::ResultExt; diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index 69ea556018fdadeb1e270b1d7c2520d25752e670..ebcabe210f8ee78af750793b36edd256ddbf984e 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -17,7 +17,7 @@ use gpui::{ use notifications::status_toast::{StatusToast, ToastIcon}; use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session::Session}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ContextMenu, Divider, DropdownMenu, FluentBuilder, IntoElement, PopoverMenuHandle, Render, ScrollableHandle, StatefulInteractiveElement, Tooltip, WithScrollbar, prelude::*, diff --git a/crates/debugger_ui/src/tests.rs b/crates/debugger_ui/src/tests.rs index cc407dfd810ceedb11c4d8030c46a6f17065b34b..4b4cebb2931d0b47e8bc18bd1f79f823528b416b 100644 --- a/crates/debugger_ui/src/tests.rs +++ b/crates/debugger_ui/src/tests.rs @@ -41,7 +41,7 @@ pub fn init_test(cx: &mut gpui::TestAppContext) { let settings = SettingsStore::test(cx); cx.set_global(settings); terminal_view::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); command_palette_hooks::init(cx); editor::init(cx); crate::init(cx); diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml index 09ee023d57fbb9b9f2c7d828f9b2ea25f73d23d9..6a19e7e40e0ce91cfb78ca44c5c5e7f74205106f 100644 --- a/crates/diagnostics/Cargo.toml +++ b/crates/diagnostics/Cargo.toml @@ -32,6 +32,7 @@ serde_json.workspace = true settings.workspace = true text.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index 89cebf8fb237a032866e14c36d3097e18388e6ab..27e1cbbac9c779056ecd9da00dd7a56ff3536f17 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -11,7 +11,7 @@ use lsp::DiagnosticSeverity; use markdown::{Markdown, MarkdownElement}; use settings::Settings; use text::{AnchorRangeExt, Point}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{CopyButton, prelude::*}; use util::maybe; diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index 06b71a583f5d02a103db69e17d4e2db48c98a415..527f5b5bfcbfa2350233f9f3a119e56e4f72b9a5 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -2034,7 +2034,7 @@ fn init_test(cx: &mut TestAppContext) { zlog::init_test(); let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); editor::init(cx); }); diff --git a/crates/edit_prediction_ui/Cargo.toml b/crates/edit_prediction_ui/Cargo.toml index b6b6473bafa0222a670e1c541e03d255ee0d2d5a..29c53bbaf8c82f3b0c2769af80d44f17250a0506 100644 --- a/crates/edit_prediction_ui/Cargo.toml +++ b/crates/edit_prediction_ui/Cargo.toml @@ -42,7 +42,7 @@ regex.workspace = true settings.workspace = true telemetry.workspace = true text.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index 15cccc777feb0a999724f2b4405fc11df8c5f252..1fb6c36bc9503e0a2fea7b3f77d1515747d1363c 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -14,7 +14,7 @@ use project::{ use settings::Settings as _; use std::rc::Rc; use std::{fmt::Write, sync::Arc}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ContextMenu, DropdownMenu, KeyBinding, List, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*, diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 22a9b8effbe52caa67812619d254076493210e68..1b2e32f19896df4863d6fd12d02b5eea6579bc97 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -83,6 +83,7 @@ telemetry.workspace = true text.workspace = true time.workspace = true theme.workspace = true +theme_settings.workspace = true tree-sitter-c = { workspace = true, optional = true } tree-sitter-html = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } diff --git a/crates/editor/benches/editor_render.rs b/crates/editor/benches/editor_render.rs index f527ddea45574720e7f86a177333f7e3ab3b919f..e93c94e1ae6e6cd44a65537172ffafe48455f3a3 100644 --- a/crates/editor/benches/editor_render.rs +++ b/crates/editor/benches/editor_render.rs @@ -122,7 +122,7 @@ pub fn benches() { let store = SettingsStore::test(cx); cx.set_global(store); assets::Assets.load_test_fonts(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); // release_channel::init(semver::Version::new(0,0,0), cx); editor::init(cx); }); diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index 5d0e7311a3d2908f498774fde81d660dd5450123..0c9fa29ae6a19ad81ec265cc832a5d3ec15cec51 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -226,7 +226,7 @@ mod tests { use serde_json::json; use settings::{AccentContent, SettingsStore}; use text::{Bias, OffsetRangeExt, ToOffset}; - use theme::ThemeStyleContent; + use theme_settings::ThemeStyleContent; use util::{path, post_inc}; diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index b9fa0a49b1b77f9e5fcf4ace7d83155628afba20..933f0e6e18e57c38b6bcc3636f60bd1ae671d3a6 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -4036,7 +4036,7 @@ pub mod tests { let settings = SettingsStore::test(cx); cx.set_global(settings); crate::init(cx); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); cx.update_global::(|store, cx| { store.update_user_settings(cx, f); }); diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index d45165660d92170ecc176ebd8e038b890933bd57..531de6da49e375a4f7ba2833106e1716de551ff2 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -4830,7 +4830,7 @@ mod tests { fn init_test(cx: &mut gpui::App) { let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); assets::Assets.load_test_fonts(cx); } diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index efb7abad6a169546c0d13de29870f939ced93eaa..95479e297cb82adcf8c3eb1f73e95f8b557eef43 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -57,7 +57,8 @@ impl FoldPlaceholder { pub fn fold_element(fold_id: FoldId, cx: &App) -> Stateful { use gpui::{InteractiveElement as _, StatefulInteractiveElement as _, Styled as _}; use settings::Settings as _; - use theme::{ActiveTheme as _, ThemeSettings}; + use theme::ActiveTheme as _; + use theme_settings::ThemeSettings; let settings = ThemeSettings::get_global(cx); gpui::div() .id(fold_id) diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 122ca6f698115c2f5e6c194246f6a378825e5675..9c05a182ef56eb803ff545a1c9d3914b505767aa 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -2227,7 +2227,7 @@ mod tests { fn init_test(cx: &mut App) { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); } /// Helper to create test highlights for an inlay diff --git a/crates/editor/src/display_map/wrap_map.rs b/crates/editor/src/display_map/wrap_map.rs index 650ee99918e9c9f7a95a367db7e4d4f01b02d6ed..d21642977ed923e15a583dfe767fd566e78c5de9 100644 --- a/crates/editor/src/display_map/wrap_map.rs +++ b/crates/editor/src/display_map/wrap_map.rs @@ -1664,7 +1664,7 @@ mod tests { cx.update(|cx| { let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); }); } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8379191e14c69593430133499eded32da94f8c62..3ebfb52637107e95729d4be84145e7334411ce82 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -204,8 +204,8 @@ use task::TaskVariables; use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _, ToPoint as _}; use theme::{ AccentColors, ActiveTheme, GlobalTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, - ThemeSettings, observe_buffer_font_size_adjustment, }; +use theme_settings::{ThemeSettings, observe_buffer_font_size_adjustment}; use ui::{ Avatar, ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName, IconSize, Indicator, Key, Tooltip, h_flex, prelude::*, scrollbars::ScrollbarAutoHide, @@ -9936,7 +9936,11 @@ impl Editor { h_flex() .px_0p5() .when(is_platform_style_mac, |parent| parent.gap_0p5()) - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font( + theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(), + ) .text_size(TextSize::XSmall.rems(cx)) .child(h_flex().children(ui::render_modifiers( keystroke.modifiers(), @@ -9967,7 +9971,11 @@ impl Editor { if keystroke.modifiers().modified() { h_flex() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font( + theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(), + ) .when(is_platform_style_mac, |parent| parent.gap_1()) .child(h_flex().children(ui::render_modifiers( keystroke.modifiers(), @@ -10473,7 +10481,11 @@ impl Editor { .gap_2() .pr_1() .overflow_x_hidden() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font( + theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(), + ) .child(left) .child(preview), ) @@ -24446,7 +24458,7 @@ impl Editor { return None; } - let theme_settings = theme::ThemeSettings::get_global(cx); + let theme_settings = theme_settings::ThemeSettings::get_global(cx); let theme = cx.theme(); let accent_colors = theme.accents().clone(); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 79f470d42496c275f3dd729aa9d21cf7cd84c168..2a0d2fbfe0199126cd8b86c016e5ffbbdbdb9ae3 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -29721,7 +29721,7 @@ pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsC assets::Assets.load_test_fonts(cx); let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); crate::init(cx); }); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 59b474b1c91c0ad62eb9c260facb2ab46ef4f9c6..285a1cf6fbb7bbcdde27e2258ee8c936711dcb14 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -83,7 +83,8 @@ use std::{ }; use sum_tree::Bias; use text::{BufferId, SelectionGoal}; -use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor}; +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::*, @@ -8448,7 +8449,7 @@ pub(crate) fn render_buffer_header( el.child(Icon::new(IconName::FileLock).color(Color::Muted)) }) .when_some(breadcrumbs, |then, breadcrumbs| { - let font = theme::ThemeSettings::get_global(cx) + let font = theme_settings::ThemeSettings::get_global(cx) .buffer_font .clone(); then.child(render_breadcrumb_text( diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index c705eb3996ace228f915303f049853bb2364aa2e..827d182a0f11508ae301691f832e7ec04a728364 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -746,7 +746,7 @@ mod tests { let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); }); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 99069cac6ceeec3983d6713777007876c74c8d19..791f0d67a14a949cd3eb916a2831f097ba320d91 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -26,7 +26,7 @@ use std::{ }; use std::{ops::Range, sync::Arc, time::Duration}; use std::{path::PathBuf, rc::Rc}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{CopyButton, Scrollbars, WithScrollbar, prelude::*, theme_is_transparent}; use url::Url; use util::TryFutureExt; diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs index 157de3c87d0c6a2f5fcde63ce89143fd8f2fb01b..8422937ab81a392ad7d1187adcab765cc7f6875f 100644 --- a/crates/editor/src/inlays/inlay_hints.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -4798,7 +4798,7 @@ let c = 3;"# cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); crate::init(cx); }); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 0cd84ec68257f7ab1e6054ab7f2464fb09113298..d14078e79abdbfe40879da09221bad7bef47475a 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -980,7 +980,9 @@ impl Item for Editor { // In a non-singleton case, the breadcrumbs are actually shown on sticky file headers of the multibuffer. fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { if self.buffer.read(cx).is_singleton() { - let font = theme::ThemeSettings::get_global(cx).buffer_font.clone(); + let font = theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(); Some((self.breadcrumbs_inner(cx)?, Some(font))) } else { None diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 6bf6449506f1c1eb2a71270546ad3b063f7e9022..955f511577d2cbfede1a4cb4eb6d99e429c879d6 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -1393,7 +1393,7 @@ mod tests { fn init_test(cx: &mut gpui::App) { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); } } diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index 1a895465277d02078f1bf23da21f061a94f94be7..8408438f17533098f906c75bcc03983edfb7acf8 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -1383,7 +1383,7 @@ mod tests { async fn test_theme_override_changes_restyle_semantic_tokens(cx: &mut TestAppContext) { use collections::IndexMap; use gpui::{Hsla, Rgba, UpdateGlobal as _}; - use theme::{HighlightStyleContent, ThemeStyleContent}; + use theme_settings::{HighlightStyleContent, ThemeStyleContent}; init_test(cx, |_| {}); @@ -1548,7 +1548,7 @@ mod tests { async fn test_per_theme_overrides_restyle_semantic_tokens(cx: &mut TestAppContext) { use collections::IndexMap; use gpui::{Hsla, Rgba, UpdateGlobal as _}; - use theme::{HighlightStyleContent, ThemeStyleContent}; + use theme_settings::{HighlightStyleContent, ThemeStyleContent}; use ui::ActiveTheme as _; init_test(cx, |_| {}); diff --git a/crates/editor/src/signature_help.rs b/crates/editor/src/signature_help.rs index 67f482339f501f46a4475bb9e9534437d9f9f1cf..27c26d4691686c16bcbafbf74bba6b5f1156b835 100644 --- a/crates/editor/src/signature_help.rs +++ b/crates/editor/src/signature_help.rs @@ -13,7 +13,7 @@ use settings::Settings; use std::ops::Range; use std::time::Duration; use text::Rope; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ActiveTheme, AnyElement, ButtonCommon, ButtonStyle, Clickable, FluentBuilder, IconButton, IconButtonShape, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index c9668bc35655dfcda62e71884a782b4edecae093..61f4526f3d4902f7972b65266de725818accef1e 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -2118,7 +2118,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); }); let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; diff --git a/crates/extension_cli/Cargo.toml b/crates/extension_cli/Cargo.toml index 24ea9cfafadc61b2753f7b739fd4b7cbbd24dbfe..c019a323196e96d0b7a0131cc518e599154cd350 100644 --- a/crates/extension_cli/Cargo.toml +++ b/crates/extension_cli/Cargo.toml @@ -29,7 +29,7 @@ serde_json_lenient.workspace = true settings_content.workspace = true snippet_provider.workspace = true task.workspace = true -theme.workspace = true +theme_settings.workspace = true tokio = { workspace = true, features = ["full"] } toml.workspace = true tree-sitter.workspace = true diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index 4d16cd4f1bba1df53c621bad2c15c01a1db4a533..57845754fc8263c516bc3aec7d1ae0a2ffe68a2f 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -413,7 +413,8 @@ async fn test_themes( ) -> Result<()> { for relative_theme_path in &manifest.themes { let theme_path = extension_path.join(relative_theme_path); - let theme_family = theme::deserialize_user_theme(&fs.load_bytes(&theme_path).await?)?; + let theme_family = + theme_settings::deserialize_user_theme(&fs.load_bytes(&theme_path).await?)?; log::info!("loaded theme family {}", theme_family.name); for theme in &theme_family.themes { diff --git a/crates/extension_host/Cargo.toml b/crates/extension_host/Cargo.toml index c6f4db47c97d69173242953926c6965c039a6397..8dd949844f03ed7d625a2374aaf99b7c38b6522f 100644 --- a/crates/extension_host/Cargo.toml +++ b/crates/extension_host/Cargo.toml @@ -68,6 +68,7 @@ project = { workspace = true, features = ["test-support"] } reqwest_client.workspace = true theme = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true theme_extension.workspace = true zlog.workspace = true diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index f1a209ca7af19589e897c42e9f5269abaa42725a..a2722da336b4d52a04a7d6da3c22347a3535bf2b 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -1007,7 +1007,7 @@ fn init_test(cx: &mut TestAppContext) { cx.set_global(store); release_channel::init(semver::Version::new(0, 0, 0), cx); extension::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); gpui_tokio::init(cx); }); } diff --git a/crates/extensions_ui/Cargo.toml b/crates/extensions_ui/Cargo.toml index a80defd128549e9f2ed6b634c188a7f2f319ef6a..6b6b6838313ecc8738df769609cf236e3f6e0bfb 100644 --- a/crates/extensions_ui/Cargo.toml +++ b/crates/extensions_ui/Cargo.toml @@ -35,7 +35,7 @@ settings.workspace = true smallvec.workspace = true strum.workspace = true telemetry.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true vim_mode_setting.workspace = true diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 2d0b151a107000e913ba4772d7d3d2bf50474fc1..fceae09e5a4fbe1116c73d6ff5ca8bf018480fd9 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -23,7 +23,7 @@ use project::DirectoryLister; use release_channel::ReleaseChannel; use settings::{Settings, SettingsContent}; use strum::IntoEnumIterator as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Banner, Chip, ContextMenu, Divider, PopoverMenu, ScrollableHandle, Switch, ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar, diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 80e466ac4c571ede217aa734a7862becd08e72ba..5eb36f0f5150263629b407dbe07dc73b6eff31cf 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -47,3 +47,4 @@ theme = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true remote_connection = { workspace = true, features = ["test-support"] } +theme_settings = { workspace = true, features = ["test-support"] } \ No newline at end of file diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 3f9d579b03c9aa2abeb408bdf6b77cf5e69de003..cd9cdeee1ff266717d380aeaecf7cbeb66ec8309 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -3789,7 +3789,7 @@ async fn open_queried_buffer( fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); super::init(cx); editor::init(cx); state diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml index 4756c55ac9232631a46056e252021a704d4a25b6..24f2a02fe3679b947fdd0c5328da45cb2d8f8ae1 100644 --- a/crates/git_graph/Cargo.toml +++ b/crates/git_graph/Cargo.toml @@ -32,6 +32,7 @@ project.workspace = true settings.workspace = true smallvec.workspace = true theme.workspace = true +theme_settings.workspace = true time.workspace = true ui.workspace = true workspace.workspace = true diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index aa53cd83e45b07cf94a6fc1b862b71053b92c81d..be813848db4268be17b5bb0bc4ae3e59bfa4ff3c 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -33,7 +33,8 @@ use std::{ sync::OnceLock, time::{Duration, Instant}, }; -use theme::{AccentColors, ThemeSettings}; +use theme::AccentColors; +use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, ScrollableHandle, @@ -2489,7 +2490,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 2525edd357143fe0514bd671953d171466ec9aa5..d95e25fbc7821d42fac4386b522c4effb9462715 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -56,6 +56,7 @@ smol.workspace = true strum.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true time.workspace = true time_format.workspace = true ui.workspace = true diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index c2d7333484224bbfbc248e25fb2ac51a19f428e2..47d781c4870ade9688b93b75db5a68dd26865ca8 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -11,7 +11,7 @@ use gpui::{ use markdown::{Markdown, MarkdownElement}; use project::{git_store::Repository, project_settings::ProjectSettings}; use settings::Settings as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ContextMenu, CopyButton, Divider, prelude::*, tooltip_container}; use workspace::Workspace; diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index ce1504dd60f0ea1a18c6cb827ee14a8f5b411695..438df6839949d46d3ba8e0509995beb1300b7c80 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -1325,7 +1325,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); }); } diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 4740e148099980a7510a1f551d0d3f51c08892a1..b22fcee7e2de5273983b6959f8c52511b877eeaf 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -12,7 +12,7 @@ use markdown::{Markdown, MarkdownElement}; use project::git_store::Repository; use settings::Settings; use std::hash::Hash; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset}; use ui::{Avatar, CopyButton, Divider, prelude::*, tooltip_container}; use workspace::Workspace; diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index bdd5dee36e2d54888d081cfefed21602ecb8fa1b..6fe3d9484b4b6aca72f39ab5672e24e1430114ec 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -379,7 +379,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 00b287f7f3d724e0fcc5275ea302c44983c9a61b..00058ee82bb8df15e63d96771f481833310f09f5 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -66,7 +66,7 @@ use std::ops::Range; use std::path::Path; use std::{sync::Arc, time::Duration, usize}; use strum::{IntoEnumIterator, VariantNames}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IndentGuideColors, @@ -6491,7 +6491,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); }); diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 6d8b91cc54cc4baeb4fdda594404e04181fe6cf4..4d3fa23d9e41d9d04688d683a1b11caf6035147d 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -1777,7 +1777,7 @@ mod tests { settings.editor.diff_view_style = Some(DiffViewStyle::Unified); }); }); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); }); diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index 8947ed233790ab65557e13518d51bd383fc93c2d..9987190f45b73f3f1132ce1295de6f412022abe2 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -632,7 +632,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); }) } diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 965f41030817d3b7434a6fd02fb3a2de18046823..2dfef13f72681456174737af61380b87caae0ae1 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -499,7 +499,7 @@ mod tests { settings.editor.diff_view_style = Some(DiffViewStyle::Unified); }); }); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/image_viewer/Cargo.toml b/crates/image_viewer/Cargo.toml index 92386e8ba8a38f79711ee50343a6e7cf4a393cbd..8d9df8c9edd194f43c3cd4c157f6c7fecc494de4 100644 --- a/crates/image_viewer/Cargo.toml +++ b/crates/image_viewer/Cargo.toml @@ -26,7 +26,7 @@ log.workspace = true project.workspace = true serde.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 8d619c82dfdac660a10210e375a8edf9bb97eee9..93729559035437f58f30abae0e5a22a7a514967a 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -19,7 +19,7 @@ use language::File as _; use persistence::ImageViewerDb; use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{Tooltip, prelude::*}; use util::paths::PathExt; use workspace::{ diff --git a/crates/inspector_ui/Cargo.toml b/crates/inspector_ui/Cargo.toml index 53d2f74b9c663496da083152ead17d479f5030eb..ec1f01195c82366a48a1ffa46397c6ce91ea6339 100644 --- a/crates/inspector_ui/Cargo.toml +++ b/crates/inspector_ui/Cargo.toml @@ -21,7 +21,7 @@ language.workspace = true project.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true util_macros.workspace = true diff --git a/crates/inspector_ui/src/inspector.rs b/crates/inspector_ui/src/inspector.rs index 3c90bd7d6c6d550140df85c4c7547bd5b5700149..b687ea70a57d0f1b8ea97e4767d98eb701b77080 100644 --- a/crates/inspector_ui/src/inspector.rs +++ b/crates/inspector_ui/src/inspector.rs @@ -57,7 +57,7 @@ fn render_inspector( window: &mut Window, cx: &mut Context, ) -> AnyElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let colors = cx.theme().colors(); let inspector_id = inspector.active_element_id(); let toolbar_height = platform_title_bar_height(window); diff --git a/crates/keymap_editor/Cargo.toml b/crates/keymap_editor/Cargo.toml index 33ba95ddd6d8df7efe2f551451af0340d83369c7..63bfba05d4e12251a9a267984dabc7420a8c7577 100644 --- a/crates/keymap_editor/Cargo.toml +++ b/crates/keymap_editor/Cargo.toml @@ -36,6 +36,7 @@ settings.workspace = true telemetry.workspace = true tempfile.workspace = true theme.workspace = true +theme_settings.workspace = true tree-sitter-json.workspace = true tree-sitter-rust.workspace = true ui_input.workspace = true diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 9f10967e72a6dbde8c97b42c465945386709d3ed..bc92763a770ce1f10a30851595a61c530f7482c2 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -3431,7 +3431,7 @@ impl ActionArgumentsEditor { impl Render for ActionArgumentsEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let settings = theme::ThemeSettings::get_global(cx); + let settings = theme_settings::ThemeSettings::get_global(cx); let colors = cx.theme().colors(); let border_color = if self.is_loading { diff --git a/crates/keymap_editor/src/ui_components/keystroke_input.rs b/crates/keymap_editor/src/ui_components/keystroke_input.rs index e1f20de587c274a164a96e3b8d7189a3710ff301..75cc2869c855283302e9e2ce57b9a511f8ba4d37 100644 --- a/crates/keymap_editor/src/ui_components/keystroke_input.rs +++ b/crates/keymap_editor/src/ui_components/keystroke_input.rs @@ -1115,7 +1115,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); let fs = FakeFs::new(cx.executor()); diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index cec6421c059335c62db9f8db4485eb939d46db01..1392ed63f64b7d3e3f6ebb9f629168f6096c5b61 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -102,6 +102,7 @@ unindent.workspace = true util = { workspace = true, features = ["test-support"] } zlog.workspace = true criterion.workspace = true +theme_settings.workspace = true [[bench]] name = "highlight_map" diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 9839c82e358682fdabcae95fea426d3b2d564969..9308ee6f0a0ee207b30be9e6fafa73ba9452d94c 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -3247,7 +3247,7 @@ fn test_undo_after_merge_into_base(cx: &mut TestAppContext) { async fn test_preview_edits(cx: &mut TestAppContext) { cx.update(|cx| { init_settings(cx, |_| {}); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); let insertion_style = HighlightStyle { diff --git a/crates/language_tools/Cargo.toml b/crates/language_tools/Cargo.toml index 1698c7294969d3d3a641f0eb4611153efb658c6d..26e230c1d92f674642eab125f62787a3c29a3665 100644 --- a/crates/language_tools/Cargo.toml +++ b/crates/language_tools/Cargo.toml @@ -44,4 +44,5 @@ release_channel.workspace = true gpui = { workspace = true, features = ["test-support"] } semver.workspace = true util = { workspace = true, features = ["test-support"] } -zlog.workspace = true \ No newline at end of file +zlog.workspace = true +theme_settings.workspace = true \ No newline at end of file diff --git a/crates/language_tools/src/lsp_log_view_tests.rs b/crates/language_tools/src/lsp_log_view_tests.rs index 0b4516f5d052260ac4274e9afe14d3bc1a5ef8ee..476f23ffd82c66a581587d8f8fb70c4192ab04e0 100644 --- a/crates/language_tools/src/lsp_log_view_tests.rs +++ b/crates/language_tools/src/lsp_log_view_tests.rs @@ -109,7 +109,7 @@ fn init_test(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); }); } diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index 18bba1fc64f193cf17be1a728fc533a6596296b1..be12bf2fe7f42e14c1723a8560a7f3c46ca83182 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -35,6 +35,7 @@ settings.workspace = true stacksafe.workspace = true sum_tree.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/markdown/examples/markdown.rs b/crates/markdown/examples/markdown.rs index aa132443ee69201f0f1eba7b69c9627aee8f27e7..26c45377c725ec4d6701e4cf177615e3de4aba7e 100644 --- a/crates/markdown/examples/markdown.rs +++ b/crates/markdown/examples/markdown.rs @@ -41,7 +41,7 @@ pub fn main() { cx.bind_keys([KeyBinding::new("cmd-c", markdown::Copy, None)]); let node_runtime = NodeRuntime::unavailable(); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); let fs = fs::FakeFs::new(cx.background_executor().clone()); let language_registry = LanguageRegistry::new(cx.background_executor().clone()); diff --git a/crates/markdown/examples/markdown_as_child.rs b/crates/markdown/examples/markdown_as_child.rs index b25b075dd3cb04ed949e1e30ed724c3b5f3088d1..38a4d2795f4ff97297c3d549f8de687827ff75ef 100644 --- a/crates/markdown/examples/markdown_as_child.rs +++ b/crates/markdown/examples/markdown_as_child.rs @@ -28,7 +28,7 @@ pub fn main() { let language_registry = Arc::new(LanguageRegistry::new(cx.background_executor().clone())); let fs = fs::FakeFs::new(cx.background_executor().clone()); languages::init(language_registry, fs, node_runtime, cx); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); Assets.load_fonts(cx).unwrap(); cx.activate(true); diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs index 6b52a98908ed8757986d7ca7f8778b330f97254f..56ab2db26b682e197c194157a87e646d9e55019d 100644 --- a/crates/markdown/src/html/html_rendering.rs +++ b/crates/markdown/src/html/html_rendering.rs @@ -505,7 +505,7 @@ mod tests { settings::init(cx); } if !cx.has_global::() { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); } }); } diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 7a8c50e0d0662e251173c2d433aee8ba2d5d3af7..7b95688df54610f92b6960d9afc3037bf484b8ed 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -16,7 +16,7 @@ use mermaid::{ }; pub use path_range::{LineCol, PathWithRange}; use settings::Settings as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::Checkbox; use ui::CopyButton; @@ -2677,7 +2677,7 @@ mod tests { settings::init(cx); } if !cx.has_global::() { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); } }); } diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs index 8b39f8c86c7b98c30c8879c362d036a333ad2c63..560a67787ff897c6f792c97fafdd9ed617c020e6 100644 --- a/crates/markdown/src/mermaid.rs +++ b/crates/markdown/src/mermaid.rs @@ -278,7 +278,7 @@ mod tests { settings::init(cx); } if !cx.has_global::() { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); } }); } diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 558b57b769953b572678c3d997ae771462f51896..19f1270bb91e8a7e9e660a62d8191a9d12b66641 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -22,7 +22,7 @@ language.workspace = true log.workspace = true markdown.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true urlencoding.workspace = true util.workspace = true diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 93ae57520d28e38d6ac843d33ab01581d3b8e890..0b9c63c3b16f5686afcfdafdba119ede8c37fe3f 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -16,7 +16,7 @@ use markdown::{ CodeBlockRenderer, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle, }; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{WithScrollbar, prelude::*}; use util::normalize_path; use workspace::item::{Item, ItemHandle}; diff --git a/crates/miniprofiler_ui/Cargo.toml b/crates/miniprofiler_ui/Cargo.toml index 3f48bdfe486da52fc0edb2a1b540b10375d4f995..a8041b8b37cb57148e6dcdcdb8df7f436e52701b 100644 --- a/crates/miniprofiler_ui/Cargo.toml +++ b/crates/miniprofiler_ui/Cargo.toml @@ -14,7 +14,7 @@ path = "src/miniprofiler_ui.rs" [dependencies] gpui.workspace = true rpc.workspace = true -theme.workspace = true +theme_settings.workspace = true zed_actions.workspace = true workspace.workspace = true util.workspace = true diff --git a/crates/miniprofiler_ui/src/miniprofiler_ui.rs b/crates/miniprofiler_ui/src/miniprofiler_ui.rs index 9ae0a33471d31f32852b4b376bbc71ff0911c60b..351d0a68e2660870923a561ac8989559dc9abd7a 100644 --- a/crates/miniprofiler_ui/src/miniprofiler_ui.rs +++ b/crates/miniprofiler_ui/src/miniprofiler_ui.rs @@ -456,7 +456,7 @@ impl Render for ProfilerWindow { window: &mut gpui::Window, cx: &mut gpui::Context, ) -> impl gpui::IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); if !self.paused { self.poll_timings(cx); window.request_animation_frame(); diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml index e5e5b5cac93aa4021f8933bd38f8711d53b89902..545a4b614160054186d4acf7bce17e36ac1cd4f1 100644 --- a/crates/onboarding/Cargo.toml +++ b/crates/onboarding/Cargo.toml @@ -32,6 +32,7 @@ serde.workspace = true settings.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true vim_mode_setting.workspace = true diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs index 7221d8104cbff2e1e0a8ebe265b419b1c725472d..b2e595b28a33ed4ee7f066c4d969baffdb2a081b 100644 --- a/crates/onboarding/src/basics_page.rs +++ b/crates/onboarding/src/basics_page.rs @@ -5,10 +5,8 @@ use fs::Fs; use gpui::{Action, App, IntoElement}; use project::project_settings::ProjectSettings; use settings::{BaseKeymap, Settings, update_settings_file}; -use theme::{ - Appearance, SystemAppearance, ThemeAppearanceMode, ThemeName, ThemeRegistry, ThemeSelection, - ThemeSettings, -}; +use theme::{Appearance, SystemAppearance, ThemeRegistry}; +use theme_settings::{ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings}; use ui::{ Divider, StatefulInteractiveElement, SwitchField, TintColor, ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip, prelude::*, @@ -197,7 +195,7 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement fn write_mode_change(mode: ThemeAppearanceMode, cx: &mut App) { let fs = ::global(cx); update_settings_file(fs, cx, move |settings, _cx| { - theme::set_mode(settings, mode); + theme_settings::set_mode(settings, mode); }); } @@ -219,13 +217,13 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement dark: ThemeName(dark_theme.into()), }); } - ThemeAppearanceMode::Light => theme::set_theme( + ThemeAppearanceMode::Light => theme_settings::set_theme( settings, theme, Appearance::Light, *SystemAppearance::global(cx), ), - ThemeAppearanceMode::Dark => theme::set_theme( + ThemeAppearanceMode::Dark => theme_settings::set_theme( settings, theme, Appearance::Dark, diff --git a/crates/open_path_prompt/Cargo.toml b/crates/open_path_prompt/Cargo.toml index 3418712abf9656cacd670882c3002cf50b3737d7..e635797cfbe042c327066494a36c3552f6736be1 100644 --- a/crates/open_path_prompt/Cargo.toml +++ b/crates/open_path_prompt/Cargo.toml @@ -24,6 +24,7 @@ editor = {workspace = true, features = ["test-support"]} gpui = {workspace = true, features = ["test-support"]} serde_json.workspace = true theme = {workspace = true, features = ["test-support"]} +theme_settings.workspace = true workspace = {workspace = true, features = ["test-support"]} [lints] diff --git a/crates/open_path_prompt/src/open_path_prompt_tests.rs b/crates/open_path_prompt/src/open_path_prompt_tests.rs index eba3a3e03be55c210f7b4ebd4fad5abc3842e74b..3acd74bdc456b8616229d30ea1da343073685e30 100644 --- a/crates/open_path_prompt/src/open_path_prompt_tests.rs +++ b/crates/open_path_prompt/src/open_path_prompt_tests.rs @@ -410,7 +410,7 @@ async fn test_open_path_prompt_with_show_hidden(cx: &mut TestAppContext) { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); state diff --git a/crates/outline/Cargo.toml b/crates/outline/Cargo.toml index 79559e03e8b2339fd8b4473d9e06ca6ff47b2b8c..2ce031bd4605e6c5dfc32e7f76be7493924af825 100644 --- a/crates/outline/Cargo.toml +++ b/crates/outline/Cargo.toml @@ -22,6 +22,7 @@ picker.workspace = true settings.workspace = true smol.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 4fb30cec9898534c8c72a83eb7634588ab78f73f..a03c87d9f68e41dd29d9d614f714db47083831ef 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -14,7 +14,8 @@ use language::{Outline, OutlineItem}; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; use settings::Settings; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; +use theme_settings::ThemeSettings; use ui::{ListItem, ListItemSpacing, prelude::*}; use util::ResultExt; use workspace::{DismissDecision, ModalView}; diff --git a/crates/outline_panel/Cargo.toml b/crates/outline_panel/Cargo.toml index fbcbd7ba74f42fc86976bb090102b86802cd4a1b..e88a0262907fcb22d1b954f25a5e74d8307bd174 100644 --- a/crates/outline_panel/Cargo.toml +++ b/crates/outline_panel/Cargo.toml @@ -33,6 +33,7 @@ settings.workspace = true smallvec.workspace = true smol.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index d4f8fe5b6dd488c1c1af522a0ed0c8b0b0a435fc..4dc6088451ec9e98c0cf823d85316951151cf126 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -47,7 +47,8 @@ use search::{BufferSearchBar, ProjectSearchView}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; use smol::channel; -use theme::{SyntaxTheme, ThemeSettings}; +use theme::SyntaxTheme; +use theme_settings::ThemeSettings; use ui::{ ContextMenu, FluentBuilder, HighlightedLabel, IconButton, IconButtonShape, IndentGuideColors, IndentGuideLayout, ListItem, ScrollAxes, Scrollbars, Tab, Tooltip, WithScrollbar, prelude::*, @@ -6899,7 +6900,7 @@ outline: struct OutlineEntryExcerpt let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); project_search::init(cx); diff --git a/crates/picker/Cargo.toml b/crates/picker/Cargo.toml index 8c76aa746453866755be322df576a519ba147b24..7c01e8bfaa13447eccb42f42f69a09b332193676 100644 --- a/crates/picker/Cargo.toml +++ b/crates/picker/Cargo.toml @@ -22,6 +22,7 @@ menu.workspace = true schemars.workspace = true serde.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true ui_input.workspace = true workspace.workspace = true diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 2eb8d71bd4aa14960f8388859c974214f3e85c72..1e529cd53f2d2527af8525886d11dbcddbf33a34 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -16,7 +16,7 @@ use serde::Deserialize; use std::{ cell::Cell, cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc, time::Duration, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Color, Divider, DocumentationAside, DocumentationSide, Label, ListItem, ListItemSpacing, ScrollAxes, Scrollbars, WithScrollbar, prelude::*, utils::WithRemSize, v_flex, @@ -955,7 +955,7 @@ mod tests { cx.update(|cx| { let store = settings::SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); }); } diff --git a/crates/platform_title_bar/Cargo.toml b/crates/platform_title_bar/Cargo.toml index 43ad6166929bc463edbea878941ba19ffe2ea3a9..7ecc624a3224025749b65d631031e3e8bf639052 100644 --- a/crates/platform_title_bar/Cargo.toml +++ b/crates/platform_title_bar/Cargo.toml @@ -19,6 +19,7 @@ project.workspace = true settings.workspace = true smallvec.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/platform_title_bar/src/system_window_tabs.rs b/crates/platform_title_bar/src/system_window_tabs.rs index a9bf46cc4f9f33586d1129dec1c64a67f1e42198..f465d2ab8476eb1c834f32e1d0eb72cc468dc230 100644 --- a/crates/platform_title_bar/src/system_window_tabs.rs +++ b/crates/platform_title_bar/src/system_window_tabs.rs @@ -5,7 +5,7 @@ use gpui::{ Styled, SystemWindowTab, SystemWindowTabController, Window, WindowId, actions, canvas, div, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Color, ContextMenu, DynamicSpacing, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize, Tab, h_flex, prelude::*, right_click_menu, diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index add85d91c26866edfae1b5790c279ee609edf477..2192b8daf3a301d580a3cef73426f6348508a566 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -36,6 +36,7 @@ serde_json.workspace = true settings.workspace = true smallvec.workspace = true theme.workspace = true +theme_settings.workspace = true rayon.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index ac348d9d5bab659c115f7b6c9f1a11c4d7c951bc..aa8c86091d526165f3406016ee49d86a30596c37 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -58,7 +58,7 @@ use std::{ sync::Arc, time::Duration, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Color, ContextMenu, ContextMenuEntry, DecoratedIcon, Divider, Icon, IconDecoration, IconDecorationKind, IndentGuideColors, IndentGuideLayout, Indicator, KeyBinding, Label, diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index afcc6db8d1600ed7df438d2e3e5546ba13fe4dd0..55b53cde8b6252f8b9732cf4effc35ea53c073e0 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -10428,7 +10428,7 @@ pub(crate) fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); cx.update_global::(|store, cx| { @@ -10446,7 +10446,7 @@ pub(crate) fn init_test(cx: &mut TestAppContext) { fn init_test_with_editor(cx: &mut TestAppContext) { cx.update(|cx| { let app_state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); workspace::init(app_state, cx); diff --git a/crates/project_symbols/Cargo.toml b/crates/project_symbols/Cargo.toml index 83e3cb587d46a5bddf1c8b30c593c18a9b131ad2..da23116e83b465a3ad1aace883d2abb15ad9aa9b 100644 --- a/crates/project_symbols/Cargo.toml +++ b/crates/project_symbols/Cargo.toml @@ -23,6 +23,7 @@ project.workspace = true serde_json.workspace = true settings.workspace = true theme.workspace = true +theme_settings.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index d62935ab3819d2e6857c233a863af434f60f93a3..84b92f3eaa4f0216b881526b3aac42f8980ffe78 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -9,7 +9,8 @@ use picker::{Picker, PickerDelegate}; use project::{Project, Symbol, lsp_store::SymbolLocation}; use settings::Settings; use std::{cmp::Reverse, sync::Arc}; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; +use theme_settings::ThemeSettings; use util::ResultExt; use workspace::{ Workspace, @@ -477,7 +478,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); editor::init(cx); }); diff --git a/crates/remote_connection/Cargo.toml b/crates/remote_connection/Cargo.toml index 53e20eb5eb0708252a90819d37b38e214aa95d67..d3b37f6985bb0b47a1a1902fc5a856c2df974a60 100644 --- a/crates/remote_connection/Cargo.toml +++ b/crates/remote_connection/Cargo.toml @@ -28,7 +28,7 @@ release_channel.workspace = true remote.workspace = true semver.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true ui_input.workspace = true workspace.workspace = true \ No newline at end of file diff --git a/crates/remote_connection/src/remote_connection.rs b/crates/remote_connection/src/remote_connection.rs index e0bb70165e9adc6e2c8c81e933cf88b2273da79f..df6260d1c5b3cd1704bfe0ce6a8476bbc0f39670 100644 --- a/crates/remote_connection/src/remote_connection.rs +++ b/crates/remote_connection/src/remote_connection.rs @@ -13,7 +13,7 @@ use release_channel::ReleaseChannel; use remote::{ConnectionIdentifier, RemoteClient, RemoteConnectionOptions, RemotePlatform}; use semver::Version; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ActiveTheme, CommonAnimationExt, Context, InteractiveElement, KeyBinding, ListItem, Tooltip, prelude::*, diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index 36944261cded68b564df8093d5b7a7621a644c11..c6ce45ba1ce28386d0776eb40299919f92aa8e53 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -98,6 +98,7 @@ node_runtime = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } remote = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true theme = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 01eb8126989668d5d6e8ea44f0313663e9d8cb4c..86b7f93eb2c737cac55dbf2882f91ec277e4e174 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -1661,7 +1661,7 @@ async fn test_remote_git_diffs_when_recv_update_repository_delay( cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); editor::init(cx); }); diff --git a/crates/repl/Cargo.toml b/crates/repl/Cargo.toml index 4329b29ada504cf536337c94b14790acea73ea11..5477c1c5107e7450ad2eaeaba6a880256b62f30f 100644 --- a/crates/repl/Cargo.toml +++ b/crates/repl/Cargo.toml @@ -53,6 +53,7 @@ telemetry.workspace = true terminal.workspace = true terminal_view.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/repl/src/notebook/cell.rs b/crates/repl/src/notebook/cell.rs index 200424742aff113d637fe9aca30999c0f95e79a5..ba70e50f8cbccc32bef5de5c1864a3d8db46aa89 100644 --- a/crates/repl/src/notebook/cell.rs +++ b/crates/repl/src/notebook/cell.rs @@ -12,7 +12,7 @@ use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use nbformat::v4::{CellId, CellMetadata, CellType}; use runtimelib::{JupyterMessage, JupyterMessageContent}; use settings::Settings as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{CommonAnimationExt, IconButtonShape, prelude::*}; use util::ResultExt; diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index f6d2bc4d3173ce64700b7b5ac45301df0fe0ab53..ad0bd56858636bf8fbd2501bab28aae25b99c2a0 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -895,7 +895,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); let fs = project::FakeFs::new(cx.background_executor.clone()); let project = project::Project::test(fs, [] as [&Path; 0], cx).await; diff --git a/crates/repl/src/outputs/plain.rs b/crates/repl/src/outputs/plain.rs index 71e2624f8ad7b0172a86793d5d81b38339b04f36..bc6d04019ce0129529a886e827c3f2ec8e6574ce 100644 --- a/crates/repl/src/outputs/plain.rs +++ b/crates/repl/src/outputs/plain.rs @@ -27,7 +27,7 @@ use language::Buffer; use settings::Settings as _; use terminal::terminal_settings::TerminalSettings; use terminal_view::terminal_element::TerminalElement; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{IntoElement, prelude::*}; use crate::outputs::OutputContent; @@ -275,7 +275,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); cx.add_empty_window() } diff --git a/crates/repl/src/outputs/table.rs b/crates/repl/src/outputs/table.rs index f6bf30f394d2232750f7f1beb21dbbc27c0ba941..fc5ccaf75a5b25ba9b32db68e47a96d876f68cf7 100644 --- a/crates/repl/src/outputs/table.rs +++ b/crates/repl/src/outputs/table.rs @@ -59,7 +59,7 @@ use runtimelib::datatable::TableSchema; use runtimelib::media::datatable::TabularDataResource; use serde_json::Value; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{IntoElement, Styled, div, prelude::*, v_flex}; use util::markdown::MarkdownEscaped; diff --git a/crates/rules_library/Cargo.toml b/crates/rules_library/Cargo.toml index 59c298de923f98135c99fca0c8da2fa42ac2e17e..352f86bd72fca294745cc0f74b401cc48f35d7fd 100644 --- a/crates/rules_library/Cargo.toml +++ b/crates/rules_library/Cargo.toml @@ -28,7 +28,7 @@ release_channel.workspace = true rope.workspace = true serde.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true ui_input.workspace = true util.workspace = true diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index b4ff8033446410d063cddccfa6b76eaa77ecfac9..1c8e90794674dfc737480981954f91312add1ee5 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -20,7 +20,7 @@ use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Duration; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*}; use ui_input::ErasedEditor; use util::{ResultExt, TryFutureExt}; @@ -1392,7 +1392,7 @@ impl RulesLibrary { impl Render for RulesLibrary { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let theme = cx.theme().clone(); client_side_decorations( diff --git a/crates/schema_generator/Cargo.toml b/crates/schema_generator/Cargo.toml index b92298a3b41d62b861c19a1f22ceaee0d63828b5..71beb54597e72286cbf539897741088dde873e6c 100644 --- a/crates/schema_generator/Cargo.toml +++ b/crates/schema_generator/Cargo.toml @@ -17,3 +17,4 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true theme.workspace = true +theme_settings.workspace = true \ No newline at end of file diff --git a/crates/schema_generator/src/main.rs b/crates/schema_generator/src/main.rs index a77060c54d1361dc96204238a282f8e75946a37b..d34cd897b9e7eb27b6c9343513d85ed8497d291a 100644 --- a/crates/schema_generator/src/main.rs +++ b/crates/schema_generator/src/main.rs @@ -2,7 +2,8 @@ use anyhow::Result; use clap::{Parser, ValueEnum}; use schemars::schema_for; use settings::ProjectSettingsContent; -use theme::{IconThemeFamilyContent, ThemeFamilyContent}; +use theme::IconThemeFamilyContent; +use theme_settings::ThemeFamilyContent; #[derive(Parser, Debug)] pub struct Args { diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 9ea013af6c315ff11508b195e9d79493d05fee6b..4213aa39a046e944cd34f9a1530bd15d1c442863 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -38,6 +38,7 @@ serde_json.workspace = true settings.workspace = true smol.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true util_macros.workspace = true diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 5381e47db092fb65ca3cdb844987c6714ca4cd76..8a8537337db66cd5ab1d53404639a5103cccb7f2 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -1906,7 +1906,7 @@ mod tests { cx.set_global(store); editor::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); }); } diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 97c6cbad52e00d991dca3cb41d118815d335e5ae..c72d7da34bb28b7048741a27c891c54785ab3123 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -5143,7 +5143,7 @@ pub mod tests { let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); diff --git a/crates/search/src/search_bar.rs b/crates/search/src/search_bar.rs index 436f70d6545a7eaaee23564058fb600fe387b739..a4757631a188752aed7cc631d987a22cd57b06c6 100644 --- a/crates/search/src/search_bar.rs +++ b/crates/search/src/search_bar.rs @@ -1,7 +1,7 @@ use editor::{Editor, EditorElement, EditorStyle, MultiBufferOffset, ToOffset}; use gpui::{Action, App, Entity, FocusHandle, Hsla, IntoElement, TextStyle}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{IconButton, IconButtonShape}; use ui::{Tooltip, prelude::*}; diff --git a/crates/settings_profile_selector/Cargo.toml b/crates/settings_profile_selector/Cargo.toml index 9fcce14b0434386068a9c94f47c9ed675210abbb..2e4608672847b608e2f6b0c48c5122bf76f3b5e7 100644 --- a/crates/settings_profile_selector/Cargo.toml +++ b/crates/settings_profile_selector/Cargo.toml @@ -29,4 +29,5 @@ project = { workspace = true, features = ["test-support"] } serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/settings_profile_selector/src/settings_profile_selector.rs b/crates/settings_profile_selector/src/settings_profile_selector.rs index 7ca91e3767efb6b550af7887e70a0187fed6daad..a948b603e04c43a6740853b7c37aebb2ba8d7ee9 100644 --- a/crates/settings_profile_selector/src/settings_profile_selector.rs +++ b/crates/settings_profile_selector/src/settings_profile_selector.rs @@ -286,7 +286,7 @@ mod tests { use project::{FakeFs, Project}; use serde_json::json; use settings::Settings; - use theme::{self, ThemeSettings}; + use theme_settings::ThemeSettings; use workspace::{self, AppState, MultiWorkspace}; use zed_actions::settings_profile_selector; @@ -299,7 +299,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); settings::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); super::init(cx); editor::init(cx); state diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 7632c2857a41ba43fe7d2b2d517752f53b8f694d..9d79481596f4b4259760ff6c2f19f8f5cf709d1e 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -54,6 +54,7 @@ shell_command_parser.workspace = true strum.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/settings_ui/src/components/input_field.rs b/crates/settings_ui/src/components/input_field.rs index e0acfe486d31db373a5de43aa64e1b6e28ce78cf..35e63078c154dd324c8dd622b8d98c2de36beb68 100644 --- a/crates/settings_ui/src/components/input_field.rs +++ b/crates/settings_ui/src/components/input_field.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use editor::Editor; use gpui::{AnyElement, ElementId, Focusable, TextStyleRefinement}; use settings::Settings as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{Tooltip, prelude::*, rems}; #[derive(IntoElement)] diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 593564c7013fa8a0fb3e6a9f49ff0d14fbe9584f..37e3e78baceccde480801c84cfe6462c8356c5ed 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -411,9 +411,9 @@ fn appearance_page() -> SettingsPage { settings::ThemeSelection::Static(_) => return, settings::ThemeSelection::Dynamic { mode, light, dark } => { match mode { - theme::ThemeAppearanceMode::Light => light.clone(), - theme::ThemeAppearanceMode::Dark => dark.clone(), - theme::ThemeAppearanceMode::System => dark.clone(), // no cx, can't determine correct choice + theme_settings::ThemeAppearanceMode::Light => light.clone(), + theme_settings::ThemeAppearanceMode::Dark => dark.clone(), + theme_settings::ThemeAppearanceMode::System => dark.clone(), // no cx, can't determine correct choice } }, }; @@ -581,9 +581,9 @@ fn appearance_page() -> SettingsPage { settings::IconThemeSelection::Static(_) => return, settings::IconThemeSelection::Dynamic { mode, light, dark } => { match mode { - theme::ThemeAppearanceMode::Light => light.clone(), - theme::ThemeAppearanceMode::Dark => dark.clone(), - theme::ThemeAppearanceMode::System => dark.clone(), // no cx, can't determine correct choice + theme_settings::ThemeAppearanceMode::Light => light.clone(), + theme_settings::ThemeAppearanceMode::Dark => dark.clone(), + theme_settings::ThemeAppearanceMode::System => dark.clone(), // no cx, can't determine correct choice } }, }; @@ -802,7 +802,8 @@ fn appearance_page() -> SettingsPage { } settings::BufferLineHeightDiscriminants::Custom => { let custom_value = - theme::BufferLineHeight::from(*settings_value).value(); + theme_settings::BufferLineHeight::from(*settings_value) + .value(); settings::BufferLineHeight::Custom(custom_value) } }; diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index 3a208d062c56a2e0617288e2212e6ba5ff2be0ed..61d6c8c6f4cb09246bc3b6ee11e87e065ed52b3a 100644 --- a/crates/settings_ui/src/pages/tool_permissions_setup.rs +++ b/crates/settings_ui/src/pages/tool_permissions_setup.rs @@ -7,7 +7,7 @@ use gpui::{ use settings::{Settings as _, SettingsStore, ToolPermissionMode}; use shell_command_parser::extract_commands; use std::sync::Arc; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{Banner, ContextMenu, Divider, PopoverMenu, Severity, Tooltip, prelude::*}; use util::ResultExt as _; use util::shell::ShellKind; diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index d130056edfd288394b45559425207545c10d262e..3a506b29e1b8c57ea9487b1425eaf4a497cdedd1 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -33,7 +33,7 @@ use std::{ sync::{Arc, LazyLock, RwLock}, time::Duration, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Banner, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, KeyBinding, KeybindingHint, PopoverMenu, Scrollbars, Switch, Tooltip, TreeViewItem, WithScrollbar, @@ -639,7 +639,9 @@ pub fn open_settings_editor( // We have to defer this to get the workspace off the stack. let path = path.map(ToOwned::to_owned); cx.defer(move |cx| { - let current_rem_size: f32 = theme::ThemeSettings::get_global(cx).ui_font_size(cx).into(); + let current_rem_size: f32 = theme_settings::ThemeSettings::get_global(cx) + .ui_font_size(cx) + .into(); let default_bounds = DEFAULT_ADDITIONAL_WINDOW_SIZE; let default_rem_size = 16.0; @@ -3650,7 +3652,7 @@ impl SettingsWindow { impl Render for SettingsWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); client_side_decorations( v_flex() @@ -4410,7 +4412,7 @@ pub mod test { pub fn register_settings(cx: &mut App) { settings::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); menu::init(); } @@ -5075,7 +5077,7 @@ mod project_settings_update_tests { cx.update(|cx| { let store = settings::SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); menu::init(); let queue = ProjectSettingsUpdateQueue::new(cx); diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 7fcb97a92695b5c3e9e1b32560f332d6bd6908d5..179d8c40135bece9d5e85142cb47bc32fe236218 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -33,6 +33,7 @@ project.workspace = true recent_projects.workspace = true settings.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true vim_mode_setting.workspace = true diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs index d03190e028082e086b30956933780090c1be07e5..ae3b64d424e43b4d1712d437418ccaf5e7e81a79 100644 --- a/crates/sidebar/src/project_group_builder.rs +++ b/crates/sidebar/src/project_group_builder.rs @@ -223,7 +223,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 123ca7a6bec8af78f25a0c3bbac5767ced38b55f..4ec50a1f52840930e61c23e5463878a173cfaa89 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -3069,7 +3069,7 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let _titlebar_height = ui::utils::platform_title_bar_height(window); - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let sticky_header = self.render_sticky_header(window, cx); let color = cx.theme().colors(); @@ -3219,7 +3219,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); diff --git a/crates/storybook/Cargo.toml b/crates/storybook/Cargo.toml index b1d512559526a00021f5339707c1e24a3110ff15..b641e5cbd8b5ce5e66f9fb082e74ea42124f8993 100644 --- a/crates/storybook/Cargo.toml +++ b/crates/storybook/Cargo.toml @@ -29,6 +29,7 @@ picker.workspace = true reqwest_client.workspace = true rust-embed.workspace = true settings.workspace = true +theme_settings.workspace = true simplelog.workspace = true story.workspace = true strum = { workspace = true, features = ["derive"] } diff --git a/crates/storybook/src/storybook.rs b/crates/storybook/src/storybook.rs index b8f659146c29162c25b94ca65d05770b4c08921b..d3df9bbc3a078793ab8e00c71cd4cb5cb9810fa6 100644 --- a/crates/storybook/src/storybook.rs +++ b/crates/storybook/src/storybook.rs @@ -15,10 +15,10 @@ use gpui::{ }; use log::LevelFilter; use reqwest_client::ReqwestClient; -use settings::{KeymapFile, Settings}; +use settings::{KeymapFile, Settings as _}; use simplelog::SimpleLogger; use strum::IntoEnumIterator; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::prelude::*; use crate::app_menus::app_menus; @@ -76,13 +76,13 @@ fn main() { cx.set_http_client(Arc::new(http_client)); settings::init(cx); - theme::init(theme::LoadThemes::All(Box::new(Assets)), cx); + theme_settings::init(theme::LoadThemes::All(Box::new(Assets)), cx); let selector = story_selector; let mut theme_settings = ThemeSettings::get_global(cx).clone(); theme_settings.theme = - theme::ThemeSelection::Static(settings::ThemeName(theme_name.into())); + theme_settings::ThemeSelection::Static(settings::ThemeName(theme_name.into())); ThemeSettings::override_global(theme_settings, cx); editor::init(cx); @@ -98,7 +98,7 @@ fn main() { ..Default::default() }, move |window, cx| { - theme::setup_ui_font(window, cx); + theme_settings::setup_ui_font(window, cx); cx.new(|cx| StoryWrapper::new(selector.story(window, cx))) }, diff --git a/crates/tab_switcher/Cargo.toml b/crates/tab_switcher/Cargo.toml index e2855aa1696c3af0c3efeb2b927f968783978332..8855c8869ab52260be668c45c20e5af7a869433f 100644 --- a/crates/tab_switcher/Cargo.toml +++ b/crates/tab_switcher/Cargo.toml @@ -33,5 +33,6 @@ ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } serde_json.workspace = true theme = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index e1e3f138252e4dc41aa67d9d5b848eac773d5f4f..5b8b9224192324cf0145417b252dcee3a07ddcc3 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -258,7 +258,7 @@ async fn test_close_selected_item(cx: &mut gpui::TestAppContext) { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); super::init(cx); editor::init(cx); state diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index fcb637f14b3785cf2d11b68b8cbf60934f055df4..8a598c1d7730ef59c19085f73cc65bd955ad4e35 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -37,6 +37,7 @@ sysinfo.workspace = true smol.workspace = true task.workspace = true theme.workspace = true +theme_settings.workspace = true thiserror.workspace = true url.workspace = true util.workspace = true diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 859a331bfb9febd238b8053bd6d46dc59de8e858..2ff689972a275e6ae4546af270838c02ec4a4b92 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -2579,7 +2579,7 @@ mod tests { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index f24bd5ead6cfd8cb0d4ded66a770a6040d957b72..9e97a398128c46e870c5b2485934a24a13be295b 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -14,7 +14,7 @@ use settings::{ merge_from::MergeFrom, }; use task::Shell; -use theme::FontFamilyName; +use theme_settings::FontFamilyName; #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct Toolbar { diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index 6fc1d4ae710a342b2d275b6dd5713d37a14b1da6..ae4c19ff59b5b944588e06c3373de754d63feaf2 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -42,6 +42,7 @@ settings.workspace = true shellexpand.workspace = true terminal.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index dc01a05dbe0c9c04398afc47a5cae1c2bd7b4e5d..0bb0837c6edb926cdcda70a54889de313cbe94f1 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -25,7 +25,8 @@ use terminal::{ }, terminal_settings::TerminalSettings, }; -use theme::{ActiveTheme, Theme, ThemeSettings}; +use theme::{ActiveTheme, Theme}; +use theme_settings::ThemeSettings; use ui::utils::ensure_minimum_contrast; use ui::{ParentElement, Tooltip}; use util::ResultExt; @@ -913,7 +914,9 @@ impl Element for TerminalElement { } TerminalMode::Standalone => terminal_settings .font_size - .map_or(buffer_font_size, |size| theme::adjusted_font_size(size, cx)), + .map_or(buffer_font_size, |size| { + theme_settings::adjusted_font_size(size, cx) + }), }; let theme = cx.theme().clone(); diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 4f79914eb889b4402eb86ce7ca5359d3d0e16085..76f27f94658d738b522959f80354f9998bf2d89a 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -2346,7 +2346,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); }); diff --git a/crates/terminal_view/src/terminal_path_like_target.rs b/crates/terminal_view/src/terminal_path_like_target.rs index 18eab6fc5b4ccca1bcc6db33a35dc490582037ac..f0f13d8fc2cd737722f30d7e56248e4284ed4495 100644 --- a/crates/terminal_view/src/terminal_path_like_target.rs +++ b/crates/terminal_view/src/terminal_path_like_target.rs @@ -554,7 +554,7 @@ mod tests { let fs = app_cx.update(AppState::test).fs.as_fake().clone(); app_cx.update(|cx| { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); }); diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 0b2bfa44870282de79d63a74e507115fb198ed66..3f38ee2f0fd7f64fd996d9011d28ec942d02c86d 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -2208,7 +2208,7 @@ mod tests { ) { let params = cx.update(AppState::test); cx.update(|cx| { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); let project = Project::test(params.fs.clone(), [], cx).await; diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index b1c689bc7c451b92e9fff86cbacebc60c9a31b58..dcfa711554ec7457c63d5ce9c9488e337de78836 100644 --- a/crates/theme/Cargo.toml +++ b/crates/theme/Cargo.toml @@ -10,7 +10,7 @@ workspace = true [features] default = [] -test-support = ["gpui/test-support", "settings/test-support"] +test-support = ["gpui/test-support"] [lib] path = "src/theme.rs" @@ -21,8 +21,6 @@ anyhow.workspace = true collections.workspace = true derive_more.workspace = true gpui.workspace = true -gpui_util.workspace = true -log.workspace = true palette = { workspace = true, default-features = false, features = ["std"] } parking_lot.workspace = true refineable.workspace = true @@ -30,11 +28,9 @@ schemars = { workspace = true, features = ["indexmap2"] } serde.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true -settings.workspace = true strum.workspace = true thiserror.workspace = true uuid.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } -settings = { workspace = true, features = ["test-support"] } diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index bfff86b5c614e41711ae1d1be3d9b4aca08cc822..ba7f600fb05cc160f8d2668cf549853c8ae39ebe 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -25,7 +25,8 @@ pub fn zed_default_themes() -> ThemeFamily { // If a theme customizes a foreground version of a status color, but does not // customize the background color, then use a partly-transparent version of the // foreground color for the background color. -pub(crate) fn apply_status_color_defaults(status: &mut StatusColorsRefinement) { +/// Applies default status color backgrounds from their foreground counterparts. +pub fn apply_status_color_defaults(status: &mut StatusColorsRefinement) { for (fg_color, bg_color) in [ (&status.deleted, &mut status.deleted_background), (&status.created, &mut status.created_background), @@ -42,7 +43,8 @@ pub(crate) fn apply_status_color_defaults(status: &mut StatusColorsRefinement) { } } -pub(crate) fn apply_theme_color_defaults( +/// Applies default theme color values derived from player colors. +pub fn apply_theme_color_defaults( theme_colors: &mut ThemeColorsRefinement, player_colors: &PlayerColors, ) { diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs index c073442c46b1a52a14e09056e8aaa2c9b22e7d11..d3a56ce42443b18d864bb00200980e703b3342e4 100644 --- a/crates/theme/src/icon_theme.rs +++ b/crates/theme/src/icon_theme.rs @@ -416,7 +416,7 @@ fn icon_keys_by_association( } /// The name of the default icon theme. -pub(crate) const DEFAULT_ICON_THEME_NAME: &str = "Zed (Default)"; +pub const DEFAULT_ICON_THEME_NAME: &str = "Zed (Default)"; static DEFAULT_ICON_THEME: LazyLock> = LazyLock::new(|| { Arc::new(IconTheme { diff --git a/crates/theme/src/registry.rs b/crates/theme/src/registry.rs index 5b8e47439cd1baf47172bd17f3fae2ca56635b29..fbe535309773fa5c90c2031d44b420cf5fad2dc7 100644 --- a/crates/theme/src/registry.rs +++ b/crates/theme/src/registry.rs @@ -1,18 +1,16 @@ use std::sync::Arc; use std::{fmt::Debug, path::Path}; -use anyhow::{Context as _, Result}; +use anyhow::Result; use collections::HashMap; use derive_more::{Deref, DerefMut}; use gpui::{App, AssetSource, Global, SharedString}; -use gpui_util::ResultExt; use parking_lot::RwLock; use thiserror::Error; use crate::{ Appearance, AppearanceContent, ChevronIcons, DEFAULT_ICON_THEME_NAME, DirectoryIcons, - IconDefinition, IconTheme, IconThemeFamilyContent, Theme, ThemeFamily, ThemeFamilyContent, - default_icon_theme, deserialize_user_theme, refine_theme_family, + IconDefinition, IconTheme, IconThemeFamilyContent, Theme, ThemeFamily, default_icon_theme, }; /// The metadata for a theme. @@ -81,6 +79,11 @@ impl ThemeRegistry { cx.set_global(GlobalThemeRegistry(Arc::new(ThemeRegistry::new(assets)))); } + /// Returns the asset source used by this registry. + pub fn assets(&self) -> &dyn AssetSource { + self.assets.as_ref() + } + /// Creates a new [`ThemeRegistry`] with the given [`AssetSource`]. pub fn new(assets: Box) -> Self { let registry = Self { @@ -116,28 +119,21 @@ impl ThemeRegistry { self.state.write().extensions_loaded = true; } - fn insert_theme_families(&self, families: impl IntoIterator) { + /// Inserts the given theme families into the registry. + pub fn insert_theme_families(&self, families: impl IntoIterator) { for family in families.into_iter() { self.insert_themes(family.themes); } } - fn insert_themes(&self, themes: impl IntoIterator) { + /// Inserts the given themes into the registry. + pub fn insert_themes(&self, themes: impl IntoIterator) { let mut state = self.state.write(); for theme in themes.into_iter() { state.themes.insert(theme.name.clone(), Arc::new(theme)); } } - #[allow(unused)] - fn insert_user_theme_families(&self, families: impl IntoIterator) { - for family in families.into_iter() { - let refined_family = refine_theme_family(family); - - self.insert_themes(refined_family.themes); - } - } - /// Removes the themes with the given names from the registry. pub fn remove_user_themes(&self, themes_to_remove: &[SharedString]) { self.state @@ -181,40 +177,6 @@ impl ThemeRegistry { .cloned() } - /// Loads the themes bundled with the Zed binary and adds them to the registry. - pub fn load_bundled_themes(&self) { - let theme_paths = self - .assets - .list("themes/") - .expect("failed to list theme assets") - .into_iter() - .filter(|path| path.ends_with(".json")); - - for path in theme_paths { - let Some(theme) = self.assets.load(&path).log_err().flatten() else { - continue; - }; - - let Some(theme_family) = serde_json::from_slice(&theme) - .with_context(|| format!("failed to parse theme at path \"{path}\"")) - .log_err() - else { - continue; - }; - - self.insert_user_theme_families([theme_family]); - } - } - - /// Loads the user theme from the specified data and adds it to the registry. - pub fn load_user_theme(&self, bytes: &[u8]) -> Result<()> { - let theme = deserialize_user_theme(bytes)?; - - self.insert_user_theme_families([theme]); - - Ok(()) - } - /// Returns the default icon theme. pub fn default_icon_theme(&self) -> Result, IconThemeNotFoundError> { self.get_icon_theme(DEFAULT_ICON_THEME_NAME) diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index 61cf869b951ac4d285e1eaca42e226a6ac3e4a6a..56b89314a3442613890322cb7b9239fc7fc5b77e 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -1,30 +1,11 @@ #![allow(missing_docs)] -use gpui::{HighlightStyle, Hsla}; +use gpui::Hsla; use palette::FromColor; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::IntoGpui; -pub use settings::{FontWeightContent, WindowBackgroundContent}; - -use crate::{StatusColorsRefinement, ThemeColorsRefinement}; - -fn ensure_non_opaque(color: Hsla) -> Hsla { - const MAXIMUM_OPACITY: f32 = 0.7; - if color.a <= MAXIMUM_OPACITY { - color - } else { - Hsla { - a: MAXIMUM_OPACITY, - ..color - } - } -} - -fn ensure_opaque(color: Hsla) -> Hsla { - Hsla { a: 1.0, ..color } -} +/// The appearance of a theme in serialized content. #[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AppearanceContent { @@ -32,819 +13,8 @@ pub enum AppearanceContent { Dark, } -/// The content of a serialized theme family. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct ThemeFamilyContent { - pub name: String, - pub author: String, - pub themes: Vec, -} - -/// The content of a serialized theme. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct ThemeContent { - pub name: String, - pub appearance: AppearanceContent, - pub style: settings::ThemeStyleContent, -} - -/// Returns the syntax style overrides in the [`ThemeContent`]. -pub fn syntax_overrides(this: &settings::ThemeStyleContent) -> Vec<(String, HighlightStyle)> { - this.syntax - .iter() - .map(|(key, style)| { - ( - key.clone(), - HighlightStyle { - color: style - .color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - background_color: style - .background_color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - font_style: style.font_style.map(|s| s.into_gpui()), - font_weight: style.font_weight.map(|w| w.into_gpui()), - ..Default::default() - }, - ) - }) - .collect() -} - -pub fn status_colors_refinement(colors: &settings::StatusColorsContent) -> StatusColorsRefinement { - StatusColorsRefinement { - conflict: colors - .conflict - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - conflict_background: colors - .conflict_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - conflict_border: colors - .conflict_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - created: colors - .created - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - created_background: colors - .created_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - created_border: colors - .created_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - deleted: colors - .deleted - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - deleted_background: colors - .deleted_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - deleted_border: colors - .deleted_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - error: colors - .error - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - error_background: colors - .error_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - error_border: colors - .error_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hidden: colors - .hidden - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hidden_background: colors - .hidden_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hidden_border: colors - .hidden_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hint: colors - .hint - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hint_background: colors - .hint_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hint_border: colors - .hint_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ignored: colors - .ignored - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ignored_background: colors - .ignored_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ignored_border: colors - .ignored_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - info: colors - .info - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - info_background: colors - .info_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - info_border: colors - .info_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - modified: colors - .modified - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - modified_background: colors - .modified_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - modified_border: colors - .modified_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - predictive: colors - .predictive - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - predictive_background: colors - .predictive_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - predictive_border: colors - .predictive_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - renamed: colors - .renamed - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - renamed_background: colors - .renamed_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - renamed_border: colors - .renamed_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - success: colors - .success - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - success_background: colors - .success_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - success_border: colors - .success_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - unreachable: colors - .unreachable - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - unreachable_background: colors - .unreachable_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - unreachable_border: colors - .unreachable_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - warning: colors - .warning - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - warning_background: colors - .warning_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - warning_border: colors - .warning_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - } -} - -pub fn theme_colors_refinement( - this: &settings::ThemeColorsContent, - status_colors: &StatusColorsRefinement, -) -> ThemeColorsRefinement { - let border = this - .border - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let editor_document_highlight_read_background = this - .editor_document_highlight_read_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let scrollbar_thumb_background = this - .scrollbar_thumb_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or_else(|| { - this.deprecated_scrollbar_thumb_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - }); - let scrollbar_thumb_hover_background = this - .scrollbar_thumb_hover_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let scrollbar_thumb_active_background = this - .scrollbar_thumb_active_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_background); - let scrollbar_thumb_border = this - .scrollbar_thumb_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let element_hover = this - .element_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let panel_background = this - .panel_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let search_match_background = this - .search_match_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let search_active_match_background = this - .search_active_match_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(search_match_background); - ThemeColorsRefinement { - border, - border_variant: this - .border_variant - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - border_focused: this - .border_focused - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - border_selected: this - .border_selected - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - border_transparent: this - .border_transparent - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - border_disabled: this - .border_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - elevated_surface_background: this - .elevated_surface_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - surface_background: this - .surface_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - background: this - .background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_background: this - .element_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_hover, - element_active: this - .element_active - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_selected: this - .element_selected - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_disabled: this - .element_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_selection_background: this - .element_selection_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - drop_target_background: this - .drop_target_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - drop_target_border: this - .drop_target_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_background: this - .ghost_element_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_hover: this - .ghost_element_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_active: this - .ghost_element_active - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_selected: this - .ghost_element_selected - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_disabled: this - .ghost_element_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text: this - .text - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text_muted: this - .text_muted - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text_placeholder: this - .text_placeholder - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text_disabled: this - .text_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text_accent: this - .text_accent - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon: this - .icon - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon_muted: this - .icon_muted - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon_disabled: this - .icon_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon_placeholder: this - .icon_placeholder - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon_accent: this - .icon_accent - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - debugger_accent: this - .debugger_accent - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - status_bar_background: this - .status_bar_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - title_bar_background: this - .title_bar_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - title_bar_inactive_background: this - .title_bar_inactive_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - toolbar_background: this - .toolbar_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - tab_bar_background: this - .tab_bar_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - tab_inactive_background: this - .tab_inactive_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - tab_active_background: this - .tab_active_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - search_match_background: search_match_background, - search_active_match_background: search_active_match_background, - panel_background, - panel_focused_border: this - .panel_focused_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - panel_indent_guide: this - .panel_indent_guide - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - panel_indent_guide_hover: this - .panel_indent_guide_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - panel_indent_guide_active: this - .panel_indent_guide_active - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - panel_overlay_background: this - .panel_overlay_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(panel_background.map(ensure_opaque)), - panel_overlay_hover: this - .panel_overlay_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(panel_background - .zip(element_hover) - .map(|(panel_bg, hover_bg)| panel_bg.blend(hover_bg)) - .map(ensure_opaque)), - pane_focused_border: this - .pane_focused_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - pane_group_border: this - .pane_group_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(border), - scrollbar_thumb_background, - scrollbar_thumb_hover_background, - scrollbar_thumb_active_background, - scrollbar_thumb_border, - scrollbar_track_background: this - .scrollbar_track_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - scrollbar_track_border: this - .scrollbar_track_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - minimap_thumb_background: this - .minimap_thumb_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_background.map(ensure_non_opaque)), - minimap_thumb_hover_background: this - .minimap_thumb_hover_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_hover_background.map(ensure_non_opaque)), - minimap_thumb_active_background: this - .minimap_thumb_active_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_active_background.map(ensure_non_opaque)), - minimap_thumb_border: this - .minimap_thumb_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_border), - editor_foreground: this - .editor_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_background: this - .editor_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_gutter_background: this - .editor_gutter_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_subheader_background: this - .editor_subheader_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_active_line_background: this - .editor_active_line_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_highlighted_line_background: this - .editor_highlighted_line_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_debugger_active_line_background: this - .editor_debugger_active_line_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_line_number: this - .editor_line_number - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_hover_line_number: this - .editor_hover_line_number - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_active_line_number: this - .editor_active_line_number - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_invisible: this - .editor_invisible - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_wrap_guide: this - .editor_wrap_guide - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_active_wrap_guide: this - .editor_active_wrap_guide - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_indent_guide: this - .editor_indent_guide - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_indent_guide_active: this - .editor_indent_guide_active - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_document_highlight_read_background, - editor_document_highlight_write_background: this - .editor_document_highlight_write_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_document_highlight_bracket_background: this - .editor_document_highlight_bracket_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `editor.document_highlight.read_background`, for backwards compatibility. - .or(editor_document_highlight_read_background), - terminal_background: this - .terminal_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_background: this - .terminal_ansi_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_foreground: this - .terminal_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_bright_foreground: this - .terminal_bright_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_dim_foreground: this - .terminal_dim_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_black: this - .terminal_ansi_black - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_black: this - .terminal_ansi_bright_black - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_black: this - .terminal_ansi_dim_black - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_red: this - .terminal_ansi_red - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_red: this - .terminal_ansi_bright_red - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_red: this - .terminal_ansi_dim_red - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_green: this - .terminal_ansi_green - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_green: this - .terminal_ansi_bright_green - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_green: this - .terminal_ansi_dim_green - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_yellow: this - .terminal_ansi_yellow - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_yellow: this - .terminal_ansi_bright_yellow - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_yellow: this - .terminal_ansi_dim_yellow - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_blue: this - .terminal_ansi_blue - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_blue: this - .terminal_ansi_bright_blue - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_blue: this - .terminal_ansi_dim_blue - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_magenta: this - .terminal_ansi_magenta - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_magenta: this - .terminal_ansi_bright_magenta - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_magenta: this - .terminal_ansi_dim_magenta - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_cyan: this - .terminal_ansi_cyan - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_cyan: this - .terminal_ansi_bright_cyan - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_cyan: this - .terminal_ansi_dim_cyan - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_white: this - .terminal_ansi_white - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_white: this - .terminal_ansi_bright_white - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_white: this - .terminal_ansi_dim_white - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - link_text_hover: this - .link_text_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - version_control_added: this - .version_control_added - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `created`, for backwards compatibility. - .or(status_colors.created), - version_control_deleted: this - .version_control_deleted - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `deleted`, for backwards compatibility. - .or(status_colors.deleted), - version_control_modified: this - .version_control_modified - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `modified`, for backwards compatibility. - .or(status_colors.modified), - version_control_renamed: this - .version_control_renamed - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `modified`, for backwards compatibility. - .or(status_colors.modified), - version_control_conflict: this - .version_control_conflict - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `ignored`, for backwards compatibility. - .or(status_colors.ignored), - version_control_ignored: this - .version_control_ignored - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `conflict`, for backwards compatibility. - .or(status_colors.ignored), - version_control_word_added: this - .version_control_word_added - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - version_control_word_deleted: this - .version_control_word_deleted - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - #[allow(deprecated)] - version_control_conflict_marker_ours: this - .version_control_conflict_marker_ours - .as_ref() - .or(this.version_control_conflict_ours_background.as_ref()) - .and_then(|color| try_parse_color(color).ok()), - #[allow(deprecated)] - version_control_conflict_marker_theirs: this - .version_control_conflict_marker_theirs - .as_ref() - .or(this.version_control_conflict_theirs_background.as_ref()) - .and_then(|color| try_parse_color(color).ok()), - vim_normal_background: this - .vim_normal_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_insert_background: this - .vim_insert_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_replace_background: this - .vim_replace_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_background: this - .vim_visual_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_line_background: this - .vim_visual_line_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_block_background: this - .vim_visual_block_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_yank_background: this - .vim_yank_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(editor_document_highlight_read_background), - vim_helix_normal_background: this - .vim_helix_normal_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_helix_select_background: this - .vim_helix_select_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_normal_foreground: this - .vim_normal_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_insert_foreground: this - .vim_insert_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_replace_foreground: this - .vim_replace_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_foreground: this - .vim_visual_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_line_foreground: this - .vim_visual_line_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_block_foreground: this - .vim_visual_block_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_helix_normal_foreground: this - .vim_helix_normal_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_helix_select_foreground: this - .vim_helix_select_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - } -} - -pub(crate) fn try_parse_color(color: &str) -> anyhow::Result { +/// Parses a color string into an [`Hsla`] value. +pub fn try_parse_color(color: &str) -> anyhow::Result { let rgba = gpui::Rgba::try_from(color)?; let rgba = palette::rgb::Srgba::from_components((rgba.r, rgba.g, rgba.b, rgba.a)); let hsla = palette::Hsla::from_color(rgba); diff --git a/crates/theme/src/styles/accents.rs b/crates/theme/src/styles/accents.rs index 7e42ffe2e5bfa6449a64203ffcd5e49720382d06..751a12849d62c3a08fc274b2ff2f12b0fa3280cc 100644 --- a/crates/theme/src/styles/accents.rs +++ b/crates/theme/src/styles/accents.rs @@ -5,7 +5,6 @@ use serde::Deserialize; use crate::{ amber, blue, cyan, gold, grass, indigo, iris, jade, lime, orange, pink, purple, tomato, - try_parse_color, }; /// A collection of colors that are used to color indent aware lines in the editor. @@ -66,25 +65,4 @@ impl AccentColors { pub fn color_for_index(&self, index: u32) -> Hsla { self.0[index as usize % self.0.len()] } - - /// Merges the given accent colors into this [`AccentColors`] instance. - pub fn merge(&mut self, accent_colors: &[settings::AccentContent]) { - if accent_colors.is_empty() { - return; - } - - let colors = accent_colors - .iter() - .filter_map(|accent_color| { - accent_color - .0 - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - }) - .collect::>(); - - if !colors.is_empty() { - self.0 = Arc::from(colors); - } - } } diff --git a/crates/theme/src/styles/players.rs b/crates/theme/src/styles/players.rs index 439dbdd437aa64e034004a4495e64a96e76ce87e..9699bf87a552e430a6bd6adb4ae8307228f35422 100644 --- a/crates/theme/src/styles/players.rs +++ b/crates/theme/src/styles/players.rs @@ -3,7 +3,7 @@ use gpui::Hsla; use serde::Deserialize; -use crate::{amber, blue, jade, lime, orange, pink, purple, red, try_parse_color}; +use crate::{amber, blue, jade, lime, orange, pink, purple, red}; #[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq)] pub struct PlayerColor { @@ -148,40 +148,4 @@ impl PlayerColors { let len = self.0.len() - 1; self.0[(participant_index as usize % len) + 1] } - - /// Merges the given player colors into this [`PlayerColors`] instance. - pub fn merge(&mut self, user_player_colors: &[settings::PlayerColorContent]) { - if user_player_colors.is_empty() { - return; - } - - for (idx, player) in user_player_colors.iter().enumerate() { - let cursor = player - .cursor - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let background = player - .background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let selection = player - .selection - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - - if let Some(player_color) = self.0.get_mut(idx) { - *player_color = PlayerColor { - cursor: cursor.unwrap_or(player_color.cursor), - background: background.unwrap_or(player_color.background), - selection: selection.unwrap_or(player_color.selection), - }; - } else { - self.0.push(PlayerColor { - cursor: cursor.unwrap_or_default(), - background: background.unwrap_or_default(), - selection: selection.unwrap_or_default(), - }); - } - } - } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 8134461dfa9dd0aac1ae685a5be9861fb78ba4a1..37845cf0c8fce5d518e8dc69dde6126b8b266694 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -16,40 +16,30 @@ mod icon_theme_schema; mod registry; mod scale; mod schema; -mod settings; mod styles; use std::sync::Arc; -use ::settings::DEFAULT_DARK_THEME; -use ::settings::IntoGpui; -use ::settings::Settings; -use ::settings::SettingsStore; -use anyhow::Result; -use fallback_themes::apply_status_color_defaults; +use derive_more::{Deref, DerefMut}; use gpui::BorrowAppContext; use gpui::Global; use gpui::{ - App, AssetSource, HighlightStyle, Hsla, Pixels, Refineable, SharedString, WindowAppearance, - WindowBackgroundAppearance, px, + App, AssetSource, Hsla, Pixels, SharedString, WindowAppearance, WindowBackgroundAppearance, px, }; use serde::Deserialize; -use uuid::Uuid; pub use crate::default_colors::*; -use crate::fallback_themes::apply_theme_color_defaults; +pub use crate::fallback_themes::{apply_status_color_defaults, apply_theme_color_defaults}; pub use crate::font_family_cache::*; pub use crate::icon_theme::*; pub use crate::icon_theme_schema::*; pub use crate::registry::*; pub use crate::scale::*; pub use crate::schema::*; -pub use crate::settings::*; pub use crate::styles::*; -pub use ::settings::{ - FontStyleContent, HighlightStyleContent, StatusColorsContent, ThemeColorsContent, - ThemeStyleContent, -}; + +/// The name of the default dark theme. +pub const DEFAULT_DARK_THEME: &str = "One Dark"; /// Defines window border radius for platforms that use client side decorations. pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0); @@ -84,15 +74,6 @@ impl From for Appearance { } } -impl From for ThemeAppearanceMode { - fn from(value: Appearance) -> Self { - match value { - Appearance::Light => Self::Light, - Appearance::Dark => Self::Dark, - } - } -} - /// Which themes should be loaded. This is used primarily for testing. pub enum LoadThemes { /// Only load the base theme. @@ -104,84 +85,31 @@ pub enum LoadThemes { All(Box), } -/// Initialize the theme system. +/// Initialize the theme system with default themes. +/// +/// This sets up the [`ThemeRegistry`], [`FontFamilyCache`], [`SystemAppearance`], +/// and [`GlobalTheme`] with the default dark theme. It does NOT load bundled +/// themes from JSON or integrate with settings — use `theme_settings::init` for that. pub fn init(themes_to_load: LoadThemes, cx: &mut App) { SystemAppearance::init(cx); - let (assets, load_user_themes) = match themes_to_load { - LoadThemes::JustBase => (Box::new(()) as Box, false), - LoadThemes::All(assets) => (assets, true), + let assets = match themes_to_load { + LoadThemes::JustBase => Box::new(()) as Box, + LoadThemes::All(assets) => assets, }; ThemeRegistry::set_global(assets, cx); - - if load_user_themes { - ThemeRegistry::global(cx).load_bundled_themes(); - } - FontFamilyCache::init_global(cx); - let theme = GlobalTheme::configured_theme(cx); - let icon_theme = GlobalTheme::configured_icon_theme(cx); + let themes = ThemeRegistry::default_global(cx); + let theme = themes.get(DEFAULT_DARK_THEME).unwrap_or_else(|_| { + themes + .list() + .into_iter() + .next() + .map(|m| themes.get(&m.name).unwrap()) + .unwrap() + }); + let icon_theme = themes.default_icon_theme().unwrap(); cx.set_global(GlobalTheme { theme, icon_theme }); - - let settings = ThemeSettings::get_global(cx); - - let mut prev_buffer_font_size_settings = settings.buffer_font_size_settings(); - let mut prev_ui_font_size_settings = settings.ui_font_size_settings(); - let mut prev_agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); - let mut prev_agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); - let mut prev_theme_name = settings.theme.name(SystemAppearance::global(cx).0); - let mut prev_icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); - let mut prev_theme_overrides = ( - settings.experimental_theme_overrides.clone(), - settings.theme_overrides.clone(), - ); - - cx.observe_global::(move |cx| { - let settings = ThemeSettings::get_global(cx); - - let buffer_font_size_settings = settings.buffer_font_size_settings(); - let ui_font_size_settings = settings.ui_font_size_settings(); - let agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); - let agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); - let theme_name = settings.theme.name(SystemAppearance::global(cx).0); - let icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); - let theme_overrides = ( - settings.experimental_theme_overrides.clone(), - settings.theme_overrides.clone(), - ); - - if buffer_font_size_settings != prev_buffer_font_size_settings { - prev_buffer_font_size_settings = buffer_font_size_settings; - reset_buffer_font_size(cx); - } - - if ui_font_size_settings != prev_ui_font_size_settings { - prev_ui_font_size_settings = ui_font_size_settings; - reset_ui_font_size(cx); - } - - if agent_ui_font_size_settings != prev_agent_ui_font_size_settings { - prev_agent_ui_font_size_settings = agent_ui_font_size_settings; - reset_agent_ui_font_size(cx); - } - - if agent_buffer_font_size_settings != prev_agent_buffer_font_size_settings { - prev_agent_buffer_font_size_settings = agent_buffer_font_size_settings; - reset_agent_buffer_font_size(cx); - } - - if theme_name != prev_theme_name || theme_overrides != prev_theme_overrides { - prev_theme_name = theme_name; - prev_theme_overrides = theme_overrides; - GlobalTheme::reload_theme(cx); - } - - if icon_theme_name != prev_icon_theme_name { - prev_icon_theme_name = icon_theme_name; - GlobalTheme::reload_icon_theme(cx); - } - }) - .detach(); } /// Implementing this trait allows accessing the active theme. @@ -196,6 +124,39 @@ impl ActiveTheme for App { } } +/// The appearance of the system. +#[derive(Debug, Clone, Copy, Deref)] +pub struct SystemAppearance(pub Appearance); + +impl Default for SystemAppearance { + fn default() -> Self { + Self(Appearance::Dark) + } +} + +#[derive(Deref, DerefMut, Default)] +struct GlobalSystemAppearance(SystemAppearance); + +impl Global for GlobalSystemAppearance {} + +impl SystemAppearance { + /// Initializes the [`SystemAppearance`] for the application. + pub fn init(cx: &mut App) { + *cx.default_global::() = + GlobalSystemAppearance(SystemAppearance(cx.window_appearance().into())); + } + + /// Returns the global [`SystemAppearance`]. + pub fn global(cx: &App) -> Self { + cx.global::().0 + } + + /// Returns a mutable reference to the global [`SystemAppearance`]. + pub fn global_mut(cx: &mut App) -> &mut Self { + cx.global_mut::() + } +} + /// A theme family is a grouping of themes under a single name. /// /// For example, the "One" theme family contains the "One Light" and "One Dark" themes. @@ -217,113 +178,6 @@ pub struct ThemeFamily { pub scales: ColorScales, } -impl ThemeFamily { - // This is on ThemeFamily because we will have variables here we will need - // in the future to resolve @references. - /// Refines ThemeContent into a theme, merging it's contents with the base theme. - pub fn refine_theme(&self, theme: &ThemeContent) -> Theme { - let appearance = match theme.appearance { - AppearanceContent::Light => Appearance::Light, - AppearanceContent::Dark => Appearance::Dark, - }; - - let mut refined_status_colors = match theme.appearance { - AppearanceContent::Light => StatusColors::light(), - AppearanceContent::Dark => StatusColors::dark(), - }; - let mut status_colors_refinement = status_colors_refinement(&theme.style.status); - apply_status_color_defaults(&mut status_colors_refinement); - refined_status_colors.refine(&status_colors_refinement); - - let mut refined_player_colors = match theme.appearance { - AppearanceContent::Light => PlayerColors::light(), - AppearanceContent::Dark => PlayerColors::dark(), - }; - refined_player_colors.merge(&theme.style.players); - - let mut refined_theme_colors = match theme.appearance { - AppearanceContent::Light => ThemeColors::light(), - AppearanceContent::Dark => ThemeColors::dark(), - }; - let mut theme_colors_refinement = - theme_colors_refinement(&theme.style.colors, &status_colors_refinement); - apply_theme_color_defaults(&mut theme_colors_refinement, &refined_player_colors); - refined_theme_colors.refine(&theme_colors_refinement); - - let mut refined_accent_colors = match theme.appearance { - AppearanceContent::Light => AccentColors::light(), - AppearanceContent::Dark => AccentColors::dark(), - }; - refined_accent_colors.merge(&theme.style.accents); - - let syntax_highlights = theme.style.syntax.iter().map(|(syntax_token, highlight)| { - ( - syntax_token.clone(), - HighlightStyle { - color: highlight - .color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - background_color: highlight - .background_color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - font_style: highlight.font_style.map(|s| s.into_gpui()), - font_weight: highlight.font_weight.map(|w| w.into_gpui()), - ..Default::default() - }, - ) - }); - let syntax_theme = Arc::new(SyntaxTheme::new(syntax_highlights)); - - let window_background_appearance = theme - .style - .window_background_appearance - .map(|w| w.into_gpui()) - .unwrap_or_default(); - - Theme { - id: uuid::Uuid::new_v4().to_string(), - name: theme.name.clone().into(), - appearance, - styles: ThemeStyles { - system: SystemColors::default(), - window_background_appearance, - accents: refined_accent_colors, - colors: refined_theme_colors, - status: refined_status_colors, - player: refined_player_colors, - syntax: syntax_theme, - }, - } - } -} - -/// Refines a [ThemeFamilyContent] and it's [ThemeContent]s into a [ThemeFamily]. -pub fn refine_theme_family(theme_family_content: ThemeFamilyContent) -> ThemeFamily { - let id = Uuid::new_v4().to_string(); - let name = theme_family_content.name.clone(); - let author = theme_family_content.author.clone(); - - let mut theme_family = ThemeFamily { - id, - name: name.into(), - author: author.into(), - themes: vec![], - scales: default_color_scales(), - }; - - let refined_themes = theme_family_content - .themes - .iter() - .map(|theme_content| theme_family.refine_theme(theme_content)) - .collect(); - - theme_family.themes = refined_themes; - - theme_family -} - /// A theme is the primary mechanism for defining the appearance of the UI. #[derive(Clone, Debug, PartialEq)] pub struct Theme { @@ -403,35 +257,14 @@ impl Theme { } } -/// Deserializes a user theme from the given bytes. -pub fn deserialize_user_theme(bytes: &[u8]) -> Result { - let theme_family: ThemeFamilyContent = serde_json_lenient::from_slice(bytes)?; - - for theme in &theme_family.themes { - if theme - .style - .colors - .deprecated_scrollbar_thumb_background - .is_some() - { - log::warn!( - r#"Theme "{theme_name}" is using a deprecated style property: scrollbar_thumb.background. Use `scrollbar.thumb.background` instead."#, - theme_name = theme.name - ) - } - } - - Ok(theme_family) -} - -/// Deserializes a icon theme from the given bytes. -pub fn deserialize_icon_theme(bytes: &[u8]) -> Result { +/// Deserializes an icon theme from the given bytes. +pub fn deserialize_icon_theme(bytes: &[u8]) -> anyhow::Result { let icon_theme_family: IconThemeFamilyContent = serde_json_lenient::from_slice(bytes)?; Ok(icon_theme_family) } -/// The active theme +/// The active theme. pub struct GlobalTheme { theme: Arc, icon_theme: Arc, @@ -439,72 +272,27 @@ pub struct GlobalTheme { impl Global for GlobalTheme {} impl GlobalTheme { - fn configured_theme(cx: &mut App) -> Arc { - let themes = ThemeRegistry::default_global(cx); - let theme_settings = ThemeSettings::get_global(cx); - let system_appearance = SystemAppearance::global(cx); - - let theme_name = theme_settings.theme.name(*system_appearance); - - let theme = match themes.get(&theme_name.0) { - Ok(theme) => theme, - Err(err) => { - if themes.extensions_loaded() { - log::error!("{err}"); - } - themes - .get(default_theme(*system_appearance)) - // fallback for tests. - .unwrap_or_else(|_| themes.get(DEFAULT_DARK_THEME).unwrap()) - } - }; - theme_settings.apply_theme_overrides(theme) + /// Creates a new [`GlobalTheme`] with the given theme and icon theme. + pub fn new(theme: Arc, icon_theme: Arc) -> Self { + Self { theme, icon_theme } } - /// Reloads the current theme. - /// - /// Reads the [`ThemeSettings`] to know which theme should be loaded, - /// taking into account the current [`SystemAppearance`]. - pub fn reload_theme(cx: &mut App) { - let theme = Self::configured_theme(cx); + /// Updates the active theme. + pub fn update_theme(cx: &mut App, theme: Arc) { cx.update_global::(|this, _| this.theme = theme); - cx.refresh_windows(); - } - - fn configured_icon_theme(cx: &mut App) -> Arc { - let themes = ThemeRegistry::default_global(cx); - let theme_settings = ThemeSettings::get_global(cx); - let system_appearance = SystemAppearance::global(cx); - - let icon_theme_name = theme_settings.icon_theme.name(*system_appearance); - - match themes.get_icon_theme(&icon_theme_name.0) { - Ok(theme) => theme, - Err(err) => { - if themes.extensions_loaded() { - log::error!("{err}"); - } - themes.get_icon_theme(DEFAULT_ICON_THEME_NAME).unwrap() - } - } } - /// Reloads the current icon theme. - /// - /// Reads the [`ThemeSettings`] to know which icon theme should be loaded, - /// taking into account the current [`SystemAppearance`]. - pub fn reload_icon_theme(cx: &mut App) { - let icon_theme = Self::configured_icon_theme(cx); + /// Updates the active icon theme. + pub fn update_icon_theme(cx: &mut App, icon_theme: Arc) { cx.update_global::(|this, _| this.icon_theme = icon_theme); - cx.refresh_windows(); } - /// the active theme + /// Returns the active theme. pub fn theme(cx: &App) -> &Arc { &cx.global::().theme } - /// the active icon theme + /// Returns the active icon theme. pub fn icon_theme(cx: &App) -> &Arc { &cx.global::().icon_theme } diff --git a/crates/theme_extension/Cargo.toml b/crates/theme_extension/Cargo.toml index d94e15914b2dfbc8250641e8957366c27c2616a4..ca5b71de20b2166b81a14b79d81f581027245d6a 100644 --- a/crates/theme_extension/Cargo.toml +++ b/crates/theme_extension/Cargo.toml @@ -17,3 +17,4 @@ extension.workspace = true fs.workspace = true gpui.workspace = true theme.workspace = true +theme_settings.workspace = true diff --git a/crates/theme_extension/src/theme_extension.rs b/crates/theme_extension/src/theme_extension.rs index 7dcb43aa74c5520399e7d3a37f30ed3f6b74d410..85351a91f37a5b776b9db0f0bbbc4c05d3fc4616 100644 --- a/crates/theme_extension/src/theme_extension.rs +++ b/crates/theme_extension/src/theme_extension.rs @@ -5,7 +5,8 @@ use anyhow::Result; use extension::{ExtensionHostProxy, ExtensionThemeProxy}; use fs::Fs; use gpui::{App, BackgroundExecutor, SharedString, Task}; -use theme::{GlobalTheme, ThemeRegistry, deserialize_icon_theme}; +use theme::{ThemeRegistry, deserialize_icon_theme}; +use theme_settings; pub fn init( extension_host_proxy: Arc, @@ -30,7 +31,8 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { fn list_theme_names(&self, theme_path: PathBuf, fs: Arc) -> Task>> { self.executor.spawn(async move { - let themes = theme::deserialize_user_theme(&fs.load_bytes(&theme_path).await?)?; + let themes = + theme_settings::deserialize_user_theme(&fs.load_bytes(&theme_path).await?)?; Ok(themes.themes.into_iter().map(|theme| theme.name).collect()) }) } @@ -42,12 +44,12 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { fn load_user_theme(&self, theme_path: PathBuf, fs: Arc) -> Task> { let theme_registry = self.theme_registry.clone(); self.executor.spawn(async move { - theme_registry.load_user_theme(&fs.load_bytes(&theme_path).await?) + theme_settings::load_user_theme(&theme_registry, &fs.load_bytes(&theme_path).await?) }) } fn reload_current_theme(&self, cx: &mut App) { - GlobalTheme::reload_theme(cx) + theme_settings::reload_theme(cx) } fn list_icon_theme_names( @@ -85,6 +87,6 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { } fn reload_current_icon_theme(&self, cx: &mut App) { - GlobalTheme::reload_icon_theme(cx) + theme_settings::reload_icon_theme(cx) } } diff --git a/crates/theme_importer/Cargo.toml b/crates/theme_importer/Cargo.toml index a91ffc44544f898be35c4514910a6081b10b4a26..a0b86a286de965143ba3ade4ee4cdff56cf773d4 100644 --- a/crates/theme_importer/Cargo.toml +++ b/crates/theme_importer/Cargo.toml @@ -22,4 +22,5 @@ serde_json_lenient.workspace = true simplelog.workspace= true strum = { workspace = true, features = ["derive"] } theme.workspace = true +theme_settings.workspace = true vscode_theme = "0.2.0" diff --git a/crates/theme_importer/src/vscode/converter.rs b/crates/theme_importer/src/vscode/converter.rs index b052e865265368234d7a1bed42957a714ca9d5bb..70b7c0e9f663c64d73cf9360dd7733c12f1fb5fe 100644 --- a/crates/theme_importer/src/vscode/converter.rs +++ b/crates/theme_importer/src/vscode/converter.rs @@ -1,7 +1,7 @@ use anyhow::Result; use collections::IndexMap; use strum::IntoEnumIterator; -use theme::{ +use theme_settings::{ FontStyleContent, FontWeightContent, HighlightStyleContent, StatusColorsContent, ThemeColorsContent, ThemeContent, ThemeStyleContent, WindowBackgroundContent, }; diff --git a/crates/theme_selector/Cargo.toml b/crates/theme_selector/Cargo.toml index 1a563e81f202b484c846ed620aee3edd122fc80b..41e0e7681436f1fd8d6bfe743528af7d4f3d3ad6 100644 --- a/crates/theme_selector/Cargo.toml +++ b/crates/theme_selector/Cargo.toml @@ -22,6 +22,7 @@ serde.workspace = true settings.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index 1ddd6879405ad69a75e038da608d034f58bb5eff..13d6a87c4ac9911bef7a86c9df84171644ca6cf9 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -7,10 +7,8 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use settings::{Settings as _, SettingsStore, update_settings_file}; use std::sync::Arc; -use theme::{ - Appearance, IconThemeName, IconThemeSelection, SystemAppearance, ThemeMeta, ThemeRegistry, - ThemeSettings, -}; +use theme::{Appearance, SystemAppearance, ThemeMeta, ThemeRegistry}; +use theme_settings::{IconThemeName, IconThemeSelection, ThemeSettings}; use ui::{ListItem, ListItemSpacing, prelude::*, v_flex}; use util::ResultExt; use workspace::{ModalView, ui::HighlightedLabel}; @@ -176,7 +174,7 @@ impl PickerDelegate for IconThemeSelectorDelegate { let appearance = Appearance::from(window.appearance()); update_settings_file(self.fs.clone(), cx, move |settings, _| { - theme::set_icon_theme(settings, theme_name, appearance); + theme_settings::set_icon_theme(settings, theme_name, appearance); }); self.selector diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index f3c32c8f2f50cbec820e043a701f382e6ac22d0a..fb4d68a9da6f4a96e52fef288e58bdec90cae6fa 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -9,9 +9,9 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use settings::{Settings, SettingsStore, update_settings_file}; use std::sync::Arc; -use theme::{ - Appearance, SystemAppearance, Theme, ThemeAppearanceMode, ThemeMeta, ThemeName, ThemeRegistry, - ThemeSelection, ThemeSettings, +use theme::{Appearance, SystemAppearance, Theme, ThemeMeta, ThemeRegistry}; +use theme_settings::{ + ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings, appearance_to_mode, }; use ui::{ListItem, ListItemSpacing, prelude::*, v_flex}; use util::ResultExt; @@ -233,7 +233,7 @@ impl ThemeSelectorDelegate { /// Overrides the global (in-memory) theme settings. /// /// Note that this does **not** update the user's `settings.json` file (see the -/// [`ThemeSelectorDelegate::confirm`] method and [`theme::set_theme`] function). +/// [`ThemeSelectorDelegate::confirm`] method and [`theme_settings::set_theme`] function). fn override_global_theme( store: &mut SettingsStore, new_theme: &Theme, @@ -303,7 +303,7 @@ fn update_mode_if_new_appearance_is_different_from_system( if original_mode == &ThemeAppearanceMode::System && system_appearance == new_appearance { ThemeAppearanceMode::System } else { - ThemeAppearanceMode::from(new_appearance) + appearance_to_mode(new_appearance) } } @@ -360,7 +360,7 @@ impl PickerDelegate for ThemeSelectorDelegate { telemetry::event!("Settings Changed", setting = "theme", value = theme_name); update_settings_file(self.fs.clone(), cx, move |settings, _| { - theme::set_theme(settings, theme_name, theme_appearance, system_appearance); + theme_settings::set_theme(settings, theme_name, theme_appearance, system_appearance); }); self.selector diff --git a/crates/theme_settings/Cargo.toml b/crates/theme_settings/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..dfe4fa0f79fb437a2b03c680642ac6b19a91d251 --- /dev/null +++ b/crates/theme_settings/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "theme_settings" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[features] +default = [] +test-support = ["gpui/test-support", "settings/test-support", "theme/test-support"] + +[lib] +path = "src/theme_settings.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +collections.workspace = true +gpui.workspace = true +gpui_util.workspace = true +log.workspace = true +palette = { workspace = true, default-features = false, features = ["std"] } +refineable.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_json_lenient.workspace = true +settings.workspace = true +theme.workspace = true +uuid.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } diff --git a/crates/theme_settings/LICENSE-GPL b/crates/theme_settings/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/theme_settings/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/theme_settings/src/schema.rs b/crates/theme_settings/src/schema.rs new file mode 100644 index 0000000000000000000000000000000000000000..93eb4d30aa7ace9e10da3a0002dae3c6a6907d21 --- /dev/null +++ b/crates/theme_settings/src/schema.rs @@ -0,0 +1,850 @@ +#![allow(missing_docs)] + +use gpui::{HighlightStyle, Hsla}; +use palette::FromColor; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::IntoGpui; +pub use settings::{ + FontStyleContent, HighlightStyleContent, StatusColorsContent, ThemeColorsContent, + ThemeStyleContent, +}; +pub use settings::{FontWeightContent, WindowBackgroundContent}; + +use theme::{StatusColorsRefinement, ThemeColorsRefinement}; + +/// The content of a serialized theme family. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ThemeFamilyContent { + pub name: String, + pub author: String, + pub themes: Vec, +} + +/// The content of a serialized theme. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ThemeContent { + pub name: String, + pub appearance: theme::AppearanceContent, + pub style: settings::ThemeStyleContent, +} + +/// Returns the syntax style overrides in the [`ThemeContent`]. +pub fn syntax_overrides(this: &settings::ThemeStyleContent) -> Vec<(String, HighlightStyle)> { + this.syntax + .iter() + .map(|(key, style)| { + ( + key.clone(), + HighlightStyle { + color: style + .color + .as_ref() + .and_then(|color| theme::try_parse_color(color).ok()), + background_color: style + .background_color + .as_ref() + .and_then(|color| theme::try_parse_color(color).ok()), + font_style: style.font_style.map(|s| s.into_gpui()), + font_weight: style.font_weight.map(|w| w.into_gpui()), + ..Default::default() + }, + ) + }) + .collect() +} + +pub fn status_colors_refinement(colors: &settings::StatusColorsContent) -> StatusColorsRefinement { + StatusColorsRefinement { + conflict: colors + .conflict + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + conflict_background: colors + .conflict_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + conflict_border: colors + .conflict_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + created: colors + .created + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + created_background: colors + .created_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + created_border: colors + .created_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + deleted: colors + .deleted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + deleted_background: colors + .deleted_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + deleted_border: colors + .deleted_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + error: colors + .error + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + error_background: colors + .error_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + error_border: colors + .error_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hidden: colors + .hidden + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hidden_background: colors + .hidden_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hidden_border: colors + .hidden_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hint: colors + .hint + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hint_background: colors + .hint_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hint_border: colors + .hint_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ignored: colors + .ignored + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ignored_background: colors + .ignored_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ignored_border: colors + .ignored_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + info: colors + .info + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + info_background: colors + .info_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + info_border: colors + .info_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + modified: colors + .modified + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + modified_background: colors + .modified_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + modified_border: colors + .modified_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + predictive: colors + .predictive + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + predictive_background: colors + .predictive_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + predictive_border: colors + .predictive_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + renamed: colors + .renamed + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + renamed_background: colors + .renamed_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + renamed_border: colors + .renamed_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + success: colors + .success + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + success_background: colors + .success_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + success_border: colors + .success_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + unreachable: colors + .unreachable + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + unreachable_background: colors + .unreachable_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + unreachable_border: colors + .unreachable_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + warning: colors + .warning + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + warning_background: colors + .warning_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + warning_border: colors + .warning_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + } +} + +pub fn theme_colors_refinement( + this: &settings::ThemeColorsContent, + status_colors: &StatusColorsRefinement, +) -> ThemeColorsRefinement { + let border = this + .border + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let editor_document_highlight_read_background = this + .editor_document_highlight_read_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let scrollbar_thumb_background = this + .scrollbar_thumb_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or_else(|| { + this.deprecated_scrollbar_thumb_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + }); + let scrollbar_thumb_hover_background = this + .scrollbar_thumb_hover_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let scrollbar_thumb_active_background = this + .scrollbar_thumb_active_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_background); + let scrollbar_thumb_border = this + .scrollbar_thumb_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let element_hover = this + .element_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let panel_background = this + .panel_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let search_match_background = this + .search_match_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let search_active_match_background = this + .search_active_match_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(search_match_background); + ThemeColorsRefinement { + border, + border_variant: this + .border_variant + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + border_focused: this + .border_focused + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + border_selected: this + .border_selected + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + border_transparent: this + .border_transparent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + border_disabled: this + .border_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + elevated_surface_background: this + .elevated_surface_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + surface_background: this + .surface_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + background: this + .background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_background: this + .element_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_hover, + element_active: this + .element_active + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_selected: this + .element_selected + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_disabled: this + .element_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_selection_background: this + .element_selection_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + drop_target_background: this + .drop_target_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + drop_target_border: this + .drop_target_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_background: this + .ghost_element_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_hover: this + .ghost_element_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_active: this + .ghost_element_active + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_selected: this + .ghost_element_selected + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_disabled: this + .ghost_element_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text: this + .text + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text_muted: this + .text_muted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text_placeholder: this + .text_placeholder + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text_disabled: this + .text_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text_accent: this + .text_accent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon: this + .icon + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon_muted: this + .icon_muted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon_disabled: this + .icon_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon_placeholder: this + .icon_placeholder + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon_accent: this + .icon_accent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + debugger_accent: this + .debugger_accent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + status_bar_background: this + .status_bar_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + title_bar_background: this + .title_bar_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + title_bar_inactive_background: this + .title_bar_inactive_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + toolbar_background: this + .toolbar_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + tab_bar_background: this + .tab_bar_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + tab_inactive_background: this + .tab_inactive_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + tab_active_background: this + .tab_active_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + search_match_background, + search_active_match_background, + panel_background, + panel_focused_border: this + .panel_focused_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_indent_guide: this + .panel_indent_guide + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_indent_guide_hover: this + .panel_indent_guide_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_indent_guide_active: this + .panel_indent_guide_active + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_overlay_background: this + .panel_overlay_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(panel_background.map(ensure_opaque)), + panel_overlay_hover: this + .panel_overlay_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(panel_background + .zip(element_hover) + .map(|(panel_bg, hover_bg)| panel_bg.blend(hover_bg)) + .map(ensure_opaque)), + pane_focused_border: this + .pane_focused_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + pane_group_border: this + .pane_group_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(border), + scrollbar_thumb_background, + scrollbar_thumb_hover_background, + scrollbar_thumb_active_background, + scrollbar_thumb_border, + scrollbar_track_background: this + .scrollbar_track_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + scrollbar_track_border: this + .scrollbar_track_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + minimap_thumb_background: this + .minimap_thumb_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_background.map(ensure_non_opaque)), + minimap_thumb_hover_background: this + .minimap_thumb_hover_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_hover_background.map(ensure_non_opaque)), + minimap_thumb_active_background: this + .minimap_thumb_active_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_active_background.map(ensure_non_opaque)), + minimap_thumb_border: this + .minimap_thumb_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_border), + editor_foreground: this + .editor_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_background: this + .editor_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_gutter_background: this + .editor_gutter_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_subheader_background: this + .editor_subheader_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_active_line_background: this + .editor_active_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_highlighted_line_background: this + .editor_highlighted_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_debugger_active_line_background: this + .editor_debugger_active_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_line_number: this + .editor_line_number + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_hover_line_number: this + .editor_hover_line_number + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_active_line_number: this + .editor_active_line_number + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_invisible: this + .editor_invisible + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_wrap_guide: this + .editor_wrap_guide + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_active_wrap_guide: this + .editor_active_wrap_guide + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_indent_guide: this + .editor_indent_guide + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_indent_guide_active: this + .editor_indent_guide_active + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_document_highlight_read_background, + editor_document_highlight_write_background: this + .editor_document_highlight_write_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_document_highlight_bracket_background: this + .editor_document_highlight_bracket_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(editor_document_highlight_read_background), + terminal_background: this + .terminal_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_background: this + .terminal_ansi_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_foreground: this + .terminal_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_bright_foreground: this + .terminal_bright_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_dim_foreground: this + .terminal_dim_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_black: this + .terminal_ansi_black + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_black: this + .terminal_ansi_bright_black + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_black: this + .terminal_ansi_dim_black + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_red: this + .terminal_ansi_red + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_red: this + .terminal_ansi_bright_red + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_red: this + .terminal_ansi_dim_red + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_green: this + .terminal_ansi_green + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_green: this + .terminal_ansi_bright_green + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_green: this + .terminal_ansi_dim_green + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_yellow: this + .terminal_ansi_yellow + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_yellow: this + .terminal_ansi_bright_yellow + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_yellow: this + .terminal_ansi_dim_yellow + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_blue: this + .terminal_ansi_blue + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_blue: this + .terminal_ansi_bright_blue + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_blue: this + .terminal_ansi_dim_blue + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_magenta: this + .terminal_ansi_magenta + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_magenta: this + .terminal_ansi_bright_magenta + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_magenta: this + .terminal_ansi_dim_magenta + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_cyan: this + .terminal_ansi_cyan + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_cyan: this + .terminal_ansi_bright_cyan + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_cyan: this + .terminal_ansi_dim_cyan + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_white: this + .terminal_ansi_white + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_white: this + .terminal_ansi_bright_white + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_white: this + .terminal_ansi_dim_white + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + link_text_hover: this + .link_text_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + version_control_added: this + .version_control_added + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.created), + version_control_deleted: this + .version_control_deleted + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.deleted), + version_control_modified: this + .version_control_modified + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.modified), + version_control_renamed: this + .version_control_renamed + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.modified), + version_control_conflict: this + .version_control_conflict + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.ignored), + version_control_ignored: this + .version_control_ignored + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.ignored), + version_control_word_added: this + .version_control_word_added + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + version_control_word_deleted: this + .version_control_word_deleted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + #[allow(deprecated)] + version_control_conflict_marker_ours: this + .version_control_conflict_marker_ours + .as_ref() + .or(this.version_control_conflict_ours_background.as_ref()) + .and_then(|color| try_parse_color(color).ok()), + #[allow(deprecated)] + version_control_conflict_marker_theirs: this + .version_control_conflict_marker_theirs + .as_ref() + .or(this.version_control_conflict_theirs_background.as_ref()) + .and_then(|color| try_parse_color(color).ok()), + vim_normal_background: this + .vim_normal_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_insert_background: this + .vim_insert_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_replace_background: this + .vim_replace_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_background: this + .vim_visual_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_line_background: this + .vim_visual_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_block_background: this + .vim_visual_block_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_yank_background: this + .vim_yank_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(editor_document_highlight_read_background), + vim_helix_normal_background: this + .vim_helix_normal_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_helix_select_background: this + .vim_helix_select_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_normal_foreground: this + .vim_normal_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_insert_foreground: this + .vim_insert_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_replace_foreground: this + .vim_replace_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_foreground: this + .vim_visual_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_line_foreground: this + .vim_visual_line_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_block_foreground: this + .vim_visual_block_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_helix_normal_foreground: this + .vim_helix_normal_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_helix_select_foreground: this + .vim_helix_select_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + } +} + +fn ensure_non_opaque(color: Hsla) -> Hsla { + const MAXIMUM_OPACITY: f32 = 0.7; + if color.a <= MAXIMUM_OPACITY { + color + } else { + Hsla { + a: MAXIMUM_OPACITY, + ..color + } + } +} + +fn ensure_opaque(color: Hsla) -> Hsla { + Hsla { a: 1.0, ..color } +} + +fn try_parse_color(color: &str) -> anyhow::Result { + let rgba = gpui::Rgba::try_from(color)?; + let rgba = palette::rgb::Srgba::from_components((rgba.r, rgba.g, rgba.b, rgba.a)); + let hsla = palette::Hsla::from_color(rgba); + + let hsla = gpui::hsla( + hsla.hue.into_positive_degrees() / 360., + hsla.saturation, + hsla.lightness, + hsla.alpha, + ); + + Ok(hsla) +} diff --git a/crates/theme/src/settings.rs b/crates/theme_settings/src/settings.rs similarity index 91% rename from crates/theme/src/settings.rs rename to crates/theme_settings/src/settings.rs index c09d3daf6074f24248de12e56ebc2122e2c123e7..f292e539fd512ce290ed85b1b6796d2af12f9c43 100644 --- a/crates/theme/src/settings.rs +++ b/crates/theme_settings/src/settings.rs @@ -1,9 +1,8 @@ -use crate::{ - Appearance, DEFAULT_ICON_THEME_NAME, SyntaxTheme, Theme, status_colors_refinement, - syntax_overrides, theme_colors_refinement, -}; +#![allow(missing_docs)] + +use crate::schema::{status_colors_refinement, syntax_overrides, theme_colors_refinement}; +use crate::{merge_accent_colors, merge_player_colors}; use collections::HashMap; -use derive_more::{Deref, DerefMut}; use gpui::{ App, Context, Font, FontFallbacks, FontStyle, Global, Pixels, Subscription, Window, px, }; @@ -13,6 +12,7 @@ use serde::{Deserialize, Serialize}; pub use settings::{FontFamilyName, IconThemeName, ThemeAppearanceMode, ThemeName}; use settings::{IntoGpui, RegisterSetting, Settings, SettingsContent}; use std::sync::Arc; +use theme::{Appearance, DEFAULT_ICON_THEME_NAME, SyntaxTheme, Theme}; const MIN_FONT_SIZE: Pixels = px(6.0); const MAX_FONT_SIZE: Pixels = px(100.0); @@ -92,6 +92,13 @@ impl From for UiDensity { } } +pub fn appearance_to_mode(appearance: Appearance) -> ThemeAppearanceMode { + match appearance { + Appearance::Light => ThemeAppearanceMode::Light, + Appearance::Dark => ThemeAppearanceMode::Dark, + } +} + /// Customizable settings for the UI and theme system. #[derive(Clone, PartialEq, RegisterSetting)] pub struct ThemeSettings { @@ -145,39 +152,6 @@ pub fn default_theme(appearance: Appearance) -> &'static str { } } -/// The appearance of the system. -#[derive(Debug, Clone, Copy, Deref)] -pub struct SystemAppearance(pub Appearance); - -impl Default for SystemAppearance { - fn default() -> Self { - Self(Appearance::Dark) - } -} - -#[derive(Deref, DerefMut, Default)] -struct GlobalSystemAppearance(SystemAppearance); - -impl Global for GlobalSystemAppearance {} - -impl SystemAppearance { - /// Initializes the [`SystemAppearance`] for the application. - pub fn init(cx: &mut App) { - *cx.default_global::() = - GlobalSystemAppearance(SystemAppearance(cx.window_appearance().into())); - } - - /// Returns the global [`SystemAppearance`]. - pub fn global(cx: &App) -> Self { - cx.global::().0 - } - - /// Returns a mutable reference to the global [`SystemAppearance`]. - pub fn global_mut(cx: &mut App) -> &mut Self { - cx.global_mut::() - } -} - #[derive(Default)] struct BufferFontSize(Pixels); @@ -327,21 +301,16 @@ pub fn set_theme( *theme = theme_name; } settings::ThemeSelection::Dynamic { mode, light, dark } => { - // Update the appropriate theme slot based on appearance. match theme_appearance { Appearance::Light => *light = theme_name, Appearance::Dark => *dark = theme_name, } - // Don't update the theme mode if it is set to system and the new theme has the same - // appearance. let should_update_mode = !(mode == &ThemeAppearanceMode::System && theme_appearance == system_appearance); if should_update_mode { - // Update the mode to the specified appearance (otherwise we might set the theme and - // nothing gets updated because the system specified the other mode appearance). - *mode = ThemeAppearanceMode::from(theme_appearance); + *mode = appearance_to_mode(theme_appearance); } } } @@ -379,9 +348,6 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) { if let Some(selection) = theme.theme.as_mut() { match selection { settings::ThemeSelection::Static(_) => { - // If the theme was previously set to a single static theme, - // reset to the default dynamic light/dark pair and let users - // customize light/dark themes explicitly afterward. *selection = settings::ThemeSelection::Dynamic { mode: ThemeAppearanceMode::System, light: ThemeName(settings::DEFAULT_LIGHT_THEME.into()), @@ -404,9 +370,6 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) { if let Some(selection) = theme.icon_theme.as_mut() { match selection { settings::IconThemeSelection::Static(icon_theme) => { - // If the icon theme was previously set to a single static - // theme, we don't know whether it was a light or dark - // theme, so we just use it for both. *selection = settings::IconThemeSelection::Dynamic { mode, light: icon_theme.clone(), @@ -424,7 +387,6 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) { ))); } } -// } /// The buffer's line height. #[derive(Clone, Copy, Debug, PartialEq, Default)] @@ -530,7 +492,6 @@ impl ThemeSettings { self.agent_buffer_font_size } - // TODO: Rename: `line_height` -> `buffer_line_height` /// Returns the buffer's line height. pub fn line_height(&self) -> f32 { f32::max(self.buffer_line_height.value(), MIN_LINE_HEIGHT) @@ -538,7 +499,6 @@ impl ThemeSettings { /// Applies the theme overrides, if there are any, to the current theme. pub fn apply_theme_overrides(&self, mut arc_theme: Arc) -> Arc { - // Apply the old overrides setting first, so that the new setting can override those. if let Some(experimental_theme_overrides) = &self.experimental_theme_overrides { let mut theme = (*arc_theme).clone(); ThemeSettings::modify_theme(&mut theme, experimental_theme_overrides); @@ -566,11 +526,11 @@ impl ThemeSettings { &status_color_refinement, )); base_theme.styles.status.refine(&status_color_refinement); - base_theme.styles.player.merge(&theme_overrides.players); - base_theme.styles.accents.merge(&theme_overrides.accents); + merge_player_colors(&mut base_theme.styles.player, &theme_overrides.players); + merge_accent_colors(&mut base_theme.styles.accents, &theme_overrides.accents); base_theme.styles.syntax = SyntaxTheme::merge( base_theme.styles.syntax.clone(), - syntax_overrides(&theme_overrides), + syntax_overrides(theme_overrides), ); } } @@ -614,7 +574,6 @@ pub fn reset_buffer_font_size(cx: &mut App) { } } -// TODO: Make private, change usages to use `get_ui_font_size` instead. #[allow(missing_docs)] pub fn setup_ui_font(window: &mut Window, cx: &mut App) -> gpui::Font { let (ui_font, ui_font_size) = { diff --git a/crates/theme_settings/src/theme_settings.rs b/crates/theme_settings/src/theme_settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..807cbf1c6544673cc81cd38fc01c5fd1ad6a5b6f --- /dev/null +++ b/crates/theme_settings/src/theme_settings.rs @@ -0,0 +1,386 @@ +#![deny(missing_docs)] + +//! # Theme Settings +//! +//! This crate provides theme settings integration for Zed, +//! bridging the theme system with the settings infrastructure. + +mod schema; +mod settings; + +use std::sync::Arc; + +use ::settings::{IntoGpui, Settings, SettingsStore}; +use anyhow::{Context as _, Result}; +use gpui::{App, HighlightStyle, Refineable}; +use gpui_util::ResultExt; +use theme::{ + AccentColors, Appearance, AppearanceContent, DEFAULT_DARK_THEME, DEFAULT_ICON_THEME_NAME, + GlobalTheme, LoadThemes, PlayerColor, PlayerColors, StatusColors, SyntaxTheme, + SystemAppearance, SystemColors, Theme, ThemeColors, ThemeFamily, ThemeRegistry, ThemeStyles, + default_color_scales, try_parse_color, +}; + +pub use crate::schema::{ + FontStyleContent, FontWeightContent, HighlightStyleContent, StatusColorsContent, + ThemeColorsContent, ThemeContent, ThemeFamilyContent, ThemeStyleContent, + WindowBackgroundContent, status_colors_refinement, syntax_overrides, theme_colors_refinement, +}; +pub use crate::settings::{ + AgentFontSize, BufferLineHeight, FontFamilyName, IconThemeName, IconThemeSelection, + ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings, UiDensity, + adjust_agent_buffer_font_size, adjust_agent_ui_font_size, adjust_buffer_font_size, + adjust_ui_font_size, adjusted_font_size, appearance_to_mode, clamp_font_size, default_theme, + observe_buffer_font_size_adjustment, reset_agent_buffer_font_size, reset_agent_ui_font_size, + reset_buffer_font_size, reset_ui_font_size, set_icon_theme, set_mode, set_theme, setup_ui_font, +}; + +/// Initialize the theme system with settings integration. +/// +/// This is the full initialization for the application. It calls [`theme::init`] +/// and then wires up settings observation for theme/font changes. +pub fn init(themes_to_load: LoadThemes, cx: &mut App) { + let load_user_themes = matches!(&themes_to_load, LoadThemes::All(_)); + + theme::init(themes_to_load, cx); + + if load_user_themes { + let registry = ThemeRegistry::global(cx); + load_bundled_themes(®istry); + } + + let theme = configured_theme(cx); + let icon_theme = configured_icon_theme(cx); + GlobalTheme::update_theme(cx, theme); + GlobalTheme::update_icon_theme(cx, icon_theme); + + let settings = ThemeSettings::get_global(cx); + + let mut prev_buffer_font_size_settings = settings.buffer_font_size_settings(); + let mut prev_ui_font_size_settings = settings.ui_font_size_settings(); + let mut prev_agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); + let mut prev_agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); + let mut prev_theme_name = settings.theme.name(SystemAppearance::global(cx).0); + let mut prev_icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); + let mut prev_theme_overrides = ( + settings.experimental_theme_overrides.clone(), + settings.theme_overrides.clone(), + ); + + cx.observe_global::(move |cx| { + let settings = ThemeSettings::get_global(cx); + + let buffer_font_size_settings = settings.buffer_font_size_settings(); + let ui_font_size_settings = settings.ui_font_size_settings(); + let agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); + let agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); + let theme_name = settings.theme.name(SystemAppearance::global(cx).0); + let icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); + let theme_overrides = ( + settings.experimental_theme_overrides.clone(), + settings.theme_overrides.clone(), + ); + + if buffer_font_size_settings != prev_buffer_font_size_settings { + prev_buffer_font_size_settings = buffer_font_size_settings; + reset_buffer_font_size(cx); + } + + if ui_font_size_settings != prev_ui_font_size_settings { + prev_ui_font_size_settings = ui_font_size_settings; + reset_ui_font_size(cx); + } + + if agent_ui_font_size_settings != prev_agent_ui_font_size_settings { + prev_agent_ui_font_size_settings = agent_ui_font_size_settings; + reset_agent_ui_font_size(cx); + } + + if agent_buffer_font_size_settings != prev_agent_buffer_font_size_settings { + prev_agent_buffer_font_size_settings = agent_buffer_font_size_settings; + reset_agent_buffer_font_size(cx); + } + + if theme_name != prev_theme_name || theme_overrides != prev_theme_overrides { + prev_theme_name = theme_name; + prev_theme_overrides = theme_overrides; + reload_theme(cx); + } + + if icon_theme_name != prev_icon_theme_name { + prev_icon_theme_name = icon_theme_name; + reload_icon_theme(cx); + } + }) + .detach(); +} + +fn configured_theme(cx: &mut App) -> Arc { + let themes = ThemeRegistry::default_global(cx); + let theme_settings = ThemeSettings::get_global(cx); + let system_appearance = SystemAppearance::global(cx); + + let theme_name = theme_settings.theme.name(*system_appearance); + + let theme = match themes.get(&theme_name.0) { + Ok(theme) => theme, + Err(err) => { + if themes.extensions_loaded() { + log::error!("{err}"); + } + themes + .get(default_theme(*system_appearance)) + .unwrap_or_else(|_| themes.get(DEFAULT_DARK_THEME).unwrap()) + } + }; + theme_settings.apply_theme_overrides(theme) +} + +fn configured_icon_theme(cx: &mut App) -> Arc { + let themes = ThemeRegistry::default_global(cx); + let theme_settings = ThemeSettings::get_global(cx); + let system_appearance = SystemAppearance::global(cx); + + let icon_theme_name = theme_settings.icon_theme.name(*system_appearance); + + match themes.get_icon_theme(&icon_theme_name.0) { + Ok(theme) => theme, + Err(err) => { + if themes.extensions_loaded() { + log::error!("{err}"); + } + themes.get_icon_theme(DEFAULT_ICON_THEME_NAME).unwrap() + } + } +} + +/// Reloads the current theme from settings. +pub fn reload_theme(cx: &mut App) { + let theme = configured_theme(cx); + GlobalTheme::update_theme(cx, theme); + cx.refresh_windows(); +} + +/// Reloads the current icon theme from settings. +pub fn reload_icon_theme(cx: &mut App) { + let icon_theme = configured_icon_theme(cx); + GlobalTheme::update_icon_theme(cx, icon_theme); + cx.refresh_windows(); +} + +/// Loads the themes bundled with the Zed binary into the registry. +pub fn load_bundled_themes(registry: &ThemeRegistry) { + let theme_paths = registry + .assets() + .list("themes/") + .expect("failed to list theme assets") + .into_iter() + .filter(|path| path.ends_with(".json")); + + for path in theme_paths { + let Some(theme) = registry.assets().load(&path).log_err().flatten() else { + continue; + }; + + let Some(theme_family) = serde_json::from_slice(&theme) + .with_context(|| format!("failed to parse theme at path \"{path}\"")) + .log_err() + else { + continue; + }; + + let refined = refine_theme_family(theme_family); + registry.insert_theme_families([refined]); + } +} + +/// Loads a user theme from the given bytes into the registry. +pub fn load_user_theme(registry: &ThemeRegistry, bytes: &[u8]) -> Result<()> { + let theme = deserialize_user_theme(bytes)?; + let refined = refine_theme_family(theme); + registry.insert_theme_families([refined]); + Ok(()) +} + +/// Deserializes a user theme from the given bytes. +pub fn deserialize_user_theme(bytes: &[u8]) -> Result { + let theme_family: ThemeFamilyContent = serde_json_lenient::from_slice(bytes)?; + + for theme in &theme_family.themes { + if theme + .style + .colors + .deprecated_scrollbar_thumb_background + .is_some() + { + log::warn!( + r#"Theme "{theme_name}" is using a deprecated style property: scrollbar_thumb.background. Use `scrollbar.thumb.background` instead."#, + theme_name = theme.name + ) + } + } + + Ok(theme_family) +} + +/// Refines a [`ThemeFamilyContent`] and its [`ThemeContent`]s into a [`ThemeFamily`]. +pub fn refine_theme_family(theme_family_content: ThemeFamilyContent) -> ThemeFamily { + let id = uuid::Uuid::new_v4().to_string(); + let name = theme_family_content.name.clone(); + let author = theme_family_content.author.clone(); + + let themes: Vec = theme_family_content + .themes + .iter() + .map(|theme_content| refine_theme(theme_content)) + .collect(); + + ThemeFamily { + id, + name: name.into(), + author: author.into(), + themes, + scales: default_color_scales(), + } +} + +/// Refines a [`ThemeContent`] into a [`Theme`]. +pub fn refine_theme(theme: &ThemeContent) -> Theme { + let appearance = match theme.appearance { + AppearanceContent::Light => Appearance::Light, + AppearanceContent::Dark => Appearance::Dark, + }; + + let mut refined_status_colors = match theme.appearance { + AppearanceContent::Light => StatusColors::light(), + AppearanceContent::Dark => StatusColors::dark(), + }; + let mut status_colors_refinement = status_colors_refinement(&theme.style.status); + theme::apply_status_color_defaults(&mut status_colors_refinement); + refined_status_colors.refine(&status_colors_refinement); + + let mut refined_player_colors = match theme.appearance { + AppearanceContent::Light => PlayerColors::light(), + AppearanceContent::Dark => PlayerColors::dark(), + }; + merge_player_colors(&mut refined_player_colors, &theme.style.players); + + let mut refined_theme_colors = match theme.appearance { + AppearanceContent::Light => ThemeColors::light(), + AppearanceContent::Dark => ThemeColors::dark(), + }; + let mut theme_colors_refinement = + theme_colors_refinement(&theme.style.colors, &status_colors_refinement); + theme::apply_theme_color_defaults(&mut theme_colors_refinement, &refined_player_colors); + refined_theme_colors.refine(&theme_colors_refinement); + + let mut refined_accent_colors = match theme.appearance { + AppearanceContent::Light => AccentColors::light(), + AppearanceContent::Dark => AccentColors::dark(), + }; + merge_accent_colors(&mut refined_accent_colors, &theme.style.accents); + + let syntax_highlights = theme.style.syntax.iter().map(|(syntax_token, highlight)| { + ( + syntax_token.clone(), + HighlightStyle { + color: highlight + .color + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + background_color: highlight + .background_color + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + font_style: highlight.font_style.map(|s| s.into_gpui()), + font_weight: highlight.font_weight.map(|w| w.into_gpui()), + ..Default::default() + }, + ) + }); + let syntax_theme = Arc::new(SyntaxTheme::new(syntax_highlights)); + + let window_background_appearance = theme + .style + .window_background_appearance + .map(|w| w.into_gpui()) + .unwrap_or_default(); + + Theme { + id: uuid::Uuid::new_v4().to_string(), + name: theme.name.clone().into(), + appearance, + styles: ThemeStyles { + system: SystemColors::default(), + window_background_appearance, + accents: refined_accent_colors, + colors: refined_theme_colors, + status: refined_status_colors, + player: refined_player_colors, + syntax: syntax_theme, + }, + } +} + +/// Merges player color overrides into the given [`PlayerColors`]. +pub fn merge_player_colors( + player_colors: &mut PlayerColors, + user_player_colors: &[::settings::PlayerColorContent], +) { + if user_player_colors.is_empty() { + return; + } + + for (idx, player) in user_player_colors.iter().enumerate() { + let cursor = player + .cursor + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let background = player + .background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let selection = player + .selection + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + + if let Some(player_color) = player_colors.0.get_mut(idx) { + *player_color = PlayerColor { + cursor: cursor.unwrap_or(player_color.cursor), + background: background.unwrap_or(player_color.background), + selection: selection.unwrap_or(player_color.selection), + }; + } else { + player_colors.0.push(PlayerColor { + cursor: cursor.unwrap_or_default(), + background: background.unwrap_or_default(), + selection: selection.unwrap_or_default(), + }); + } + } +} + +/// Merges accent color overrides into the given [`AccentColors`]. +pub fn merge_accent_colors( + accent_colors: &mut AccentColors, + user_accent_colors: &[::settings::AccentContent], +) { + if user_accent_colors.is_empty() { + return; + } + + let colors = user_accent_colors + .iter() + .filter_map(|accent_color| { + accent_color + .0 + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + }) + .collect::>(); + + if !colors.is_empty() { + accent_colors.0 = Arc::from(colors); + } +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 6ea1b6d26f700c9c44a8dda5e510d0505d7e7db8..9fb86398dd0b4787d1be900811509845a7c2bc58 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -28,6 +28,7 @@ smallvec.workspace = true story = { workspace = true, optional = true } strum.workspace = true theme.workspace = true +theme_settings.workspace = true ui_macros.workspace = true gpui_util.workspace = true diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 064b67a433f0d053db9552e8def1064237db3980..c36ffe1d0a665fa83543ac0c1f2b2fe5ec693365 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -15,7 +15,7 @@ use std::{ rc::Rc, time::{Duration, Instant}, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum SubmenuOpenTrigger { diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index d87bdf6c12323c4858881f36af62f1a91cdd2aa1..7570cba61b61048ae78f9c9da41afa3d281baaca 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -2,7 +2,7 @@ use crate::prelude::*; use gpui::{FontWeight, Rems, StyleRefinement, UnderlineStyle}; use settings::Settings; use smallvec::SmallVec; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; /// Sets the size of a label #[derive(Debug, PartialEq, Clone, Copy, Default)] @@ -191,7 +191,9 @@ impl LabelCommon for LabelLike { } fn buffer_font(mut self, cx: &App) -> Self { - let font = theme::ThemeSettings::get_global(cx).buffer_font.clone(); + let font = theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(); self.weight = Some(font.weight); self.base = self.base.font(font); self @@ -200,7 +202,11 @@ impl LabelCommon for LabelLike { fn inline_code(mut self, cx: &App) -> Self { self.base = self .base - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font( + theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(), + ) .bg(cx.theme().colors().element_background) .rounded_sm() .px_0p5(); diff --git a/crates/ui/src/components/list/list_header.rs b/crates/ui/src/components/list/list_header.rs index 8726dca50dada193b3051f14b6609a373fc60730..264a7c755b48f484ba1bec550258b1cc2af5db41 100644 --- a/crates/ui/src/components/list/list_header.rs +++ b/crates/ui/src/components/list/list_header.rs @@ -4,7 +4,7 @@ use crate::{Disclosure, prelude::*}; use component::{Component, ComponentScope, example_group_with_title, single_example}; use gpui::{AnyElement, ClickEvent}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; #[derive(IntoElement, RegisterComponent)] pub struct ListHeader { @@ -91,7 +91,7 @@ impl RenderOnce for ListHeader { .child( div() .map(|this| match ui_density { - theme::UiDensity::Comfortable => this.h_5(), + theme_settings::UiDensity::Comfortable => this.h_5(), _ => this.h_7(), }) .when(self.inset, |this| this.px_2()) diff --git a/crates/ui/src/components/tooltip.rs b/crates/ui/src/components/tooltip.rs index 8b4ff3f73163f38e19da80462e687db3d88efc6f..0717f077e30c1962fe3384f6d33018ff2b469e95 100644 --- a/crates/ui/src/components/tooltip.rs +++ b/crates/ui/src/components/tooltip.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gpui::{Action, AnyElement, AnyView, AppContext, FocusHandle, IntoElement, Render}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use crate::prelude::*; use crate::{Color, KeyBinding, Label, LabelSize, StyledExt, h_flex, v_flex}; diff --git a/crates/ui/src/styles/spacing.rs b/crates/ui/src/styles/spacing.rs index c6629f5d8829b2ebd59a80a2a22c033ab8c389f6..5984b7946ab691981cf5c591fb2e6a855b882cd9 100644 --- a/crates/ui/src/styles/spacing.rs +++ b/crates/ui/src/styles/spacing.rs @@ -1,6 +1,6 @@ use gpui::{App, Pixels, Rems, px, rems}; use settings::Settings; -use theme::{ThemeSettings, UiDensity}; +use theme_settings::{ThemeSettings, UiDensity}; use ui_macros::derive_dynamic_spacing; // Derives [DynamicSpacing]. See [ui_macros::derive_dynamic_spacing]. diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index 2bb0b35720be715251bc7c11a139a1fccfaf6035..abc5cde303e5ad327b008efc1bf24e75836e756c 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -4,7 +4,8 @@ use gpui::{ div, rems, }; use settings::Settings; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; +use theme_settings::ThemeSettings; use crate::{Color, rems_from_px}; diff --git a/crates/ui_macros/src/dynamic_spacing.rs b/crates/ui_macros/src/dynamic_spacing.rs index 15ba3e241ec43d02b83e4143eb620505a0a2f02e..17c91d7de143dd56d9b7cfb1df1cb94d6c71214f 100644 --- a/crates/ui_macros/src/dynamic_spacing.rs +++ b/crates/ui_macros/src/dynamic_spacing.rs @@ -66,9 +66,9 @@ pub fn derive_spacing(input: TokenStream) -> TokenStream { let n = n.base10_parse::().unwrap(); quote! { DynamicSpacing::#variant => match ThemeSettings::get_global(cx).ui_density { - ::theme::UiDensity::Compact => (#n - 4.0).max(0.0) / BASE_REM_SIZE_IN_PX, - ::theme::UiDensity::Default => #n / BASE_REM_SIZE_IN_PX, - ::theme::UiDensity::Comfortable => (#n + 4.0) / BASE_REM_SIZE_IN_PX, + ::theme_settings::UiDensity::Compact => (#n - 4.0).max(0.0) / BASE_REM_SIZE_IN_PX, + ::theme_settings::UiDensity::Default => #n / BASE_REM_SIZE_IN_PX, + ::theme_settings::UiDensity::Comfortable => (#n + 4.0) / BASE_REM_SIZE_IN_PX, } } } @@ -78,9 +78,9 @@ pub fn derive_spacing(input: TokenStream) -> TokenStream { let c = c.base10_parse::().unwrap(); quote! { DynamicSpacing::#variant => match ThemeSettings::get_global(cx).ui_density { - ::theme::UiDensity::Compact => #a / BASE_REM_SIZE_IN_PX, - ::theme::UiDensity::Default => #b / BASE_REM_SIZE_IN_PX, - ::theme::UiDensity::Comfortable => #c / BASE_REM_SIZE_IN_PX, + ::theme_settings::UiDensity::Compact => #a / BASE_REM_SIZE_IN_PX, + ::theme_settings::UiDensity::Default => #b / BASE_REM_SIZE_IN_PX, + ::theme_settings::UiDensity::Comfortable => #c / BASE_REM_SIZE_IN_PX, } } } diff --git a/crates/ui_prompt/Cargo.toml b/crates/ui_prompt/Cargo.toml index 55a98288433a7b31507310e20c4209a9d419e45f..9bcce107f3f7d6bd95ebddf6d33c4a9a29ec4493 100644 --- a/crates/ui_prompt/Cargo.toml +++ b/crates/ui_prompt/Cargo.toml @@ -19,6 +19,6 @@ gpui.workspace = true markdown.workspace = true menu.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true workspace.workspace = true diff --git a/crates/ui_prompt/src/ui_prompt.rs b/crates/ui_prompt/src/ui_prompt.rs index 3b2716fd92ea7889668767d66e47e5c43792f39e..92b1c9e74dcd2f7e227f5c325ea5defb0d9c8ed3 100644 --- a/crates/ui_prompt/src/ui_prompt.rs +++ b/crates/ui_prompt/src/ui_prompt.rs @@ -5,7 +5,7 @@ use gpui::{ }; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use settings::{Settings, SettingsStore}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{FluentBuilder, TintColor, prelude::*}; use workspace::WorkspaceSettings; diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 7b4cff5ff9bdf37666076c403593c45131a63067..64282953a33312b85cc1e7cf21076b0cb61dccab 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -44,6 +44,7 @@ settings.workspace = true task.workspace = true text.workspace = true theme.workspace = true +theme_settings.workspace = true menu.workspace = true tokio = { version = "1.15", features = ["full"], optional = true } ui.workspace = true diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 0b68eb7dd0cac51352d5119c7a0c07ae8f8abb3d..2ae4abe33a0fbb4bc6f8a838e60dc0857949e0dc 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -29,7 +29,7 @@ use std::collections::HashSet; use std::path::Path; use std::{fmt::Display, ops::Range, sync::Arc}; use text::{Bias, ToPoint}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ActiveTheme, Context, Div, FluentBuilder, KeyBinding, ParentElement, SharedString, Styled, StyledTypography, Window, h_flex, rems, diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index d8574bb1b76b707fe9d36545ea054480cf097d64..510d218df050455d0df0f9c2b7b782a651694cd7 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -27,7 +27,7 @@ impl VimTestContext { git_ui::init(cx); crate::init(cx); search::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); settings_ui::init(cx); markdown_preview::init(cx); zed_actions::init(); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 11cf59f590823068088308a74354badf3bacfbd1..eb2248ded0e574ca0d30206237694d50bc7f152e 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -51,7 +51,7 @@ pub use settings::{ use state::{Mode, Operator, RecordedSelection, SearchState, VimGlobals}; use std::{mem, ops::Range, sync::Arc}; use surrounds::SurroundsType; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{IntoElement, SharedString, px}; use vim_mode_setting::HelixModeSetting; use vim_mode_setting::VimModeSetting; diff --git a/crates/which_key/Cargo.toml b/crates/which_key/Cargo.toml index f53ba45dd71abc972ce23efb8871f485dfe47207..cafcc2306b89d805f3e02b70060e4bb23b3436ff 100644 --- a/crates/which_key/Cargo.toml +++ b/crates/which_key/Cargo.toml @@ -17,7 +17,7 @@ command_palette.workspace = true gpui.workspace = true serde.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/which_key/src/which_key_modal.rs b/crates/which_key/src/which_key_modal.rs index 238431b90a8eafdd0e085a3f109e8f812fbe709b..38b99207ea693b0cfc4113c4d4a4d70940090014 100644 --- a/crates/which_key/src/which_key_modal.rs +++ b/crates/which_key/src/which_key_modal.rs @@ -7,7 +7,7 @@ use gpui::{ }; use settings::Settings; use std::collections::HashMap; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*, text_for_keystrokes, diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index fd160fd3024564d7451be0c29958cbb4a33eee38..42e64504f348a727d17d2538d06556497fba54df 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -63,6 +63,7 @@ strum.workspace = true task.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 9e043e9ae7feb9f4ece21945d48d818f7345a03d..924471d4dd08fa14f08723ffb990e9d8f555c048 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -883,7 +883,7 @@ impl Render for MultiWorkspace { (sidebar, None) }; - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let text_color = cx.theme().colors().text; let workspace = self.workspace().clone(); @@ -970,7 +970,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); DisableAiSettings::register(cx); cx.update_flags(false, vec!["agent-v2".into()]); }); diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 85b1fe4e707acbc7107df14d23caa3bda24519e5..dbf2accf3dd9910426ca3557daf9cee0e5b0a82b 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -9,7 +9,7 @@ use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; use project::project_settings::ProjectSettings; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use std::ops::Deref; use std::sync::{Arc, LazyLock}; diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 2d9f8be5c363b5b5c10c5432a543ae46de7611d2..113e5af6424904655d21d83591c8e9a361df3b50 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -42,7 +42,7 @@ use std::{ }, time::Duration, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButtonShape, IconDecoration, IconDecorationKind, Indicator, PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, @@ -8515,7 +8515,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); }); } diff --git a/crates/workspace/src/tasks.rs b/crates/workspace/src/tasks.rs index 8a2ae6a40ab6328c2a2328fbdbe0e5be5972cf22..0ebb97b9d75543986bb6727546aad872a11a4f87 100644 --- a/crates/workspace/src/tasks.rs +++ b/crates/workspace/src/tasks.rs @@ -254,7 +254,7 @@ mod tests { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); register_serializable_item::(cx); }); let fs = FakeFs::new(cx.executor()); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 1d7c71c1ea4d66c65155a6491b7cf8a526256d82..2d56be01c48139168d2fda5fead3929872c1c2d9 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -130,7 +130,8 @@ use std::{ time::Duration, }; use task::{DebugScenario, SharedTaskContext, SpawnInTerminal}; -use theme::{ActiveTheme, GlobalTheme, SystemAppearance, ThemeSettings}; +use theme::{ActiveTheme, SystemAppearance}; +use theme_settings::ThemeSettings; pub use toolbar::{ PaneSearchBarCallbacks, Toolbar, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, }; @@ -1142,7 +1143,7 @@ impl AppState { let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); client::init(&client, cx); Arc::new(Self { @@ -1682,8 +1683,8 @@ impl Workspace { *SystemAppearance::global_mut(cx) = SystemAppearance(window_appearance.into()); - GlobalTheme::reload_theme(cx); - GlobalTheme::reload_icon_theme(cx); + theme_settings::reload_theme(cx); + theme_settings::reload_icon_theme(cx); }), cx.on_release({ let weak_handle = weak_handle.clone(); @@ -7473,17 +7474,23 @@ impl Workspace { fn toggle_theme_mode(&mut self, _: &ToggleMode, _window: &mut Window, cx: &mut Context) { let current_mode = ThemeSettings::get_global(cx).theme.mode(); let next_mode = match current_mode { - Some(theme::ThemeAppearanceMode::Light) => theme::ThemeAppearanceMode::Dark, - Some(theme::ThemeAppearanceMode::Dark) => theme::ThemeAppearanceMode::Light, - Some(theme::ThemeAppearanceMode::System) | None => match cx.theme().appearance() { - theme::Appearance::Light => theme::ThemeAppearanceMode::Dark, - theme::Appearance::Dark => theme::ThemeAppearanceMode::Light, - }, + Some(theme_settings::ThemeAppearanceMode::Light) => { + theme_settings::ThemeAppearanceMode::Dark + } + Some(theme_settings::ThemeAppearanceMode::Dark) => { + theme_settings::ThemeAppearanceMode::Light + } + Some(theme_settings::ThemeAppearanceMode::System) | None => { + match cx.theme().appearance() { + theme::Appearance::Light => theme_settings::ThemeAppearanceMode::Dark, + theme::Appearance::Dark => theme_settings::ThemeAppearanceMode::Light, + } + } }; let fs = self.project().read(cx).fs().clone(); settings::update_settings_file(fs, cx, move |settings, _cx| { - theme::set_mode(settings, next_mode); + theme_settings::set_mode(settings, next_mode); }); } @@ -7912,7 +7919,7 @@ impl Render for Workspace { } else { (None, None) }; - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let theme = cx.theme().clone(); let colors = theme.colors(); @@ -14409,7 +14416,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); cx.set_global(db::AppDatabase::test_new()); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 4529fe35ccf1866a21539eeafa09aafaa3239cbf..a602220f2c02a0e510b46e86f4cec5fed2488ac9 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -197,6 +197,7 @@ telemetry.workspace = true telemetry_events.workspace = true terminal_view.workspace = true theme.workspace = true +theme_settings.workspace = true theme_extension.workspace = true theme_selector.workspace = true time.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 60d696d05f0136cacc3c5d71ad9885b6acf3630d..ebe2d2c07b303657f26c83e04c7484bbaf794bd5 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -52,6 +52,7 @@ use std::{ time::Instant, }; use theme::{ActiveTheme, GlobalTheme, ThemeRegistry}; +use theme_settings::load_user_theme; use util::{ResultExt, TryFutureExt, maybe}; use uuid::Uuid; use workspace::{ @@ -639,7 +640,7 @@ fn main() { cx, ); - theme::init(theme::LoadThemes::All(Box::new(Assets)), cx); + theme_settings::init(theme::LoadThemes::All(Box::new(Assets)), cx); eager_load_active_theme_and_icon_theme(fs.clone(), cx); theme_extension::init( extension_host_proxy, @@ -1796,10 +1797,10 @@ fn load_user_themes_in_background(fs: Arc, cx: &mut App) { continue; }; - theme_registry.load_user_theme(&bytes).log_err(); + load_user_theme(&theme_registry, &bytes).log_err(); } - cx.update(GlobalTheme::reload_theme); + cx.update(theme_settings::reload_theme); anyhow::Ok(()) } }) @@ -1819,9 +1820,9 @@ fn watch_themes(fs: Arc, cx: &mut App) { if fs.metadata(&event.path).await.ok().flatten().is_some() { let theme_registry = cx.update(|cx| ThemeRegistry::global(cx)); if let Some(bytes) = fs.load_bytes(&event.path).await.log_err() - && theme_registry.load_user_theme(&bytes).log_err().is_some() + && load_user_theme(&theme_registry, &bytes).log_err().is_some() { - cx.update(GlobalTheme::reload_theme); + cx.update(theme_settings::reload_theme); } } } diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index e20c8f034833c2f7ebb3ce132c843b37fe1816be..ecee1190ce8865f54dc68f0c0052c9a7c1b5b59c 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -176,7 +176,7 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> // Initialize all Zed subsystems cx.update(|cx| { gpui_tokio::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); client::init(&app_state.client, cx); audio::init(cx); workspace::init(app_state.clone(), cx); @@ -965,7 +965,7 @@ fn init_app_state(cx: &mut App) -> Arc { let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx)); let workspace_store = cx.new(|cx| workspace::WorkspaceStore::new(client.clone(), cx)); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); client::init(&client, cx); let app_state = Arc::new(AppState { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index e4ccedcf4143d194406307e84b2ff03ef375609f..de90cc0cacf8c0c94acbd799475129b1dd49e706 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -77,10 +77,8 @@ use std::{ sync::atomic::{self, AtomicBool}, }; use terminal_view::terminal_panel::{self, TerminalPanel}; -use theme::{ - ActiveTheme, GlobalTheme, SystemAppearance, ThemeRegistry, ThemeSettings, - deserialize_icon_theme, -}; +use theme::{ActiveTheme, SystemAppearance, ThemeRegistry, deserialize_icon_theme}; +use theme_settings::{ThemeSettings, load_user_theme}; use ui::{PopoverMenuHandle, prelude::*}; use util::markdown::MarkdownString; use util::rel_path::RelPath; @@ -903,10 +901,10 @@ fn register_actions( let _ = settings .theme .ui_font_size - .insert(f32::from(theme::clamp_font_size(ui_font_size)).into()); + .insert(f32::from(theme_settings::clamp_font_size(ui_font_size)).into()); }); } else { - theme::adjust_ui_font_size(cx, |size| size + px(1.0)); + theme_settings::adjust_ui_font_size(cx, |size| size + px(1.0)); } } }) @@ -919,10 +917,10 @@ fn register_actions( let _ = settings .theme .ui_font_size - .insert(f32::from(theme::clamp_font_size(ui_font_size)).into()); + .insert(f32::from(theme_settings::clamp_font_size(ui_font_size)).into()); }); } else { - theme::adjust_ui_font_size(cx, |size| size - px(1.0)); + theme_settings::adjust_ui_font_size(cx, |size| size - px(1.0)); } } }) @@ -934,7 +932,7 @@ fn register_actions( settings.theme.ui_font_size = None; }); } else { - theme::reset_ui_font_size(cx); + theme_settings::reset_ui_font_size(cx); } } }) @@ -948,10 +946,10 @@ fn register_actions( let _ = settings .theme .buffer_font_size - .insert(f32::from(theme::clamp_font_size(buffer_font_size)).into()); + .insert(f32::from(theme_settings::clamp_font_size(buffer_font_size)).into()); }); } else { - theme::adjust_buffer_font_size(cx, |size| size + px(1.0)); + theme_settings::adjust_buffer_font_size(cx, |size| size + px(1.0)); } } }) @@ -965,10 +963,10 @@ fn register_actions( let _ = settings .theme .buffer_font_size - .insert(f32::from(theme::clamp_font_size(buffer_font_size)).into()); + .insert(f32::from(theme_settings::clamp_font_size(buffer_font_size)).into()); }); } else { - theme::adjust_buffer_font_size(cx, |size| size - px(1.0)); + theme_settings::adjust_buffer_font_size(cx, |size| size - px(1.0)); } } }) @@ -980,7 +978,7 @@ fn register_actions( settings.theme.buffer_font_size = None; }); } else { - theme::reset_buffer_font_size(cx); + theme_settings::reset_buffer_font_size(cx); } } }) @@ -995,10 +993,10 @@ fn register_actions( settings.theme.agent_buffer_font_size = None; }); } else { - theme::reset_ui_font_size(cx); - theme::reset_buffer_font_size(cx); - theme::reset_agent_ui_font_size(cx); - theme::reset_agent_buffer_font_size(cx); + theme_settings::reset_ui_font_size(cx); + theme_settings::reset_buffer_font_size(cx); + theme_settings::reset_agent_ui_font_size(cx); + theme_settings::reset_agent_buffer_font_size(cx); } } }) @@ -2228,7 +2226,7 @@ pub(crate) fn eager_load_active_theme_and_icon_theme(fs: Arc, cx: &mut A match load_target { LoadTarget::Theme(theme_path) => { if let Some(bytes) = fs.load_bytes(&theme_path).await.log_err() - && theme_registry.load_user_theme(&bytes).log_err().is_some() + && load_user_theme(theme_registry, &bytes).log_err().is_some() { reload_tasks.lock().push(ReloadTarget::Theme); } @@ -2252,8 +2250,8 @@ pub(crate) fn eager_load_active_theme_and_icon_theme(fs: Arc, cx: &mut A for reload_target in reload_tasks.into_inner() { match reload_target { - ReloadTarget::Theme => GlobalTheme::reload_theme(cx), - ReloadTarget::IconTheme => GlobalTheme::reload_icon_theme(cx), + ReloadTarget::Theme => theme_settings::reload_theme(cx), + ReloadTarget::IconTheme => theme_settings::reload_icon_theme(cx), }; } } @@ -4457,7 +4455,7 @@ mod tests { cx.update(|cx| { let app_state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); client::init(&app_state.client, cx); workspace::init(app_state.clone(), cx); onboarding::init(cx); @@ -4875,7 +4873,7 @@ mod tests { .unwrap(); let themes = ThemeRegistry::default(); settings::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); let mut has_default_theme = false; for theme_name in themes.list().into_iter().map(|meta| meta.name) { @@ -5013,7 +5011,7 @@ mod tests { app_state.languages.add(markdown_lang()); gpui_tokio::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); audio::init(cx); channel::init(&app_state.client, app_state.user_store.clone(), cx); call::init(app_state.client.clone(), app_state.user_store.clone(), cx); diff --git a/crates/zed/src/zed/migrate.rs b/crates/zed/src/zed/migrate.rs index f8bec397f1cf54fe37962c6a318a816a3158423e..f7d320a0814f17c47298f0d903800c5a98e353f1 100644 --- a/crates/zed/src/zed/migrate.rs +++ b/crates/zed/src/zed/migrate.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use gpui::{Entity, EventEmitter, Global, Task, TextStyle, TextStyleRefinement}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::prelude::*; use workspace::item::ItemHandle; use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace}; diff --git a/crates/zed/src/zed/telemetry_log.rs b/crates/zed/src/zed/telemetry_log.rs index 06e13ef5d86fb665151b13ce01de5a60def9ba15..cc07783f57b27cc57a281089effb208fc3947050 100644 --- a/crates/zed/src/zed/telemetry_log.rs +++ b/crates/zed/src/zed/telemetry_log.rs @@ -16,7 +16,7 @@ use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; use project::Project; use settings::Settings; use telemetry_events::{Event, EventWrapper}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Icon, IconButton, IconName, IconSize, Label, TextSize, Tooltip, WithScrollbar, prelude::*, }; diff --git a/crates/zed/src/zed/visual_tests.rs b/crates/zed/src/zed/visual_tests.rs index 0aab800eaf0e8664a875751d0b1df1abce98c945..982db08782207a9bfef96ec8f17c28c8abac41f3 100644 --- a/crates/zed/src/zed/visual_tests.rs +++ b/crates/zed/src/zed/visual_tests.rs @@ -51,7 +51,7 @@ pub fn init_visual_test(cx: &mut VisualTestAppContext) -> Arc { let app_state = AppState::test(cx); gpui_tokio::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); audio::init(cx); workspace::init(app_state.clone(), cx); release_channel::init(semver::Version::new(0, 0, 0), cx); From cb97ac48a8fd0fe2da88272df4f05b950d48c027 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 27 Mar 2026 15:38:53 +0100 Subject: [PATCH 36/53] Always pass `--no-optional-lock` to git (#52499) Release Notes: - N/A or Added/Fixed/Improved ... --- crates/git/src/repository.rs | 27 ++++++----------------- crates/worktree/src/worktree.rs | 38 ++++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 32904aa9a9001187193c91a055a5e0393221514d..728d84e34d59626585fcec72d023bcef4dc79249 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1046,7 +1046,6 @@ impl GitRepository for RealGitRepository { let git = git_binary?; let output = git .build_command(&[ - "--no-optional-locks", "show", "--no-patch", "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00", @@ -1084,7 +1083,6 @@ impl GitRepository for RealGitRepository { let git = git_binary?; let show_output = git .build_command(&[ - "--no-optional-locks", "show", "--format=", "-z", @@ -1105,7 +1103,7 @@ impl GitRepository for RealGitRepository { let parent_sha = format!("{}^", commit); let mut cat_file_process = git - .build_command(&["--no-optional-locks", "cat-file", "--batch=%(objectsize)"]) + .build_command(&["cat-file", "--batch=%(objectsize)"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1417,11 +1415,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let mut process = git - .build_command(&[ - "--no-optional-locks", - "cat-file", - "--batch-check=%(objectname)", - ]) + .build_command(&["cat-file", "--batch-check=%(objectname)"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1495,7 +1489,6 @@ impl GitRepository for RealGitRepository { }; let mut args = vec![ - OsString::from("--no-optional-locks"), OsString::from("diff-tree"), OsString::from("-r"), OsString::from("-z"), @@ -1612,7 +1605,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let output = git - .build_command(&["--no-optional-locks", "worktree", "list", "--porcelain"]) + .build_command(&["worktree", "list", "--porcelain"]) .output() .await?; if output.status.success() { @@ -1634,7 +1627,6 @@ impl GitRepository for RealGitRepository { ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); let mut args = vec![ - OsString::from("--no-optional-locks"), OsString::from("worktree"), OsString::from("add"), OsString::from("-b"), @@ -1668,11 +1660,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - let mut args: Vec = vec![ - "--no-optional-locks".into(), - "worktree".into(), - "remove".into(), - ]; + let mut args: Vec = vec!["worktree".into(), "remove".into()]; if force { args.push("--force".into()); } @@ -1690,7 +1678,6 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let args: Vec = vec![ - "--no-optional-locks".into(), "worktree".into(), "move".into(), "--".into(), @@ -1833,7 +1820,7 @@ impl GitRepository for RealGitRepository { commit_delimiter ); - let mut args = vec!["--no-optional-locks", "log", "--follow", &format_string]; + let mut args = vec!["log", "--follow", &format_string]; let skip_str; let limit_str; @@ -2741,7 +2728,7 @@ async fn run_commit_data_reader( request_rx: smol::channel::Receiver, ) -> Result<()> { let mut process = git - .build_command(&["--no-optional-locks", "cat-file", "--batch"]) + .build_command(&["cat-file", "--batch"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -2855,7 +2842,6 @@ fn parse_initial_graph_output<'a>( fn git_status_args(path_prefixes: &[RepoPath]) -> Vec { let mut args = vec![ - OsString::from("--no-optional-locks"), OsString::from("status"), OsString::from("--porcelain=v1"), OsString::from("--untracked-files=all"), @@ -3039,6 +3025,7 @@ impl GitBinary { let mut command = new_command(&self.git_binary_path); command.current_dir(&self.working_directory); command.args(["-c", "core.fsmonitor=false"]); + command.arg("--no-optional-locks"); command.arg("--no-pager"); if !self.is_trusted { diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 6bd78f55ad709212002d3cf6ffcd3d41da5d5f8b..07f01e21758aa79509e7d6466e2f3b798eb7b8d3 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -7,7 +7,9 @@ use chardetng::EncodingDetector; use clock::ReplicaId; use collections::{HashMap, HashSet, VecDeque}; use encoding_rs::Encoding; -use fs::{Fs, MTime, PathEvent, RemoveOptions, Watcher, copy_recursive, read_dir_items}; +use fs::{ + Fs, MTime, PathEvent, PathEventKind, RemoveOptions, Watcher, copy_recursive, read_dir_items, +}; use futures::{ FutureExt as _, Stream, StreamExt, channel::{ @@ -4137,7 +4139,7 @@ impl BackgroundScanner { } for (ix, event) in events.iter().enumerate() { - let abs_path = &SanitizedPath::new(&event.path); + let abs_path = SanitizedPath::new(&event.path); let mut is_git_related = false; let mut dot_git_paths = None; @@ -4154,13 +4156,33 @@ impl BackgroundScanner { } if let Some((dot_git_abs_path, path_in_git_dir)) = dot_git_paths { - if skipped_files_in_dot_git + // We ignore `""` as well, as that is going to be the + // `.git` folder itself. WE do not care about it, if + // there are changes within we will see them, we need + // this ignore to prevent us from accidentally observing + // the ignored created file due to the events not being + // empty after filtering. + + let is_dot_git_changed = { + path_in_git_dir == Path::new("") + && event.kind == Some(PathEventKind::Changed) + && abs_path + .strip_prefix(root_canonical_path) + .ok() + .and_then(|it| RelPath::new(it, PathStyle::local()).ok()) + .is_some_and(|it| { + snapshot + .entry_for_path(&it) + .is_some_and(|entry| entry.kind == EntryKind::Dir) + }) + }; + let condition = skipped_files_in_dot_git.iter().any(|skipped| { + OsStr::new(skipped) == path_in_git_dir.as_path().as_os_str() + }) || skipped_dirs_in_dot_git .iter() - .any(|skipped| OsStr::new(skipped) == path_in_git_dir.as_path().as_os_str()) - || skipped_dirs_in_dot_git.iter().any(|skipped_git_subdir| { - path_in_git_dir.starts_with(skipped_git_subdir) - }) - { + .any(|skipped_git_subdir| path_in_git_dir.starts_with(skipped_git_subdir)) + || is_dot_git_changed; + if condition { log::debug!( "ignoring event {abs_path:?} as it's in the .git directory among skipped files or directories" ); From 852b4fc5f4b30cbe8a2df04c056562331d355b3d Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:10:01 -0400 Subject: [PATCH 37/53] git_graph: Implement basic search functionality (#51886) ## Context This uses `git log` to get a basic search working in the git graph. This is one of the last blockers until a full release, the others being improvements to the graph canvas UI. ## 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: Remco Smits Co-authored-by: Danilo Leal --- Cargo.lock | 3 + crates/fs/src/fake_git_repo.rs | 11 +- crates/git/src/git.rs | 8 + crates/git/src/repository.rs | 71 +++ crates/git_graph/Cargo.toml | 3 + crates/git_graph/src/git_graph.rs | 437 ++++++++++++++++-- crates/project/src/git_store.rs | 28 +- crates/search/src/search.rs | 4 +- .../src/components/label/highlighted_label.rs | 27 ++ 9 files changed, 549 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b30819007b3688ee50e92d4f7c1a6e63ec9b44b..edc6d7248d356404db79c66dcb360a0cf7677a6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7302,6 +7302,7 @@ dependencies = [ "anyhow", "collections", "db", + "editor", "feature_flags", "fs", "git", @@ -7311,9 +7312,11 @@ dependencies = [ "menu", "project", "rand 0.9.2", + "search", "serde_json", "settings", "smallvec", + "smol", "theme", "theme_settings", "time", diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 9c218c8e53f9a2135ee09fadc78f627e3960da54..38cb1e6b3c467dba4430767c2f4d6705c1d8b2aa 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -8,7 +8,7 @@ use git::{ repository::{ AskPassDelegate, Branch, CommitDataReader, CommitDetails, CommitOptions, FetchOptions, GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, - LogSource, PushOptions, Remote, RepoPath, ResetMode, Worktree, + LogSource, PushOptions, Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree, }, status::{ DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus, @@ -1017,6 +1017,15 @@ impl GitRepository for FakeGitRepository { .boxed() } + fn search_commits( + &self, + _log_source: LogSource, + _search_args: SearchCommitArgs, + _request_tx: Sender, + ) -> BoxFuture<'_, Result<()>> { + async { bail!("search_commits not supported for FakeGitRepository") }.boxed() + } + fn commit_data_reader(&self) -> Result { anyhow::bail!("commit_data_reader not supported for FakeGitRepository") } diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 13745c1fdfc0523d850b95e45a81cae286a77a00..766378bf2e514d8a50348b608d52e9e764072f21 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -161,6 +161,14 @@ impl Oid { } } +impl TryFrom<&str> for Oid { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::prelude::v1::Result { + Oid::from_str(value) + } +} + impl FromStr for Oid { type Err = anyhow::Error; diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 728d84e34d59626585fcec72d023bcef4dc79249..036ceeb620e1aa0345b6f9a296c16069c0fa09bf 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -50,6 +50,10 @@ pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user"; /// %x00 - Null byte separator, used to split up commit data static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D"; +/// Used to get commits that match with a search +/// %H - Full commit hash +static SEARCH_COMMIT_FORMAT: &str = "--format=%H"; + /// Number of commits to load per chunk for the git graph. pub const GRAPH_CHUNK_SIZE: usize = 1000; @@ -623,6 +627,11 @@ impl LogSource { } } +pub struct SearchCommitArgs { + pub query: SharedString, + pub case_sensitive: bool, +} + pub trait GitRepository: Send + Sync { fn reload_index(&self); @@ -875,6 +884,13 @@ pub trait GitRepository: Send + Sync { request_tx: Sender>>, ) -> BoxFuture<'_, Result<()>>; + fn search_commits( + &self, + log_source: LogSource, + search_args: SearchCommitArgs, + request_tx: Sender, + ) -> BoxFuture<'_, Result<()>>; + fn commit_data_reader(&self) -> Result; fn set_trusted(&self, trusted: bool); @@ -2696,6 +2712,61 @@ impl GitRepository for RealGitRepository { .boxed() } + fn search_commits( + &self, + log_source: LogSource, + search_args: SearchCommitArgs, + request_tx: Sender, + ) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary(); + + async move { + let git = git_binary?; + + let mut args = vec!["log", SEARCH_COMMIT_FORMAT, log_source.get_arg()?]; + + args.push("--fixed-strings"); + + if !search_args.case_sensitive { + args.push("--regexp-ignore-case"); + } + + args.push("--grep"); + args.push(search_args.query.as_str()); + + let mut command = git.build_command(&args); + command.stdout(Stdio::piped()); + command.stderr(Stdio::null()); + + let mut child = command.spawn()?; + let stdout = child.stdout.take().context("failed to get stdout")?; + let mut reader = BufReader::new(stdout); + + let mut line_buffer = String::new(); + + loop { + line_buffer.clear(); + let bytes_read = reader.read_line(&mut line_buffer).await?; + + if bytes_read == 0 { + break; + } + + let sha = line_buffer.trim_end_matches('\n'); + + if let Ok(oid) = Oid::from_str(sha) + && request_tx.send(oid).await.is_err() + { + break; + } + } + + child.status().await?; + Ok(()) + } + .boxed() + } + fn commit_data_reader(&self) -> Result { let git_binary = self.git_binary()?; diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml index 24f2a02fe3679b947fdd0c5328da45cb2d8f8ae1..6aeaefe7e9b32ab01b19e6f9747f9128f3718edf 100644 --- a/crates/git_graph/Cargo.toml +++ b/crates/git_graph/Cargo.toml @@ -22,6 +22,7 @@ test-support = [ anyhow.workspace = true collections.workspace = true db.workspace = true +editor.workspace = true feature_flags.workspace = true git.workspace = true git_ui.workspace = true @@ -29,8 +30,10 @@ gpui.workspace = true language.workspace = true menu.workspace = true project.workspace = true +search.workspace = true settings.workspace = true smallvec.workspace = true +smol.workspace = true theme.workspace = true theme_settings.workspace = true time.workspace = true diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index be813848db4268be17b5bb0bc4ae3e59bfa4ff3c..1c2bdae6a193a8dce6e7e9e2d894fabdb9274b8b 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -1,16 +1,20 @@ -use collections::{BTreeMap, HashMap}; +use collections::{BTreeMap, HashMap, IndexSet}; +use editor::Editor; use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, - repository::{CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath}, + repository::{ + CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath, + SearchCommitArgs, + }, status::{FileStatus, StatusCode, TrackedStatus}, }; use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon}; use gpui::{ AnyElement, App, Bounds, ClickEvent, ClipboardItem, Corner, DefiniteLength, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, PathBuilder, Pixels, - Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, + Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, px, uniform_list, }; @@ -23,6 +27,10 @@ use project::{ RepositoryEvent, RepositoryId, }, }; +use search::{ + SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, + ToggleCaseSensitive, +}; use settings::Settings; use smallvec::{SmallVec, smallvec}; use std::{ @@ -37,9 +45,9 @@ use theme::AccentColors; use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ - ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, ScrollableHandle, - Table, TableColumnWidths, TableInteractionState, TableResizeBehavior, Tooltip, WithScrollbar, - prelude::*, + ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, HighlightedLabel, + ScrollableHandle, Table, TableColumnWidths, TableInteractionState, TableResizeBehavior, + Tooltip, WithScrollbar, prelude::*, }; use workspace::{ Workspace, @@ -198,6 +206,29 @@ impl ChangedFileEntry { } } +enum QueryState { + Pending(SharedString), + Confirmed((SharedString, Task<()>)), + Empty, +} + +impl QueryState { + fn next_state(&mut self) { + match self { + Self::Confirmed((query, _)) => *self = Self::Pending(std::mem::take(query)), + _ => {} + }; + } +} + +struct SearchState { + case_sensitive: bool, + editor: Entity, + state: QueryState, + pub matches: IndexSet, + pub selected_index: Option, +} + pub struct SplitState { left_ratio: f32, visible_left_ratio: f32, @@ -743,7 +774,7 @@ pub fn init(cx: &mut App) { let existing = workspace.items_of_type::(cx).next(); if let Some(existing) = existing { existing.update(cx, |graph, cx| { - graph.select_commit_by_sha(&sha, cx); + graph.select_commit_by_sha(sha.as_str(), cx); }); workspace.activate_item(&existing, true, true, window, cx); return; @@ -754,7 +785,7 @@ pub fn init(cx: &mut App) { let git_graph = cx.new(|cx| { let mut graph = GitGraph::new(project, workspace_handle, window, cx); - graph.select_commit_by_sha(&sha, cx); + graph.select_commit_by_sha(sha.as_str(), cx); graph }); workspace.add_item_to_active_pane( @@ -836,6 +867,7 @@ fn compute_diff_stats(diff: &CommitDiff) -> (usize, usize) { pub struct GitGraph { focus_handle: FocusHandle, + search_state: SearchState, graph_data: GraphData, project: Entity, workspace: WeakEntity, @@ -860,6 +892,14 @@ pub struct GitGraph { } impl GitGraph { + fn invalidate_state(&mut self, cx: &mut Context) { + self.graph_data.clear(); + self.search_state.matches.clear(); + self.search_state.selected_index = None; + self.search_state.state.next_state(); + cx.notify(); + } + fn row_height(cx: &App) -> Pixels { let settings = ThemeSettings::get_global(cx); let font_size = settings.buffer_font_size(cx); @@ -902,8 +942,7 @@ impl GitGraph { // todo(git_graph): Make this selectable from UI so we don't have to always use active repository if this.selected_repo_id != *changed_repo_id { this.selected_repo_id = *changed_repo_id; - this.graph_data.clear(); - cx.notify(); + this.invalidate_state(cx); } } _ => {} @@ -915,6 +954,12 @@ impl GitGraph { .active_repository(cx) .map(|repo| repo.read(cx).id); + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search commits…", window, cx); + editor + }); + let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx)); let table_column_widths = cx.new(|cx| TableColumnWidths::new(4, cx)); let mut row_height = Self::row_height(cx); @@ -934,6 +979,13 @@ impl GitGraph { let mut this = GitGraph { focus_handle, + search_state: SearchState { + case_sensitive: false, + editor: search_editor, + matches: IndexSet::default(), + selected_index: None, + state: QueryState::Empty, + }, project, workspace, graph_data: graph, @@ -981,7 +1033,7 @@ impl GitGraph { .and_then(|data| data.commit_oid_to_index.get(&oid).copied()) }) { - self.select_entry(pending_sha_index, cx); + self.select_entry(pending_sha_index, ScrollStrategy::Nearest, cx); } } GitGraphEvent::LoadingError => { @@ -1017,7 +1069,7 @@ impl GitGraph { pending_sha_index }) { - self.select_entry(pending_selection_index, cx); + self.select_entry(pending_selection_index, ScrollStrategy::Nearest, cx); self.pending_select_sha.take(); } @@ -1031,8 +1083,7 @@ impl GitGraph { // meaning we are not inside the initial repo loading state // NOTE: this fixes an loading performance regression if repository.read(cx).scan_id > 1 { - self.graph_data.clear(); - cx.notify(); + self.invalidate_state(cx); } } RepositoryEvent::GraphEvent(_, _) => {} @@ -1129,6 +1180,7 @@ impl GitGraph { .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); let is_selected = self.selected_entry_idx == Some(idx); + let is_matched = self.search_state.matches.contains(&commit.data.sha); let column_label = |label: SharedString| { Label::new(label) .when(!is_selected, |c| c.color(Color::Muted)) @@ -1136,11 +1188,49 @@ impl GitGraph { .into_any_element() }; + let subject_label = if is_matched { + let query = match &self.search_state.state { + QueryState::Confirmed((query, _)) => Some(query.clone()), + _ => None, + }; + let highlight_ranges = query + .and_then(|q| { + let ranges = if self.search_state.case_sensitive { + subject + .match_indices(q.as_str()) + .map(|(start, matched)| start..start + matched.len()) + .collect::>() + } else { + let q = q.to_lowercase(); + let subject_lower = subject.to_lowercase(); + + subject_lower + .match_indices(&q) + .filter_map(|(start, matched)| { + let end = start + matched.len(); + subject.is_char_boundary(start).then_some(()).and_then( + |_| subject.is_char_boundary(end).then_some(start..end), + ) + }) + .collect::>() + }; + + (!ranges.is_empty()).then_some(ranges) + }) + .unwrap_or_default(); + HighlightedLabel::from_ranges(subject.clone(), highlight_ranges) + .when(!is_selected, |c| c.color(Color::Muted)) + .truncate() + .into_any_element() + } else { + column_label(subject.clone()) + }; + vec![ div() .id(ElementId::NamedInteger("commit-subject".into(), idx as u64)) .overflow_hidden() - .tooltip(Tooltip::text(subject.clone())) + .tooltip(Tooltip::text(subject)) .child( h_flex() .gap_2() @@ -1154,7 +1244,7 @@ impl GitGraph { .map(|name| self.render_chip(name, accent_color)), ) })) - .child(column_label(subject)), + .child(subject_label), ) .into_any_element(), column_label(formatted_time.into()), @@ -1173,12 +1263,16 @@ impl GitGraph { } fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { - self.select_entry(0, cx); + self.select_entry(0, ScrollStrategy::Nearest, cx); } fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { if let Some(selected_entry_idx) = &self.selected_entry_idx { - self.select_entry(selected_entry_idx.saturating_sub(1), cx); + self.select_entry( + selected_entry_idx.saturating_sub(1), + ScrollStrategy::Nearest, + cx, + ); } else { self.select_first(&SelectFirst, window, cx); } @@ -1190,6 +1284,7 @@ impl GitGraph { selected_entry_idx .saturating_add(1) .min(self.graph_data.commits.len().saturating_sub(1)), + ScrollStrategy::Nearest, cx, ); } else { @@ -1198,14 +1293,88 @@ impl GitGraph { } fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { - self.select_entry(self.graph_data.commits.len().saturating_sub(1), cx); + self.select_entry( + self.graph_data.commits.len().saturating_sub(1), + ScrollStrategy::Nearest, + cx, + ); } fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { self.open_selected_commit_view(window, cx); } - fn select_entry(&mut self, idx: usize, cx: &mut Context) { + fn search(&mut self, query: SharedString, cx: &mut Context) { + let Some(repo) = self.get_selected_repository(cx) else { + return; + }; + + self.search_state.matches.clear(); + self.search_state.selected_index = None; + self.search_state.editor.update(cx, |editor, _cx| { + editor.set_text_style_refinement(Default::default()); + }); + + let (request_tx, request_rx) = smol::channel::unbounded::(); + + repo.update(cx, |repo, cx| { + repo.search_commits( + self.log_source.clone(), + SearchCommitArgs { + query: query.clone(), + case_sensitive: self.search_state.case_sensitive, + }, + request_tx, + cx, + ); + }); + + let search_task = cx.spawn(async move |this, cx| { + while let Ok(first_oid) = request_rx.recv().await { + let mut pending_oids = vec![first_oid]; + while let Ok(oid) = request_rx.try_recv() { + pending_oids.push(oid); + } + + this.update(cx, |this, cx| { + if this.search_state.selected_index.is_none() { + this.search_state.selected_index = Some(0); + this.select_commit_by_sha(first_oid, cx); + } + + this.search_state.matches.extend(pending_oids); + cx.notify(); + }) + .ok(); + } + + this.update(cx, |this, cx| { + if this.search_state.matches.is_empty() { + this.search_state.editor.update(cx, |editor, cx| { + editor.set_text_style_refinement(TextStyleRefinement { + color: Some(Color::Error.color(cx)), + ..Default::default() + }); + }); + } + }) + .ok(); + }); + + self.search_state.state = QueryState::Confirmed((query, search_task)); + } + + fn confirm_search(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + let query = self.search_state.editor.read(cx).text(cx).into(); + self.search(query, cx); + } + + fn select_entry( + &mut self, + idx: usize, + scroll_strategy: ScrollStrategy, + cx: &mut Context, + ) { if self.selected_entry_idx == Some(idx) { return; } @@ -1216,9 +1385,7 @@ impl GitGraph { self.changed_files_scroll_handle .scroll_to_item(0, ScrollStrategy::Top); self.table_interaction_state.update(cx, |state, cx| { - state - .scroll_handle - .scroll_to_item(idx, ScrollStrategy::Nearest); + state.scroll_handle.scroll_to_item(idx, scroll_strategy); cx.notify(); }); @@ -1249,25 +1416,71 @@ impl GitGraph { cx.notify(); } - pub fn select_commit_by_sha(&mut self, sha: &str, cx: &mut Context) { - let Ok(oid) = sha.parse::() else { + fn select_previous_match(&mut self, cx: &mut Context) { + if self.search_state.matches.is_empty() { return; - }; + } + + let mut prev_selection = self.search_state.selected_index.unwrap_or_default(); + + if prev_selection == 0 { + prev_selection = self.search_state.matches.len() - 1; + } else { + prev_selection -= 1; + } - let Some(selected_repository) = self.get_selected_repository(cx) else { + let Some(&oid) = self.search_state.matches.get_index(prev_selection) else { return; }; - let Some(index) = selected_repository - .read(cx) - .get_graph_data(self.log_source.clone(), self.log_order) - .and_then(|data| data.commit_oid_to_index.get(&oid)) - .copied() - else { + self.search_state.selected_index = Some(prev_selection); + self.select_commit_by_sha(oid, cx); + } + + fn select_next_match(&mut self, cx: &mut Context) { + if self.search_state.matches.is_empty() { + return; + } + + let mut next_selection = self + .search_state + .selected_index + .map(|index| index + 1) + .unwrap_or_default(); + + if next_selection >= self.search_state.matches.len() { + next_selection = 0; + } + + let Some(&oid) = self.search_state.matches.get_index(next_selection) else { return; }; - self.select_entry(index, cx); + self.search_state.selected_index = Some(next_selection); + self.select_commit_by_sha(oid, cx); + } + + pub fn select_commit_by_sha(&mut self, sha: impl TryInto, cx: &mut Context) { + fn inner(this: &mut GitGraph, oid: Oid, cx: &mut Context) { + let Some(selected_repository) = this.get_selected_repository(cx) else { + return; + }; + + let Some(index) = selected_repository + .read(cx) + .get_graph_data(this.log_source.clone(), this.log_order) + .and_then(|data| data.commit_oid_to_index.get(&oid)) + .copied() + else { + return; + }; + + this.select_entry(index, ScrollStrategy::Center, cx); + } + + if let Ok(oid) = sha.try_into() { + inner(self, oid, cx); + } } fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context) { @@ -1319,6 +1532,129 @@ impl GitGraph { }) } + fn render_search_bar(&self, cx: &mut Context) -> impl IntoElement { + let color = cx.theme().colors(); + let query_focus_handle = self.search_state.editor.focus_handle(cx); + let search_options = { + let mut options = SearchOptions::NONE; + options.set( + SearchOptions::CASE_SENSITIVE, + self.search_state.case_sensitive, + ); + options + }; + + h_flex() + .w_full() + .p_1p5() + .gap_1p5() + .border_b_1() + .border_color(color.border_variant) + .child( + h_flex() + .h_8() + .flex_1() + .min_w_0() + .px_1p5() + .gap_1() + .border_1() + .border_color(color.border) + .rounded_md() + .bg(color.toolbar_background) + .on_action(cx.listener(Self::confirm_search)) + .child(self.search_state.editor.clone()) + .child(SearchOption::CaseSensitive.as_button( + search_options, + SearchSource::Buffer, + query_focus_handle, + )), + ) + .child( + h_flex() + .min_w_64() + .gap_1() + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("git-graph-search-prev", IconName::ChevronLeft) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Select Previous Match", + &SelectPreviousMatch, + &focus_handle, + cx, + ) + }) + .map(|this| { + if self.search_state.matches.is_empty() { + this.disabled(true) + } else { + this.disabled(false).on_click(cx.listener(|this, _, _, cx| { + this.select_previous_match(cx); + })) + } + }) + }) + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("git-graph-search-next", IconName::ChevronRight) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Select Next Match", + &SelectNextMatch, + &focus_handle, + cx, + ) + }) + .map(|this| { + if self.search_state.matches.is_empty() { + this.disabled(true) + } else { + this.disabled(false).on_click(cx.listener(|this, _, _, cx| { + this.select_next_match(cx); + })) + } + }) + }) + .child( + h_flex() + .gap_1p5() + .child( + Label::new(format!( + "{}/{}", + self.search_state + .selected_index + .map(|index| index + 1) + .unwrap_or(0), + self.search_state.matches.len() + )) + .size(LabelSize::Small) + .when(self.search_state.matches.is_empty(), |this| { + this.color(Color::Disabled) + }), + ) + .when( + matches!( + &self.search_state.state, + QueryState::Confirmed((_, task)) if !task.is_ready() + ), + |this| { + this.child( + Icon::new(IconName::ArrowCircle) + .color(Color::Accent) + .size(IconSize::Small) + .with_rotate_animation(2) + .into_any_element(), + ) + }, + ), + ), + ) + } + fn render_loading_spinner(&self, cx: &App) -> AnyElement { let rems = TextSize::Large.rems(cx); Icon::new(IconName::LoadCircle) @@ -1361,7 +1697,8 @@ impl GitGraph { .copied() .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); - let (author_name, author_email, commit_timestamp, subject) = match &data { + // todo(git graph): We should use the full commit message here + let (author_name, author_email, commit_timestamp, commit_message) = match &data { CommitDataState::Loaded(data) => ( data.author_name.clone(), data.author_email.clone(), @@ -1617,7 +1954,7 @@ impl GitGraph { ), ) .child(Divider::horizontal()) - .child(div().min_w_0().p_2().child(Label::new(subject))) + .child(div().p_2().child(Label::new(commit_message))) .child(Divider::horizontal()) .child( v_flex() @@ -1977,7 +2314,7 @@ impl GitGraph { cx: &mut Context, ) { if let Some(row) = self.row_at_position(event.position().y, cx) { - self.select_entry(row, cx); + self.select_entry(row, ScrollStrategy::Nearest, cx); if event.click_count() >= 2 { self.open_commit_view(row, window, cx); } @@ -2068,6 +2405,12 @@ impl GitGraph { impl Render for GitGraph { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // This happens when we changed branches, we should refresh our search as well + if let QueryState::Pending(query) = &mut self.search_state.state { + let query = std::mem::take(query); + self.search_state.state = QueryState::Empty; + self.search(query, cx); + } let description_width_fraction = 0.72; let date_width_fraction = 0.12; let author_width_fraction = 0.10; @@ -2230,7 +2573,7 @@ impl Render for GitGraph { .on_click(move |event, window, cx| { let click_count = event.click_count(); weak.update(cx, |this, cx| { - this.select_entry(index, cx); + this.select_entry(index, ScrollStrategy::Center, cx); if click_count >= 2 { this.open_commit_view(index, window, cx); } @@ -2276,7 +2619,23 @@ impl Render for GitGraph { .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) - .child(content) + .on_action(cx.listener(|this, _: &SelectNextMatch, _window, cx| { + this.select_next_match(cx); + })) + .on_action(cx.listener(|this, _: &SelectPreviousMatch, _window, cx| { + this.select_previous_match(cx); + })) + .on_action(cx.listener(|this, _: &ToggleCaseSensitive, _window, cx| { + this.search_state.case_sensitive = !this.search_state.case_sensitive; + this.search_state.state.next_state(); + cx.notify(); + })) + .child( + v_flex() + .size_full() + .child(self.render_search_bar(cx)) + .child(div().flex_1().child(content)), + ) .children(self.context_menu.as_ref().map(|(menu, position, _)| { deferred( anchored() diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 346ebb1614e3b536d78765ce7ca90ad1e30f6bfc..f439c5da157cdcdaec813a1fd63ea119af78cb83 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -34,7 +34,7 @@ use git::{ repository::{ Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, FetchOptions, GitRepository, GitRepositoryCheckpoint, GraphCommitData, InitialGraphCommitData, LogOrder, - LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, + LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree, }, stash::{GitStash, StashEntry}, @@ -4570,6 +4570,32 @@ impl Repository { self.initial_graph_data.get(&(log_source, log_order)) } + pub fn search_commits( + &mut self, + log_source: LogSource, + search_args: SearchCommitArgs, + request_tx: smol::channel::Sender, + cx: &mut Context, + ) { + let repository_state = self.repository_state.clone(); + + cx.background_spawn(async move { + let repo_state = repository_state.await; + + match repo_state { + Ok(RepositoryState::Local(LocalRepositoryState { backend, .. })) => { + backend + .search_commits(log_source, search_args, request_tx) + .await + .log_err(); + } + Ok(RepositoryState::Remote(_)) => {} + Err(_) => {} + }; + }) + .detach(); + } + pub fn graph_data( &mut self, log_source: LogSource, diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index d2104492bebf529821f8ad8571fd3fbb8bdbc69e..8edcdd600bd352d4e33c0c8c1ec9aed3f427c71c 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -85,7 +85,7 @@ pub enum SearchOption { Backwards, } -pub(crate) enum SearchSource<'a, 'b> { +pub enum SearchSource<'a, 'b> { Buffer, Project(&'a Context<'b, ProjectSearchBar>), } @@ -126,7 +126,7 @@ impl SearchOption { } } - pub(crate) fn as_button( + pub fn as_button( &self, active: SearchOptions, search_source: SearchSource, diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index 1b10d910dd0ed1501188781622851e720c0ca102..73e03f82dfdef38f10c62b69be3b75da8a24dd08 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -29,6 +29,33 @@ impl HighlightedLabel { } } + /// Constructs a label with the given byte ranges highlighted. + /// Assumes that the highlight ranges are valid UTF-8 byte positions. + pub fn from_ranges( + label: impl Into, + highlight_ranges: Vec>, + ) -> Self { + let label = label.into(); + let highlight_indices = highlight_ranges + .iter() + .flat_map(|range| { + let mut indices = Vec::new(); + let mut index = range.start; + while index < range.end { + indices.push(index); + index += label[index..].chars().next().map_or(0, |c| c.len_utf8()); + } + indices + }) + .collect(); + + Self { + base: LabelLike::new(), + label, + highlight_indices, + } + } + pub fn text(&self) -> &str { self.label.as_str() } From 58e63ffcb760fd8f5ee256d10560cf25969d9b77 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:19:36 -0400 Subject: [PATCH 38/53] git_graph: Fix checkout curve rendering (#52580) This makes the curves start later in the graph which is one of the last things for the graph to be ready for release ### Before image ### After image Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A or Added/Fixed/Improved ... --- crates/git_graph/src/git_graph.rs | 80 +++++++++++++++++++------------ 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 1c2bdae6a193a8dce6e7e9e2d894fabdb9274b8b..305dc8e42d3789cf8495fa30fbb5ca5770b10600 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -2192,13 +2192,45 @@ impl GitGraph { -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH }; - let control = match curve_kind { + match curve_kind { CurveKind::Checkout => { if is_last { to_column -= column_shift; } builder.move_to(point(current_column, current_row)); - point(current_column, to_row) + + if (to_column - current_column).abs() > LANE_WIDTH { + // Multi-lane checkout: straight down, small + // curve turn, then straight horizontal. + if (to_row - current_row).abs() > row_height { + let vertical_end = + point(current_column, to_row - row_height); + builder.line_to(vertical_end); + builder.move_to(vertical_end); + } + + let lane_shift = if going_right { + LANE_WIDTH + } else { + -LANE_WIDTH + }; + let curve_end = + point(current_column + lane_shift, to_row); + let curve_control = point(current_column, to_row); + builder.curve_to(curve_end, curve_control); + builder.move_to(curve_end); + + builder.line_to(point(to_column, to_row)); + } else { + if (to_row - current_row).abs() > row_height { + let start_curve = + point(current_column, to_row - row_height); + builder.line_to(start_curve); + builder.move_to(start_curve); + } + let control = point(current_column, to_row); + builder.curve_to(point(to_column, to_row), control); + } } CurveKind::Merge => { if is_last { @@ -2208,37 +2240,25 @@ impl GitGraph { current_column + column_shift, current_row - COMMIT_CIRCLE_RADIUS, )); - point(to_column, current_row) - } - }; - match curve_kind { - CurveKind::Checkout - if (to_row - current_row).abs() > row_height => - { - let start_curve = - point(current_column, current_row + row_height); - builder.line_to(start_curve); - builder.move_to(start_curve); - } - CurveKind::Merge - if (to_column - current_column).abs() > LANE_WIDTH => - { - let column_shift = - if going_right { LANE_WIDTH } else { -LANE_WIDTH }; - - let start_curve = point( - current_column + column_shift, - current_row - COMMIT_CIRCLE_RADIUS, - ); + if (to_column - current_column).abs() > LANE_WIDTH { + let column_shift = if going_right { + LANE_WIDTH + } else { + -LANE_WIDTH + }; + let start_curve = point( + current_column + column_shift, + current_row - COMMIT_CIRCLE_RADIUS, + ); + builder.line_to(start_curve); + builder.move_to(start_curve); + } - builder.line_to(start_curve); - builder.move_to(start_curve); + let control = point(to_column, current_row); + builder.curve_to(point(to_column, to_row), control); } - _ => {} - }; - - builder.curve_to(point(to_column, to_row), control); + } current_row = to_row; current_column = to_column; builder.move_to(point(current_column, current_row)); From 257712e90b7b89069669616dd7d4d141966efca5 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:31:49 +0100 Subject: [PATCH 39/53] ui: Make UI independent from settings crate (#52578) This will allow us to use UI crate on the web Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A --------- Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> --- Cargo.lock | 1 - crates/theme/src/theme.rs | 4 + crates/theme/src/theme_settings_provider.rs | 43 +++++++++++ crates/theme/src/ui_density.rs | 65 ++++++++++++++++ crates/theme_settings/src/settings.rs | 80 ++------------------ crates/theme_settings/src/theme_settings.rs | 42 ++++++++-- crates/ui/Cargo.toml | 1 - crates/ui/src/components/context_menu.rs | 4 +- crates/ui/src/components/label/label_like.rs | 14 +--- crates/ui/src/components/list/list_header.rs | 7 +- crates/ui/src/components/tooltip.rs | 7 +- crates/ui/src/styles/spacing.rs | 5 +- crates/ui/src/styles/typography.rs | 26 +++---- crates/ui_macros/src/dynamic_spacing.rs | 18 ++--- 14 files changed, 185 insertions(+), 132 deletions(-) create mode 100644 crates/theme/src/theme_settings_provider.rs create mode 100644 crates/theme/src/ui_density.rs diff --git a/Cargo.lock b/Cargo.lock index edc6d7248d356404db79c66dcb360a0cf7677a6b..04299a6cd3c899c9da0f9eb1c2d3110ceed26ef3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18840,7 +18840,6 @@ dependencies = [ "story", "strum 0.27.2", "theme", - "theme_settings", "ui_macros", "windows 0.61.3", ] diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 37845cf0c8fce5d518e8dc69dde6126b8b266694..faa18bd3ce9ed71f4afed6d21d577d48b14680fb 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -17,6 +17,8 @@ mod registry; mod scale; mod schema; mod styles; +mod theme_settings_provider; +mod ui_density; use std::sync::Arc; @@ -37,6 +39,8 @@ pub use crate::registry::*; pub use crate::scale::*; pub use crate::schema::*; pub use crate::styles::*; +pub use crate::theme_settings_provider::*; +pub use crate::ui_density::*; /// The name of the default dark theme. pub const DEFAULT_DARK_THEME: &str = "One Dark"; diff --git a/crates/theme/src/theme_settings_provider.rs b/crates/theme/src/theme_settings_provider.rs new file mode 100644 index 0000000000000000000000000000000000000000..f3e05bc77bdd91de46024951aa3bef1f01736502 --- /dev/null +++ b/crates/theme/src/theme_settings_provider.rs @@ -0,0 +1,43 @@ +use gpui::{App, Font, Global, Pixels}; + +use crate::UiDensity; + +/// Trait for providing theme-related settings (fonts, font sizes, UI density) +/// without coupling to the concrete settings infrastructure. +/// +/// A concrete implementation is registered as a global by the `theme_settings` crate. +pub trait ThemeSettingsProvider: Send + Sync + 'static { + /// Returns the font used for UI elements. + fn ui_font<'a>(&'a self, cx: &'a App) -> &'a Font; + + /// Returns the font used for buffers and the terminal. + fn buffer_font<'a>(&'a self, cx: &'a App) -> &'a Font; + + /// Returns the UI font size in pixels. + fn ui_font_size(&self, cx: &App) -> Pixels; + + /// Returns the buffer font size in pixels. + fn buffer_font_size(&self, cx: &App) -> Pixels; + + /// Returns the current UI density setting. + fn ui_density(&self, cx: &App) -> UiDensity; +} + +struct GlobalThemeSettingsProvider(Box); + +impl Global for GlobalThemeSettingsProvider {} + +/// Registers the global [`ThemeSettingsProvider`] implementation. +/// +/// This should be called during application initialization by the crate +/// that owns the concrete theme settings (e.g. `theme_settings`). +pub fn set_theme_settings_provider(provider: Box, cx: &mut App) { + cx.set_global(GlobalThemeSettingsProvider(provider)); +} + +/// Returns the global [`ThemeSettingsProvider`]. +/// +/// Panics if no provider has been registered via [`set_theme_settings_provider`]. +pub fn theme_settings(cx: &App) -> &dyn ThemeSettingsProvider { + &*cx.global::().0 +} diff --git a/crates/theme/src/ui_density.rs b/crates/theme/src/ui_density.rs new file mode 100644 index 0000000000000000000000000000000000000000..5510e330e55c5b63ca125ff3be9dad2f0357e5c2 --- /dev/null +++ b/crates/theme/src/ui_density.rs @@ -0,0 +1,65 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Specifies the density of the UI. +/// Note: This setting is still experimental. See [this tracking issue](https://github.com/zed-industries/zed/issues/18078) +#[derive( + Debug, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Clone, + Copy, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum UiDensity { + /// A denser UI with tighter spacing and smaller elements. + #[serde(alias = "compact")] + Compact, + #[default] + #[serde(alias = "default")] + /// The default UI density. + Default, + #[serde(alias = "comfortable")] + /// A looser UI with more spacing and larger elements. + Comfortable, +} + +impl UiDensity { + /// The spacing ratio of a given density. + /// TODO: Standardize usage throughout the app or remove + pub fn spacing_ratio(self) -> f32 { + match self { + UiDensity::Compact => 0.75, + UiDensity::Default => 1.0, + UiDensity::Comfortable => 1.25, + } + } +} + +impl From for UiDensity { + fn from(s: String) -> Self { + match s.as_str() { + "compact" => Self::Compact, + "default" => Self::Default, + "comfortable" => Self::Comfortable, + _ => Self::default(), + } + } +} + +impl From for String { + fn from(val: UiDensity) -> Self { + match val { + UiDensity::Compact => "compact".to_string(), + UiDensity::Default => "default".to_string(), + UiDensity::Comfortable => "comfortable".to_string(), + } + } +} diff --git a/crates/theme_settings/src/settings.rs b/crates/theme_settings/src/settings.rs index f292e539fd512ce290ed85b1b6796d2af12f9c43..cda63ab9c8aa10d0f006f3bf371aab6491dff6de 100644 --- a/crates/theme_settings/src/settings.rs +++ b/crates/theme_settings/src/settings.rs @@ -12,83 +12,17 @@ use serde::{Deserialize, Serialize}; pub use settings::{FontFamilyName, IconThemeName, ThemeAppearanceMode, ThemeName}; use settings::{IntoGpui, RegisterSetting, Settings, SettingsContent}; use std::sync::Arc; -use theme::{Appearance, DEFAULT_ICON_THEME_NAME, SyntaxTheme, Theme}; +use theme::{Appearance, DEFAULT_ICON_THEME_NAME, SyntaxTheme, Theme, UiDensity}; const MIN_FONT_SIZE: Pixels = px(6.0); const MAX_FONT_SIZE: Pixels = px(100.0); const MIN_LINE_HEIGHT: f32 = 1.0; -#[derive( - Debug, - Default, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - Clone, - Copy, - Serialize, - Deserialize, - JsonSchema, -)] - -/// Specifies the density of the UI. -/// Note: This setting is still experimental. See [this tracking issue](https://github.com/zed-industries/zed/issues/18078) -#[serde(rename_all = "snake_case")] -pub enum UiDensity { - /// A denser UI with tighter spacing and smaller elements. - #[serde(alias = "compact")] - Compact, - #[default] - #[serde(alias = "default")] - /// The default UI density. - Default, - #[serde(alias = "comfortable")] - /// A looser UI with more spacing and larger elements. - Comfortable, -} - -impl UiDensity { - /// The spacing ratio of a given density. - /// TODO: Standardize usage throughout the app or remove - pub fn spacing_ratio(self) -> f32 { - match self { - UiDensity::Compact => 0.75, - UiDensity::Default => 1.0, - UiDensity::Comfortable => 1.25, - } - } -} - -impl From for UiDensity { - fn from(s: String) -> Self { - match s.as_str() { - "compact" => Self::Compact, - "default" => Self::Default, - "comfortable" => Self::Comfortable, - _ => Self::default(), - } - } -} - -impl From for String { - fn from(val: UiDensity) -> Self { - match val { - UiDensity::Compact => "compact".to_string(), - UiDensity::Default => "default".to_string(), - UiDensity::Comfortable => "comfortable".to_string(), - } - } -} - -impl From for UiDensity { - fn from(val: settings::UiDensity) -> Self { - match val { - settings::UiDensity::Compact => Self::Compact, - settings::UiDensity::Default => Self::Default, - settings::UiDensity::Comfortable => Self::Comfortable, - } +pub(crate) fn ui_density_from_settings(val: settings::UiDensity) -> UiDensity { + match val { + settings::UiDensity::Compact => UiDensity::Compact, + settings::UiDensity::Default => UiDensity::Default, + settings::UiDensity::Comfortable => UiDensity::Comfortable, } } @@ -693,7 +627,7 @@ impl settings::Settings for ThemeSettings { experimental_theme_overrides: content.experimental_theme_overrides.clone(), theme_overrides: content.theme_overrides.clone(), icon_theme: icon_theme_selection, - ui_density: content.ui_density.unwrap_or_default().into(), + ui_density: ui_density_from_settings(content.ui_density.unwrap_or_default()), unnecessary_code_fade: content.unnecessary_code_fade.unwrap().0.clamp(0.0, 0.9), } } diff --git a/crates/theme_settings/src/theme_settings.rs b/crates/theme_settings/src/theme_settings.rs index 807cbf1c6544673cc81cd38fc01c5fd1ad6a5b6f..f5bc96ba02a63088b6311055899b39de65ea9de2 100644 --- a/crates/theme_settings/src/theme_settings.rs +++ b/crates/theme_settings/src/theme_settings.rs @@ -12,13 +12,13 @@ use std::sync::Arc; use ::settings::{IntoGpui, Settings, SettingsStore}; use anyhow::{Context as _, Result}; -use gpui::{App, HighlightStyle, Refineable}; +use gpui::{App, Font, HighlightStyle, Pixels, Refineable}; use gpui_util::ResultExt; use theme::{ AccentColors, Appearance, AppearanceContent, DEFAULT_DARK_THEME, DEFAULT_ICON_THEME_NAME, GlobalTheme, LoadThemes, PlayerColor, PlayerColors, StatusColors, SyntaxTheme, - SystemAppearance, SystemColors, Theme, ThemeColors, ThemeFamily, ThemeRegistry, ThemeStyles, - default_color_scales, try_parse_color, + SystemAppearance, SystemColors, Theme, ThemeColors, ThemeFamily, ThemeRegistry, + ThemeSettingsProvider, ThemeStyles, default_color_scales, try_parse_color, }; pub use crate::schema::{ @@ -28,12 +28,37 @@ pub use crate::schema::{ }; pub use crate::settings::{ AgentFontSize, BufferLineHeight, FontFamilyName, IconThemeName, IconThemeSelection, - ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings, UiDensity, - adjust_agent_buffer_font_size, adjust_agent_ui_font_size, adjust_buffer_font_size, - adjust_ui_font_size, adjusted_font_size, appearance_to_mode, clamp_font_size, default_theme, - observe_buffer_font_size_adjustment, reset_agent_buffer_font_size, reset_agent_ui_font_size, - reset_buffer_font_size, reset_ui_font_size, set_icon_theme, set_mode, set_theme, setup_ui_font, + ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings, adjust_agent_buffer_font_size, + adjust_agent_ui_font_size, adjust_buffer_font_size, adjust_ui_font_size, adjusted_font_size, + appearance_to_mode, clamp_font_size, default_theme, observe_buffer_font_size_adjustment, + reset_agent_buffer_font_size, reset_agent_ui_font_size, reset_buffer_font_size, + reset_ui_font_size, set_icon_theme, set_mode, set_theme, setup_ui_font, }; +pub use theme::UiDensity; + +struct ThemeSettingsProviderImpl; + +impl ThemeSettingsProvider for ThemeSettingsProviderImpl { + fn ui_font<'a>(&'a self, cx: &'a App) -> &'a Font { + &ThemeSettings::get_global(cx).ui_font + } + + fn buffer_font<'a>(&'a self, cx: &'a App) -> &'a Font { + &ThemeSettings::get_global(cx).buffer_font + } + + fn ui_font_size(&self, cx: &App) -> Pixels { + ThemeSettings::get_global(cx).ui_font_size(cx) + } + + fn buffer_font_size(&self, cx: &App) -> Pixels { + ThemeSettings::get_global(cx).buffer_font_size(cx) + } + + fn ui_density(&self, cx: &App) -> UiDensity { + ThemeSettings::get_global(cx).ui_density + } +} /// Initialize the theme system with settings integration. /// @@ -43,6 +68,7 @@ pub fn init(themes_to_load: LoadThemes, cx: &mut App) { let load_user_themes = matches!(&themes_to_load, LoadThemes::All(_)); theme::init(themes_to_load, cx); + theme::set_theme_settings_provider(Box::new(ThemeSettingsProviderImpl), cx); if load_user_themes { let registry = ThemeRegistry::global(cx); diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 9fb86398dd0b4787d1be900811509845a7c2bc58..6ea1b6d26f700c9c44a8dda5e510d0505d7e7db8 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -28,7 +28,6 @@ smallvec.workspace = true story = { workspace = true, optional = true } strum.workspace = true theme.workspace = true -theme_settings.workspace = true ui_macros.workspace = true gpui_util.workspace = true diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index c36ffe1d0a665fa83543ac0c1f2b2fe5ec693365..2fcfd73b93d7c47018819fd9ec4426e9f1b38147 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -8,14 +8,12 @@ use gpui::{ Subscription, anchored, canvas, prelude::*, px, }; use menu::{SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious}; -use settings::Settings; use std::{ cell::{Cell, RefCell}, collections::HashMap, rc::Rc, time::{Duration, Instant}, }; -use theme_settings::ThemeSettings; #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum SubmenuOpenTrigger { @@ -2050,7 +2048,7 @@ impl ContextMenuItem { impl Render for ContextMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); + let ui_font_size = theme::theme_settings(cx).ui_font_size(cx); let window_size = window.viewport_size(); let rem_size = window.rem_size(); let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0; diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 7570cba61b61048ae78f9c9da41afa3d281baaca..5cad04efcfabcc80648c005f8d18ec5805970a39 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -1,8 +1,6 @@ use crate::prelude::*; use gpui::{FontWeight, Rems, StyleRefinement, UnderlineStyle}; -use settings::Settings; use smallvec::SmallVec; -use theme_settings::ThemeSettings; /// Sets the size of a label #[derive(Debug, PartialEq, Clone, Copy, Default)] @@ -191,9 +189,7 @@ impl LabelCommon for LabelLike { } fn buffer_font(mut self, cx: &App) -> Self { - let font = theme_settings::ThemeSettings::get_global(cx) - .buffer_font - .clone(); + let font = theme::theme_settings(cx).buffer_font(cx).clone(); self.weight = Some(font.weight); self.base = self.base.font(font); self @@ -202,11 +198,7 @@ impl LabelCommon for LabelLike { fn inline_code(mut self, cx: &App) -> Self { self.base = self .base - .font( - theme_settings::ThemeSettings::get_global(cx) - .buffer_font - .clone(), - ) + .font(theme::theme_settings(cx).buffer_font(cx).clone()) .bg(cx.theme().colors().element_background) .rounded_sm() .px_0p5(); @@ -264,7 +256,7 @@ impl RenderOnce for LabelLike { .text_color(color) .font_weight( self.weight - .unwrap_or(ThemeSettings::get_global(cx).ui_font.weight), + .unwrap_or(theme::theme_settings(cx).ui_font(cx).weight), ) .children(self.children) } diff --git a/crates/ui/src/components/list/list_header.rs b/crates/ui/src/components/list/list_header.rs index 264a7c755b48f484ba1bec550258b1cc2af5db41..9d72366c3be4907c7d4e9e3dc0466903cbc58069 100644 --- a/crates/ui/src/components/list/list_header.rs +++ b/crates/ui/src/components/list/list_header.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use crate::{Disclosure, prelude::*}; use component::{Component, ComponentScope, example_group_with_title, single_example}; use gpui::{AnyElement, ClickEvent}; -use settings::Settings; -use theme_settings::ThemeSettings; +use theme::UiDensity; #[derive(IntoElement, RegisterComponent)] pub struct ListHeader { @@ -81,7 +80,7 @@ impl Toggleable for ListHeader { impl RenderOnce for ListHeader { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let ui_density = ThemeSettings::get_global(cx).ui_density; + let ui_density = theme::theme_settings(cx).ui_density(cx); h_flex() .id(self.label.clone()) @@ -91,7 +90,7 @@ impl RenderOnce for ListHeader { .child( div() .map(|this| match ui_density { - theme_settings::UiDensity::Comfortable => this.h_5(), + UiDensity::Comfortable => this.h_5(), _ => this.h_7(), }) .when(self.inset, |this| this.px_2()) diff --git a/crates/ui/src/components/tooltip.rs b/crates/ui/src/components/tooltip.rs index 0717f077e30c1962fe3384f6d33018ff2b469e95..8124b4ecbafdc6b096e91892741fe774e3ba032f 100644 --- a/crates/ui/src/components/tooltip.rs +++ b/crates/ui/src/components/tooltip.rs @@ -1,12 +1,9 @@ use std::borrow::Borrow; use std::rc::Rc; -use gpui::{Action, AnyElement, AnyView, AppContext, FocusHandle, IntoElement, Render}; -use settings::Settings; -use theme_settings::ThemeSettings; - use crate::prelude::*; use crate::{Color, KeyBinding, Label, LabelSize, StyledExt, h_flex, v_flex}; +use gpui::{Action, AnyElement, AnyView, AppContext, FocusHandle, IntoElement, Render}; #[derive(RegisterComponent)] pub struct Tooltip { @@ -221,7 +218,7 @@ where C: AppContext + Borrow, { let app = (*cx).borrow(); - let ui_font = ThemeSettings::get_global(app).ui_font.clone(); + let ui_font = theme::theme_settings(app).ui_font(app).clone(); // padding to avoid tooltip appearing right below the mouse cursor div().pl_2().pt_2p5().child( diff --git a/crates/ui/src/styles/spacing.rs b/crates/ui/src/styles/spacing.rs index 5984b7946ab691981cf5c591fb2e6a855b882cd9..50d5446ebc25826e6c0665e906141d77ba78d584 100644 --- a/crates/ui/src/styles/spacing.rs +++ b/crates/ui/src/styles/spacing.rs @@ -1,6 +1,5 @@ use gpui::{App, Pixels, Rems, px, rems}; -use settings::Settings; -use theme_settings::{ThemeSettings, UiDensity}; +use theme::UiDensity; use ui_macros::derive_dynamic_spacing; // Derives [DynamicSpacing]. See [ui_macros::derive_dynamic_spacing]. @@ -51,5 +50,5 @@ derive_dynamic_spacing![ /// /// Always use [DynamicSpacing] for spacing values. pub fn ui_density(cx: &mut App) -> UiDensity { - ThemeSettings::get_global(cx).ui_density + theme::theme_settings(cx).ui_density(cx) } diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index abc5cde303e5ad327b008efc1bf24e75836e756c..69790d3d3dae6bbc8728a63af806357a276ed67a 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -3,9 +3,7 @@ use gpui::{ AnyElement, App, IntoElement, ParentElement, Rems, RenderOnce, SharedString, Styled, Window, div, rems, }; -use settings::Settings; use theme::ActiveTheme; -use theme_settings::ThemeSettings; use crate::{Color, rems_from_px}; @@ -13,16 +11,16 @@ use crate::{Color, rems_from_px}; pub trait StyledTypography: Styled + Sized { /// Sets the font family to the buffer font. fn font_buffer(self, cx: &App) -> Self { - let settings = ThemeSettings::get_global(cx); - let buffer_font_family = settings.buffer_font.family.clone(); + let settings = theme::theme_settings(cx); + let buffer_font_family = settings.buffer_font(cx).family.clone(); self.font_family(buffer_font_family) } /// Sets the font family to the UI font. fn font_ui(self, cx: &App) -> Self { - let settings = ThemeSettings::get_global(cx); - let ui_font_family = settings.ui_font.family.clone(); + let settings = theme::theme_settings(cx); + let ui_font_family = settings.ui_font(cx).family.clone(); self.font_family(ui_font_family) } @@ -83,7 +81,7 @@ pub trait StyledTypography: Styled + Sized { /// This should only be used for text that is displayed in a buffer, /// or other places that text needs to match the user's buffer font size. fn text_buffer(self, cx: &App) -> Self { - let settings = ThemeSettings::get_global(cx); + let settings = theme::theme_settings(cx); self.text_size(settings.buffer_font_size(cx)) } } @@ -134,28 +132,28 @@ pub enum TextSize { impl TextSize { /// Returns the text size in rems. pub fn rems(self, cx: &App) -> Rems { - let theme_settings = ThemeSettings::get_global(cx); + let settings = theme::theme_settings(cx); match self { Self::Large => rems_from_px(16.), Self::Default => rems_from_px(14.), Self::Small => rems_from_px(12.), Self::XSmall => rems_from_px(10.), - Self::Ui => rems_from_px(theme_settings.ui_font_size(cx)), - Self::Editor => rems_from_px(theme_settings.buffer_font_size(cx)), + Self::Ui => rems_from_px(settings.ui_font_size(cx)), + Self::Editor => rems_from_px(settings.buffer_font_size(cx)), } } pub fn pixels(self, cx: &App) -> Pixels { - let theme_settings = ThemeSettings::get_global(cx); + let settings = theme::theme_settings(cx); match self { Self::Large => px(16.), Self::Default => px(14.), Self::Small => px(12.), Self::XSmall => px(10.), - Self::Ui => theme_settings.ui_font_size(cx), - Self::Editor => theme_settings.buffer_font_size(cx), + Self::Ui => settings.ui_font_size(cx), + Self::Editor => settings.buffer_font_size(cx), } } } @@ -213,7 +211,7 @@ pub struct Headline { impl RenderOnce for Headline { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let ui_font = ThemeSettings::get_global(cx).ui_font.clone(); + let ui_font = theme::theme_settings(cx).ui_font(cx).clone(); div() .font(ui_font) diff --git a/crates/ui_macros/src/dynamic_spacing.rs b/crates/ui_macros/src/dynamic_spacing.rs index 17c91d7de143dd56d9b7cfb1df1cb94d6c71214f..f1207f5487a89f0afbd23e620da4c4cf4172be9a 100644 --- a/crates/ui_macros/src/dynamic_spacing.rs +++ b/crates/ui_macros/src/dynamic_spacing.rs @@ -65,10 +65,10 @@ pub fn derive_spacing(input: TokenStream) -> TokenStream { DynamicSpacingValue::Single(n) => { let n = n.base10_parse::().unwrap(); quote! { - DynamicSpacing::#variant => match ThemeSettings::get_global(cx).ui_density { - ::theme_settings::UiDensity::Compact => (#n - 4.0).max(0.0) / BASE_REM_SIZE_IN_PX, - ::theme_settings::UiDensity::Default => #n / BASE_REM_SIZE_IN_PX, - ::theme_settings::UiDensity::Comfortable => (#n + 4.0) / BASE_REM_SIZE_IN_PX, + DynamicSpacing::#variant => match ::theme::theme_settings(cx).ui_density(cx) { + ::theme::UiDensity::Compact => (#n - 4.0).max(0.0) / BASE_REM_SIZE_IN_PX, + ::theme::UiDensity::Default => #n / BASE_REM_SIZE_IN_PX, + ::theme::UiDensity::Comfortable => (#n + 4.0) / BASE_REM_SIZE_IN_PX, } } } @@ -77,10 +77,10 @@ pub fn derive_spacing(input: TokenStream) -> TokenStream { let b = b.base10_parse::().unwrap(); let c = c.base10_parse::().unwrap(); quote! { - DynamicSpacing::#variant => match ThemeSettings::get_global(cx).ui_density { - ::theme_settings::UiDensity::Compact => #a / BASE_REM_SIZE_IN_PX, - ::theme_settings::UiDensity::Default => #b / BASE_REM_SIZE_IN_PX, - ::theme_settings::UiDensity::Comfortable => #c / BASE_REM_SIZE_IN_PX, + DynamicSpacing::#variant => match ::theme::theme_settings(cx).ui_density(cx) { + ::theme::UiDensity::Compact => #a / BASE_REM_SIZE_IN_PX, + ::theme::UiDensity::Default => #b / BASE_REM_SIZE_IN_PX, + ::theme::UiDensity::Comfortable => #c / BASE_REM_SIZE_IN_PX, } } } @@ -157,7 +157,7 @@ pub fn derive_spacing(input: TokenStream) -> TokenStream { /// Returns the spacing value in pixels. pub fn px(&self, cx: &App) -> Pixels { - let ui_font_size_f32: f32 = ThemeSettings::get_global(cx).ui_font_size(cx).into(); + let ui_font_size_f32: f32 = ::theme::theme_settings(cx).ui_font_size(cx).into(); px(ui_font_size_f32 * self.spacing_ratio(cx)) } } From 7d3ccce952bfbd0caabb075e6eb87fedc78482c7 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 27 Mar 2026 11:30:51 -0600 Subject: [PATCH 40/53] Don't auto-close in search (#52553) This was incidentally broken when we switched to the auto_height editor Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Closes #52124 Release Notes: - Disabled autoclose of brackets in the project search --- crates/search/src/project_search.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index c72d7da34bb28b7048741a27c891c54785ab3123..991f8d1076a985e1413b0045aa42d424f094cd9c 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -936,6 +936,7 @@ impl ProjectSearchView { let query_editor = cx.new(|cx| { let mut editor = Editor::auto_height(1, 4, window, cx); editor.set_placeholder_text("Search all files…", window, cx); + editor.set_use_autoclose(false); editor.set_text(query_text, window, cx); editor }); From 0969363698d61e7a05055d8011b4105e45c8b56d Mon Sep 17 00:00:00 2001 From: "John D. Swanson" Date: Fri, 27 Mar 2026 14:09:39 -0400 Subject: [PATCH 41/53] Skip PR assignee selection for org-member PRs (#52593) 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 - [ ] 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 - [x] Performance impact has been considered and is acceptable Closes N/A — deploying [codeowner-coordinator#90](https://github.com/zed-industries/codeowner-coordinator/pull/90) to zed repo. ## Summary Pass `ASSIGN_INTERNAL` and `ASSIGN_EXTERNAL` repository variables to the assign-reviewers workflow. The coordinator script now uses these to control whether an individual PR assignee is set based on the author's org membership. **Defaults:** org members skip assignee (teams self-organize accountability), external contributors get an assignee (identifies who should shepherd the PR). Both are togglable from repo Settings → Variables without code changes. Side benefit: skips the 30-55 second `poll_for_reviewers` polling loop for org-member PRs. ## Post-merge manual step Set repository variables (Settings → Secrets and variables → Actions → Variables): - `ASSIGN_INTERNAL` = `false` - `ASSIGN_EXTERNAL` = `true` Release Notes: - N/A --- .github/workflows/assign-reviewers.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/assign-reviewers.yml b/.github/workflows/assign-reviewers.yml index c16a363db18c9ac11f000ad65961a165db43c982..2a12a69defdd4f8933f1c549f0624d9bdcc9fd40 100644 --- a/.github/workflows/assign-reviewers.yml +++ b/.github/workflows/assign-reviewers.yml @@ -83,6 +83,8 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_URL: ${{ github.event.pull_request.html_url }} TARGET_REPO: ${{ github.repository }} + ASSIGN_INTERNAL: ${{ vars.ASSIGN_INTERNAL || 'false' }} + ASSIGN_EXTERNAL: ${{ vars.ASSIGN_EXTERNAL || 'true' }} run: | cd codeowner-coordinator python .github/scripts/assign-reviewers.py \ From 79b9cae2cf46453790686651175520ae17342c3e Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 27 Mar 2026 19:28:24 +0100 Subject: [PATCH 42/53] language_core: Remove imports support (#52570) This PR removes the imports query and all surrounding support from the language_core crate. The imports query was experimented with during the addition of Zeta 2. However, it has been unused for a few months now and the direction does not seemt to be pursued anymore. Thus, removing the support here. Release Notes: - N/A --- crates/grammars/src/c/config.toml | 1 - crates/grammars/src/c/imports.scm | 7 --- crates/grammars/src/cpp/config.toml | 1 - crates/grammars/src/cpp/imports.scm | 6 -- crates/grammars/src/go/imports.scm | 12 ---- crates/grammars/src/javascript/config.toml | 1 - crates/grammars/src/javascript/imports.scm | 16 ----- crates/grammars/src/python/config.toml | 1 - crates/grammars/src/python/imports.scm | 38 ------------ crates/grammars/src/rust/config.toml | 2 - crates/grammars/src/rust/imports.scm | 29 --------- crates/grammars/src/tsx/imports.scm | 16 ----- crates/grammars/src/typescript/config.toml | 1 - crates/grammars/src/typescript/imports.scm | 23 -------- crates/language/src/language.rs | 22 +++---- crates/language_core/src/grammar.rs | 65 --------------------- crates/language_core/src/language_config.rs | 11 ---- crates/language_core/src/language_core.rs | 6 +- crates/language_core/src/queries.rs | 2 - 19 files changed, 10 insertions(+), 250 deletions(-) delete mode 100644 crates/grammars/src/c/imports.scm delete mode 100644 crates/grammars/src/cpp/imports.scm delete mode 100644 crates/grammars/src/go/imports.scm delete mode 100644 crates/grammars/src/javascript/imports.scm delete mode 100644 crates/grammars/src/python/imports.scm delete mode 100644 crates/grammars/src/rust/imports.scm delete mode 100644 crates/grammars/src/tsx/imports.scm delete mode 100644 crates/grammars/src/typescript/imports.scm diff --git a/crates/grammars/src/c/config.toml b/crates/grammars/src/c/config.toml index c490269b12309632d2fd8fb944ed48ee74c46075..a3b55f4f2d4fe3bfb19100e5877661c5841126a9 100644 --- a/crates/grammars/src/c/config.toml +++ b/crates/grammars/src/c/config.toml @@ -17,4 +17,3 @@ brackets = [ ] debuggers = ["CodeLLDB", "GDB"] documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } -import_path_strip_regex = "^<|>$" diff --git a/crates/grammars/src/c/imports.scm b/crates/grammars/src/c/imports.scm deleted file mode 100644 index 2aaab2106f5422db426876a7fa65c9674fe93174..0000000000000000000000000000000000000000 --- a/crates/grammars/src/c/imports.scm +++ /dev/null @@ -1,7 +0,0 @@ -(preproc_include - path: [ - ((system_lib_string) @source @wildcard - (#strip! @source "[<>]")) - (string_literal - (string_content) @source @wildcard) - ]) @import diff --git a/crates/grammars/src/cpp/config.toml b/crates/grammars/src/cpp/config.toml index dfce8ae7b2bfcfc7a7004822e9c6dca18e6cbe26..138d4a78e45f153eaa2eeb72a91654416154ed33 100644 --- a/crates/grammars/src/cpp/config.toml +++ b/crates/grammars/src/cpp/config.toml @@ -19,4 +19,3 @@ brackets = [ ] debuggers = ["CodeLLDB", "GDB"] documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } -import_path_strip_regex = "^<|>$" diff --git a/crates/grammars/src/cpp/imports.scm b/crates/grammars/src/cpp/imports.scm deleted file mode 100644 index 43adde711b5352ef0d92566d4bdde91a847319b8..0000000000000000000000000000000000000000 --- a/crates/grammars/src/cpp/imports.scm +++ /dev/null @@ -1,6 +0,0 @@ -(preproc_include - path: [ - (system_lib_string) @source @wildcard - (string_literal - (string_content) @source @wildcard) - ]) @import diff --git a/crates/grammars/src/go/imports.scm b/crates/grammars/src/go/imports.scm deleted file mode 100644 index 23e480c10b20b76c6724df29a550e627c2aee799..0000000000000000000000000000000000000000 --- a/crates/grammars/src/go/imports.scm +++ /dev/null @@ -1,12 +0,0 @@ -(import_spec - name: [ - (dot) - (package_identifier) - ] - path: (interpreted_string_literal - (interpreted_string_literal_content) @namespace)) @wildcard @import - -(import_spec - !name - path: (interpreted_string_literal - (interpreted_string_literal_content) @namespace)) @wildcard @import diff --git a/crates/grammars/src/javascript/config.toml b/crates/grammars/src/javascript/config.toml index 2850fd6bc47fe7d23fdfbf9588b2331fdef6e0fa..118024494a7b8f98bcff9354fd3d27f4fc1dcfc4 100644 --- a/crates/grammars/src/javascript/config.toml +++ b/crates/grammars/src/javascript/config.toml @@ -24,7 +24,6 @@ tab_size = 2 scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-server"] prettier_parser_name = "babel" debuggers = ["JavaScript"] -import_path_strip_regex = "(?:/index)?\\.[jt]s$" [jsx_tag_auto_close] open_tag_node_name = "jsx_opening_element" diff --git a/crates/grammars/src/javascript/imports.scm b/crates/grammars/src/javascript/imports.scm deleted file mode 100644 index 0e688d53fb6ed639c55c1fa84917711d19c3108a..0000000000000000000000000000000000000000 --- a/crates/grammars/src/javascript/imports.scm +++ /dev/null @@ -1,16 +0,0 @@ -(import_statement - import_clause: (import_clause - [ - (identifier) @name - (named_imports - (import_specifier - name: (_) @name - alias: (_)? @alias)) - ]) - source: (string - (string_fragment) @source)) @import - -(import_statement - !import_clause - source: (string - (string_fragment) @source @wildcard)) @import diff --git a/crates/grammars/src/python/config.toml b/crates/grammars/src/python/config.toml index fa409c5dd6519121e7130e4b33a6c3277ae1654b..0c2072393bf6cc1db6b152d80779cd7c81af1a7e 100644 --- a/crates/grammars/src/python/config.toml +++ b/crates/grammars/src/python/config.toml @@ -36,4 +36,3 @@ decrease_indent_patterns = [ { pattern = "^\\s*except\\b.*:\\s*(#.*)?", valid_after = ["try", "except"] }, { pattern = "^\\s*finally\\b.*:\\s*(#.*)?", valid_after = ["try", "except", "else"] }, ] -import_path_strip_regex = "/__init__\\.py$" diff --git a/crates/grammars/src/python/imports.scm b/crates/grammars/src/python/imports.scm deleted file mode 100644 index 26538fee1b41df13f258c8b315cc5e266458efa1..0000000000000000000000000000000000000000 --- a/crates/grammars/src/python/imports.scm +++ /dev/null @@ -1,38 +0,0 @@ -(import_statement - name: [ - (dotted_name - ((identifier) @namespace - ".")* - (identifier) @namespace .) - (aliased_import - name: (dotted_name - ((identifier) @namespace - ".")* - (identifier) @namespace .)) - ]) @wildcard @import - -(import_from_statement - module_name: [ - (dotted_name - ((identifier) @namespace - ".")* - (identifier) @namespace .) - (relative_import - (dotted_name - ((identifier) @namespace - ".")* - (identifier) @namespace .)?) - ] - (wildcard_import)? @wildcard - name: [ - (dotted_name - ((identifier) @namespace - ".")* - (identifier) @name .) - (aliased_import - name: (dotted_name - ((identifier) @namespace - ".")* - (identifier) @name .) - alias: (identifier) @alias) - ]?) @import diff --git a/crates/grammars/src/rust/config.toml b/crates/grammars/src/rust/config.toml index 203a44853f8bd20f952d3db8f0c64dc4babe1017..f739b370f4b5c3fe7bc53f4818ffabedfa1bbd0b 100644 --- a/crates/grammars/src/rust/config.toml +++ b/crates/grammars/src/rust/config.toml @@ -18,5 +18,3 @@ brackets = [ collapsed_placeholder = " /* ... */ " debuggers = ["CodeLLDB", "GDB"] documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } -ignored_import_segments = ["crate", "super"] -import_path_strip_regex = "/(lib|mod)\\.rs$" diff --git a/crates/grammars/src/rust/imports.scm b/crates/grammars/src/rust/imports.scm deleted file mode 100644 index 2c368523d63b9c6ae9494b1ab801192161fd7000..0000000000000000000000000000000000000000 --- a/crates/grammars/src/rust/imports.scm +++ /dev/null @@ -1,29 +0,0 @@ -(use_declaration) @import - -(scoped_use_list - path: (_) @namespace - list: (_) @list) - -(scoped_identifier - path: (_) @namespace - name: (identifier) @name) - -(use_list - (identifier) @name) - -(use_declaration - (identifier) @name) - -(use_as_clause - path: (scoped_identifier - path: (_) @namespace - name: (_) @name) - alias: (_) @alias) - -(use_as_clause - path: (identifier) @name - alias: (_) @alias) - -(use_wildcard - (_)? @namespace - "*" @wildcard) diff --git a/crates/grammars/src/tsx/imports.scm b/crates/grammars/src/tsx/imports.scm deleted file mode 100644 index 0e688d53fb6ed639c55c1fa84917711d19c3108a..0000000000000000000000000000000000000000 --- a/crates/grammars/src/tsx/imports.scm +++ /dev/null @@ -1,16 +0,0 @@ -(import_statement - import_clause: (import_clause - [ - (identifier) @name - (named_imports - (import_specifier - name: (_) @name - alias: (_)? @alias)) - ]) - source: (string - (string_fragment) @source)) @import - -(import_statement - !import_clause - source: (string - (string_fragment) @source @wildcard)) @import diff --git a/crates/grammars/src/typescript/config.toml b/crates/grammars/src/typescript/config.toml index c0e8a8899a99b0b65e2d073547f3eaf0fe714da2..473a347cdd611d096e5fb3b584c2f0990da185de 100644 --- a/crates/grammars/src/typescript/config.toml +++ b/crates/grammars/src/typescript/config.toml @@ -23,7 +23,6 @@ prettier_parser_name = "typescript" tab_size = 2 debuggers = ["JavaScript"] scope_opt_in_language_servers = ["tailwindcss-language-server"] -import_path_strip_regex = "(?:/index)?\\.[jt]s$" [overrides.string] completion_query_characters = ["-", "."] diff --git a/crates/grammars/src/typescript/imports.scm b/crates/grammars/src/typescript/imports.scm deleted file mode 100644 index de8f8db418157511d5756d6b5ede1a02a03bd831..0000000000000000000000000000000000000000 --- a/crates/grammars/src/typescript/imports.scm +++ /dev/null @@ -1,23 +0,0 @@ -(import_statement - import_clause: (import_clause - [ - (identifier) @name - (named_imports - (import_specifier - name: (_) @name - alias: (_)? @alias)) - (namespace_import) @wildcard - ]) - source: (string - (string_fragment) @source)) @import - -(import_statement - !source - import_clause: (import_require_clause - source: (string - (string_fragment) @source))) @wildcard @import - -(import_statement - !import_clause - source: (string - (string_fragment) @source)) @wildcard @import diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 469759a24c74b8d1349fbc3a66d5037d8ef8587d..035cb3a2009241cc4ff97a7adf4c82de73166a76 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -39,14 +39,13 @@ pub use language_core::highlight_map::{HighlightId, HighlightMap}; pub use language_core::{ BlockCommentConfig, BracketPair, BracketPairConfig, BracketPairContent, BracketsConfig, BracketsPatternConfig, CodeLabel, CodeLabelBuilder, DebugVariablesConfig, DebuggerTextObject, - DecreaseIndentConfig, Grammar, GrammarId, HighlightsConfig, ImportsConfig, IndentConfig, - InjectionConfig, InjectionPatternConfig, JsxTagAutoCloseConfig, LanguageConfig, - LanguageConfigOverride, LanguageId, LanguageMatcher, OrderedListConfig, OutlineConfig, - Override, OverrideConfig, OverrideEntry, PromptResponseContext, RedactionConfig, - RunnableCapture, RunnableConfig, SoftWrap, Symbol, TaskListConfig, TextObject, - TextObjectConfig, ToLspPosition, WrapCharactersConfig, - auto_indent_using_last_non_empty_line_default, deserialize_regex, deserialize_regex_vec, - regex_json_schema, regex_vec_json_schema, serialize_regex, + DecreaseIndentConfig, Grammar, GrammarId, HighlightsConfig, IndentConfig, InjectionConfig, + InjectionPatternConfig, JsxTagAutoCloseConfig, LanguageConfig, LanguageConfigOverride, + LanguageId, LanguageMatcher, OrderedListConfig, OutlineConfig, Override, OverrideConfig, + OverrideEntry, PromptResponseContext, RedactionConfig, RunnableCapture, RunnableConfig, + SoftWrap, Symbol, TaskListConfig, TextObject, TextObjectConfig, ToLspPosition, + WrapCharactersConfig, auto_indent_using_last_non_empty_line_default, deserialize_regex, + deserialize_regex_vec, regex_json_schema, regex_vec_json_schema, serialize_regex, }; pub use language_registry::{ LanguageName, LanguageServerStatusUpdate, LoadedLanguage, ServerHealth, @@ -908,10 +907,6 @@ impl Language { }) } - pub fn with_imports_query(self, source: &str) -> Result { - self.with_grammar_query_and_name(|grammar, name| grammar.with_imports_query(source, name)) - } - pub fn with_brackets_query(self, source: &str) -> Result { self.with_grammar_query_and_name(|grammar, name| grammar.with_brackets_query(source, name)) } @@ -1579,9 +1574,6 @@ pub fn rust_lang() -> Arc { debugger: Some(Cow::from(include_str!( "../../grammars/src/rust/debugger.scm" ))), - imports: Some(Cow::from(include_str!( - "../../grammars/src/rust/imports.scm" - ))), }) .expect("Could not parse queries"); Arc::new(language) diff --git a/crates/language_core/src/grammar.rs b/crates/language_core/src/grammar.rs index f3a4c3d7c993dfde14657a330999c383ff9f0994..77e3805e52415a20f5d343bff98682744a50fdc2 100644 --- a/crates/language_core/src/grammar.rs +++ b/crates/language_core/src/grammar.rs @@ -41,7 +41,6 @@ pub struct Grammar { pub injection_config: Option, pub override_config: Option, pub debug_variables_config: Option, - pub imports_config: Option, pub highlight_map: Mutex, } @@ -185,17 +184,6 @@ pub struct DebugVariablesConfig { pub objects_by_capture_ix: Vec<(u32, DebuggerTextObject)>, } -pub struct ImportsConfig { - pub query: Query, - pub import_ix: u32, - pub name_ix: Option, - pub namespace_ix: Option, - pub source_ix: Option, - pub list_ix: Option, - pub wildcard_ix: Option, - pub alias_ix: Option, -} - enum Capture<'a> { Required(&'static str, &'a mut u32), Optional(&'static str, &'a mut Option), @@ -273,7 +261,6 @@ impl Grammar { runnable_config: None, error_query: Query::new(&ts_language, "(ERROR) @error").ok(), debug_variables_config: None, - imports_config: None, ts_language, highlight_map: Default::default(), } @@ -300,10 +287,6 @@ impl Grammar { self.debug_variables_config.as_ref() } - pub fn imports_config(&self) -> Option<&ImportsConfig> { - self.imports_config.as_ref() - } - /// Load all queries from `LanguageQueries` into this grammar, mutating the /// associated `LanguageConfig` (the override query clears /// `brackets.disabled_scopes_by_bracket_ix`). @@ -369,11 +352,6 @@ impl Grammar { .with_debug_variables_query(query.as_ref(), name) .context("Error loading debug variables query")?; } - if let Some(query) = queries.imports { - self = self - .with_imports_query(query.as_ref(), name) - .context("Error loading imports query")?; - } Ok(self) } @@ -519,49 +497,6 @@ impl Grammar { Ok(self) } - pub fn with_imports_query( - mut self, - source: &str, - language_name: &LanguageName, - ) -> Result { - let query = Query::new(&self.ts_language, source)?; - - let mut import_ix = 0; - let mut name_ix = None; - let mut namespace_ix = None; - let mut source_ix = None; - let mut list_ix = None; - let mut wildcard_ix = None; - let mut alias_ix = None; - if populate_capture_indices( - &query, - language_name, - "imports", - &[], - &mut [ - Capture::Required("import", &mut import_ix), - Capture::Optional("name", &mut name_ix), - Capture::Optional("namespace", &mut namespace_ix), - Capture::Optional("source", &mut source_ix), - Capture::Optional("list", &mut list_ix), - Capture::Optional("wildcard", &mut wildcard_ix), - Capture::Optional("alias", &mut alias_ix), - ], - ) { - self.imports_config = Some(ImportsConfig { - query, - import_ix, - name_ix, - namespace_ix, - source_ix, - list_ix, - wildcard_ix, - alias_ix, - }); - } - Ok(self) - } - pub fn with_brackets_query( mut self, source: &str, diff --git a/crates/language_core/src/language_config.rs b/crates/language_core/src/language_config.rs index e07c11d811cdaae3b540c57314cebf0d92d0023d..f412af418b7948b40e3bdac5a3a649d12d008e8a 100644 --- a/crates/language_core/src/language_config.rs +++ b/crates/language_core/src/language_config.rs @@ -148,15 +148,6 @@ pub struct LanguageConfig { /// A list of preferred debuggers for this language. #[serde(default)] pub debuggers: IndexSet, - /// A list of import namespace segments that aren't expected to appear in file paths. For - /// example, "super" and "crate" in Rust. - #[serde(default)] - pub ignored_import_segments: HashSet>, - /// Regular expression that matches substrings to omit from import paths, to make the paths more - /// similar to how they are specified when imported. For example, "/mod\.rs$" or "/__init__\.py$". - #[serde(default, deserialize_with = "deserialize_regex")] - #[schemars(schema_with = "regex_json_schema")] - pub import_path_strip_regex: Option, } impl LanguageConfig { @@ -204,8 +195,6 @@ impl Default for LanguageConfig { completion_query_characters: Default::default(), linked_edit_characters: Default::default(), debuggers: Default::default(), - ignored_import_segments: Default::default(), - import_path_strip_regex: None, } } } diff --git a/crates/language_core/src/language_core.rs b/crates/language_core/src/language_core.rs index c908db7ecefd96b59f601ec74adc7a1a9a6425bc..f3292e1978d976ce638ebe26c079b939648ffe52 100644 --- a/crates/language_core/src/language_core.rs +++ b/crates/language_core/src/language_core.rs @@ -9,9 +9,9 @@ pub mod language_config; pub use diagnostic::{Diagnostic, DiagnosticSourceKind}; pub use grammar::{ BracketsConfig, BracketsPatternConfig, DebugVariablesConfig, DebuggerTextObject, Grammar, - GrammarId, HighlightsConfig, ImportsConfig, IndentConfig, InjectionConfig, - InjectionPatternConfig, NEXT_GRAMMAR_ID, OutlineConfig, OverrideConfig, OverrideEntry, - RedactionConfig, RunnableCapture, RunnableConfig, TextObject, TextObjectConfig, + GrammarId, HighlightsConfig, IndentConfig, InjectionConfig, InjectionPatternConfig, + NEXT_GRAMMAR_ID, OutlineConfig, OverrideConfig, OverrideEntry, RedactionConfig, + RunnableCapture, RunnableConfig, TextObject, TextObjectConfig, }; pub use highlight_map::{HighlightId, HighlightMap}; pub use language_config::{ diff --git a/crates/language_core/src/queries.rs b/crates/language_core/src/queries.rs index a0ec6890814e08013badef97bb26ac12d89c02f5..510fb2e03c9b3a6876a2d72180ea238c9a3be4b6 100644 --- a/crates/language_core/src/queries.rs +++ b/crates/language_core/src/queries.rs @@ -13,7 +13,6 @@ pub const QUERY_FILENAME_PREFIXES: &[(&str, QueryFieldAccessor)] = &[ ("runnables", |q| &mut q.runnables), ("debugger", |q| &mut q.debugger), ("textobjects", |q| &mut q.text_objects), - ("imports", |q| &mut q.imports), ]; /// Tree-sitter language queries for a given language. @@ -29,5 +28,4 @@ pub struct LanguageQueries { pub runnables: Option>, pub text_objects: Option>, pub debugger: Option>, - pub imports: Option>, } From 9d6208b2091061e88105a74d2b2993223381a7c8 Mon Sep 17 00:00:00 2001 From: Austin Cummings Date: Fri, 27 Mar 2026 11:52:11 -0700 Subject: [PATCH 43/53] Add check to prevent closing pinned items (#50333) Pinned items should not be closed when the close action is triggered. Closes #50309 Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed a bug where middle-clicking a pinned tab would close it. --- crates/workspace/src/pane.rs | 75 +++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 113e5af6424904655d21d83591c8e9a361df3b50..ca8e0dce44dee6f7da3c0e3f20083645974da4b0 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2849,7 +2849,7 @@ impl Pane { })) .on_aux_click( cx.listener(move |pane: &mut Self, event: &ClickEvent, window, cx| { - if !event.is_middle_click() { + if !event.is_middle_click() || is_pinned { return; } @@ -6858,6 +6858,79 @@ mod tests { assert_item_labels(&pane, ["A!", "B!", "D", "E", "C*", "F"], cx); } + #[gpui::test] + async fn test_middle_click_pinned_tab_does_not_close(cx: &mut TestAppContext) { + use gpui::{Modifiers, MouseButton, MouseDownEvent, MouseUpEvent}; + + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + let item_a = add_labeled_item(&pane, "A", false, cx); + add_labeled_item(&pane, "B", false, cx); + + pane.update_in(cx, |pane, window, cx| { + pane.pin_tab_at( + pane.index_for_item_id(item_a.item_id()).unwrap(), + window, + cx, + ); + }); + assert_item_labels(&pane, ["A!", "B*"], cx); + cx.run_until_parked(); + + let tab_a_bounds = cx + .debug_bounds("TAB-0") + .expect("Tab A (index 1) should have debug bounds"); + let tab_b_bounds = cx + .debug_bounds("TAB-1") + .expect("Tab B (index 2) should have debug bounds"); + + cx.simulate_event(MouseDownEvent { + position: tab_a_bounds.center(), + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + + cx.run_until_parked(); + + cx.simulate_event(MouseUpEvent { + position: tab_a_bounds.center(), + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + }); + + cx.run_until_parked(); + + cx.simulate_event(MouseDownEvent { + position: tab_b_bounds.center(), + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + + cx.run_until_parked(); + + cx.simulate_event(MouseUpEvent { + position: tab_b_bounds.center(), + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + }); + + cx.run_until_parked(); + + assert_item_labels(&pane, ["A*!"], cx); + } + #[gpui::test] async fn test_add_item_with_new_item(cx: &mut TestAppContext) { init_test(cx); From 9fe2a602b94908b0a513437d09c418c43e1fedf8 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Fri, 27 Mar 2026 13:09:47 -0700 Subject: [PATCH 44/53] Add VecMap and use it in ProjectGroupBuilder (#52596) This adds a collection that has a map-like API but is backed by vectors. For small collections, this often outperforms a HashMap because you don't have the overhead of hashing things and everything is guaranteed to be contiguous in memory. I hand-rolled one of these in `ProjectGroupBuilder` but this factors it into its own collection. This implements the API that `ProjectGroupBuilder` needed, but if this becomes more widely used we can expand to include more of the `HashMap` API. 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 | 1 + crates/collections/Cargo.toml | 2 +- crates/collections/src/collections.rs | 4 + crates/collections/src/vecmap.rs | 119 ++++++++++++++++++++ crates/collections/src/vecmap_tests.rs | 96 ++++++++++++++++ crates/sidebar/Cargo.toml | 1 + crates/sidebar/src/project_group_builder.rs | 24 +--- 7 files changed, 228 insertions(+), 19 deletions(-) create mode 100644 crates/collections/src/vecmap.rs create mode 100644 crates/collections/src/vecmap_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 04299a6cd3c899c9da0f9eb1c2d3110ceed26ef3..d25b76456c75b7b98e727fe297de25154eb37e32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16022,6 +16022,7 @@ dependencies = [ "anyhow", "assistant_text_thread", "chrono", + "collections", "editor", "feature_flags", "fs", diff --git a/crates/collections/Cargo.toml b/crates/collections/Cargo.toml index 8675504347f171397ea7372841cb00b7959eafe3..aa3dd899a7222f38377ea5f62927eea23534d1d8 100644 --- a/crates/collections/Cargo.toml +++ b/crates/collections/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true publish = false license = "Apache-2.0" -description = "Standard collection type re-exports used by Zed and GPUI" +description = "Standard collection types used by Zed and GPUI" [lints] workspace = true diff --git a/crates/collections/src/collections.rs b/crates/collections/src/collections.rs index ea5ea7332fb14e5e2ac33ba2d6f957dbfdc28c7a..8e6c334d2bd5d544f36666184df5fe095c3fdbe1 100644 --- a/crates/collections/src/collections.rs +++ b/crates/collections/src/collections.rs @@ -7,3 +7,7 @@ pub use indexmap::Equivalent; pub use rustc_hash::FxHasher; pub use rustc_hash::{FxHashMap, FxHashSet}; pub use std::collections::*; + +pub mod vecmap; +#[cfg(test)] +mod vecmap_tests; diff --git a/crates/collections/src/vecmap.rs b/crates/collections/src/vecmap.rs new file mode 100644 index 0000000000000000000000000000000000000000..97972e7bf9f5ae43957648b8a44b10e3e45bc32f --- /dev/null +++ b/crates/collections/src/vecmap.rs @@ -0,0 +1,119 @@ +/// A collection that provides a map interface but is backed by vectors. +/// +/// This is suitable for small key-value stores where the item count is not +/// large enough to overcome the overhead of a more complex algorithm. +/// +/// If this meets your use cases, then [`VecMap`] should be a drop-in +/// replacement for [`std::collections::HashMap`] or [`crate::HashMap`]. Note +/// that we are adding APIs on an as-needed basis. If the API you need is not +/// present yet, please add it! +/// +/// Because it uses vectors as a backing store, the map also iterates over items +/// in insertion order, like [`crate::IndexMap`]. +/// +/// This struct uses a struct-of-arrays (SoA) representation which tends to be +/// more cache efficient and promotes autovectorization when using simple key or +/// value types. +#[derive(Default)] +pub struct VecMap { + keys: Vec, + values: Vec, +} + +impl VecMap { + pub fn new() -> Self { + Self { + keys: Vec::new(), + values: Vec::new(), + } + } + + pub fn iter(&self) -> Iter<'_, K, V> { + Iter { + iter: self.keys.iter().zip(self.values.iter()), + } + } +} + +impl VecMap { + pub fn entry(&mut self, key: K) -> Entry<'_, K, V> { + match self.keys.iter().position(|k| k == &key) { + Some(index) => Entry::Occupied(OccupiedEntry { + key: &self.keys[index], + value: &mut self.values[index], + }), + None => Entry::Vacant(VacantEntry { map: self, key }), + } + } +} + +pub struct Iter<'a, K, V> { + iter: std::iter::Zip, std::slice::Iter<'a, V>>, +} + +impl<'a, K, V> Iterator for Iter<'a, K, V> { + type Item = (&'a K, &'a V); + + fn next(&mut self) -> Option { + self.iter.next() + } +} + +pub enum Entry<'a, K, V> { + Occupied(OccupiedEntry<'a, K, V>), + Vacant(VacantEntry<'a, K, V>), +} + +impl<'a, K, V> Entry<'a, K, V> { + pub fn key(&self) -> &K { + match self { + Entry::Occupied(entry) => entry.key, + Entry::Vacant(entry) => &entry.key, + } + } + + pub fn or_insert_with_key(self, default: F) -> &'a mut V + where + F: FnOnce(&K) -> V, + { + match self { + Entry::Occupied(entry) => entry.value, + Entry::Vacant(entry) => { + entry.map.values.push(default(&entry.key)); + entry.map.keys.push(entry.key); + match entry.map.values.last_mut() { + Some(value) => value, + None => unreachable!("vec empty after pushing to it"), + } + } + } + } + + pub fn or_insert_with(self, default: F) -> &'a mut V + where + F: FnOnce() -> V, + { + self.or_insert_with_key(|_| default()) + } + + pub fn or_insert(self, value: V) -> &'a mut V { + self.or_insert_with_key(|_| value) + } + + pub fn or_insert_default(self) -> &'a mut V + where + V: Default, + { + self.or_insert_with_key(|_| Default::default()) + } +} + +pub struct OccupiedEntry<'a, K, V> { + key: &'a K, + value: &'a mut V, +} + +pub struct VacantEntry<'a, K, V> { + map: &'a mut VecMap, + key: K, +} diff --git a/crates/collections/src/vecmap_tests.rs b/crates/collections/src/vecmap_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..9fc7d430f3422374a7662bb476cbd99dd09d9a43 --- /dev/null +++ b/crates/collections/src/vecmap_tests.rs @@ -0,0 +1,96 @@ +//! Tests for the VecMap collection. +//! +//! This is in a sibling module so that the tests are guaranteed to only cover +//! states that can be created by the public API. + +use crate::vecmap::*; + +#[test] +fn test_entry_vacant_or_insert() { + let mut map: VecMap<&str, i32> = VecMap::new(); + let value = map.entry("a").or_insert(1); + assert_eq!(*value, 1); + assert_eq!(map.iter().collect::>(), vec![(&"a", &1)]); +} + +#[test] +fn test_entry_occupied_or_insert_keeps_existing() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("a").or_insert(1); + let value = map.entry("a").or_insert(99); + assert_eq!(*value, 1); + assert_eq!(map.iter().collect::>(), vec![(&"a", &1)]); +} + +#[test] +fn test_entry_or_insert_with() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("a").or_insert_with(|| 42); + assert_eq!(map.iter().collect::>(), vec![(&"a", &42)]); +} + +#[test] +fn test_entry_or_insert_with_not_called_when_occupied() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("a").or_insert(1); + map.entry("a") + .or_insert_with(|| panic!("should not be called")); + assert_eq!(map.iter().collect::>(), vec![(&"a", &1)]); +} + +#[test] +fn test_entry_or_insert_with_key() { + let mut map: VecMap<&str, String> = VecMap::new(); + map.entry("hello").or_insert_with_key(|k| k.to_uppercase()); + assert_eq!( + map.iter().collect::>(), + vec![(&"hello", &"HELLO".to_string())] + ); +} + +#[test] +fn test_entry_or_insert_default() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("a").or_insert_default(); + assert_eq!(map.iter().collect::>(), vec![(&"a", &0)]); +} + +#[test] +fn test_entry_key() { + let mut map: VecMap<&str, i32> = VecMap::new(); + assert_eq!(*map.entry("a").key(), "a"); + map.entry("a").or_insert(1); + assert_eq!(*map.entry("a").key(), "a"); +} + +#[test] +fn test_entry_mut_ref_can_be_updated() { + let mut map: VecMap<&str, i32> = VecMap::new(); + let value = map.entry("a").or_insert(0); + *value = 5; + assert_eq!(map.iter().collect::>(), vec![(&"a", &5)]); +} + +#[test] +fn test_insertion_order_preserved() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("b").or_insert(2); + map.entry("a").or_insert(1); + map.entry("c").or_insert(3); + assert_eq!( + map.iter().collect::>(), + vec![(&"b", &2), (&"a", &1), (&"c", &3)] + ); +} + +#[test] +fn test_multiple_entries_independent() { + let mut map: VecMap = VecMap::new(); + map.entry(1).or_insert(10); + map.entry(2).or_insert(20); + map.entry(3).or_insert(30); + assert_eq!(map.iter().count(), 3); + // Re-inserting does not duplicate keys + map.entry(1).or_insert(99); + assert_eq!(map.iter().count(), 3); +} diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 179d8c40135bece9d5e85142cb47bc32fe236218..a75a6b1af7a26723c1691b27676072c1869b5847 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -23,6 +23,7 @@ agent_settings.workspace = true agent_ui.workspace = true anyhow.workspace = true chrono.workspace = true +collections.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs index ae3b64d424e43b4d1712d437418ccaf5e7e81a79..bab0060186a7cae2d79aaead61946a15bf109a5a 100644 --- a/crates/sidebar/src/project_group_builder.rs +++ b/crates/sidebar/src/project_group_builder.rs @@ -8,8 +8,8 @@ //! This module is provides the functions and structures necessary to do this //! lookup and mapping. +use collections::{HashMap, HashSet, vecmap::VecMap}; use std::{ - collections::{HashMap, HashSet}, path::{Path, PathBuf}, sync::Arc, }; @@ -78,16 +78,14 @@ impl ProjectGroup { pub struct ProjectGroupBuilder { /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path directory_mappings: HashMap, - project_group_names: Vec, - project_groups: Vec, + project_groups: VecMap, } impl ProjectGroupBuilder { fn new() -> Self { Self { - directory_mappings: HashMap::new(), - project_group_names: Vec::new(), - project_groups: Vec::new(), + directory_mappings: HashMap::default(), + project_groups: VecMap::new(), } } @@ -113,15 +111,7 @@ impl ProjectGroupBuilder { } fn project_group_entry(&mut self, name: &ProjectGroupName) -> &mut ProjectGroup { - match self.project_group_names.iter().position(|n| n == name) { - Some(idx) => &mut self.project_groups[idx], - None => { - let idx = self.project_group_names.len(); - self.project_group_names.push(name.clone()); - self.project_groups.push(ProjectGroup::default()); - &mut self.project_groups[idx] - } - } + self.project_groups.entry(name.clone()).or_insert_default() } fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) { @@ -204,9 +194,7 @@ impl ProjectGroupBuilder { } pub fn groups(&self) -> impl Iterator { - self.project_group_names - .iter() - .zip(self.project_groups.iter()) + self.project_groups.iter() } } From a6aad85cab544b2be56d03b988c58947f69373b4 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 27 Mar 2026 17:15:39 -0400 Subject: [PATCH 45/53] git: Fix out-of-bounds indexing when diff bases contain CRLF (#52605) 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/buffer_diff/src/buffer_diff.rs | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 2ea1c349074dcadf9723e11df154f3d9e9bf3d75..a1acefe2f315b29e9a321177e4395ead337fe06a 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -1651,6 +1651,7 @@ impl BufferDiff { language: Option>, cx: &App, ) -> Task { + let base_text = base_text.map(|t| text::LineEnding::normalize_arc(t)); let prev_base_text = self.base_text(cx).as_rope().clone(); let base_text_changed = base_text_change.is_some(); let compute_base_text_edits = base_text_change == Some(true); @@ -3947,4 +3948,36 @@ mod tests { } } } + + #[gpui::test] + async fn test_set_base_text_with_crlf(cx: &mut gpui::TestAppContext) { + let base_text_crlf = "one\r\ntwo\r\nthree\r\nfour\r\nfive\r\n"; + let base_text_lf = "one\ntwo\nthree\nfour\nfive\n"; + assert_ne!(base_text_crlf.len(), base_text_lf.len()); + + let buffer_text = "one\nTWO\nthree\nfour\nfive\n"; + let buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + buffer_text.to_string(), + ); + let buffer_snapshot = buffer.snapshot(); + + let diff = cx.new(|cx| BufferDiff::new(&buffer_snapshot, cx)); + diff.update(cx, |diff, cx| { + diff.set_base_text( + Some(Arc::from(base_text_crlf)), + None, + buffer_snapshot.clone(), + cx, + ) + }) + .await + .ok(); + cx.run_until_parked(); + + let snapshot = diff.update(cx, |diff, cx| diff.snapshot(cx)); + snapshot.buffer_point_to_base_text_range(Point::new(0, 0), &buffer_snapshot); + snapshot.buffer_point_to_base_text_range(Point::new(1, 0), &buffer_snapshot); + } } From 54b0ec9325f77b6e6c7608e87dfe0eea954d8b75 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:21:43 -0300 Subject: [PATCH 46/53] agent_ui: Use the CircularProgress component also for split token display (#52599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR swaps numbers for two circular progress components for the models that support displaying token usage broken down by input and output tokens. Here's how the UI looks like: Screenshot 2026-03-27 at 5  03@2x Release Notes: - Agent: Make token usage display consistent between the models that support displaying split usage (input and output) and those that don't. --- .../src/conversation_view/thread_view.rs | 110 ++++++++---------- 1 file changed, 49 insertions(+), 61 deletions(-) diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index b6708647868214d3ca02a2952ce718defe6ab557..7caeb687bebb32083c2647a2bc0d359b36e03b58 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -3402,29 +3402,14 @@ impl ThreadView { fn render_token_usage(&self, cx: &mut Context) -> Option { let thread = self.thread.read(cx); let usage = thread.token_usage()?; - let is_generating = thread.status() != ThreadStatus::Idle; let show_split = self.supports_split_token_display(cx); - let separator_color = Color::Custom(cx.theme().colors().text_muted.opacity(0.5)); - let token_label = |text: String, animation_id: &'static str| { - Label::new(text) - .size(LabelSize::Small) - .color(Color::Muted) - .map(|label| { - if is_generating { - label - .with_animation( - animation_id, - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.3, 0.8)), - |label, delta| label.alpha(delta), - ) - .into_any() - } else { - label.into_any_element() - } - }) + let progress_color = |ratio: f32| -> Hsla { + if ratio >= 0.85 { + cx.theme().status().warning + } else { + cx.theme().colors().text_muted + } }; let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); @@ -3439,6 +3424,10 @@ impl ThreadView { } else { 0.0 }; + + let ring_size = px(16.0); + let stroke_width = px(2.); + let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32); let tooltip_separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6)); @@ -3478,8 +3467,6 @@ impl ThreadView { let output_max_label = crate::text_thread_editor::humanize_token_count(max_output_tokens); let build_tooltip = { - let input_max_label = input_max_label.clone(); - let output_max_label = output_max_label.clone(); move |_window: &mut Window, cx: &mut App| { let percentage = percentage.clone(); let used = used.clone(); @@ -3511,17 +3498,26 @@ impl ThreadView { }; if show_split { - let input = crate::text_thread_editor::humanize_token_count(usage.input_tokens); - let input_max = input_max_label; - let output = crate::text_thread_editor::humanize_token_count(usage.output_tokens); - let output_max = output_max_label; + let input_max_raw = usage.max_tokens.saturating_sub(max_output_tokens); + let output_max_raw = max_output_tokens; + + let input_ratio = if input_max_raw > 0 { + usage.input_tokens as f32 / input_max_raw as f32 + } else { + 0.0 + }; + let output_ratio = if output_max_raw > 0 { + usage.output_tokens as f32 / output_max_raw as f32 + } else { + 0.0 + }; Some( h_flex() .id("split_token_usage") .flex_shrink_0() - .gap_1() - .mr_1p5() + .gap_1p5() + .mr_1() .child( h_flex() .gap_0p5() @@ -3530,16 +3526,15 @@ impl ThreadView { .size(IconSize::XSmall) .color(Color::Muted), ) - .child(token_label(input, "input-tokens-label")) - .child( - Label::new("/") - .size(LabelSize::Small) - .color(separator_color), - ) .child( - Label::new(input_max) - .size(LabelSize::Small) - .color(Color::Muted), + CircularProgress::new( + usage.input_tokens as f32, + input_max_raw as f32, + ring_size, + cx, + ) + .stroke_width(stroke_width) + .progress_color(progress_color(input_ratio)), ), ) .child( @@ -3550,28 +3545,21 @@ impl ThreadView { .size(IconSize::XSmall) .color(Color::Muted), ) - .child(token_label(output, "output-tokens-label")) .child( - Label::new("/") - .size(LabelSize::Small) - .color(separator_color), - ) - .child( - Label::new(output_max) - .size(LabelSize::Small) - .color(Color::Muted), + CircularProgress::new( + usage.output_tokens as f32, + output_max_raw as f32, + ring_size, + cx, + ) + .stroke_width(stroke_width) + .progress_color(progress_color(output_ratio)), ), ) .hoverable_tooltip(build_tooltip) .into_any_element(), ) } else { - let progress_color = if progress_ratio >= 0.85 { - cx.theme().status().warning - } else { - cx.theme().colors().text_muted - }; - Some( h_flex() .id("circular_progress_tokens") @@ -3581,11 +3569,11 @@ impl ThreadView { CircularProgress::new( usage.used_tokens as f32, usage.max_tokens as f32, - px(16.0), + ring_size, cx, ) - .stroke_width(px(2.)) - .progress_color(progress_color), + .stroke_width(stroke_width) + .progress_color(progress_color(progress_ratio)), ) .hoverable_tooltip(build_tooltip) .into_any_element(), @@ -4173,13 +4161,13 @@ impl Render for TokenUsageTooltip { ui::tooltip_container(cx, move |container, cx| { container .min_w_40() + .child( + Label::new("Context") + .color(Color::Muted) + .size(LabelSize::Small), + ) .when(!show_split, |this| { this.child( - Label::new("Context") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child( h_flex() .gap_0p5() .child(Label::new(percentage.clone())) From 4aa0ed1e54175cba883ca32efcc3e54af2478f57 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 Mar 2026 14:32:14 -0700 Subject: [PATCH 47/53] Bump tree-sitter-rust for string literal fixes (#52606) This PR bumps tree-sitter-rust for this fix: https://github.com/tree-sitter/tree-sitter-rust/pull/307 Release Notes: - N/A --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d25b76456c75b7b98e727fe297de25154eb37e32..0318edb208e98758241785352d6a067141c5e8f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18629,9 +18629,9 @@ dependencies = [ [[package]] name = "tree-sitter-rust" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f715f73a0687261ddb686f0d64a1e5af57bd199c4d12be5fdda6676ce1885bf9" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" dependencies = [ "cc", "tree-sitter-language", diff --git a/Cargo.toml b/Cargo.toml index 57466b06e99198f60cd2d62f375e56cef0fe006a..998a4705f28c82160b7124a98c1eb23c22360125 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -758,7 +758,7 @@ tree-sitter-md = { git = "https://github.com/tree-sitter-grammars/tree-sitter-ma tree-sitter-python = "0.25" tree-sitter-regex = "0.24" tree-sitter-ruby = "0.23" -tree-sitter-rust = "0.24.1" +tree-sitter-rust = "0.24.2" tree-sitter-typescript = { git = "https://github.com/zed-industries/tree-sitter-typescript", rev = "e2c53597d6a5d9cf7bbe8dccde576fe1e46c5899" } # https://github.com/tree-sitter/tree-sitter-typescript/pull/347 tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "baff0b51c64ef6a1fb1f8390f3ad6015b83ec13a" } tracing = "0.1.40" From 6d09eded79111d00c96ea094bca0eb74945b7826 Mon Sep 17 00:00:00 2001 From: Bryan Mehall Date: Fri, 27 Mar 2026 15:32:30 -0600 Subject: [PATCH 48/53] Fix bracketed paste in terminal on middle click (#52158) ## Context **Current Behavior:** Middle click pastes multiple lines of text and runs each as an individual terminal command **Expected Behavior:** On Linux middle click should use bracketed paste to paste multiple lines and wait for the user to hit "Enter" before running all commands together like when pressing Ctrl+Shift+V Steps to reproduce old behavior: 1. Open terminal 2. Copy multiple lines of text from outside the terminal 3. Middle click to paste text in terminal ## 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 multiple line paste behavior in terminal on middle click Co-authored-by: Bryan Mehall <1575089+bryanmehall@users.noreply.github.com> --- crates/terminal/src/terminal.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 2ff689972a275e6ae4546af270838c02ec4a4b92..b620f5f03c2debf19cdc4856da8c039fe690651f 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -1949,7 +1949,7 @@ impl Terminal { MouseButton::Middle => { if let Some(item) = _cx.read_from_primary() { let text = item.text().unwrap_or_default(); - self.input(text.into_bytes()); + self.paste(&text); } } _ => {} From f3120aca180b26766c08959367e6072bd30d0b6d Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Fri, 27 Mar 2026 14:49:41 -0700 Subject: [PATCH 49/53] Show multiple worktree chips in sidebar for threads with mismatched worktrees (#52544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When a thread was created in a workspace whose roots span different git worktrees (e.g. the `olivetti` branch of project_a and the `selectric` branch of project_b), the sidebar now shows a worktree chip for each distinct branch name — like `{olivetti} {selectric}` — each with its own git worktree icon. Same-named worktrees are collapsed into a single chip. The tooltip on any chip shows the full list of worktree paths. Previously, only one worktree chip was ever shown (from the first path). ### Implementation - Introduces `WorktreeInfo` struct replacing the flat `worktree_name`/`worktree_full_path`/`worktree_highlight_positions` fields on `ThreadEntry` with `worktrees: Vec` - Adds `worktree_info_from_thread_paths` which derives worktree display info from a thread's own stored `folder_paths` metadata rather than from the workspace path - Updates `ThreadItem` in the UI crate to accept `Vec` and render multiple chips, deduplicating by name - Updates fuzzy search to match against all worktree names - Adds tests for multiple worktree chips and same-name deduplication Release Notes: - N/A --- crates/sidebar/src/project_group_builder.rs | 14 +- crates/sidebar/src/sidebar.rs | 413 +++++++++++++++----- crates/ui/src/components/ai/thread_item.rs | 127 +++--- 3 files changed, 404 insertions(+), 150 deletions(-) diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs index bab0060186a7cae2d79aaead61946a15bf109a5a..2443a719639c2dc4b83b26d1213db214e13d70f7 100644 --- a/crates/sidebar/src/project_group_builder.rs +++ b/crates/sidebar/src/project_group_builder.rs @@ -166,24 +166,26 @@ impl ProjectGroupBuilder { .unwrap_or(path) } - /// Whether the given group should load threads for a linked worktree at - /// `worktree_path`. Returns `false` if the worktree already has an open - /// workspace in the group (its threads are loaded via the workspace loop) - /// or if the worktree's canonical path list doesn't match `group_path_list`. + /// Whether the given group should load threads for a linked worktree + /// at `worktree_path`. Returns `false` if the worktree already has an + /// open workspace in the group (its threads are loaded via the + /// workspace loop) or if the worktree's canonical path list doesn't + /// match `group_path_list`. pub fn group_owns_worktree( &self, group: &ProjectGroup, group_path_list: &PathList, worktree_path: &Path, ) -> bool { - let worktree_arc: Arc = Arc::from(worktree_path); - if group.covered_paths.contains(&worktree_arc) { + if group.covered_paths.contains(worktree_path) { return false; } let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path])); canonical == *group_path_list } + /// Canonicalizes every path in a [`PathList`] using the builder's + /// directory mappings. fn canonicalize_path_list(&self, path_list: &PathList) -> PathList { let paths: Vec<_> = path_list .paths() diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 4ec50a1f52840930e61c23e5463878a173cfaa89..a5c26a0b3c56c3ad3483d5f445daa87d3cd8ae6f 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -30,7 +30,8 @@ use std::rc::Rc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding, - PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*, + PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, + WithScrollbar, prelude::*, }; use util::ResultExt as _; use util::path_list::PathList; @@ -102,6 +103,13 @@ enum ThreadEntryWorkspace { Closed(PathList), } +#[derive(Clone)] +struct WorktreeInfo { + name: SharedString, + full_path: SharedString, + highlight_positions: Vec, +} + #[derive(Clone)] struct ThreadEntry { agent: Agent, @@ -114,9 +122,7 @@ struct ThreadEntry { is_background: bool, is_title_generating: bool, highlight_positions: Vec, - worktree_name: Option, - worktree_full_path: Option, - worktree_highlight_positions: Vec, + worktrees: Vec, diff_stats: DiffStats, } @@ -229,6 +235,33 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } +/// Derives worktree display info from a thread's stored path list. +/// +/// For each path in the thread's `folder_paths` that canonicalizes to a +/// different path (i.e. it's a git worktree), produces a [`WorktreeInfo`] +/// with the short worktree name and full path. +fn worktree_info_from_thread_paths( + folder_paths: &PathList, + project_groups: &ProjectGroupBuilder, +) -> Vec { + folder_paths + .paths() + .iter() + .filter_map(|path| { + let canonical = project_groups.canonicalize_path(path); + if canonical != path.as_path() { + Some(WorktreeInfo { + name: linked_worktree_short_name(canonical, path).unwrap_or_default(), + full_path: SharedString::from(path.display().to_string()), + highlight_positions: Vec::new(), + }) + } else { + None + } + }) + .collect() +} + /// The sidebar re-derives its entire entry list from scratch on every /// change via `update_entries` → `rebuild_contents`. Avoid adding /// incremental or inter-event coordination state — if something can @@ -693,39 +726,21 @@ impl Sidebar { for workspace in &group.workspaces { let ws_path_list = workspace_path_list(workspace, cx); - // Determine if this workspace covers a git worktree (its - // path canonicalizes to the main repo, not itself). If so, - // threads from it get a worktree chip in the sidebar. - let worktree_info: Option<(SharedString, SharedString)> = - ws_path_list.paths().first().and_then(|path| { - let canonical = project_groups.canonicalize_path(path); - if canonical != path.as_path() { - let name = - linked_worktree_short_name(canonical, path).unwrap_or_default(); - let full_path: SharedString = path.display().to_string().into(); - Some((name, full_path)) - } else { - None - } - }); - - let workspace_threads: Vec<_> = thread_store - .read(cx) - .entries_for_path(&ws_path_list) - .collect(); - for thread in workspace_threads { - if !seen_session_ids.insert(thread.session_id.clone()) { + for row in thread_store.read(cx).entries_for_path(&ws_path_list) { + if !seen_session_ids.insert(row.session_id.clone()) { continue; } - let (agent, icon, icon_from_external_svg) = resolve_agent(&thread); + let (agent, icon, icon_from_external_svg) = resolve_agent(&row); + let worktrees = + worktree_info_from_thread_paths(&row.folder_paths, &project_groups); threads.push(ThreadEntry { agent, session_info: acp_thread::AgentSessionInfo { - session_id: thread.session_id.clone(), + session_id: row.session_id.clone(), work_dirs: None, - title: Some(thread.title.clone()), - updated_at: Some(thread.updated_at), - created_at: thread.created_at, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, meta: None, }, icon, @@ -736,20 +751,15 @@ impl Sidebar { is_background: false, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()), - worktree_full_path: worktree_info - .as_ref() - .map(|(_, path)| path.clone()), - worktree_highlight_positions: Vec::new(), + worktrees, diff_stats: DiffStats::default(), }); } } - // Load threads from linked git worktrees that don't have an - // open workspace in this group. Only include worktrees that - // belong to this group (not shared with another group). - let linked_worktree_path_lists = group + // Load threads from linked git worktrees whose + // canonical paths belong to this group. + let linked_worktree_queries = group .workspaces .iter() .flat_map(|ws| root_repository_snapshots(ws, cx)) @@ -765,23 +775,14 @@ impl Sidebar { .collect::>() }); - for worktree_path_list in linked_worktree_path_lists { + for worktree_path_list in linked_worktree_queries { for row in thread_store.read(cx).entries_for_path(&worktree_path_list) { if !seen_session_ids.insert(row.session_id.clone()) { continue; } - let worktree_info = row.folder_paths.paths().first().and_then(|path| { - let canonical = project_groups.canonicalize_path(path); - if canonical != path.as_path() { - let name = - linked_worktree_short_name(canonical, path).unwrap_or_default(); - let full_path: SharedString = path.display().to_string().into(); - Some((name, full_path)) - } else { - None - } - }); let (agent, icon, icon_from_external_svg) = resolve_agent(&row); + let worktrees = + worktree_info_from_thread_paths(&row.folder_paths, &project_groups); threads.push(ThreadEntry { agent, session_info: acp_thread::AgentSessionInfo { @@ -795,14 +796,12 @@ impl Sidebar { icon, icon_from_external_svg, status: AgentThreadStatus::default(), - workspace: ThreadEntryWorkspace::Closed(row.folder_paths.clone()), + workspace: ThreadEntryWorkspace::Closed(worktree_path_list.clone()), is_live: false, is_background: false, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()), - worktree_full_path: worktree_info.map(|(_, path)| path), - worktree_highlight_positions: Vec::new(), + worktrees, diff_stats: DiffStats::default(), }); } @@ -882,12 +881,13 @@ impl Sidebar { if let Some(positions) = fuzzy_match_positions(&query, title) { thread.highlight_positions = positions; } - if let Some(worktree_name) = &thread.worktree_name { - if let Some(positions) = fuzzy_match_positions(&query, worktree_name) { - thread.worktree_highlight_positions = positions; + let mut worktree_matched = false; + for worktree in &mut thread.worktrees { + if let Some(positions) = fuzzy_match_positions(&query, &worktree.name) { + worktree.highlight_positions = positions; + worktree_matched = true; } } - let worktree_matched = !thread.worktree_highlight_positions.is_empty(); if workspace_matched || !thread.highlight_positions.is_empty() || worktree_matched @@ -2437,14 +2437,17 @@ impl Sidebar { .when_some(thread.icon_from_external_svg.clone(), |this, svg| { this.custom_icon_from_external_svg(svg) }) - .when_some(thread.worktree_name.clone(), |this, name| { - let this = this.worktree(name); - match thread.worktree_full_path.clone() { - Some(path) => this.worktree_full_path(path), - None => this, - } - }) - .worktree_highlight_positions(thread.worktree_highlight_positions.clone()) + .worktrees( + thread + .worktrees + .iter() + .map(|wt| ThreadItemWorktreeInfo { + name: wt.name.clone(), + full_path: wt.full_path.clone(), + highlight_positions: wt.highlight_positions.clone(), + }) + .collect(), + ) .when_some(timestamp, |this, ts| this.timestamp(ts)) .highlight_positions(thread.highlight_positions.to_vec()) .title_generating(thread.is_title_generating) @@ -3400,11 +3403,19 @@ mod tests { } else { "" }; - let worktree = thread - .worktree_name - .as_ref() - .map(|name| format!(" {{{}}}", name)) - .unwrap_or_default(); + let worktree = if thread.worktrees.is_empty() { + String::new() + } else { + let mut seen = Vec::new(); + let mut chips = Vec::new(); + for wt in &thread.worktrees { + if !seen.contains(&wt.name) { + seen.push(wt.name.clone()); + chips.push(format!("{{{}}}", wt.name)); + } + } + format!(" {}", chips.join(", ")) + }; format!( " {}{}{}{}{}{}", title, worktree, active, status_str, notified, selected @@ -3777,9 +3788,7 @@ mod tests { is_background: false, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + worktrees: Vec::new(), diff_stats: DiffStats::default(), }), // Active thread with Running status @@ -3801,9 +3810,7 @@ mod tests { is_background: false, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + worktrees: Vec::new(), diff_stats: DiffStats::default(), }), // Active thread with Error status @@ -3825,9 +3832,7 @@ mod tests { is_background: false, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + worktrees: Vec::new(), diff_stats: DiffStats::default(), }), // Thread with WaitingForConfirmation status, not active @@ -3849,9 +3854,7 @@ mod tests { is_background: false, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + worktrees: Vec::new(), diff_stats: DiffStats::default(), }), // Background thread that completed (should show notification) @@ -3873,9 +3876,7 @@ mod tests { is_background: true, is_title_generating: false, highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + worktrees: Vec::new(), diff_stats: DiffStats::default(), }), // View More entry @@ -5817,6 +5818,227 @@ mod tests { ); } + #[gpui::test] + async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) { + // A thread created in a workspace with roots from different git + // worktrees should show a chip for each distinct worktree name. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Two main repos. + fs.insert_tree( + "/project_a", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + "selectric": { + "commondir": "../../", + "HEAD": "ref: refs/heads/selectric", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/project_b", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + "selectric": { + "commondir": "../../", + "HEAD": "ref: refs/heads/selectric", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkouts. + for (repo, branch) in &[ + ("project_a", "olivetti"), + ("project_a", "selectric"), + ("project_b", "olivetti"), + ("project_b", "selectric"), + ] { + let worktree_path = format!("/worktrees/{repo}/{branch}/{repo}"); + let gitdir = format!("gitdir: /{repo}/.git/worktrees/{branch}"); + fs.insert_tree( + &worktree_path, + serde_json::json!({ + ".git": gitdir, + "src": {}, + }), + ) + .await; + } + + // Register linked worktrees. + for repo in &["project_a", "project_b"] { + let git_path = format!("/{repo}/.git"); + fs.with_git_state(std::path::Path::new(&git_path), false, |state| { + for branch in &["olivetti", "selectric"] { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from(format!( + "/worktrees/{repo}/{branch}/{repo}" + )), + ref_name: Some(format!("refs/heads/{branch}").into()), + sha: "aaa".into(), + }); + } + }) + .unwrap(); + } + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Open a workspace with the worktree checkout paths as roots + // (this is the workspace the thread was created in). + let project = project::Project::test( + fs.clone(), + [ + "/worktrees/project_a/olivetti/project_a".as_ref(), + "/worktrees/project_b/selectric/project_b".as_ref(), + ], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread under the same paths as the workspace roots. + let thread_paths = PathList::new(&[ + std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), + std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"), + ]); + save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Should show two distinct worktree chips. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project_a, project_b]", + " Cross Worktree Thread {olivetti}, {selectric}", + ] + ); + } + + #[gpui::test] + async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) { + // When a thread's roots span multiple repos but share the same + // worktree name (e.g. both in "olivetti"), only one chip should + // appear. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project_a", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/project_b", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + }, + }, + "src": {}, + }), + ) + .await; + + for repo in &["project_a", "project_b"] { + let worktree_path = format!("/worktrees/{repo}/olivetti/{repo}"); + let gitdir = format!("gitdir: /{repo}/.git/worktrees/olivetti"); + fs.insert_tree( + &worktree_path, + serde_json::json!({ + ".git": gitdir, + "src": {}, + }), + ) + .await; + + let git_path = format!("/{repo}/.git"); + fs.with_git_state(std::path::Path::new(&git_path), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")), + ref_name: Some("refs/heads/olivetti".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + } + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project = project::Project::test( + fs.clone(), + [ + "/worktrees/project_a/olivetti/project_a".as_ref(), + "/worktrees/project_b/olivetti/project_b".as_ref(), + ], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Thread with roots in both repos' "olivetti" worktrees. + let thread_paths = PathList::new(&[ + std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), + std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"), + ]); + save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Both worktree paths have the name "olivetti", so only one chip. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project_a, project_b]", + " Same Branch Thread {olivetti}", + ] + ); + } + #[gpui::test] async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) { // When a worktree workspace is absorbed under the main repo, a @@ -6251,7 +6473,7 @@ mod tests { .as_ref() .map(|title| title.as_ref()) == Some("WT Thread") - && thread.worktree_name.as_ref().map(|name| name.as_ref()) + && thread.worktrees.first().map(|wt| wt.name.as_ref()) == Some("wt-feature-a") => { saw_expected_thread = true; @@ -6264,9 +6486,9 @@ mod tests { .map(|title| title.as_ref()) .unwrap_or("Untitled"); let worktree_name = thread - .worktree_name - .as_ref() - .map(|name| name.as_ref()) + .worktrees + .first() + .map(|wt| wt.name.as_ref()) .unwrap_or(""); panic!( "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`" @@ -7070,6 +7292,7 @@ mod tests { init_test(cx); let fs = FakeFs::new(cx.executor()); + // Two independent repos, each with their own git history. fs.insert_tree( "/project", serde_json::json!({ @@ -7102,6 +7325,7 @@ mod tests { ) .await; + // Register the linked worktree in the main repo. fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { state.worktrees.push(git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), @@ -7113,11 +7337,13 @@ mod tests { cx.update(|cx| ::set_global(fs.clone(), cx)); + // Workspace 1: just /project. let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; project_only .update(cx, |p, cx| p.git_scans_complete(cx)) .await; + // Workspace 2: /other and /project together (multi-root). let multi_root = project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await; multi_root @@ -7132,12 +7358,15 @@ mod tests { }); let sidebar = setup_sidebar(&multi_workspace, cx); + // Save a thread under the linked worktree path. let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); + // The thread should appear only under [project] (the dedicated + // group for the /project repo), not under [other, project]. assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 95244a382b988380339d649473c35fcac66f6d7a..b20692740cb1399a562d160c80297a076f7d4516 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -18,6 +18,13 @@ pub enum AgentThreadStatus { Error, } +#[derive(Clone)] +pub struct ThreadItemWorktreeInfo { + pub name: SharedString, + pub full_path: SharedString, + pub highlight_positions: Vec, +} + #[derive(IntoElement, RegisterComponent)] pub struct ThreadItem { id: ElementId, @@ -37,9 +44,7 @@ pub struct ThreadItem { hovered: bool, added: Option, removed: Option, - worktree: Option, - worktree_full_path: Option, - worktree_highlight_positions: Vec, + worktrees: Vec, on_click: Option>, on_hover: Box, action_slot: Option, @@ -66,9 +71,7 @@ impl ThreadItem { hovered: false, added: None, removed: None, - worktree: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + worktrees: Vec::new(), on_click: None, on_hover: Box::new(|_, _, _| {}), action_slot: None, @@ -146,18 +149,8 @@ impl ThreadItem { self } - pub fn worktree(mut self, worktree: impl Into) -> Self { - self.worktree = Some(worktree.into()); - self - } - - pub fn worktree_full_path(mut self, worktree_full_path: impl Into) -> Self { - self.worktree_full_path = Some(worktree_full_path.into()); - self - } - - pub fn worktree_highlight_positions(mut self, positions: Vec) -> Self { - self.worktree_highlight_positions = positions; + pub fn worktrees(mut self, worktrees: Vec) -> Self { + self.worktrees = worktrees; self } @@ -319,7 +312,7 @@ impl RenderOnce for ThreadItem { let added_count = self.added.unwrap_or(0); let removed_count = self.removed.unwrap_or(0); - let has_worktree = self.worktree.is_some(); + let has_worktree = !self.worktrees.is_empty(); let has_timestamp = !self.timestamp.is_empty(); let timestamp = self.timestamp; @@ -376,48 +369,67 @@ impl RenderOnce for ThreadItem { }), ) .when(has_worktree || has_diff_stats || has_timestamp, |this| { - let worktree_full_path = self.worktree_full_path.clone().unwrap_or_default(); - let worktree_label = self.worktree.map(|worktree| { - let positions = self.worktree_highlight_positions; - if positions.is_empty() { - Label::new(worktree) + // Collect all full paths for the shared tooltip. + let worktree_tooltip: SharedString = self + .worktrees + .iter() + .map(|wt| wt.full_path.as_ref()) + .collect::>() + .join("\n") + .into(); + let worktree_tooltip_title = if self.worktrees.len() > 1 { + "Thread Running in Local Git Worktrees" + } else { + "Thread Running in a Local Git Worktree" + }; + + // Deduplicate chips by name — e.g. two paths both named + // "olivetti" produce a single chip. Highlight positions + // come from the first occurrence. + let mut seen_names: Vec = Vec::new(); + let mut worktree_chips: Vec = Vec::new(); + for wt in self.worktrees { + if seen_names.contains(&wt.name) { + continue; + } + let chip_index = seen_names.len(); + seen_names.push(wt.name.clone()); + let label = if wt.highlight_positions.is_empty() { + Label::new(wt.name) .size(LabelSize::Small) .color(Color::Muted) .into_any_element() } else { - HighlightedLabel::new(worktree, positions) + HighlightedLabel::new(wt.name, wt.highlight_positions) .size(LabelSize::Small) .color(Color::Muted) .into_any_element() - } - }); + }; + let tooltip_title = worktree_tooltip_title; + let tooltip_meta = worktree_tooltip.clone(); + worktree_chips.push( + h_flex() + .id(format!("{}-worktree-{chip_index}", self.id.clone())) + .gap_0p5() + .child( + Icon::new(IconName::GitWorktree) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child(label) + .tooltip(move |_, cx| { + Tooltip::with_meta(tooltip_title, None, tooltip_meta.clone(), cx) + }) + .into_any_element(), + ); + } this.child( h_flex() .min_w_0() .gap_1p5() .child(icon_container()) // Icon Spacing - .when_some(worktree_label, |this, label| { - this.child( - h_flex() - .id(format!("{}-worktree", self.id.clone())) - .gap_0p5() - .child( - Icon::new(IconName::GitWorktree) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(label) - .tooltip(move |_, cx| { - Tooltip::with_meta( - "Thread Running in a Local Git Worktree", - None, - worktree_full_path.clone(), - cx, - ) - }), - ) - }) + .children(worktree_chips) .when(has_worktree && (has_diff_stats || has_timestamp), |this| { this.child(dot_separator()) }) @@ -526,7 +538,11 @@ impl Component for ThreadItem { ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock") .icon(IconName::AiClaude) .timestamp("2w") - .worktree("link-agent-panel"), + .worktrees(vec![ThreadItemWorktreeInfo { + name: "link-agent-panel".into(), + full_path: "link-agent-panel".into(), + highlight_positions: Vec::new(), + }]), ) .into_any_element(), ), @@ -548,7 +564,11 @@ impl Component for ThreadItem { .child( ThreadItem::new("ti-5b", "Full metadata example") .icon(IconName::AiClaude) - .worktree("my-project") + .worktrees(vec![ThreadItemWorktreeInfo { + name: "my-project".into(), + full_path: "my-project".into(), + highlight_positions: Vec::new(), + }]) .added(42) .removed(17) .timestamp("3w"), @@ -623,8 +643,11 @@ impl Component for ThreadItem { ThreadItem::new("ti-11", "Search in worktree name") .icon(IconName::AiClaude) .timestamp("3mo") - .worktree("my-project-name") - .worktree_highlight_positions(vec![3, 4, 5, 6, 7, 8, 9, 10, 11]), + .worktrees(vec![ThreadItemWorktreeInfo { + name: "my-project-name".into(), + full_path: "my-project-name".into(), + highlight_positions: vec![3, 4, 5, 6, 7, 8, 9, 10, 11], + }]), ) .into_any_element(), ), From 8cdae317588b4b9ae9571bef1f3ef763b63a9158 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Fri, 27 Mar 2026 14:56:46 -0700 Subject: [PATCH 50/53] Strong app state (#52602) This PR upgrades the workspace's app state to a strong pointer. It's an app-global concern, and it should only die when the app dies. When the app dies the process exits, so the cyclic reference problem isn't an issue. 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 --- .../examples/component_preview.rs | 2 +- crates/copilot_ui/src/sign_in.rs | 11 +- crates/edit_prediction/src/edit_prediction.rs | 2 +- .../src/edit_prediction_tests.rs | 4 +- .../src/edit_prediction_button.rs | 6 +- crates/editor/src/editor.rs | 40 ++--- crates/recent_projects/src/remote_servers.rs | 1 - crates/recent_projects/src/wsl_picker.rs | 3 - .../pages/edit_prediction_provider_setup.rs | 9 +- crates/settings_ui/src/settings_ui.rs | 111 +++++------- crates/workspace/src/workspace.rs | 84 ++++----- crates/zed/src/main.rs | 6 +- crates/zed/src/visual_test_runner.rs | 4 +- crates/zed/src/zed.rs | 167 +++++++++--------- 14 files changed, 202 insertions(+), 248 deletions(-) diff --git a/crates/component_preview/examples/component_preview.rs b/crates/component_preview/examples/component_preview.rs index 463eb976b667012f58186e385f719b27c4cd2702..8deaff1a8a61a404f482ac30f071164807267f5b 100644 --- a/crates/component_preview/examples/component_preview.rs +++ b/crates/component_preview/examples/component_preview.rs @@ -65,7 +65,7 @@ fn main() { node_runtime, session, }); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); workspace::init(app_state.clone(), cx); init(app_state.clone(), cx); diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index 033effd230d65fee7594d0241b2828a41908a432..09267020e5c3599675807f01777097d23b4d9ab0 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -481,7 +481,6 @@ impl ConfigurationView { cx: &mut Context, ) -> Self { let copilot = AppState::try_global(cx) - .and_then(|state| state.upgrade()) .and_then(|state| GlobalCopilotAuth::try_get_or_init(state, cx)); Self { @@ -578,9 +577,8 @@ impl ConfigurationView { ) .when(edit_prediction, |this| this.tab_index(0isize)) .on_click(|_, window, cx| { - if let Some(app_state) = AppState::global(cx).upgrade() - && let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) - { + let app_state = AppState::global(cx); + if let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) { initiate_sign_in(copilot.0, window, cx) } }) @@ -608,9 +606,8 @@ impl ConfigurationView { .color(Color::Muted), ) .on_click(|_, window, cx| { - if let Some(app_state) = AppState::global(cx).upgrade() - && let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) - { + let app_state = AppState::global(cx); + if let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) { reinstall_and_sign_in(copilot.0, window, cx); } }) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 3ae4eb72b3a60ab56d865a235c43e2f0e3adab31..34980e00cedb7da6b6273e69ec64b35b0d7e9785 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -1992,7 +1992,7 @@ impl EditPredictionStore { } fn currently_following(project: &Entity, cx: &App) -> bool { - let Some(app_state) = AppState::try_global(cx).and_then(|app_state| app_state.upgrade()) else { + let Some(app_state) = AppState::try_global(cx) else { return false; }; diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 7583ba629bc2c490c5f8e8dd83218c200025fe7c..6fe61338e764a40aec9cf6f3191f1191bafe9200 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -204,7 +204,7 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon let app_state = cx.update(|cx| { let app_state = AppState::test(cx); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); app_state }); @@ -214,7 +214,7 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) .unwrap(); cx.update(|cx| { - AppState::set_global(Arc::downgrade(workspace.read(cx).app_state()), cx); + AppState::set_global(workspace.read(cx).app_state().clone(), cx); }); let _ = app_state; diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index e6e65012123c0fdf3571115bded43f8840f997ee..377e53da265e4c2b6ada252b68402960f39b18dc 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -1418,9 +1418,9 @@ pub fn get_available_providers(cx: &mut App) -> Vec { providers.push(EditPredictionProvider::Zed); - if let Some(app_state) = workspace::AppState::global(cx).upgrade() - && copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx) - .is_some_and(|copilot| copilot.0.read(cx).is_authenticated()) + let app_state = workspace::AppState::global(cx); + if copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx) + .is_some_and(|copilot| copilot.0.read(cx).is_authenticated()) { providers.push(EditPredictionProvider::Copilot); }; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3ebfb52637107e95729d4be84145e7334411ce82..405924edb227e4c561caafeee2f8cd3e51567023 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -354,32 +354,26 @@ pub fn init(cx: &mut App) { cx.on_action(move |_: &workspace::NewFile, cx| { let app_state = workspace::AppState::global(cx); - if let Some(app_state) = app_state.upgrade() { - workspace::open_new( - Default::default(), - app_state, - cx, - |workspace, window, cx| { - Editor::new_file(workspace, &Default::default(), window, cx) - }, - ) - .detach_and_log_err(cx); - } + workspace::open_new( + Default::default(), + app_state, + cx, + |workspace, window, cx| Editor::new_file(workspace, &Default::default(), window, cx), + ) + .detach_and_log_err(cx); }) .on_action(move |_: &workspace::NewWindow, cx| { let app_state = workspace::AppState::global(cx); - if let Some(app_state) = app_state.upgrade() { - workspace::open_new( - Default::default(), - app_state, - cx, - |workspace, window, cx| { - cx.activate(true); - Editor::new_file(workspace, &Default::default(), window, cx) - }, - ) - .detach_and_log_err(cx); - } + workspace::open_new( + Default::default(), + app_state, + cx, + |workspace, window, cx| { + cx.activate(true); + Editor::new_file(workspace, &Default::default(), window, cx) + }, + ) + .detach_and_log_err(cx); }); _ = ui_input::ERASED_EDITOR_FACTORY.set(|window, cx| { Arc::new(ErasedEditorImpl( diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 4569492d4c73b6e8087cf8363db805a645e5314e..2d285cbd36396b8cc30d456c7b37f4b5f187aeb3 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1852,7 +1852,6 @@ impl RemoteServerProjects { cx: &mut Context, ) { let replace_window = window.window_handle().downcast::(); - let app_state = Arc::downgrade(&app_state); cx.spawn_in(window, async move |entity, cx| { let (connection, starting_dir) = diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index 7f2a69eb68cb93742d98f438f75f74c95bf3f7d5..d366f1090dac91ac0e778c578ceb864dac80cf86 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -235,9 +235,6 @@ impl WslOpenModal { cx: &mut Context, ) { let app_state = workspace::AppState::global(cx); - let Some(app_state) = app_state.upgrade() else { - return; - }; let connection_options = RemoteConnectionOptions::Wsl(WslConnectionOptions { distro_name: distro.to_string(), diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs index 0357f2040b0125d39d34fd36b1aca3d299a8501b..193be67aad4760763637f116fad23066438b5b61 100644 --- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs +++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs @@ -710,12 +710,9 @@ fn render_github_copilot_provider(window: &mut Window, cx: &mut App) -> Option Vec { - workspace::AppState::global(cx) - .upgrade() - .map_or(vec![], |state| { - state - .languages - .language_names() - .into_iter() - .filter(|name| name.as_ref() != "Zed Keybind Context") - .map(Into::into) - .collect() - }) + let state = workspace::AppState::global(cx); + state + .languages + .language_names() + .into_iter() + .filter(|name| name.as_ref() != "Zed Keybind Context") + .map(Into::into) + .collect() } #[allow(unused)] @@ -1535,29 +1532,26 @@ impl SettingsWindow { }) .detach(); - if let Some(app_state) = AppState::global(cx).upgrade() { - let workspaces: Vec> = app_state - .workspace_store - .read(cx) - .workspaces() - .filter_map(|weak| weak.upgrade()) - .collect(); + let app_state = AppState::global(cx); + let workspaces: Vec> = app_state + .workspace_store + .read(cx) + .workspaces() + .filter_map(|weak| weak.upgrade()) + .collect(); - for workspace in workspaces { - let project = workspace.read(cx).project().clone(); - cx.observe_release_in(&project, window, |this, _, window, cx| { - this.fetch_files(window, cx) - }) - .detach(); - cx.subscribe_in(&project, window, Self::handle_project_event) - .detach(); - cx.observe_release_in(&workspace, window, |this, _, window, cx| { - this.fetch_files(window, cx) - }) + for workspace in workspaces { + let project = workspace.read(cx).project().clone(); + cx.observe_release_in(&project, window, |this, _, window, cx| { + this.fetch_files(window, cx) + }) + .detach(); + cx.subscribe_in(&project, window, Self::handle_project_event) .detach(); - } - } else { - log::error!("App state doesn't exist when creating a new settings window"); + cx.observe_release_in(&workspace, window, |this, _, window, cx| { + this.fetch_files(window, cx) + }) + .detach(); } let this_weak = cx.weak_entity(); @@ -3362,9 +3356,7 @@ impl SettingsWindow { } SettingsUiFile::Project((worktree_id, path)) => { let settings_path = path.join(paths::local_settings_file_relative_path()); - let Some(app_state) = workspace::AppState::global(cx).upgrade() else { - return; - }; + let app_state = workspace::AppState::global(cx); let Some((workspace_window, worktree, corresponding_workspace)) = app_state .workspace_store @@ -3745,31 +3737,26 @@ fn all_projects( cx: &App, ) -> impl Iterator> { let mut seen_project_ids = std::collections::HashSet::new(); - workspace::AppState::global(cx) - .upgrade() - .map(|app_state| { - app_state - .workspace_store - .read(cx) - .workspaces() - .filter_map(|weak| weak.upgrade()) - .map(|workspace: Entity| workspace.read(cx).project().clone()) - .chain( - window - .and_then(|handle| handle.read(cx).ok()) - .into_iter() - .flat_map(|multi_workspace| { - multi_workspace - .workspaces() - .iter() - .map(|workspace| workspace.read(cx).project().clone()) - .collect::>() - }), - ) - .filter(move |project| seen_project_ids.insert(project.entity_id())) - }) - .into_iter() - .flatten() + let app_state = workspace::AppState::global(cx); + app_state + .workspace_store + .read(cx) + .workspaces() + .filter_map(|weak| weak.upgrade()) + .map(|workspace: Entity| workspace.read(cx).project().clone()) + .chain( + window + .and_then(|handle| handle.read(cx).ok()) + .into_iter() + .flat_map(|multi_workspace| { + multi_workspace + .workspaces() + .iter() + .map(|workspace| workspace.read(cx).project().clone()) + .collect::>() + }), + ) + .filter(move |project| seen_project_ids.insert(project.entity_id())) } fn open_user_settings_in_workspace( @@ -4723,7 +4710,7 @@ pub mod test { let app_state = cx.update(|cx| { let app_state = AppState::test(cx); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); app_state }); @@ -4897,7 +4884,7 @@ pub mod test { let app_state = cx.update(|cx| { let app_state = AppState::test(cx); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); app_state }); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 2d56be01c48139168d2fda5fead3929872c1c2d9..4328fb3e307295fb5123be79999a60285e208f4b 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -124,7 +124,7 @@ use std::{ process::ExitStatus, rc::Rc, sync::{ - Arc, LazyLock, Weak, + Arc, LazyLock, atomic::{AtomicBool, AtomicUsize}, }, time::Duration, @@ -732,40 +732,32 @@ pub fn init(app_state: Arc, cx: &mut App) { cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)) .on_action(|_: &Reload, cx| reload(cx)) - .on_action({ - let app_state = Arc::downgrade(&app_state); - move |_: &Open, cx: &mut App| { - if let Some(app_state) = app_state.upgrade() { - prompt_and_open_paths( - app_state, - PathPromptOptions { - files: true, - directories: true, - multiple: true, - prompt: None, - }, - cx, - ); - } - } + .on_action(|_: &Open, cx: &mut App| { + let app_state = AppState::global(cx); + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories: true, + multiple: true, + prompt: None, + }, + cx, + ); }) - .on_action({ - let app_state = Arc::downgrade(&app_state); - move |_: &OpenFiles, cx: &mut App| { - let directories = cx.can_select_mixed_files_and_dirs(); - if let Some(app_state) = app_state.upgrade() { - prompt_and_open_paths( - app_state, - PathPromptOptions { - files: true, - directories, - multiple: true, - prompt: None, - }, - cx, - ); - } - } + .on_action(|_: &OpenFiles, cx: &mut App| { + let directories = cx.can_select_mixed_files_and_dirs(); + let app_state = AppState::global(cx); + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories, + multiple: true, + prompt: None, + }, + cx, + ); }); } @@ -1074,7 +1066,7 @@ pub struct AppState { pub session: Entity, } -struct GlobalAppState(Weak); +struct GlobalAppState(Arc); impl Global for GlobalAppState {} @@ -1110,14 +1102,14 @@ struct Follower { impl AppState { #[track_caller] - pub fn global(cx: &App) -> Weak { + pub fn global(cx: &App) -> Arc { cx.global::().0.clone() } - pub fn try_global(cx: &App) -> Option> { + pub fn try_global(cx: &App) -> Option> { cx.try_global::() .map(|state| state.0.clone()) } - pub fn set_global(state: Weak, cx: &mut App) { + pub fn set_global(state: Arc, cx: &mut App) { cx.set_global(GlobalAppState(state)); } @@ -10320,15 +10312,13 @@ pub fn with_active_or_new_workspace( } None => { let app_state = AppState::global(cx); - if let Some(app_state) = app_state.upgrade() { - open_new( - OpenOptions::default(), - app_state, - cx, - move |workspace, window, cx| f(workspace, window, cx), - ) - .detach_and_log_err(cx); - } + open_new( + OpenOptions::default(), + app_state, + cx, + move |workspace, window, cx| f(workspace, window, cx), + ) + .detach_and_log_err(cx); } } } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index ebe2d2c07b303657f26c83e04c7484bbaf794bd5..f80608b8aaa4c0bd2b4513de766dc42a73876ab8 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -441,10 +441,8 @@ fn main() { } }); app.on_reopen(move |cx| { - if let Some(app_state) = AppState::try_global(cx).and_then(|app_state| app_state.upgrade()) - { + if let Some(app_state) = AppState::try_global(cx) { cx.spawn({ - let app_state = app_state; async move |cx| { if let Err(e) = restore_or_create_workspace(app_state, cx).await { fail_to_open_window_async(e, cx) @@ -626,7 +624,7 @@ fn main() { node_runtime, session: app_session, }); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); auto_update::init(client.clone(), cx); dap_adapters::init(cx); diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index ecee1190ce8865f54dc68f0c0052c9a7c1b5b59c..cb70a8573f831c5da20afc15fd0e55cd6ca2c3e6 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -170,7 +170,7 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> // Set the global app state so settings_ui and other subsystems can find it cx.update(|cx| { - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); }); // Initialize all Zed subsystems @@ -978,7 +978,7 @@ fn init_app_state(cx: &mut App) -> Arc { build_window_options: |_, _| Default::default(), session, }); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); app_state } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index de90cc0cacf8c0c94acbd799475129b1dd49e706..a560a077220500259d72101f7890bc8edd2cf552 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1072,104 +1072,99 @@ fn register_actions( }, ) .register_action({ - let app_state = Arc::downgrade(&app_state); + let app_state = app_state.clone(); move |_, _: &NewWindow, _, cx| { - if let Some(app_state) = app_state.upgrade() { - open_new( - Default::default(), - app_state, - cx, - |workspace, window, cx| { - cx.activate(true); - // Create buffer synchronously to avoid flicker - let project = workspace.project().clone(); - let buffer = project.update(cx, |project, cx| { - project.create_local_buffer("", None, true, cx) - }); - let editor = cx.new(|cx| { - Editor::for_buffer(buffer, Some(project), window, cx) - }); - workspace.add_item_to_active_pane( - Box::new(editor), - None, - true, - window, - cx, - ); - }, - ) - .detach(); - } + open_new( + Default::default(), + app_state.clone(), + cx, + |workspace, window, cx| { + cx.activate(true); + // Create buffer synchronously to avoid flicker + let project = workspace.project().clone(); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer("", None, true, cx) + }); + let editor = cx.new(|cx| { + Editor::for_buffer(buffer, Some(project), window, cx) + }); + workspace.add_item_to_active_pane( + Box::new(editor), + None, + true, + window, + cx, + ); + }, + ) + .detach(); } }) .register_action({ - let app_state = Arc::downgrade(&app_state); + let app_state = app_state.clone(); move |_workspace, _: &CloseProject, window, cx| { let Some(window_handle) = window.window_handle().downcast::() else { return; }; - if let Some(app_state) = app_state.upgrade() { - cx.spawn_in(window, async move |this, cx| { - let should_continue = this - .update_in(cx, |workspace, window, cx| { - workspace.prepare_to_close( - CloseIntent::ReplaceWindow, - window, - cx, - ) - })? - .await?; - if should_continue { - let task = cx.update(|_window, cx| { - open_new( - workspace::OpenOptions { - replace_window: Some(window_handle), - ..Default::default() - }, - app_state, - cx, - |workspace, window, cx| { - cx.activate(true); - let project = workspace.project().clone(); - let buffer = project.update(cx, |project, cx| { - project.create_local_buffer("", None, true, cx) - }); - let editor = cx.new(|cx| { - Editor::for_buffer(buffer, Some(project), window, cx) - }); - workspace.add_item_to_active_pane( - Box::new(editor), - None, - true, - window, - cx, - ); - }, - ) - })?; - task.await - } else { - Ok(()) - } - }) - .detach_and_log_err(cx); - } + let app_state = app_state.clone(); + cx.spawn_in(window, async move |this, cx| { + let should_continue = this + .update_in(cx, |workspace, window, cx| { + workspace.prepare_to_close( + CloseIntent::ReplaceWindow, + window, + cx, + ) + })? + .await?; + if should_continue { + let task = cx.update(|_window, cx| { + open_new( + workspace::OpenOptions { + replace_window: Some(window_handle), + ..Default::default() + }, + app_state, + cx, + |workspace, window, cx| { + cx.activate(true); + let project = workspace.project().clone(); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer("", None, true, cx) + }); + let editor = cx.new(|cx| { + Editor::for_buffer(buffer, Some(project), window, cx) + }); + workspace.add_item_to_active_pane( + Box::new(editor), + None, + true, + window, + cx, + ); + }, + ) + })?; + task.await + } else { + Ok(()) + } + }) + .detach_and_log_err(cx); } }) .register_action({ - let app_state = Arc::downgrade(&app_state); + let app_state = app_state.clone(); move |_, _: &NewFile, _, cx| { - if let Some(app_state) = app_state.upgrade() { - open_new( - Default::default(), - app_state, - cx, - |workspace, window, cx| { - Editor::new_file(workspace, &Default::default(), window, cx) - }, - ) - .detach_and_log_err(cx); - } + open_new( + Default::default(), + app_state.clone(), + cx, + |workspace, window, cx| { + Editor::new_file(workspace, &Default::default(), window, cx) + }, + ) + .detach_and_log_err(cx); } }); From 4cb10dfdbdff2fb0aef98bf719f907a0ae470da1 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:46:53 -0400 Subject: [PATCH 51/53] sidebar: Add property tests (#52540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context ## How to Review ## 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: Mikayla Maki --- crates/sidebar/src/project_group_builder.rs | 16 +- crates/sidebar/src/sidebar.rs | 4279 +---------------- crates/sidebar/src/sidebar_tests.rs | 4664 +++++++++++++++++++ 3 files changed, 4705 insertions(+), 4254 deletions(-) create mode 100644 crates/sidebar/src/sidebar_tests.rs diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs index 2443a719639c2dc4b83b26d1213db214e13d70f7..8b4a0862dcd269310eb571f4db6703ed0e508fef 100644 --- a/crates/sidebar/src/project_group_builder.rs +++ b/crates/sidebar/src/project_group_builder.rs @@ -73,6 +73,16 @@ impl ProjectGroup { .first() .expect("groups always have at least one workspace") } + + pub fn main_workspace(&self, cx: &App) -> &Entity { + self.workspaces + .iter() + .find(|ws| { + !crate::root_repository_snapshots(ws, cx) + .any(|snapshot| snapshot.is_linked_worktree()) + }) + .unwrap_or_else(|| self.first_workspace()) + } } pub struct ProjectGroupBuilder { @@ -91,7 +101,6 @@ impl ProjectGroupBuilder { pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self { let mut builder = Self::new(); - // First pass: collect all directory mappings from every workspace // so we know how to canonicalize any path (including linked // worktree paths discovered by the main repo's workspace). @@ -148,9 +157,8 @@ impl ProjectGroupBuilder { workspace: &Entity, cx: &App, ) -> ProjectGroupName { - let paths: Vec<_> = workspace - .read(cx) - .root_paths(cx) + let root_paths = workspace.read(cx).root_paths(cx); + let paths: Vec<_> = root_paths .iter() .map(|p| self.canonicalize_path(p).to_path_buf()) .collect(); diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index a5c26a0b3c56c3ad3483d5f445daa87d3cd8ae6f..420942da64acec08f9d7acf2d618764e36066aa0 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -50,6 +50,9 @@ use crate::project_group_builder::ProjectGroupBuilder; mod project_group_builder; +#[cfg(test)] +mod sidebar_tests; + gpui::actions!( agents_sidebar, [ @@ -167,6 +170,28 @@ enum ListEntry { }, } +#[cfg(test)] +impl ListEntry { + fn workspace(&self) -> Option> { + match self { + ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()), + ListEntry::Thread(thread_entry) => match &thread_entry.workspace { + ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()), + ThreadEntryWorkspace::Closed(_) => None, + }, + ListEntry::ViewMore { .. } => None, + ListEntry::NewThread { workspace, .. } => Some(workspace.clone()), + } + } + + fn session_id(&self) -> Option<&acp::SessionId> { + match self { + ListEntry::Thread(thread_entry) => Some(&thread_entry.session_info.session_id), + _ => None, + } + } +} + impl From for ListEntry { fn from(thread: ThreadEntry) -> Self { ListEntry::Thread(thread) @@ -418,7 +443,7 @@ impl Sidebar { cx.subscribe_in( &git_store, window, - |this, _, event: &project::git_store::GitStoreEvent, window, cx| { + |this, _, event: &project::git_store::GitStoreEvent, _window, cx| { if matches!( event, project::git_store::GitStoreEvent::RepositoryUpdated( @@ -427,7 +452,6 @@ impl Sidebar { _, ) ) { - this.prune_stale_worktree_workspaces(window, cx); this.update_entries(cx); } }, @@ -698,14 +722,12 @@ impl Sidebar { .is_some_and(|active| group.workspaces.contains(active)); // Pick a representative workspace for the group: prefer the active - // workspace if it belongs to this group, otherwise use the first. - // - // This is the workspace that will be activated by the project group - // header. + // workspace if it belongs to this group, otherwise use the main + // repo workspace (not a linked worktree). let representative_workspace = active_workspace .as_ref() .filter(|_| is_active) - .unwrap_or_else(|| group.first_workspace()); + .unwrap_or_else(|| group.main_workspace(cx)); // Collect live thread infos from all workspaces in this group. let live_infos: Vec<_> = group @@ -1594,72 +1616,6 @@ impl Sidebar { Some(element) } - fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context) { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return; - }; - let workspaces = multi_workspace.read(cx).workspaces().to_vec(); - - // Collect all worktree paths that are currently listed by any main - // repo open in any workspace. - let mut known_worktree_paths: HashSet = HashSet::new(); - for workspace in &workspaces { - for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.is_linked_worktree() { - continue; - } - for git_worktree in snapshot.linked_worktrees() { - known_worktree_paths.insert(git_worktree.path.to_path_buf()); - } - } - } - - // Find workspaces that consist of exactly one root folder which is a - // stale worktree checkout. Multi-root workspaces are never pruned — - // losing one worktree shouldn't destroy a workspace that also - // contains other folders. - let mut to_remove: Vec> = Vec::new(); - for workspace in &workspaces { - let path_list = workspace_path_list(workspace, cx); - if path_list.paths().len() != 1 { - continue; - } - let should_prune = root_repository_snapshots(workspace, cx).any(|snapshot| { - snapshot.is_linked_worktree() - && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref()) - }); - if should_prune { - to_remove.push(workspace.clone()); - } - } - - for workspace in &to_remove { - self.remove_workspace(workspace, window, cx); - } - } - - fn remove_workspace( - &mut self, - workspace: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return; - }; - - multi_workspace.update(cx, |multi_workspace, cx| { - let Some(index) = multi_workspace - .workspaces() - .iter() - .position(|w| w == workspace) - else { - return; - }; - multi_workspace.remove_workspace(index, window, cx); - }); - } - fn toggle_collapse( &mut self, path_list: &PathList, @@ -3201,4180 +3157,3 @@ fn all_thread_infos_for_workspace( Some(threads).into_iter().flatten() } - -#[cfg(test)] -mod tests { - use super::*; - use acp_thread::StubAgentConnection; - use agent::ThreadStore; - use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message}; - use assistant_text_thread::TextThreadStore; - use chrono::DateTime; - use feature_flags::FeatureFlagAppExt as _; - use fs::FakeFs; - use gpui::TestAppContext; - use pretty_assertions::assert_eq; - use settings::SettingsStore; - use std::{path::PathBuf, sync::Arc}; - use util::path_list::PathList; - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme_settings::init(theme::LoadThemes::JustBase, cx); - editor::init(cx); - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - } - - fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { - sidebar.contents.entries.iter().any(|entry| { - matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id) - }) - } - - async fn init_test_project( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> Entity { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - project::Project::test(fs, [worktree_path.as_ref()], cx).await - } - - fn setup_sidebar( - multi_workspace: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Entity { - let multi_workspace = multi_workspace.clone(); - let sidebar = - cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx))); - multi_workspace.update(cx, |mw, cx| { - mw.register_sidebar(sidebar.clone(), cx); - }); - cx.run_until_parked(); - sidebar - } - - async fn save_n_test_threads( - count: u32, - path_list: &PathList, - cx: &mut gpui::VisualTestContext, - ) { - for i in 0..count { - save_thread_metadata( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - format!("Thread {}", i + 1).into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - } - - async fn save_test_thread_metadata( - session_id: &acp::SessionId, - path_list: PathList, - cx: &mut TestAppContext, - ) { - save_thread_metadata( - session_id.clone(), - "Test".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list, - cx, - ) - .await; - } - - async fn save_named_thread_metadata( - session_id: &str, - title: &str, - path_list: &PathList, - cx: &mut gpui::VisualTestContext, - ) { - save_thread_metadata( - acp::SessionId::new(Arc::from(session_id)), - SharedString::from(title.to_string()), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - } - - async fn save_thread_metadata( - session_id: acp::SessionId, - title: SharedString, - updated_at: DateTime, - path_list: PathList, - cx: &mut TestAppContext, - ) { - let metadata = ThreadMetadata { - session_id, - agent_id: None, - title, - updated_at, - created_at: None, - folder_paths: path_list, - }; - cx.update(|cx| { - SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) - }); - cx.run_until_parked(); - } - - fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { - let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade()); - if let Some(multi_workspace) = multi_workspace { - multi_workspace.update_in(cx, |mw, window, cx| { - if !mw.sidebar_open() { - mw.toggle_sidebar(window, cx); - } - }); - } - cx.run_until_parked(); - sidebar.update_in(cx, |_, window, cx| { - cx.focus_self(window); - }); - cx.run_until_parked(); - } - - fn visible_entries_as_strings( - sidebar: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Vec { - sidebar.read_with(cx, |sidebar, _cx| { - sidebar - .contents - .entries - .iter() - .enumerate() - .map(|(ix, entry)| { - let selected = if sidebar.selection == Some(ix) { - " <== selected" - } else { - "" - }; - match entry { - ListEntry::ProjectHeader { - label, - path_list, - highlight_positions: _, - .. - } => { - let icon = if sidebar.collapsed_groups.contains(path_list) { - ">" - } else { - "v" - }; - format!("{} [{}]{}", icon, label, selected) - } - ListEntry::Thread(thread) => { - let title = thread - .session_info - .title - .as_ref() - .map(|s| s.as_ref()) - .unwrap_or("Untitled"); - let active = if thread.is_live { " *" } else { "" }; - let status_str = match thread.status { - AgentThreadStatus::Running => " (running)", - AgentThreadStatus::Error => " (error)", - AgentThreadStatus::WaitingForConfirmation => " (waiting)", - _ => "", - }; - let notified = if sidebar - .contents - .is_thread_notified(&thread.session_info.session_id) - { - " (!)" - } else { - "" - }; - let worktree = if thread.worktrees.is_empty() { - String::new() - } else { - let mut seen = Vec::new(); - let mut chips = Vec::new(); - for wt in &thread.worktrees { - if !seen.contains(&wt.name) { - seen.push(wt.name.clone()); - chips.push(format!("{{{}}}", wt.name)); - } - } - format!(" {}", chips.join(", ")) - }; - format!( - " {}{}{}{}{}{}", - title, worktree, active, status_str, notified, selected - ) - } - ListEntry::ViewMore { - is_fully_expanded, .. - } => { - if *is_fully_expanded { - format!(" - Collapse{}", selected) - } else { - format!(" + View More{}", selected) - } - } - ListEntry::NewThread { .. } => { - format!(" [+ New Thread]{}", selected) - } - } - }) - .collect() - }) - } - - #[test] - fn test_clean_mention_links() { - // Simple mention link - assert_eq!( - Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"), - "check @Button.tsx" - ); - - // Multiple mention links - assert_eq!( - Sidebar::clean_mention_links( - "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)" - ), - "look at @foo.rs and @bar.rs" - ); - - // No mention links — passthrough - assert_eq!( - Sidebar::clean_mention_links("plain text with no mentions"), - "plain text with no mentions" - ); - - // Incomplete link syntax — preserved as-is - assert_eq!( - Sidebar::clean_mention_links("broken [@mention without closing"), - "broken [@mention without closing" - ); - - // Regular markdown link (no @) — not touched - assert_eq!( - Sidebar::clean_mention_links("see [docs](https://example.com)"), - "see [docs](https://example.com)" - ); - - // Empty input - assert_eq!(Sidebar::clean_mention_links(""), ""); - } - - #[gpui::test] - async fn test_entities_released_on_window_close(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade()); - let weak_sidebar = sidebar.downgrade(); - let weak_multi_workspace = multi_workspace.downgrade(); - - drop(sidebar); - drop(multi_workspace); - cx.update(|window, _cx| window.remove_window()); - cx.run_until_parked(); - - weak_multi_workspace.assert_released(); - weak_sidebar.assert_released(); - weak_workspace.assert_released(); - } - - #[gpui::test] - async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]"] - ); - } - - #[gpui::test] - async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-1")), - "Fix crash in project panel".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-2")), - "Add inline diff view".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in project panel", - " Add inline diff view", - ] - ); - } - - #[gpui::test] - async fn test_workspace_lifecycle(cx: &mut TestAppContext) { - let project = init_test_project("/project-a", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Single workspace with a thread - let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-a1")), - "Thread A1".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1"] - ); - - // Add a second workspace - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1",] - ); - - // Remove the second workspace - multi_workspace.update_in(cx, |mw, window, cx| { - mw.remove_workspace(1, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1"] - ); - } - - #[gpui::test] - async fn test_view_more_pagination(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(12, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Thread 12", - " Thread 11", - " Thread 10", - " Thread 9", - " Thread 8", - " + View More", - ] - ); - } - - #[gpui::test] - async fn test_view_more_batched_expansion(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse - save_n_test_threads(17, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Initially shows 5 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); // header + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Focus and navigate to View More, then confirm to expand by one batch - open_and_focus_sidebar(&sidebar, cx); - for _ in 0..7 { - cx.dispatch_action(SelectNext); - } - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // Now shows 10 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 12); // header + 10 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Expand again by one batch - sidebar.update_in(cx, |s, _window, cx| { - let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); - s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(cx); - }); - cx.run_until_parked(); - - // Now shows 15 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 17); // header + 15 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Expand one more time - should show all 17 threads with Collapse button - sidebar.update_in(cx, |s, _window, cx| { - let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); - s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(cx); - }); - cx.run_until_parked(); - - // All 17 threads shown with Collapse button - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 19); // header + 17 threads + Collapse - assert!(!entries.iter().any(|e| e.contains("View More"))); - assert!(entries.iter().any(|e| e.contains("Collapse"))); - - // Click collapse - should go back to showing 5 threads - sidebar.update_in(cx, |s, _window, cx| { - s.expanded_groups.remove(&path_list); - s.update_entries(cx); - }); - cx.run_until_parked(); - - // Back to initial state: 5 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); // header + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - } - - #[gpui::test] - async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - - // Collapse - sidebar.update_in(cx, |s, window, cx| { - s.toggle_collapse(&path_list, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project]"] - ); - - // Expand - sidebar.update_in(cx, |s, window, cx| { - s.toggle_collapse(&path_list, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - } - - #[gpui::test] - async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]); - let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]); - - sidebar.update_in(cx, |s, _window, _cx| { - s.collapsed_groups.insert(collapsed_path.clone()); - s.contents - .notified_threads - .insert(acp::SessionId::new(Arc::from("t-5"))); - s.contents.entries = vec![ - // Expanded project header - ListEntry::ProjectHeader { - path_list: expanded_path.clone(), - label: "expanded-project".into(), - workspace: workspace.clone(), - highlight_positions: Vec::new(), - has_running_threads: false, - waiting_thread_count: 0, - is_active: true, - }, - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-1")), - work_dirs: None, - title: Some("Completed thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Completed, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees: Vec::new(), - diff_stats: DiffStats::default(), - }), - // Active thread with Running status - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-2")), - work_dirs: None, - title: Some("Running thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Running, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: true, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees: Vec::new(), - diff_stats: DiffStats::default(), - }), - // Active thread with Error status - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-3")), - work_dirs: None, - title: Some("Error thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Error, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: true, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees: Vec::new(), - diff_stats: DiffStats::default(), - }), - // Thread with WaitingForConfirmation status, not active - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-4")), - work_dirs: None, - title: Some("Waiting thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::WaitingForConfirmation, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees: Vec::new(), - diff_stats: DiffStats::default(), - }), - // Background thread that completed (should show notification) - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-5")), - work_dirs: None, - title: Some("Notified thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Completed, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: true, - is_background: true, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees: Vec::new(), - diff_stats: DiffStats::default(), - }), - // View More entry - ListEntry::ViewMore { - path_list: expanded_path.clone(), - is_fully_expanded: false, - }, - // Collapsed project header - ListEntry::ProjectHeader { - path_list: collapsed_path.clone(), - label: "collapsed-project".into(), - workspace: workspace.clone(), - highlight_positions: Vec::new(), - has_running_threads: false, - waiting_thread_count: 0, - is_active: false, - }, - ]; - - // Select the Running thread (index 2) - s.selection = Some(2); - }); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [expanded-project]", - " Completed thread", - " Running thread * (running) <== selected", - " Error thread * (error)", - " Waiting thread (waiting)", - " Notified thread * (!)", - " + View More", - "> [collapsed-project]", - ] - ); - - // Move selection to the collapsed header - sidebar.update_in(cx, |s, _window, _cx| { - s.selection = Some(7); - }); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx).last().cloned(), - Some("> [collapsed-project] <== selected".to_string()), - ); - - // Clear selection - sidebar.update_in(cx, |s, _window, _cx| { - s.selection = None; - }); - - // No entry should have the selected marker - let entries = visible_entries_as_strings(&sidebar, cx); - for entry in &entries { - assert!( - !entry.contains("<== selected"), - "unexpected selection marker in: {}", - entry - ); - } - } - - #[gpui::test] - async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(3, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Entries: [header, thread3, thread2, thread1] - // Focusing the sidebar does not set a selection; select_next/select_previous - // handle None gracefully by starting from the first or last entry. - open_and_focus_sidebar(&sidebar, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // First SelectNext from None starts at index 0 - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // Move down through remaining entries - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // At the end, wraps back to first entry - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // Navigate back to the end - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // Move back up - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // At the top, selection clears (focus returns to editor) - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - } - - #[gpui::test] - async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(3, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - open_and_focus_sidebar(&sidebar, cx); - - // SelectLast jumps to the end - cx.dispatch_action(SelectLast); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // SelectFirst jumps to the beginning - cx.dispatch_action(SelectFirst); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - } - - #[gpui::test] - async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Initially no selection - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // Open the sidebar so it's rendered, then focus it to trigger focus_in. - // focus_in no longer sets a default selection. - open_and_focus_sidebar(&sidebar, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // Manually set a selection, blur, then refocus — selection should be preserved - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - cx.update(|window, _cx| { - window.blur(); - }); - cx.run_until_parked(); - - sidebar.update_in(cx, |_, window, cx| { - cx.focus_self(window); - }); - cx.run_until_parked(); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - } - - #[gpui::test] - async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - - // Focus the sidebar and select the header (index 0) - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - // Confirm on project header collapses the group - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // Confirm again expands the group - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project] <== selected", " Thread 1",] - ); - } - - #[gpui::test] - async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(8, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Should show header + 5 threads + "View More" - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) - open_and_focus_sidebar(&sidebar, cx); - for _ in 0..7 { - cx.dispatch_action(SelectNext); - } - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); - - // Confirm on "View More" to expand - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // All 8 threads should now be visible with a "Collapse" button - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button - assert!(!entries.iter().any(|e| e.contains("View More"))); - assert!(entries.iter().any(|e| e.contains("Collapse"))); - } - - #[gpui::test] - async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - - // Focus sidebar and manually select the header (index 0). Press left to collapse. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // Press right to expand - cx.dispatch_action(SelectChild); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project] <== selected", " Thread 1",] - ); - - // Press right again on already-expanded header moves selection down - cx.dispatch_action(SelectChild); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - } - - #[gpui::test] - async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Focus sidebar (selection starts at None), then navigate down to the thread (child) - open_and_focus_sidebar(&sidebar, cx); - cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1 <== selected",] - ); - - // Pressing left on a child collapses the parent group and selects it - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - } - - #[gpui::test] - async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { - let project = init_test_project("/empty-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // An empty project has the header and a new thread button. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [empty-project]", " [+ New Thread]"] - ); - - // Focus sidebar — focus_in does not set a selection - open_and_focus_sidebar(&sidebar, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // First SelectNext from None starts at index 0 (header) - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // SelectNext moves to the new thread button - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - // At the end, wraps back to first entry - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // SelectPrevious from first entry clears selection (returns to editor) - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - } - - #[gpui::test] - async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Focus sidebar (selection starts at None), navigate down to the thread (index 1) - open_and_focus_sidebar(&sidebar, cx); - cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - // Collapse the group, which removes the thread from the list - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - // Selection should be clamped to the last valid index (0 = header) - let selection = sidebar.read_with(cx, |s, _| s.selection); - let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len()); - assert!( - selection.unwrap_or(0) < entry_count, - "selection {} should be within bounds (entries: {})", - selection.unwrap_or(0), - entry_count, - ); - } - - async fn init_test_project_with_agent_panel( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> Entity { - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - project::Project::test(fs, [worktree_path.as_ref()], cx).await - } - - fn add_agent_panel( - workspace: &Entity, - project: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Entity { - workspace.update_in(cx, |workspace, window, cx| { - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx)); - workspace.add_panel(panel.clone(), window, cx); - panel - }) - } - - fn setup_sidebar_with_agent_panel( - multi_workspace: &Entity, - project: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> (Entity, Entity) { - let sidebar = setup_sidebar(multi_workspace, cx); - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - let panel = add_agent_panel(&workspace, project, cx); - (sidebar, panel) - } - - #[gpui::test] - async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - // Open thread A and keep it generating. - let connection = StubAgentConnection::new(); - open_thread_with_connection(&panel, connection.clone(), cx); - send_message(&panel, cx); - - let session_id_a = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await; - - cx.update(|_, cx| { - connection.send_update( - session_id_a.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - cx.run_until_parked(); - - // Open thread B (idle, default response) — thread A goes to background. - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - - let session_id_b = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await; - - cx.run_until_parked(); - - let mut entries = visible_entries_as_strings(&sidebar, cx); - entries[1..].sort(); - assert_eq!( - entries, - vec!["v [my-project]", " Hello *", " Hello * (running)",] - ); - } - - #[gpui::test] - async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) { - let project_a = init_test_project_with_agent_panel("/project-a", cx).await; - let (multi_workspace, cx) = cx - .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - // Open thread on workspace A and keep it generating. - let connection_a = StubAgentConnection::new(); - open_thread_with_connection(&panel_a, connection_a.clone(), cx); - send_message(&panel_a, cx); - - let session_id_a = active_session_id(&panel_a, cx); - save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; - - cx.update(|_, cx| { - connection_a.send_update( - session_id_a.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), - cx, - ); - }); - cx.run_until_parked(); - - // Add a second workspace and activate it (making workspace A the background). - let fs = cx.update(|_, cx| ::global(cx)); - let project_b = project::Project::test(fs, [], cx).await; - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - cx.run_until_parked(); - - // Thread A is still running; no notification yet. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello * (running)",] - ); - - // Complete thread A's turn (transition Running → Completed). - connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn); - cx.run_until_parked(); - - // The completed background thread shows a notification indicator. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello * (!)",] - ); - } - - fn type_in_search(sidebar: &Entity, query: &str, cx: &mut gpui::VisualTestContext) { - sidebar.update_in(cx, |sidebar, window, cx| { - window.focus(&sidebar.filter_editor.focus_handle(cx), cx); - sidebar.filter_editor.update(cx, |editor, cx| { - editor.set_text(query, window, cx); - }); - }); - cx.run_until_parked(); - } - - #[gpui::test] - async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - for (id, title, hour) in [ - ("t-1", "Fix crash in project panel", 3), - ("t-2", "Add inline diff view", 2), - ("t-3", "Refactor settings module", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in project panel", - " Add inline diff view", - " Refactor settings module", - ] - ); - - // User types "diff" in the search box — only the matching thread remains, - // with its workspace header preserved for context. - type_in_search(&sidebar, "diff", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Add inline diff view <== selected",] - ); - - // User changes query to something with no matches — list is empty. - type_in_search(&sidebar, "nonexistent", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - Vec::::new() - ); - } - - #[gpui::test] - async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { - // Scenario: A user remembers a thread title but not the exact casing. - // Search should match case-insensitively so they can still find it. - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-1")), - "Fix Crash In Project Panel".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - // Lowercase query matches mixed-case title. - type_in_search(&sidebar, "fix crash", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix Crash In Project Panel <== selected", - ] - ); - - // Uppercase query also matches the same title. - type_in_search(&sidebar, "FIX CRASH", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix Crash In Project Panel <== selected", - ] - ); - } - - #[gpui::test] - async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) { - // Scenario: A user searches, finds what they need, then presses Escape - // to dismiss the filter and see the full list again. - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - // Confirm the full list is showing. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Alpha thread", " Beta thread",] - ); - - // User types a search query to filter down. - open_and_focus_sidebar(&sidebar, cx); - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Alpha thread <== selected",] - ); - - // User presses Escape — filter clears, full list is restored. - // The selection index (1) now points at the first thread entry. - cx.dispatch_action(Cancel); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Alpha thread <== selected", - " Beta thread", - ] - ); - } - - #[gpui::test] - async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) { - let project_a = init_test_project("/project-a", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - for (id, title, hour) in [ - ("a1", "Fix bug in sidebar", 2), - ("a2", "Add tests for editor", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_a.clone(), - cx, - ) - .await; - } - - // Add a second workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - let path_list_b = PathList::new::(&[]); - - for (id, title, hour) in [ - ("b1", "Refactor sidebar layout", 3), - ("b2", "Fix typo in README", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_b.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a]", - " Fix bug in sidebar", - " Add tests for editor", - ] - ); - - // "sidebar" matches a thread in each workspace — both headers stay visible. - type_in_search(&sidebar, "sidebar", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Fix bug in sidebar <== selected",] - ); - - // "typo" only matches in the second workspace — the first header disappears. - type_in_search(&sidebar, "typo", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - Vec::::new() - ); - - // "project-a" matches the first workspace name — the header appears - // with all child threads included. - type_in_search(&sidebar, "project-a", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - } - - #[gpui::test] - async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { - let project_a = init_test_project("/alpha-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); - - for (id, title, hour) in [ - ("a1", "Fix bug in sidebar", 2), - ("a2", "Add tests for editor", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_a.clone(), - cx, - ) - .await; - } - - // Add a second workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - let path_list_b = PathList::new::(&[]); - - for (id, title, hour) in [ - ("b1", "Refactor sidebar layout", 3), - ("b2", "Fix typo in README", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_b.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - // "alpha" matches the workspace name "alpha-project" but no thread titles. - // The workspace header should appear with all child threads included. - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - - // "sidebar" matches thread titles in both workspaces but not workspace names. - // Both headers appear with their matching threads. - type_in_search(&sidebar, "sidebar", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] - ); - - // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r - // doesn't match) — but does not match either workspace name or any thread. - // Actually let's test something simpler: a query that matches both a workspace - // name AND some threads in that workspace. Matching threads should still appear. - type_in_search(&sidebar, "fix", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] - ); - - // A query that matches a workspace name AND a thread in that same workspace. - // Both the header (highlighted) and all child threads should appear. - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - - // Now search for something that matches only a workspace name when there - // are also threads with matching titles — the non-matching workspace's - // threads should still appear if their titles match. - type_in_search(&sidebar, "alp", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - } - - #[gpui::test] - async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - // Create 8 threads. The oldest one has a unique name and will be - // behind View More (only 5 shown by default). - for i in 0..8u32 { - let title = if i == 0 { - "Hidden gem thread".to_string() - } else { - format!("Thread {}", i + 1) - }; - save_thread_metadata( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - // Confirm the thread is not visible and View More is shown. - let entries = visible_entries_as_strings(&sidebar, cx); - assert!( - entries.iter().any(|e| e.contains("View More")), - "should have View More button" - ); - assert!( - !entries.iter().any(|e| e.contains("Hidden gem")), - "Hidden gem should be behind View More" - ); - - // User searches for the hidden thread — it appears, and View More is gone. - type_in_search(&sidebar, "hidden gem", cx); - let filtered = visible_entries_as_strings(&sidebar, cx); - assert_eq!( - filtered, - vec!["v [my-project]", " Hidden gem thread <== selected",] - ); - assert!( - !filtered.iter().any(|e| e.contains("View More")), - "View More should not appear when filtering" - ); - } - - #[gpui::test] - async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-1")), - "Important thread".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - // User focuses the sidebar and collapses the group using keyboard: - // manually select the header, then press SelectParent to collapse. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // User types a search — the thread appears even though its group is collapsed. - type_in_search(&sidebar, "important", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project]", " Important thread <== selected",] - ); - } - - #[gpui::test] - async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - for (id, title, hour) in [ - ("t-1", "Fix crash in panel", 3), - ("t-2", "Fix lint warnings", 2), - ("t-3", "Add new feature", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - open_and_focus_sidebar(&sidebar, cx); - - // User types "fix" — two threads match. - type_in_search(&sidebar, "fix", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel <== selected", - " Fix lint warnings", - ] - ); - - // Selection starts on the first matching thread. User presses - // SelectNext to move to the second match. - cx.dispatch_action(SelectNext); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel", - " Fix lint warnings <== selected", - ] - ); - - // User can also jump back with SelectPrevious. - cx.dispatch_action(SelectPrevious); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel <== selected", - " Fix lint warnings", - ] - ); - } - - #[gpui::test] - async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("hist-1")), - "Historical Thread".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Historical Thread",] - ); - - // Switch to workspace 1 so we can verify the confirm switches back. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); - }); - cx.run_until_parked(); - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1 - ); - - // Confirm on the historical (non-live) thread at index 1. - // Before a previous fix, the workspace field was Option and - // historical threads had None, so activate_thread early-returned - // without switching the workspace. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = Some(1); - sidebar.confirm(&Confirm, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 - ); - } - - #[gpui::test] - async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("t-1")), - "Thread A".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - - save_thread_metadata( - acp::SessionId::new(Arc::from("t-2")), - "Thread B".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - - cx.run_until_parked(); - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread A", " Thread B",] - ); - - // Keyboard confirm preserves selection. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = Some(1); - sidebar.confirm(&Confirm, window, cx); - }); - assert_eq!( - sidebar.read_with(cx, |sidebar, _| sidebar.selection), - Some(1) - ); - - // Click handlers clear selection to None so no highlight lingers - // after a click regardless of focus state. The hover style provides - // visual feedback during mouse interaction instead. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = None; - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - sidebar.toggle_collapse(&path_list, window, cx); - }); - assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); - - // When the user tabs back into the sidebar, focus_in no longer - // restores selection — it stays None. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.focus_in(window, cx); - }); - assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); - } - - #[gpui::test] - async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Hi there!".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - - let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list.clone(), cx).await; - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"] - ); - - // Simulate the agent generating a title. The notification chain is: - // AcpThread::set_title emits TitleUpdated → - // ConnectionView::handle_thread_event calls cx.notify() → - // AgentPanel observer fires and emits AgentPanelEvent → - // Sidebar subscription calls update_entries / rebuild_contents. - // - // Before the fix, handle_thread_event did NOT call cx.notify() for - // TitleUpdated, so the AgentPanel observer never fired and the - // sidebar kept showing the old title. - let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); - thread.update(cx, |thread, cx| { - thread - .set_title("Friendly Greeting with AI".into(), cx) - .detach(); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Friendly Greeting with AI *"] - ); - } - - #[gpui::test] - async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { - let project_a = init_test_project_with_agent_panel("/project-a", cx).await; - let (multi_workspace, cx) = cx - .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - // Save a thread so it appears in the list. - let connection_a = StubAgentConnection::new(); - connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel_a, connection_a, cx); - send_message(&panel_a, cx); - let session_id_a = active_session_id(&panel_a, cx); - save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; - - // Add a second workspace with its own agent panel. - let fs = cx.update(|_, cx| ::global(cx)); - fs.as_fake() - .insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await; - let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b.clone(), window, cx) - }); - let panel_b = add_agent_panel(&workspace_b, &project_b, cx); - cx.run_until_parked(); - - let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); - - // ── 1. Initial state: focused thread derived from active panel ───── - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "The active panel's thread should be focused on startup" - ); - }); - - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id_a.clone(), - work_dirs: None, - title: Some("Test".into()), - updated_at: None, - created_at: None, - meta: None, - }, - &workspace_a, - window, - cx, - ); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "After clicking a thread, it should be the focused thread" - ); - assert!( - has_thread_entry(sidebar, &session_id_a), - "The clicked thread should be present in the entries" - ); - }); - - workspace_a.read_with(cx, |workspace, cx| { - assert!( - workspace.panel::(cx).is_some(), - "Agent panel should exist" - ); - let dock = workspace.right_dock().read(cx); - assert!( - dock.is_open(), - "Clicking a thread should open the agent panel dock" - ); - }); - - let connection_b = StubAgentConnection::new(); - connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Thread B".into()), - )]); - open_thread_with_connection(&panel_b, connection_b, cx); - send_message(&panel_b, cx); - let session_id_b = active_session_id(&panel_b, cx); - let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); - save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await; - cx.run_until_parked(); - - // Workspace A is currently active. Click a thread in workspace B, - // which also triggers a workspace switch. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id_b.clone(), - work_dirs: None, - title: Some("Thread B".into()), - updated_at: None, - created_at: None, - meta: None, - }, - &workspace_b, - window, - cx, - ); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b), - "Clicking a thread in another workspace should focus that thread" - ); - assert!( - has_thread_entry(sidebar, &session_id_b), - "The cross-workspace thread should be present in the entries" - ); - }); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "Switching workspace should seed focused_thread from the new active panel" - ); - assert!( - has_thread_entry(sidebar, &session_id_a), - "The seeded thread should be present in the entries" - ); - }); - - let connection_b2 = StubAgentConnection::new(); - connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()), - )]); - open_thread_with_connection(&panel_b, connection_b2, cx); - send_message(&panel_b, cx); - let session_id_b2 = active_session_id(&panel_b, cx); - save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; - cx.run_until_parked(); - - // Panel B is not the active workspace's panel (workspace A is - // active), so opening a thread there should not change focused_thread. - // This prevents running threads in background workspaces from causing - // the selection highlight to jump around. - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "Opening a thread in a non-active panel should not change focused_thread" - ); - }); - - workspace_b.update_in(cx, |workspace, window, cx| { - workspace.focus_handle(cx).focus(window, cx); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "Defocusing the sidebar should not change focused_thread" - ); - }); - - // Switching workspaces via the multi_workspace (simulates clicking - // a workspace header) should clear focused_thread. - multi_workspace.update_in(cx, |mw, window, cx| { - if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) { - mw.activate_index(index, window, cx); - } - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Switching workspace should seed focused_thread from the new active panel" - ); - assert!( - has_thread_entry(sidebar, &session_id_b2), - "The seeded thread should be present in the entries" - ); - }); - - // ── 8. Focusing the agent panel thread keeps focused_thread ──── - // Workspace B still has session_id_b2 loaded in the agent panel. - // Clicking into the thread (simulated by focusing its view) should - // keep focused_thread since it was already seeded on workspace switch. - panel_b.update_in(cx, |panel, window, cx| { - if let Some(thread_view) = panel.active_conversation_view() { - thread_view.read(cx).focus_handle(cx).focus(window, cx); - } - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Focusing the agent panel thread should set focused_thread" - ); - assert!( - has_thread_entry(sidebar, &session_id_b2), - "The focused thread should be present in the entries" - ); - }); - } - - #[gpui::test] - async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/project-a", cx).await; - let fs = cx.update(|cx| ::global(cx)); - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - // Start a thread and send a message so it has history. - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await; - cx.run_until_parked(); - - // Verify the thread appears in the sidebar. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello *",] - ); - - // The "New Thread" button should NOT be in "active/draft" state - // because the panel has a thread with messages. - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - !sidebar.active_thread_is_draft, - "Panel has a thread with messages, so it should not be a draft" - ); - }); - - // Now add a second folder to the workspace, changing the path_list. - fs.as_fake() - .insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - project - .update(cx, |project, cx| { - project.find_or_create_worktree("/project-b", true, cx) - }) - .await - .expect("should add worktree"); - cx.run_until_parked(); - - // The workspace path_list is now [project-a, project-b]. The old - // thread was stored under [project-a], so it no longer appears in - // the sidebar list for this workspace. - let entries = visible_entries_as_strings(&sidebar, cx); - assert!( - !entries.iter().any(|e| e.contains("Hello")), - "Thread stored under the old path_list should not appear: {:?}", - entries - ); - - // The "New Thread" button must still be clickable (not stuck in - // "active/draft" state). Verify that `active_thread_is_draft` is - // false — the panel still has the old thread with messages. - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - !sidebar.active_thread_is_draft, - "After adding a folder the panel still has a thread with messages, \ - so active_thread_is_draft should be false" - ); - }); - - // Actually click "New Thread" by calling create_new_thread and - // verify a new draft is created. - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.create_new_thread(&workspace, window, cx); - }); - cx.run_until_parked(); - - // After creating a new thread, the panel should now be in draft - // state (no messages on the new thread). - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - sidebar.active_thread_is_draft, - "After creating a new thread the panel should be in draft state" - ); - }); - } - - #[gpui::test] - async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { - // When the user presses Cmd-N (NewThread action) while viewing a - // non-empty thread, the sidebar should show the "New Thread" entry. - // This exercises the same code path as the workspace action handler - // (which bypasses the sidebar's create_new_thread method). - let project = init_test_project_with_agent_panel("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - // Create a non-empty thread (has messages). - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - - let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list.clone(), cx).await; - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"] - ); - - // Simulate cmd-n - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - panel.update_in(cx, |panel, window, cx| { - panel.new_thread(&NewThread, window, cx); - }); - workspace.update_in(cx, |workspace, window, cx| { - workspace.focus_panel::(window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Hello *"], - "After Cmd-N the sidebar should show a highlighted New Thread entry" - ); - - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - sidebar.focused_thread.is_none(), - "focused_thread should be cleared after Cmd-N" - ); - assert!( - sidebar.active_thread_is_draft, - "the new blank thread should be a draft" - ); - }); - } - - #[gpui::test] - async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) { - // When the active workspace is an absorbed git worktree, cmd-n - // should still show the "New Thread" entry under the main repo's - // header and highlight it as active. - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - // Main repo with a linked worktree. - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Worktree checkout pointing back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - // Switch to the worktree workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Create a non-empty thread in the worktree workspace. - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&worktree_panel, connection, cx); - send_message(&worktree_panel, cx); - - let session_id = active_session_id(&worktree_panel, cx); - let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_path_list, cx).await; - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Hello {wt-feature-a} *"] - ); - - // Simulate Cmd-N in the worktree workspace. - worktree_panel.update_in(cx, |panel, window, cx| { - panel.new_thread(&NewThread, window, cx); - }); - worktree_workspace.update_in(cx, |workspace, window, cx| { - workspace.focus_panel::(window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Hello {wt-feature-a} *" - ], - "After Cmd-N in an absorbed worktree, the sidebar should show \ - a highlighted New Thread entry under the main repo header" - ); - - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - sidebar.focused_thread.is_none(), - "focused_thread should be cleared after Cmd-N" - ); - assert!( - sidebar.active_thread_is_draft, - "the new blank thread should be a draft" - ); - }); - } - - async fn init_test_project_with_git( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> (Entity, Arc) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - worktree_path, - serde_json::json!({ - ".git": {}, - "src": {}, - }), - ) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await; - (project, fs) - } - - #[gpui::test] - async fn test_search_matches_worktree_name(cx: &mut TestAppContext) { - let (project, fs) = init_test_project_with_git("/project", cx).await; - - fs.as_fake() - .with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt/rosewood"), - ref_name: Some("refs/heads/rosewood".into()), - sha: "abc".into(), - }); - }) - .unwrap(); - - project - .update(cx, |project, cx| project.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await; - save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Search for "rosewood" — should match the worktree name, not the title. - type_in_search(&sidebar, "rosewood", cx); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Fix Bug {rosewood} <== selected"], - ); - } - - #[gpui::test] - async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { - let (project, fs) = init_test_project_with_git("/project", cx).await; - - project - .update(cx, |project, cx| project.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread against a worktree path that doesn't exist yet. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Thread is not visible yet — no worktree knows about this path. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " [+ New Thread]"] - ); - - // Now add the worktree to the git state and trigger a rescan. - fs.as_fake() - .with_git_state(std::path::Path::new("/project/.git"), true, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt/rosewood"), - ref_name: Some("refs/heads/rosewood".into()), - sha: "abc".into(), - }); - }) - .unwrap(); - - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Worktree Thread {rosewood}",] - ); - } - - #[gpui::test] - async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - // Create the main repo directory (not opened as a workspace yet). - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - "feature-b": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-b", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Two worktree checkouts whose .git files point back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/wt-feature-b", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-b", - "src": {}, - }), - ) - .await; - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; - - project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await; - project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await; - - // Open both worktrees as workspaces — no main repo yet. - let (multi_workspace, cx) = cx - .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b.clone(), window, cx); - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); - save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; - save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Without the main repo, each worktree has its own header. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " Thread A {wt-feature-a}", - " Thread B {wt-feature-b}", - ] - ); - - // Configure the main repo to list both worktrees before opening - // it so the initial git scan picks them up. - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-b"), - ref_name: Some("refs/heads/feature-b".into()), - sha: "bbb".into(), - }); - }) - .unwrap(); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(main_project.clone(), window, cx); - }); - cx.run_until_parked(); - - // Both worktree workspaces should now be absorbed under the main - // repo header, with worktree chips. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " Thread A {wt-feature-a}", - " Thread B {wt-feature-b}", - ] - ); - - // Remove feature-b from the main repo's linked worktrees. - // The feature-b workspace should be pruned automatically. - fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| { - state - .worktrees - .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b")); - }) - .unwrap(); - - cx.run_until_parked(); - - // feature-b's workspace is pruned; feature-a remains absorbed - // under the main repo. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Thread A {wt-feature-a}",] - ); - } - - #[gpui::test] - async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) { - // A thread created in a workspace with roots from different git - // worktrees should show a chip for each distinct worktree name. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - // Two main repos. - fs.insert_tree( - "/project_a", - serde_json::json!({ - ".git": { - "worktrees": { - "olivetti": { - "commondir": "../../", - "HEAD": "ref: refs/heads/olivetti", - }, - "selectric": { - "commondir": "../../", - "HEAD": "ref: refs/heads/selectric", - }, - }, - }, - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/project_b", - serde_json::json!({ - ".git": { - "worktrees": { - "olivetti": { - "commondir": "../../", - "HEAD": "ref: refs/heads/olivetti", - }, - "selectric": { - "commondir": "../../", - "HEAD": "ref: refs/heads/selectric", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Worktree checkouts. - for (repo, branch) in &[ - ("project_a", "olivetti"), - ("project_a", "selectric"), - ("project_b", "olivetti"), - ("project_b", "selectric"), - ] { - let worktree_path = format!("/worktrees/{repo}/{branch}/{repo}"); - let gitdir = format!("gitdir: /{repo}/.git/worktrees/{branch}"); - fs.insert_tree( - &worktree_path, - serde_json::json!({ - ".git": gitdir, - "src": {}, - }), - ) - .await; - } - - // Register linked worktrees. - for repo in &["project_a", "project_b"] { - let git_path = format!("/{repo}/.git"); - fs.with_git_state(std::path::Path::new(&git_path), false, |state| { - for branch in &["olivetti", "selectric"] { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from(format!( - "/worktrees/{repo}/{branch}/{repo}" - )), - ref_name: Some(format!("refs/heads/{branch}").into()), - sha: "aaa".into(), - }); - } - }) - .unwrap(); - } - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - // Open a workspace with the worktree checkout paths as roots - // (this is the workspace the thread was created in). - let project = project::Project::test( - fs.clone(), - [ - "/worktrees/project_a/olivetti/project_a".as_ref(), - "/worktrees/project_b/selectric/project_b".as_ref(), - ], - cx, - ) - .await; - project.update(cx, |p, cx| p.git_scans_complete(cx)).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread under the same paths as the workspace roots. - let thread_paths = PathList::new(&[ - std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), - std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"), - ]); - save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Should show two distinct worktree chips. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project_a, project_b]", - " Cross Worktree Thread {olivetti}, {selectric}", - ] - ); - } - - #[gpui::test] - async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) { - // When a thread's roots span multiple repos but share the same - // worktree name (e.g. both in "olivetti"), only one chip should - // appear. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project_a", - serde_json::json!({ - ".git": { - "worktrees": { - "olivetti": { - "commondir": "../../", - "HEAD": "ref: refs/heads/olivetti", - }, - }, - }, - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/project_b", - serde_json::json!({ - ".git": { - "worktrees": { - "olivetti": { - "commondir": "../../", - "HEAD": "ref: refs/heads/olivetti", - }, - }, - }, - "src": {}, - }), - ) - .await; - - for repo in &["project_a", "project_b"] { - let worktree_path = format!("/worktrees/{repo}/olivetti/{repo}"); - let gitdir = format!("gitdir: /{repo}/.git/worktrees/olivetti"); - fs.insert_tree( - &worktree_path, - serde_json::json!({ - ".git": gitdir, - "src": {}, - }), - ) - .await; - - let git_path = format!("/{repo}/.git"); - fs.with_git_state(std::path::Path::new(&git_path), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")), - ref_name: Some("refs/heads/olivetti".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - } - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project = project::Project::test( - fs.clone(), - [ - "/worktrees/project_a/olivetti/project_a".as_ref(), - "/worktrees/project_b/olivetti/project_b".as_ref(), - ], - cx, - ) - .await; - project.update(cx, |p, cx| p.git_scans_complete(cx)).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Thread with roots in both repos' "olivetti" worktrees. - let thread_paths = PathList::new(&[ - std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), - std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"), - ]); - save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Both worktree paths have the name "olivetti", so only one chip. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project_a, project_b]", - " Same Branch Thread {olivetti}", - ] - ); - } - - #[gpui::test] - async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) { - // When a worktree workspace is absorbed under the main repo, a - // running thread in the worktree's agent panel should still show - // live status (spinner + "(running)") in the sidebar. - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - // Main repo with a linked worktree. - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Worktree checkout pointing back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - // Create the MultiWorkspace with both projects. - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - // Add an agent panel to the worktree workspace so we can run a - // thread inside it. - let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - // Switch back to the main workspace before setting up the sidebar. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Start a thread in the worktree workspace's panel and keep it - // generating (don't resolve it). - let connection = StubAgentConnection::new(); - open_thread_with_connection(&worktree_panel, connection.clone(), cx); - send_message(&worktree_panel, cx); - - let session_id = active_session_id(&worktree_panel, cx); - - // Save metadata so the sidebar knows about this thread. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_paths, cx).await; - - // Keep the thread generating by sending a chunk without ending - // the turn. - cx.update(|_, cx| { - connection.send_update( - session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - cx.run_until_parked(); - - // The worktree thread should be absorbed under the main project - // and show live running status. - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!( - entries, - vec!["v [project]", " Hello {wt-feature-a} * (running)",] - ); - } - - #[gpui::test] - async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) { - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - let connection = StubAgentConnection::new(); - open_thread_with_connection(&worktree_panel, connection.clone(), cx); - send_message(&worktree_panel, cx); - - let session_id = active_session_id(&worktree_panel, cx); - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_paths, cx).await; - - cx.update(|_, cx| { - connection.send_update( - session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Hello {wt-feature-a} * (running)",] - ); - - connection.end_turn(session_id, acp::StopReason::EndTurn); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Hello {wt-feature-a} * (!)",] - ); - } - - #[gpui::test] - async fn test_clicking_worktree_thread_opens_workspace_when_none_exists( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - // Only open the main repo — no workspace for the worktree. - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread for the worktree path (no workspace for it). - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Thread should appear under the main repo with a worktree chip. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " WT Thread {wt-feature-a}"], - ); - - // Only 1 workspace should exist. - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), - 1, - ); - - // Focus the sidebar and select the worktree thread. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(1); // index 0 is header, 1 is the thread - }); - - // Confirm to open the worktree thread. - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // A new workspace should have been created for the worktree path. - let new_workspace = multi_workspace.read_with(cx, |mw, _| { - assert_eq!( - mw.workspaces().len(), - 2, - "confirming a worktree thread without a workspace should open one", - ); - mw.workspaces()[1].clone() - }); - - let new_path_list = - new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx)); - assert_eq!( - new_path_list, - PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), - "the new workspace should have been opened for the worktree path", - ); - } - - #[gpui::test] - async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " WT Thread {wt-feature-a}"], - ); - - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(1); - }); - - let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context| { - let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| { - if let ListEntry::ProjectHeader { label, .. } = entry { - Some(label.as_ref()) - } else { - None - } - }); - - let Some(project_header) = project_headers.next() else { - panic!("expected exactly one sidebar project header named `project`, found none"); - }; - assert_eq!( - project_header, "project", - "expected the only sidebar project header to be `project`" - ); - if let Some(unexpected_header) = project_headers.next() { - panic!( - "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`" - ); - } - - let mut saw_expected_thread = false; - for entry in &sidebar.contents.entries { - match entry { - ListEntry::ProjectHeader { label, .. } => { - assert_eq!( - label.as_ref(), - "project", - "expected the only sidebar project header to be `project`" - ); - } - ListEntry::Thread(thread) - if thread - .session_info - .title - .as_ref() - .map(|title| title.as_ref()) - == Some("WT Thread") - && thread.worktrees.first().map(|wt| wt.name.as_ref()) - == Some("wt-feature-a") => - { - saw_expected_thread = true; - } - ListEntry::Thread(thread) => { - let title = thread - .session_info - .title - .as_ref() - .map(|title| title.as_ref()) - .unwrap_or("Untitled"); - let worktree_name = thread - .worktrees - .first() - .map(|wt| wt.name.as_ref()) - .unwrap_or(""); - panic!( - "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`" - ); - } - ListEntry::ViewMore { .. } => { - panic!("unexpected `View More` entry while opening linked worktree thread"); - } - ListEntry::NewThread { .. } => { - panic!( - "unexpected `New Thread` entry while opening linked worktree thread" - ); - } - } - } - - assert!( - saw_expected_thread, - "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`" - ); - }; - - sidebar - .update(cx, |_, cx| cx.observe_self(assert_sidebar_state)) - .detach(); - - let window = cx.windows()[0]; - cx.update_window(window, |_, window, cx| { - window.dispatch_action(Confirm.boxed_clone(), cx); - }) - .unwrap(); - - cx.run_until_parked(); - - sidebar.update(cx, assert_sidebar_state); - } - - #[gpui::test] - async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - // Activate the main workspace before setting up the sidebar. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]); - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await; - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // The worktree workspace should be absorbed under the main repo. - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 3); - assert_eq!(entries[0], "v [project]"); - assert!(entries.contains(&" Main Thread".to_string())); - assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string())); - - let wt_thread_index = entries - .iter() - .position(|e| e.contains("WT Thread")) - .expect("should find the worktree thread entry"); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0, - "main workspace should be active initially" - ); - - // Focus the sidebar and select the absorbed worktree thread. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(wt_thread_index); - }); - - // Confirm to activate the worktree thread. - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // The worktree workspace should now be active, not the main one. - let active_workspace = multi_workspace.read_with(cx, |mw, _| { - mw.workspaces()[mw.active_workspace_index()].clone() - }); - assert_eq!( - active_workspace, worktree_workspace, - "clicking an absorbed worktree thread should activate the worktree workspace" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace( - cx: &mut TestAppContext, - ) { - // Thread has saved metadata in ThreadStore. A matching workspace is - // already open. Expected: activates the matching workspace. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread with path_list pointing to project-b. - let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); - let session_id = acp::SessionId::new(Arc::from("archived-1")); - save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await; - - // Ensure workspace A is active. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - cx.run_until_parked(); - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 - ); - - // Call activate_archived_thread – should resolve saved paths and - // switch to the workspace for project-b. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), - title: Some("Archived Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1, - "should have activated the workspace matching the saved path_list" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace( - cx: &mut TestAppContext, - ) { - // Thread has no saved metadata but session_info has cwd. A matching - // workspace is open. Expected: uses cwd to find and activate it. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Start with workspace A active. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - cx.run_until_parked(); - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 - ); - - // No thread saved to the store – cwd is the only path hint. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("unknown-session")), - work_dirs: Some(PathList::new(&[std::path::PathBuf::from("/project-b")])), - title: Some("CWD Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1, - "should have activated the workspace matching the cwd" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace( - cx: &mut TestAppContext, - ) { - // Thread has no saved metadata and no cwd. Expected: falls back to - // the currently active workspace. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Activate workspace B (index 1) to make it the active one. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); - }); - cx.run_until_parked(); - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1 - ); - - // No saved thread, no cwd – should fall back to the active workspace. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("no-context-session")), - work_dirs: None, - title: Some("Contextless Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1, - "should have stayed on the active workspace when no path info is available" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_saved_paths_opens_new_workspace( - cx: &mut TestAppContext, - ) { - // Thread has saved metadata pointing to a path with no open workspace. - // Expected: opens a new workspace for that path. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread with path_list pointing to project-b – which has no - // open workspace. - let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); - let session_id = acp::SessionId::new(Arc::from("archived-new-ws")); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), - 1, - "should start with one workspace" - ); - - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(path_list_b), - title: Some("New WS Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), - 2, - "should have opened a second workspace for the archived thread's saved paths" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_reuses_workspace_in_another_window( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let multi_workspace_a = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let multi_workspace_b = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); - - let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); - - let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); - let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a); - - let session_id = acp::SessionId::new(Arc::from("archived-cross-window")); - - sidebar.update_in(cx_a, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), - title: Some("Cross Window Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx_a.run_until_parked(); - - assert_eq!( - multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should not add the other window's workspace into the current window" - ); - assert_eq!( - multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should reuse the existing workspace in the other window" - ); - assert!( - cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, - "should activate the window that already owns the matching workspace" - ); - sidebar.read_with(cx_a, |sidebar, _| { - assert_eq!( - sidebar.focused_thread, None, - "source window's sidebar should not eagerly claim focus for a thread opened in another window" - ); - }); - } - - #[gpui::test] - async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let multi_workspace_a = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let multi_workspace_b = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx)); - - let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); - let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap(); - - let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); - let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); - - let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx); - let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b); - let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone()); - let _panel_b = add_agent_panel(&workspace_b, &project_b, cx_b); - - let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar")); - - sidebar_a.update_in(cx_a, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), - title: Some("Cross Window Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx_a.run_until_parked(); - - assert_eq!( - multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should not add the other window's workspace into the current window" - ); - assert_eq!( - multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should reuse the existing workspace in the other window" - ); - assert!( - cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, - "should activate the window that already owns the matching workspace" - ); - sidebar_a.read_with(cx_a, |sidebar, _| { - assert_eq!( - sidebar.focused_thread, None, - "source window's sidebar should not eagerly claim focus for a thread opened in another window" - ); - }); - sidebar_b.read_with(cx_b, |sidebar, _| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id), - "target window's sidebar should eagerly focus the activated archived thread" - ); - }); - } - - #[gpui::test] - async fn test_activate_archived_thread_prefers_current_window_for_matching_paths( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - - let multi_workspace_b = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); - let multi_workspace_a = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); - - let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); - let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); - - let session_id = acp::SessionId::new(Arc::from("archived-current-window")); - - sidebar_a.update_in(cx_a, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-a")])), - title: Some("Current Window Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx_a.run_until_parked(); - - assert!( - cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a, - "should keep activation in the current window when it already has a matching workspace" - ); - sidebar_a.read_with(cx_a, |sidebar, _| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id), - "current window's sidebar should eagerly focus the activated archived thread" - ); - }); - assert_eq!( - multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "current window should continue reusing its existing workspace" - ); - assert_eq!( - multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "other windows should not be activated just because they also match the saved paths" - ); - } - - #[gpui::test] - async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) { - // Regression test: archive_thread previously always loaded the next thread - // through group_workspace (the main workspace's ProjectHeader), even when - // the next thread belonged to an absorbed linked-worktree workspace. That - // caused the worktree thread to be loaded in the main panel, which bound it - // to the main project and corrupted its stored folder_paths. - // - // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available, - // falling back to group_workspace only for Closed workspaces. - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - // Activate main workspace so the sidebar tracks the main panel. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone()); - let main_panel = add_agent_panel(&main_workspace, &main_project, cx); - let _worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - // Open Thread 2 in the main panel and keep it running. - let connection = StubAgentConnection::new(); - open_thread_with_connection(&main_panel, connection.clone(), cx); - send_message(&main_panel, cx); - - let thread2_session_id = active_session_id(&main_panel, cx); - - cx.update(|_, cx| { - connection.send_update( - thread2_session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - - // Save thread 2's metadata with a newer timestamp so it sorts above thread 1. - save_thread_metadata( - thread2_session_id.clone(), - "Thread 2".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - PathList::new(&[std::path::PathBuf::from("/project")]), - cx, - ) - .await; - - // Save thread 1's metadata with the worktree path and an older timestamp so - // it sorts below thread 2. archive_thread will find it as the "next" candidate. - let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session")); - save_thread_metadata( - thread1_session_id.clone(), - "Thread 1".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), - cx, - ) - .await; - - cx.run_until_parked(); - - // Verify the sidebar absorbed thread 1 under [project] with the worktree chip. - let entries_before = visible_entries_as_strings(&sidebar, cx); - assert!( - entries_before.iter().any(|s| s.contains("{wt-feature-a}")), - "Thread 1 should appear with the linked-worktree chip before archiving: {:?}", - entries_before - ); - - // The sidebar should track T2 as the focused thread (derived from the - // main panel's active view). - let focused = sidebar.read_with(cx, |s, _| s.focused_thread.clone()); - assert_eq!( - focused, - Some(thread2_session_id.clone()), - "focused thread should be Thread 2 before archiving: {:?}", - focused - ); - - // Archive thread 2. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.archive_thread(&thread2_session_id, window, cx); - }); - - cx.run_until_parked(); - - // The main panel's active thread must still be thread 2. - let main_active = main_panel.read_with(cx, |panel, cx| { - panel - .active_agent_thread(cx) - .map(|t| t.read(cx).session_id().clone()) - }); - assert_eq!( - main_active, - Some(thread2_session_id.clone()), - "main panel should not have been taken over by loading the linked-worktree thread T1; \ - before the fix, archive_thread used group_workspace instead of next.workspace, \ - causing T1 to be loaded in the wrong panel" - ); - - // Thread 1 should still appear in the sidebar with its worktree chip - // (Thread 2 was archived so it is gone from the list). - let entries_after = visible_entries_as_strings(&sidebar, cx); - assert!( - entries_after.iter().any(|s| s.contains("{wt-feature-a}")), - "T1 should still carry its linked-worktree chip after archiving T2: {:?}", - entries_after - ); - } - - #[gpui::test] - async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) { - // When a multi-root workspace (e.g. [/other, /project]) shares a - // repo with a single-root workspace (e.g. [/project]), linked - // worktree threads from the shared repo should only appear under - // the dedicated group [project], not under [other, project]. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - // Two independent repos, each with their own git history. - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/other", - serde_json::json!({ - ".git": {}, - "src": {}, - }), - ) - .await; - - // Register the linked worktree in the main repo. - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - // Workspace 1: just /project. - let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - project_only - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - // Workspace 2: /other and /project together (multi-root). - let multi_root = - project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await; - multi_root - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(project_only.clone(), window, cx) - }); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(multi_root.clone(), window, cx); - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread under the linked worktree path. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // The thread should appear only under [project] (the dedicated - // group for the /project repo), not under [other, project]. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " Worktree Thread {wt-feature-a}", - "v [other, project]", - " [+ New Thread]", - ] - ); - } -} diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..04b2d129e0fadabbb3da07364d31ccbbaa965f35 --- /dev/null +++ b/crates/sidebar/src/sidebar_tests.rs @@ -0,0 +1,4664 @@ +use super::*; +use acp_thread::StubAgentConnection; +use agent::ThreadStore; +use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message}; +use assistant_text_thread::TextThreadStore; +use chrono::DateTime; +use feature_flags::FeatureFlagAppExt as _; +use fs::FakeFs; +use gpui::TestAppContext; +use pretty_assertions::assert_eq; +use settings::SettingsStore; +use std::{path::PathBuf, sync::Arc}; +use util::path_list::PathList; + +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); +} + +fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { + sidebar.contents.entries.iter().any( + |entry| matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id), + ) +} + +async fn init_test_project( + worktree_path: &str, + cx: &mut TestAppContext, +) -> Entity { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + project::Project::test(fs, [worktree_path.as_ref()], cx).await +} + +fn setup_sidebar( + multi_workspace: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Entity { + let multi_workspace = multi_workspace.clone(); + let sidebar = + cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx))); + multi_workspace.update(cx, |mw, cx| { + mw.register_sidebar(sidebar.clone(), cx); + }); + cx.run_until_parked(); + sidebar +} + +async fn save_n_test_threads(count: u32, path_list: &PathList, cx: &mut gpui::VisualTestContext) { + for i in 0..count { + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + format!("Thread {}", i + 1).into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); +} + +async fn save_test_thread_metadata( + session_id: &acp::SessionId, + path_list: PathList, + cx: &mut TestAppContext, +) { + save_thread_metadata( + session_id.clone(), + "Test".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list, + cx, + ) + .await; +} + +async fn save_named_thread_metadata( + session_id: &str, + title: &str, + path_list: &PathList, + cx: &mut gpui::VisualTestContext, +) { + save_thread_metadata( + acp::SessionId::new(Arc::from(session_id)), + SharedString::from(title.to_string()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); +} + +async fn save_thread_metadata( + session_id: acp::SessionId, + title: SharedString, + updated_at: DateTime, + path_list: PathList, + cx: &mut TestAppContext, +) { + let metadata = ThreadMetadata { + session_id, + agent_id: None, + title, + updated_at, + created_at: None, + folder_paths: path_list, + }; + cx.update(|cx| { + SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) + }); + cx.run_until_parked(); +} + +fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { + let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade()); + if let Some(multi_workspace) = multi_workspace { + multi_workspace.update_in(cx, |mw, window, cx| { + if !mw.sidebar_open() { + mw.toggle_sidebar(window, cx); + } + }); + } + cx.run_until_parked(); + sidebar.update_in(cx, |_, window, cx| { + cx.focus_self(window); + }); + cx.run_until_parked(); +} + +fn visible_entries_as_strings( + sidebar: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Vec { + sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .enumerate() + .map(|(ix, entry)| { + let selected = if sidebar.selection == Some(ix) { + " <== selected" + } else { + "" + }; + match entry { + ListEntry::ProjectHeader { + label, + path_list, + highlight_positions: _, + .. + } => { + let icon = if sidebar.collapsed_groups.contains(path_list) { + ">" + } else { + "v" + }; + format!("{} [{}]{}", icon, label, selected) + } + ListEntry::Thread(thread) => { + let title = thread + .session_info + .title + .as_ref() + .map(|s| s.as_ref()) + .unwrap_or("Untitled"); + let active = if thread.is_live { " *" } else { "" }; + let status_str = match thread.status { + AgentThreadStatus::Running => " (running)", + AgentThreadStatus::Error => " (error)", + AgentThreadStatus::WaitingForConfirmation => " (waiting)", + _ => "", + }; + let notified = if sidebar + .contents + .is_thread_notified(&thread.session_info.session_id) + { + " (!)" + } else { + "" + }; + let worktree = if thread.worktrees.is_empty() { + String::new() + } else { + let mut seen = Vec::new(); + let mut chips = Vec::new(); + for wt in &thread.worktrees { + if !seen.contains(&wt.name) { + seen.push(wt.name.clone()); + chips.push(format!("{{{}}}", wt.name)); + } + } + format!(" {}", chips.join(", ")) + }; + format!( + " {}{}{}{}{}{}", + title, worktree, active, status_str, notified, selected + ) + } + ListEntry::ViewMore { + is_fully_expanded, .. + } => { + if *is_fully_expanded { + format!(" - Collapse{}", selected) + } else { + format!(" + View More{}", selected) + } + } + ListEntry::NewThread { .. } => { + format!(" [+ New Thread]{}", selected) + } + } + }) + .collect() + }) +} + +#[test] +fn test_clean_mention_links() { + // Simple mention link + assert_eq!( + Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"), + "check @Button.tsx" + ); + + // Multiple mention links + assert_eq!( + Sidebar::clean_mention_links( + "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)" + ), + "look at @foo.rs and @bar.rs" + ); + + // No mention links — passthrough + assert_eq!( + Sidebar::clean_mention_links("plain text with no mentions"), + "plain text with no mentions" + ); + + // Incomplete link syntax — preserved as-is + assert_eq!( + Sidebar::clean_mention_links("broken [@mention without closing"), + "broken [@mention without closing" + ); + + // Regular markdown link (no @) — not touched + assert_eq!( + Sidebar::clean_mention_links("see [docs](https://example.com)"), + "see [docs](https://example.com)" + ); + + // Empty input + assert_eq!(Sidebar::clean_mention_links(""), ""); +} + +#[gpui::test] +async fn test_entities_released_on_window_close(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade()); + let weak_sidebar = sidebar.downgrade(); + let weak_multi_workspace = multi_workspace.downgrade(); + + drop(sidebar); + drop(multi_workspace); + cx.update(|window, _cx| window.remove_window()); + cx.run_until_parked(); + + weak_multi_workspace.assert_released(); + weak_sidebar.assert_released(); + weak_workspace.assert_released(); +} + +#[gpui::test] +async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [+ New Thread]"] + ); +} + +#[gpui::test] +async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix crash in project panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-2")), + "Add inline diff view".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in project panel", + " Add inline diff view", + ] + ); +} + +#[gpui::test] +async fn test_workspace_lifecycle(cx: &mut TestAppContext) { + let project = init_test_project("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Single workspace with a thread + let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-a1")), + "Thread A1".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1"] + ); + + // Add a second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1",] + ); + + // Remove the second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + mw.remove_workspace(1, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1"] + ); +} + +#[gpui::test] +async fn test_view_more_pagination(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(12, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Thread 12", + " Thread 11", + " Thread 10", + " Thread 9", + " Thread 8", + " + View More", + ] + ); +} + +#[gpui::test] +async fn test_view_more_batched_expansion(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse + save_n_test_threads(17, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Initially shows 5 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Focus and navigate to View More, then confirm to expand by one batch + open_and_focus_sidebar(&sidebar, cx); + for _ in 0..7 { + cx.dispatch_action(SelectNext); + } + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // Now shows 10 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 12); // header + 10 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Expand again by one batch + sidebar.update_in(cx, |s, _window, cx| { + let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); + s.expanded_groups.insert(path_list.clone(), current + 1); + s.update_entries(cx); + }); + cx.run_until_parked(); + + // Now shows 15 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 17); // header + 15 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Expand one more time - should show all 17 threads with Collapse button + sidebar.update_in(cx, |s, _window, cx| { + let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); + s.expanded_groups.insert(path_list.clone(), current + 1); + s.update_entries(cx); + }); + cx.run_until_parked(); + + // All 17 threads shown with Collapse button + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 19); // header + 17 threads + Collapse + assert!(!entries.iter().any(|e| e.contains("View More"))); + assert!(entries.iter().any(|e| e.contains("Collapse"))); + + // Click collapse - should go back to showing 5 threads + sidebar.update_in(cx, |s, _window, cx| { + s.expanded_groups.remove(&path_list); + s.update_entries(cx); + }); + cx.run_until_parked(); + + // Back to initial state: 5 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); +} + +#[gpui::test] +async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Collapse + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]"] + ); + + // Expand + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); +} + +#[gpui::test] +async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]); + let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]); + + sidebar.update_in(cx, |s, _window, _cx| { + s.collapsed_groups.insert(collapsed_path.clone()); + s.contents + .notified_threads + .insert(acp::SessionId::new(Arc::from("t-5"))); + s.contents.entries = vec![ + // Expanded project header + ListEntry::ProjectHeader { + path_list: expanded_path.clone(), + label: "expanded-project".into(), + workspace: workspace.clone(), + highlight_positions: Vec::new(), + has_running_threads: false, + waiting_thread_count: 0, + is_active: true, + }, + ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-1")), + work_dirs: None, + title: Some("Completed thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Completed, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // Active thread with Running status + ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-2")), + work_dirs: None, + title: Some("Running thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Running, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // Active thread with Error status + ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-3")), + work_dirs: None, + title: Some("Error thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Error, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // Thread with WaitingForConfirmation status, not active + ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-4")), + work_dirs: None, + title: Some("Waiting thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::WaitingForConfirmation, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // Background thread that completed (should show notification) + ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-5")), + work_dirs: None, + title: Some("Notified thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Completed, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: true, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // View More entry + ListEntry::ViewMore { + path_list: expanded_path.clone(), + is_fully_expanded: false, + }, + // Collapsed project header + ListEntry::ProjectHeader { + path_list: collapsed_path.clone(), + label: "collapsed-project".into(), + workspace: workspace.clone(), + highlight_positions: Vec::new(), + has_running_threads: false, + waiting_thread_count: 0, + is_active: false, + }, + ]; + + // Select the Running thread (index 2) + s.selection = Some(2); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [expanded-project]", + " Completed thread", + " Running thread * (running) <== selected", + " Error thread * (error)", + " Waiting thread (waiting)", + " Notified thread * (!)", + " + View More", + "> [collapsed-project]", + ] + ); + + // Move selection to the collapsed header + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = Some(7); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx).last().cloned(), + Some("> [collapsed-project] <== selected".to_string()), + ); + + // Clear selection + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = None; + }); + + // No entry should have the selected marker + let entries = visible_entries_as_strings(&sidebar, cx); + for entry in &entries { + assert!( + !entry.contains("<== selected"), + "unexpected selection marker in: {}", + entry + ); + } +} + +#[gpui::test] +async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(3, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Entries: [header, thread3, thread2, thread1] + // Focusing the sidebar does not set a selection; select_next/select_previous + // handle None gracefully by starting from the first or last entry. + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // First SelectNext from None starts at index 0 + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // Move down through remaining entries + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // At the end, wraps back to first entry + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // Navigate back to the end + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // Move back up + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // At the top, selection clears (focus returns to editor) + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); +} + +#[gpui::test] +async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(3, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + open_and_focus_sidebar(&sidebar, cx); + + // SelectLast jumps to the end + cx.dispatch_action(SelectLast); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // SelectFirst jumps to the beginning + cx.dispatch_action(SelectFirst); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); +} + +#[gpui::test] +async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Initially no selection + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // Open the sidebar so it's rendered, then focus it to trigger focus_in. + // focus_in no longer sets a default selection. + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // Manually set a selection, blur, then refocus — selection should be preserved + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + cx.update(|window, _cx| { + window.blur(); + }); + cx.run_until_parked(); + + sidebar.update_in(cx, |_, window, cx| { + cx.focus_self(window); + }); + cx.run_until_parked(); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); +} + +#[gpui::test] +async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Focus the sidebar and select the header (index 0) + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + // Confirm on project header collapses the group + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // Confirm again expands the group + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project] <== selected", " Thread 1",] + ); +} + +#[gpui::test] +async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(8, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Should show header + 5 threads + "View More" + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) + open_and_focus_sidebar(&sidebar, cx); + for _ in 0..7 { + cx.dispatch_action(SelectNext); + } + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); + + // Confirm on "View More" to expand + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // All 8 threads should now be visible with a "Collapse" button + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button + assert!(!entries.iter().any(|e| e.contains("View More"))); + assert!(entries.iter().any(|e| e.contains("Collapse"))); +} + +#[gpui::test] +async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Focus sidebar and manually select the header (index 0). Press left to collapse. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // Press right to expand + cx.dispatch_action(SelectChild); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project] <== selected", " Thread 1",] + ); + + // Press right again on already-expanded header moves selection down + cx.dispatch_action(SelectChild); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); +} + +#[gpui::test] +async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Focus sidebar (selection starts at None), then navigate down to the thread (child) + open_and_focus_sidebar(&sidebar, cx); + cx.dispatch_action(SelectNext); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1 <== selected",] + ); + + // Pressing left on a child collapses the parent group and selects it + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); +} + +#[gpui::test] +async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { + let project = init_test_project("/empty-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // An empty project has the header and a new thread button. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [empty-project]", " [+ New Thread]"] + ); + + // Focus sidebar — focus_in does not set a selection + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // First SelectNext from None starts at index 0 (header) + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // SelectNext moves to the new thread button + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // At the end, wraps back to first entry + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // SelectPrevious from first entry clears selection (returns to editor) + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); +} + +#[gpui::test] +async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Focus sidebar (selection starts at None), navigate down to the thread (index 1) + open_and_focus_sidebar(&sidebar, cx); + cx.dispatch_action(SelectNext); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // Collapse the group, which removes the thread from the list + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + // Selection should be clamped to the last valid index (0 = header) + let selection = sidebar.read_with(cx, |s, _| s.selection); + let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len()); + assert!( + selection.unwrap_or(0) < entry_count, + "selection {} should be within bounds (entries: {})", + selection.unwrap_or(0), + entry_count, + ); +} + +async fn init_test_project_with_agent_panel( + worktree_path: &str, + cx: &mut TestAppContext, +) -> Entity { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + project::Project::test(fs, [worktree_path.as_ref()], cx).await +} + +fn add_agent_panel( + workspace: &Entity, + project: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Entity { + workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }) +} + +fn setup_sidebar_with_agent_panel( + multi_workspace: &Entity, + project: &Entity, + cx: &mut gpui::VisualTestContext, +) -> (Entity, Entity) { + let sidebar = setup_sidebar(multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + let panel = add_agent_panel(&workspace, project, cx); + (sidebar, panel) +} + +#[gpui::test] +async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Open thread A and keep it generating. + let connection = StubAgentConnection::new(); + open_thread_with_connection(&panel, connection.clone(), cx); + send_message(&panel, cx); + + let session_id_a = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await; + + cx.update(|_, cx| { + connection.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + cx.run_until_parked(); + + // Open thread B (idle, default response) — thread A goes to background. + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id_b = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await; + + cx.run_until_parked(); + + let mut entries = visible_entries_as_strings(&sidebar, cx); + entries[1..].sort(); + assert_eq!( + entries, + vec!["v [my-project]", " Hello *", " Hello * (running)",] + ); +} + +#[gpui::test] +async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) { + let project_a = init_test_project_with_agent_panel("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Open thread on workspace A and keep it generating. + let connection_a = StubAgentConnection::new(); + open_thread_with_connection(&panel_a, connection_a.clone(), cx); + send_message(&panel_a, cx); + + let session_id_a = active_session_id(&panel_a, cx); + save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; + + cx.update(|_, cx| { + connection_a.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), + cx, + ); + }); + cx.run_until_parked(); + + // Add a second workspace and activate it (making workspace A the background). + let fs = cx.update(|_, cx| ::global(cx)); + let project_b = project::Project::test(fs, [], cx).await; + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + cx.run_until_parked(); + + // Thread A is still running; no notification yet. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Hello * (running)",] + ); + + // Complete thread A's turn (transition Running → Completed). + connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn); + cx.run_until_parked(); + + // The completed background thread shows a notification indicator. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Hello * (!)",] + ); +} + +fn type_in_search(sidebar: &Entity, query: &str, cx: &mut gpui::VisualTestContext) { + sidebar.update_in(cx, |sidebar, window, cx| { + window.focus(&sidebar.filter_editor.focus_handle(cx), cx); + sidebar.filter_editor.update(cx, |editor, cx| { + editor.set_text(query, window, cx); + }); + }); + cx.run_until_parked(); +} + +#[gpui::test] +async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [ + ("t-1", "Fix crash in project panel", 3), + ("t-2", "Add inline diff view", 2), + ("t-3", "Refactor settings module", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in project panel", + " Add inline diff view", + " Refactor settings module", + ] + ); + + // User types "diff" in the search box — only the matching thread remains, + // with its workspace header preserved for context. + type_in_search(&sidebar, "diff", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Add inline diff view <== selected",] + ); + + // User changes query to something with no matches — list is empty. + type_in_search(&sidebar, "nonexistent", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); +} + +#[gpui::test] +async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { + // Scenario: A user remembers a thread title but not the exact casing. + // Search should match case-insensitively so they can still find it. + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix Crash In Project Panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + // Lowercase query matches mixed-case title. + type_in_search(&sidebar, "fix crash", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix Crash In Project Panel <== selected", + ] + ); + + // Uppercase query also matches the same title. + type_in_search(&sidebar, "FIX CRASH", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix Crash In Project Panel <== selected", + ] + ); +} + +#[gpui::test] +async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) { + // Scenario: A user searches, finds what they need, then presses Escape + // to dismiss the filter and see the full list again. + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // Confirm the full list is showing. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Alpha thread", " Beta thread",] + ); + + // User types a search query to filter down. + open_and_focus_sidebar(&sidebar, cx); + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Alpha thread <== selected",] + ); + + // User presses Escape — filter clears, full list is restored. + // The selection index (1) now points at the first thread entry. + cx.dispatch_action(Cancel); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Alpha thread <== selected", + " Beta thread", + ] + ); +} + +#[gpui::test] +async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) { + let project_a = init_test_project("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + for (id, title, hour) in [ + ("a1", "Fix bug in sidebar", 2), + ("a2", "Add tests for editor", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; + } + + // Add a second workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + let path_list_b = PathList::new::(&[]); + + for (id, title, hour) in [ + ("b1", "Refactor sidebar layout", 3), + ("b2", "Fix typo in README", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Fix bug in sidebar", + " Add tests for editor", + ] + ); + + // "sidebar" matches a thread in each workspace — both headers stay visible. + type_in_search(&sidebar, "sidebar", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Fix bug in sidebar <== selected",] + ); + + // "typo" only matches in the second workspace — the first header disappears. + type_in_search(&sidebar, "typo", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); + + // "project-a" matches the first workspace name — the header appears + // with all child threads included. + type_in_search(&sidebar, "project-a", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); +} + +#[gpui::test] +async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { + let project_a = init_test_project("/alpha-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); + + for (id, title, hour) in [ + ("a1", "Fix bug in sidebar", 2), + ("a2", "Add tests for editor", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; + } + + // Add a second workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + let path_list_b = PathList::new::(&[]); + + for (id, title, hour) in [ + ("b1", "Refactor sidebar layout", 3), + ("b2", "Fix typo in README", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // "alpha" matches the workspace name "alpha-project" but no thread titles. + // The workspace header should appear with all child threads included. + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); + + // "sidebar" matches thread titles in both workspaces but not workspace names. + // Both headers appear with their matching threads. + type_in_search(&sidebar, "sidebar", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] + ); + + // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r + // doesn't match) — but does not match either workspace name or any thread. + // Actually let's test something simpler: a query that matches both a workspace + // name AND some threads in that workspace. Matching threads should still appear. + type_in_search(&sidebar, "fix", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] + ); + + // A query that matches a workspace name AND a thread in that same workspace. + // Both the header (highlighted) and all child threads should appear. + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); + + // Now search for something that matches only a workspace name when there + // are also threads with matching titles — the non-matching workspace's + // threads should still appear if their titles match. + type_in_search(&sidebar, "alp", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); +} + +#[gpui::test] +async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Create 8 threads. The oldest one has a unique name and will be + // behind View More (only 5 shown by default). + for i in 0..8u32 { + let title = if i == 0 { + "Hidden gem thread".to_string() + } else { + format!("Thread {}", i + 1) + }; + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // Confirm the thread is not visible and View More is shown. + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + entries.iter().any(|e| e.contains("View More")), + "should have View More button" + ); + assert!( + !entries.iter().any(|e| e.contains("Hidden gem")), + "Hidden gem should be behind View More" + ); + + // User searches for the hidden thread — it appears, and View More is gone. + type_in_search(&sidebar, "hidden gem", cx); + let filtered = visible_entries_as_strings(&sidebar, cx); + assert_eq!( + filtered, + vec!["v [my-project]", " Hidden gem thread <== selected",] + ); + assert!( + !filtered.iter().any(|e| e.contains("View More")), + "View More should not appear when filtering" + ); +} + +#[gpui::test] +async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Important thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + // User focuses the sidebar and collapses the group using keyboard: + // manually select the header, then press SelectParent to collapse. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // User types a search — the thread appears even though its group is collapsed. + type_in_search(&sidebar, "important", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]", " Important thread <== selected",] + ); +} + +#[gpui::test] +async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [ + ("t-1", "Fix crash in panel", 3), + ("t-2", "Fix lint warnings", 2), + ("t-3", "Add new feature", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + open_and_focus_sidebar(&sidebar, cx); + + // User types "fix" — two threads match. + type_in_search(&sidebar, "fix", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel <== selected", + " Fix lint warnings", + ] + ); + + // Selection starts on the first matching thread. User presses + // SelectNext to move to the second match. + cx.dispatch_action(SelectNext); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel", + " Fix lint warnings <== selected", + ] + ); + + // User can also jump back with SelectPrevious. + cx.dispatch_action(SelectPrevious); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel <== selected", + " Fix lint warnings", + ] + ); +} + +#[gpui::test] +async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("hist-1")), + "Historical Thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Historical Thread",] + ); + + // Switch to workspace 1 so we can verify the confirm switches back. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1 + ); + + // Confirm on the historical (non-live) thread at index 1. + // Before a previous fix, the workspace field was Option and + // historical threads had None, so activate_thread early-returned + // without switching the workspace. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = Some(1); + sidebar.confirm(&Confirm, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); +} + +#[gpui::test] +async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-1")), + "Thread A".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-2")), + "Thread B".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + cx.run_until_parked(); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread A", " Thread B",] + ); + + // Keyboard confirm preserves selection. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = Some(1); + sidebar.confirm(&Confirm, window, cx); + }); + assert_eq!( + sidebar.read_with(cx, |sidebar, _| sidebar.selection), + Some(1) + ); + + // Click handlers clear selection to None so no highlight lingers + // after a click regardless of focus state. The hover style provides + // visual feedback during mouse interaction instead. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = None; + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + sidebar.toggle_collapse(&path_list, window, cx); + }); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); + + // When the user tabs back into the sidebar, focus_in no longer + // restores selection — it stays None. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.focus_in(window, cx); + }); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); +} + +#[gpui::test] +async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Hi there!".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list.clone(), cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Hello *"] + ); + + // Simulate the agent generating a title. The notification chain is: + // AcpThread::set_title emits TitleUpdated → + // ConnectionView::handle_thread_event calls cx.notify() → + // AgentPanel observer fires and emits AgentPanelEvent → + // Sidebar subscription calls update_entries / rebuild_contents. + // + // Before the fix, handle_thread_event did NOT call cx.notify() for + // TitleUpdated, so the AgentPanel observer never fired and the + // sidebar kept showing the old title. + let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); + thread.update(cx, |thread, cx| { + thread + .set_title("Friendly Greeting with AI".into(), cx) + .detach(); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Friendly Greeting with AI *"] + ); +} + +#[gpui::test] +async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { + let project_a = init_test_project_with_agent_panel("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Save a thread so it appears in the list. + let connection_a = StubAgentConnection::new(); + connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel_a, connection_a, cx); + send_message(&panel_a, cx); + let session_id_a = active_session_id(&panel_a, cx); + save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; + + // Add a second workspace with its own agent panel. + let fs = cx.update(|_, cx| ::global(cx)); + fs.as_fake() + .insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await; + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx) + }); + let panel_b = add_agent_panel(&workspace_b, &project_b, cx); + cx.run_until_parked(); + + let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); + + // ── 1. Initial state: focused thread derived from active panel ───── + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "The active panel's thread should be focused on startup" + ); + }); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id_a.clone(), + work_dirs: None, + title: Some("Test".into()), + updated_at: None, + created_at: None, + meta: None, + }, + &workspace_a, + window, + cx, + ); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "After clicking a thread, it should be the focused thread" + ); + assert!( + has_thread_entry(sidebar, &session_id_a), + "The clicked thread should be present in the entries" + ); + }); + + workspace_a.read_with(cx, |workspace, cx| { + assert!( + workspace.panel::(cx).is_some(), + "Agent panel should exist" + ); + let dock = workspace.right_dock().read(cx); + assert!( + dock.is_open(), + "Clicking a thread should open the agent panel dock" + ); + }); + + let connection_b = StubAgentConnection::new(); + connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Thread B".into()), + )]); + open_thread_with_connection(&panel_b, connection_b, cx); + send_message(&panel_b, cx); + let session_id_b = active_session_id(&panel_b, cx); + let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); + save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await; + cx.run_until_parked(); + + // Workspace A is currently active. Click a thread in workspace B, + // which also triggers a workspace switch. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id_b.clone(), + work_dirs: None, + title: Some("Thread B".into()), + updated_at: None, + created_at: None, + meta: None, + }, + &workspace_b, + window, + cx, + ); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b), + "Clicking a thread in another workspace should focus that thread" + ); + assert!( + has_thread_entry(sidebar, &session_id_b), + "The cross-workspace thread should be present in the entries" + ); + }); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Switching workspace should seed focused_thread from the new active panel" + ); + assert!( + has_thread_entry(sidebar, &session_id_a), + "The seeded thread should be present in the entries" + ); + }); + + let connection_b2 = StubAgentConnection::new(); + connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()), + )]); + open_thread_with_connection(&panel_b, connection_b2, cx); + send_message(&panel_b, cx); + let session_id_b2 = active_session_id(&panel_b, cx); + save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; + cx.run_until_parked(); + + // Panel B is not the active workspace's panel (workspace A is + // active), so opening a thread there should not change focused_thread. + // This prevents running threads in background workspaces from causing + // the selection highlight to jump around. + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Opening a thread in a non-active panel should not change focused_thread" + ); + }); + + workspace_b.update_in(cx, |workspace, window, cx| { + workspace.focus_handle(cx).focus(window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Defocusing the sidebar should not change focused_thread" + ); + }); + + // Switching workspaces via the multi_workspace (simulates clicking + // a workspace header) should clear focused_thread. + multi_workspace.update_in(cx, |mw, window, cx| { + if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) { + mw.activate_index(index, window, cx); + } + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Switching workspace should seed focused_thread from the new active panel" + ); + assert!( + has_thread_entry(sidebar, &session_id_b2), + "The seeded thread should be present in the entries" + ); + }); + + // ── 8. Focusing the agent panel thread keeps focused_thread ──── + // Workspace B still has session_id_b2 loaded in the agent panel. + // Clicking into the thread (simulated by focusing its view) should + // keep focused_thread since it was already seeded on workspace switch. + panel_b.update_in(cx, |panel, window, cx| { + if let Some(thread_view) = panel.active_conversation_view() { + thread_view.read(cx).focus_handle(cx).focus(window, cx); + } + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Focusing the agent panel thread should set focused_thread" + ); + assert!( + has_thread_entry(sidebar, &session_id_b2), + "The focused thread should be present in the entries" + ); + }); +} + +#[gpui::test] +async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/project-a", cx).await; + let fs = cx.update(|cx| ::global(cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Start a thread and send a message so it has history. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await; + cx.run_until_parked(); + + // Verify the thread appears in the sidebar. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Hello *",] + ); + + // The "New Thread" button should NOT be in "active/draft" state + // because the panel has a thread with messages. + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + !sidebar.active_thread_is_draft, + "Panel has a thread with messages, so it should not be a draft" + ); + }); + + // Now add a second folder to the workspace, changing the path_list. + fs.as_fake() + .insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + project + .update(cx, |project, cx| { + project.find_or_create_worktree("/project-b", true, cx) + }) + .await + .expect("should add worktree"); + cx.run_until_parked(); + + // The workspace path_list is now [project-a, project-b]. The old + // thread was stored under [project-a], so it no longer appears in + // the sidebar list for this workspace. + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + !entries.iter().any(|e| e.contains("Hello")), + "Thread stored under the old path_list should not appear: {:?}", + entries + ); + + // The "New Thread" button must still be clickable (not stuck in + // "active/draft" state). Verify that `active_thread_is_draft` is + // false — the panel still has the old thread with messages. + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + !sidebar.active_thread_is_draft, + "After adding a folder the panel still has a thread with messages, \ + so active_thread_is_draft should be false" + ); + }); + + // Actually click "New Thread" by calling create_new_thread and + // verify a new draft is created. + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace, window, cx); + }); + cx.run_until_parked(); + + // After creating a new thread, the panel should now be in draft + // state (no messages on the new thread). + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.active_thread_is_draft, + "After creating a new thread the panel should be in draft state" + ); + }); +} + +#[gpui::test] +async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { + // When the user presses Cmd-N (NewThread action) while viewing a + // non-empty thread, the sidebar should show the "New Thread" entry. + // This exercises the same code path as the workspace action handler + // (which bypasses the sidebar's create_new_thread method). + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Create a non-empty thread (has messages). + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list.clone(), cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Hello *"] + ); + + // Simulate cmd-n + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [+ New Thread]", " Hello *"], + "After Cmd-N the sidebar should show a highlighted New Thread entry" + ); + + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.focused_thread.is_none(), + "focused_thread should be cleared after Cmd-N" + ); + assert!( + sidebar.active_thread_is_draft, + "the new blank thread should be a draft" + ); + }); +} + +#[gpui::test] +async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) { + // When the active workspace is an absorbed git worktree, cmd-n + // should still show the "New Thread" entry under the main repo's + // header and highlight it as active. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + // Main repo with a linked worktree. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkout pointing back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + // Switch to the worktree workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Create a non-empty thread in the worktree workspace. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&worktree_panel, connection, cx); + send_message(&worktree_panel, cx); + + let session_id = active_session_id(&worktree_panel, cx); + let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_test_thread_metadata(&session_id, wt_path_list, cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Hello {wt-feature-a} *"] + ); + + // Simulate Cmd-N in the worktree workspace. + worktree_panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + worktree_workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " Hello {wt-feature-a} *" + ], + "After Cmd-N in an absorbed worktree, the sidebar should show \ + a highlighted New Thread entry under the main repo header" + ); + + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.focused_thread.is_none(), + "focused_thread should be cleared after Cmd-N" + ); + assert!( + sidebar.active_thread_is_draft, + "the new blank thread should be a draft" + ); + }); +} + +async fn init_test_project_with_git( + worktree_path: &str, + cx: &mut TestAppContext, +) -> (Entity, Arc) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + worktree_path, + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await; + (project, fs) +} + +#[gpui::test] +async fn test_search_matches_worktree_name(cx: &mut TestAppContext) { + let (project, fs) = init_test_project_with_git("/project", cx).await; + + fs.as_fake() + .with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/rosewood"), + ref_name: Some("refs/heads/rosewood".into()), + sha: "abc".into(), + }); + }) + .unwrap(); + + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); + save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await; + save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Search for "rosewood" — should match the worktree name, not the title. + type_in_search(&sidebar, "rosewood", cx); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Fix Bug {rosewood} <== selected"], + ); +} + +#[gpui::test] +async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { + let (project, fs) = init_test_project_with_git("/project", cx).await; + + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread against a worktree path that doesn't exist yet. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); + save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Thread is not visible yet — no worktree knows about this path. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " [+ New Thread]"] + ); + + // Now add the worktree to the git state and trigger a rescan. + fs.as_fake() + .with_git_state(std::path::Path::new("/project/.git"), true, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/rosewood"), + ref_name: Some("refs/heads/rosewood".into()), + sha: "abc".into(), + }); + }) + .unwrap(); + + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Worktree Thread {rosewood}",] + ); +} + +#[gpui::test] +async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Create the main repo directory (not opened as a workspace yet). + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + "feature-b": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-b", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Two worktree checkouts whose .git files point back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-b", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-b", + "src": {}, + }), + ) + .await; + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; + + project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await; + project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + // Open both worktrees as workspaces — no main repo yet. + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); + save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; + save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Without the main repo, each worktree has its own header. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Thread A {wt-feature-a}", + " Thread B {wt-feature-b}", + ] + ); + + // Configure the main repo to list both worktrees before opening + // it so the initial git scan picks them up. + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-b"), + ref_name: Some("refs/heads/feature-b".into()), + sha: "bbb".into(), + }); + }) + .unwrap(); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(main_project.clone(), window, cx); + }); + cx.run_until_parked(); + + // Both worktree workspaces should now be absorbed under the main + // repo header, with worktree chips. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Thread A {wt-feature-a}", + " Thread B {wt-feature-b}", + ] + ); +} + +#[gpui::test] +async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) { + // A thread created in a workspace with roots from different git + // worktrees should show a chip for each distinct worktree name. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Two main repos. + fs.insert_tree( + "/project_a", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + "selectric": { + "commondir": "../../", + "HEAD": "ref: refs/heads/selectric", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/project_b", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + "selectric": { + "commondir": "../../", + "HEAD": "ref: refs/heads/selectric", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkouts. + for (repo, branch) in &[ + ("project_a", "olivetti"), + ("project_a", "selectric"), + ("project_b", "olivetti"), + ("project_b", "selectric"), + ] { + let worktree_path = format!("/worktrees/{repo}/{branch}/{repo}"); + let gitdir = format!("gitdir: /{repo}/.git/worktrees/{branch}"); + fs.insert_tree( + &worktree_path, + serde_json::json!({ + ".git": gitdir, + "src": {}, + }), + ) + .await; + } + + // Register linked worktrees. + for repo in &["project_a", "project_b"] { + let git_path = format!("/{repo}/.git"); + fs.with_git_state(std::path::Path::new(&git_path), false, |state| { + for branch in &["olivetti", "selectric"] { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")), + ref_name: Some(format!("refs/heads/{branch}").into()), + sha: "aaa".into(), + }); + } + }) + .unwrap(); + } + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Open a workspace with the worktree checkout paths as roots + // (this is the workspace the thread was created in). + let project = project::Project::test( + fs.clone(), + [ + "/worktrees/project_a/olivetti/project_a".as_ref(), + "/worktrees/project_b/selectric/project_b".as_ref(), + ], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread under the same paths as the workspace roots. + let thread_paths = PathList::new(&[ + std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), + std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"), + ]); + save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Should show two distinct worktree chips. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project_a, project_b]", + " Cross Worktree Thread {olivetti}, {selectric}", + ] + ); +} + +#[gpui::test] +async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) { + // When a thread's roots span multiple repos but share the same + // worktree name (e.g. both in "olivetti"), only one chip should + // appear. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project_a", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/project_b", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + }, + }, + "src": {}, + }), + ) + .await; + + for repo in &["project_a", "project_b"] { + let worktree_path = format!("/worktrees/{repo}/olivetti/{repo}"); + let gitdir = format!("gitdir: /{repo}/.git/worktrees/olivetti"); + fs.insert_tree( + &worktree_path, + serde_json::json!({ + ".git": gitdir, + "src": {}, + }), + ) + .await; + + let git_path = format!("/{repo}/.git"); + fs.with_git_state(std::path::Path::new(&git_path), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")), + ref_name: Some("refs/heads/olivetti".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + } + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project = project::Project::test( + fs.clone(), + [ + "/worktrees/project_a/olivetti/project_a".as_ref(), + "/worktrees/project_b/olivetti/project_b".as_ref(), + ], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Thread with roots in both repos' "olivetti" worktrees. + let thread_paths = PathList::new(&[ + std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), + std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"), + ]); + save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Both worktree paths have the name "olivetti", so only one chip. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project_a, project_b]", + " Same Branch Thread {olivetti}", + ] + ); +} + +#[gpui::test] +async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) { + // When a worktree workspace is absorbed under the main repo, a + // running thread in the worktree's agent panel should still show + // live status (spinner + "(running)") in the sidebar. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + // Main repo with a linked worktree. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkout pointing back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + // Create the MultiWorkspace with both projects. + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Add an agent panel to the worktree workspace so we can run a + // thread inside it. + let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + // Switch back to the main workspace before setting up the sidebar. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Start a thread in the worktree workspace's panel and keep it + // generating (don't resolve it). + let connection = StubAgentConnection::new(); + open_thread_with_connection(&worktree_panel, connection.clone(), cx); + send_message(&worktree_panel, cx); + + let session_id = active_session_id(&worktree_panel, cx); + + // Save metadata so the sidebar knows about this thread. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_test_thread_metadata(&session_id, wt_paths, cx).await; + + // Keep the thread generating by sending a chunk without ending + // the turn. + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + cx.run_until_parked(); + + // The worktree thread should be absorbed under the main project + // and show live running status. + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!( + entries, + vec!["v [project]", " Hello {wt-feature-a} * (running)",] + ); +} + +#[gpui::test] +async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let connection = StubAgentConnection::new(); + open_thread_with_connection(&worktree_panel, connection.clone(), cx); + send_message(&worktree_panel, cx); + + let session_id = active_session_id(&worktree_panel, cx); + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_test_thread_metadata(&session_id, wt_paths, cx).await; + + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Hello {wt-feature-a} * (running)",] + ); + + connection.end_turn(session_id, acp::StopReason::EndTurn); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Hello {wt-feature-a} * (!)",] + ); +} + +#[gpui::test] +async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Only open the main repo — no workspace for the worktree. + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread for the worktree path (no workspace for it). + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Thread should appear under the main repo with a worktree chip. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " WT Thread {wt-feature-a}"], + ); + + // Only 1 workspace should exist. + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + 1, + ); + + // Focus the sidebar and select the worktree thread. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(1); // index 0 is header, 1 is the thread + }); + + // Confirm to open the worktree thread. + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // A new workspace should have been created for the worktree path. + let new_workspace = multi_workspace.read_with(cx, |mw, _| { + assert_eq!( + mw.workspaces().len(), + 2, + "confirming a worktree thread without a workspace should open one", + ); + mw.workspaces()[1].clone() + }); + + let new_path_list = + new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx)); + assert_eq!( + new_path_list, + PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), + "the new workspace should have been opened for the worktree path", + ); +} + +#[gpui::test] +async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " WT Thread {wt-feature-a}"], + ); + + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(1); + }); + + let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context| { + let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| { + if let ListEntry::ProjectHeader { label, .. } = entry { + Some(label.as_ref()) + } else { + None + } + }); + + let Some(project_header) = project_headers.next() else { + panic!("expected exactly one sidebar project header named `project`, found none"); + }; + assert_eq!( + project_header, "project", + "expected the only sidebar project header to be `project`" + ); + if let Some(unexpected_header) = project_headers.next() { + panic!( + "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`" + ); + } + + let mut saw_expected_thread = false; + for entry in &sidebar.contents.entries { + match entry { + ListEntry::ProjectHeader { label, .. } => { + assert_eq!( + label.as_ref(), + "project", + "expected the only sidebar project header to be `project`" + ); + } + ListEntry::Thread(thread) + if thread + .session_info + .title + .as_ref() + .map(|title| title.as_ref()) + == Some("WT Thread") + && thread.worktrees.first().map(|wt| wt.name.as_ref()) + == Some("wt-feature-a") => + { + saw_expected_thread = true; + } + ListEntry::Thread(thread) => { + let title = thread + .session_info + .title + .as_ref() + .map(|title| title.as_ref()) + .unwrap_or("Untitled"); + let worktree_name = thread + .worktrees + .first() + .map(|wt| wt.name.as_ref()) + .unwrap_or(""); + panic!( + "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`" + ); + } + ListEntry::ViewMore { .. } => { + panic!("unexpected `View More` entry while opening linked worktree thread"); + } + ListEntry::NewThread { .. } => { + panic!("unexpected `New Thread` entry while opening linked worktree thread"); + } + } + } + + assert!( + saw_expected_thread, + "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`" + ); + }; + + sidebar + .update(cx, |_, cx| cx.observe_self(assert_sidebar_state)) + .detach(); + + let window = cx.windows()[0]; + cx.update_window(window, |_, window, cx| { + window.dispatch_action(Confirm.boxed_clone(), cx); + }) + .unwrap(); + + cx.run_until_parked(); + + sidebar.update(cx, assert_sidebar_state); +} + +#[gpui::test] +async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Activate the main workspace before setting up the sidebar. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]); + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await; + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // The worktree workspace should be absorbed under the main repo. + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0], "v [project]"); + assert!(entries.contains(&" Main Thread".to_string())); + assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string())); + + let wt_thread_index = entries + .iter() + .position(|e| e.contains("WT Thread")) + .expect("should find the worktree thread entry"); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0, + "main workspace should be active initially" + ); + + // Focus the sidebar and select the absorbed worktree thread. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(wt_thread_index); + }); + + // Confirm to activate the worktree thread. + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // The worktree workspace should now be active, not the main one. + let active_workspace = multi_workspace.read_with(cx, |mw, _| { + mw.workspaces()[mw.active_workspace_index()].clone() + }); + assert_eq!( + active_workspace, worktree_workspace, + "clicking an absorbed worktree thread should activate the worktree workspace" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace( + cx: &mut TestAppContext, +) { + // Thread has saved metadata in ThreadStore. A matching workspace is + // already open. Expected: activates the matching workspace. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread with path_list pointing to project-b. + let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); + let session_id = acp::SessionId::new(Arc::from("archived-1")); + save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await; + + // Ensure workspace A is active. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); + + // Call activate_archived_thread – should resolve saved paths and + // switch to the workspace for project-b. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), + title: Some("Archived Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1, + "should have activated the workspace matching the saved path_list" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace( + cx: &mut TestAppContext, +) { + // Thread has no saved metadata but session_info has cwd. A matching + // workspace is open. Expected: uses cwd to find and activate it. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Start with workspace A active. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); + + // No thread saved to the store – cwd is the only path hint. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("unknown-session")), + work_dirs: Some(PathList::new(&[std::path::PathBuf::from("/project-b")])), + title: Some("CWD Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1, + "should have activated the workspace matching the cwd" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace( + cx: &mut TestAppContext, +) { + // Thread has no saved metadata and no cwd. Expected: falls back to + // the currently active workspace. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Activate workspace B (index 1) to make it the active one. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1 + ); + + // No saved thread, no cwd – should fall back to the active workspace. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("no-context-session")), + work_dirs: None, + title: Some("Contextless Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1, + "should have stayed on the active workspace when no path info is available" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) { + // Thread has saved metadata pointing to a path with no open workspace. + // Expected: opens a new workspace for that path. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread with path_list pointing to project-b – which has no + // open workspace. + let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); + let session_id = acp::SessionId::new(Arc::from("archived-new-ws")); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + 1, + "should start with one workspace" + ); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + work_dirs: Some(path_list_b), + title: Some("New WS Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + 2, + "should have opened a second workspace for the archived thread's saved paths" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let multi_workspace_a = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let multi_workspace_b = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); + + let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + + let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); + let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a); + + let session_id = acp::SessionId::new(Arc::from("archived-cross-window")); + + sidebar.update_in(cx_a, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), + title: Some("Cross Window Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx_a.run_until_parked(); + + assert_eq!( + multi_workspace_a + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should not add the other window's workspace into the current window" + ); + assert_eq!( + multi_workspace_b + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should reuse the existing workspace in the other window" + ); + assert!( + cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, + "should activate the window that already owns the matching workspace" + ); + sidebar.read_with(cx_a, |sidebar, _| { + assert_eq!( + sidebar.focused_thread, None, + "source window's sidebar should not eagerly claim focus for a thread opened in another window" + ); + }); +} + +#[gpui::test] +async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let multi_workspace_a = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let multi_workspace_b = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx)); + + let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap(); + + let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); + let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); + + let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx); + let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b); + let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone()); + let _panel_b = add_agent_panel(&workspace_b, &project_b, cx_b); + + let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar")); + + sidebar_a.update_in(cx_a, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), + title: Some("Cross Window Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx_a.run_until_parked(); + + assert_eq!( + multi_workspace_a + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should not add the other window's workspace into the current window" + ); + assert_eq!( + multi_workspace_b + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should reuse the existing workspace in the other window" + ); + assert!( + cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, + "should activate the window that already owns the matching workspace" + ); + sidebar_a.read_with(cx_a, |sidebar, _| { + assert_eq!( + sidebar.focused_thread, None, + "source window's sidebar should not eagerly claim focus for a thread opened in another window" + ); + }); + sidebar_b.read_with(cx_b, |sidebar, _| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id), + "target window's sidebar should eagerly focus the activated archived thread" + ); + }); +} + +#[gpui::test] +async fn test_activate_archived_thread_prefers_current_window_for_matching_paths( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + + let multi_workspace_b = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); + let multi_workspace_a = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + + let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); + let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); + + let session_id = acp::SessionId::new(Arc::from("archived-current-window")); + + sidebar_a.update_in(cx_a, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-a")])), + title: Some("Current Window Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx_a.run_until_parked(); + + assert!( + cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a, + "should keep activation in the current window when it already has a matching workspace" + ); + sidebar_a.read_with(cx_a, |sidebar, _| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id), + "current window's sidebar should eagerly focus the activated archived thread" + ); + }); + assert_eq!( + multi_workspace_a + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "current window should continue reusing its existing workspace" + ); + assert_eq!( + multi_workspace_b + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "other windows should not be activated just because they also match the saved paths" + ); +} + +#[gpui::test] +async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) { + // Regression test: archive_thread previously always loaded the next thread + // through group_workspace (the main workspace's ProjectHeader), even when + // the next thread belonged to an absorbed linked-worktree workspace. That + // caused the worktree thread to be loaded in the main panel, which bound it + // to the main project and corrupted its stored folder_paths. + // + // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available, + // falling back to group_workspace only for Closed workspaces. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Activate main workspace so the sidebar tracks the main panel. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone()); + let main_panel = add_agent_panel(&main_workspace, &main_project, cx); + let _worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + // Open Thread 2 in the main panel and keep it running. + let connection = StubAgentConnection::new(); + open_thread_with_connection(&main_panel, connection.clone(), cx); + send_message(&main_panel, cx); + + let thread2_session_id = active_session_id(&main_panel, cx); + + cx.update(|_, cx| { + connection.send_update( + thread2_session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + + // Save thread 2's metadata with a newer timestamp so it sorts above thread 1. + save_thread_metadata( + thread2_session_id.clone(), + "Thread 2".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + PathList::new(&[std::path::PathBuf::from("/project")]), + cx, + ) + .await; + + // Save thread 1's metadata with the worktree path and an older timestamp so + // it sorts below thread 2. archive_thread will find it as the "next" candidate. + let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session")); + save_thread_metadata( + thread1_session_id.clone(), + "Thread 1".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), + cx, + ) + .await; + + cx.run_until_parked(); + + // Verify the sidebar absorbed thread 1 under [project] with the worktree chip. + let entries_before = visible_entries_as_strings(&sidebar, cx); + assert!( + entries_before.iter().any(|s| s.contains("{wt-feature-a}")), + "Thread 1 should appear with the linked-worktree chip before archiving: {:?}", + entries_before + ); + + // The sidebar should track T2 as the focused thread (derived from the + // main panel's active view). + let focused = sidebar.read_with(cx, |s, _| s.focused_thread.clone()); + assert_eq!( + focused, + Some(thread2_session_id.clone()), + "focused thread should be Thread 2 before archiving: {:?}", + focused + ); + + // Archive thread 2. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.archive_thread(&thread2_session_id, window, cx); + }); + + cx.run_until_parked(); + + // The main panel's active thread must still be thread 2. + let main_active = main_panel.read_with(cx, |panel, cx| { + panel + .active_agent_thread(cx) + .map(|t| t.read(cx).session_id().clone()) + }); + assert_eq!( + main_active, + Some(thread2_session_id.clone()), + "main panel should not have been taken over by loading the linked-worktree thread T1; \ + before the fix, archive_thread used group_workspace instead of next.workspace, \ + causing T1 to be loaded in the wrong panel" + ); + + // Thread 1 should still appear in the sidebar with its worktree chip + // (Thread 2 was archived so it is gone from the list). + let entries_after = visible_entries_as_strings(&sidebar, cx); + assert!( + entries_after.iter().any(|s| s.contains("{wt-feature-a}")), + "T1 should still carry its linked-worktree chip after archiving T2: {:?}", + entries_after + ); +} + +#[gpui::test] +async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) { + // When a multi-root workspace (e.g. [/other, /project]) shares a + // repo with a single-root workspace (e.g. [/project]), linked + // worktree threads from the shared repo should only appear under + // the dedicated group [project], not under [other, project]. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Two independent repos, each with their own git history. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/other", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + + // Register the linked worktree in the main repo. + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Workspace 1: just /project. + let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + project_only + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + // Workspace 2: /other and /project together (multi-root). + let multi_root = + project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await; + multi_root + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(multi_root.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread under the linked worktree path. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // The thread should appear only under [project] (the dedicated + // group for the /project repo), not under [other, project]. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Worktree Thread {wt-feature-a}", + "v [other, project]", + " [+ New Thread]", + ] + ); +} + +mod property_test { + use super::*; + use gpui::EntityId; + + struct UnopenedWorktree { + path: String, + } + + struct TestState { + fs: Arc, + thread_counter: u32, + workspace_counter: u32, + worktree_counter: u32, + saved_thread_ids: Vec, + workspace_paths: Vec, + main_repo_indices: Vec, + unopened_worktrees: Vec, + } + + impl TestState { + fn new(fs: Arc, initial_workspace_path: String) -> Self { + Self { + fs, + thread_counter: 0, + workspace_counter: 1, + worktree_counter: 0, + saved_thread_ids: Vec::new(), + workspace_paths: vec![initial_workspace_path], + main_repo_indices: vec![0], + unopened_worktrees: Vec::new(), + } + } + + fn next_thread_id(&mut self) -> acp::SessionId { + let id = self.thread_counter; + self.thread_counter += 1; + let session_id = acp::SessionId::new(Arc::from(format!("prop-thread-{id}"))); + self.saved_thread_ids.push(session_id.clone()); + session_id + } + + fn remove_thread(&mut self, index: usize) -> acp::SessionId { + self.saved_thread_ids.remove(index) + } + + fn next_workspace_path(&mut self) -> String { + let id = self.workspace_counter; + self.workspace_counter += 1; + format!("/prop-project-{id}") + } + + fn next_worktree_name(&mut self) -> String { + let id = self.worktree_counter; + self.worktree_counter += 1; + format!("wt-{id}") + } + } + + #[derive(Debug)] + enum Operation { + SaveThread { workspace_index: usize }, + SaveWorktreeThread { worktree_index: usize }, + DeleteThread { index: usize }, + ToggleAgentPanel, + AddWorkspace, + OpenWorktreeAsWorkspace { worktree_index: usize }, + RemoveWorkspace { index: usize }, + SwitchWorkspace { index: usize }, + AddLinkedWorktree { workspace_index: usize }, + } + + // Distribution (out of 20 slots): + // SaveThread: 5 slots (25%) + // SaveWorktreeThread: 2 slots (10%) + // DeleteThread: 2 slots (10%) + // ToggleAgentPanel: 2 slots (10%) + // AddWorkspace: 1 slot (5%) + // OpenWorktreeAsWorkspace: 1 slot (5%) + // RemoveWorkspace: 1 slot (5%) + // SwitchWorkspace: 2 slots (10%) + // AddLinkedWorktree: 4 slots (20%) + const DISTRIBUTION_SLOTS: u32 = 20; + + impl TestState { + fn generate_operation(&self, raw: u32) -> Operation { + let extra = (raw / DISTRIBUTION_SLOTS) as usize; + let workspace_count = self.workspace_paths.len(); + + match raw % DISTRIBUTION_SLOTS { + 0..=4 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + 5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread { + worktree_index: extra % self.unopened_worktrees.len(), + }, + 5..=6 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + 7..=8 if !self.saved_thread_ids.is_empty() => Operation::DeleteThread { + index: extra % self.saved_thread_ids.len(), + }, + 7..=8 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + 9..=10 => Operation::ToggleAgentPanel, + 11 if !self.unopened_worktrees.is_empty() => Operation::OpenWorktreeAsWorkspace { + worktree_index: extra % self.unopened_worktrees.len(), + }, + 11 => Operation::AddWorkspace, + 12 if workspace_count > 1 => Operation::RemoveWorkspace { + index: extra % workspace_count, + }, + 12 => Operation::AddWorkspace, + 13..=14 => Operation::SwitchWorkspace { + index: extra % workspace_count, + }, + 15..=19 if !self.main_repo_indices.is_empty() => { + let main_index = self.main_repo_indices[extra % self.main_repo_indices.len()]; + Operation::AddLinkedWorktree { + workspace_index: main_index, + } + } + 15..=19 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + _ => unreachable!(), + } + } + } + + fn save_thread_to_path( + state: &mut TestState, + path_list: PathList, + cx: &mut gpui::VisualTestContext, + ) { + let session_id = state.next_thread_id(); + let title: SharedString = format!("Thread {}", session_id).into(); + let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0) + .unwrap() + + chrono::Duration::seconds(state.thread_counter as i64); + let metadata = ThreadMetadata { + session_id, + agent_id: None, + title, + updated_at, + created_at: None, + folder_paths: path_list, + }; + cx.update(|_, cx| { + SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); + }); + } + + async fn perform_operation( + operation: Operation, + state: &mut TestState, + multi_workspace: &Entity, + sidebar: &Entity, + cx: &mut gpui::VisualTestContext, + ) { + match operation { + Operation::SaveThread { workspace_index } => { + let workspace = + multi_workspace.read_with(cx, |mw, _| mw.workspaces()[workspace_index].clone()); + let path_list = workspace + .read_with(cx, |workspace, cx| PathList::new(&workspace.root_paths(cx))); + save_thread_to_path(state, path_list, cx); + } + Operation::SaveWorktreeThread { worktree_index } => { + let worktree = &state.unopened_worktrees[worktree_index]; + let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]); + save_thread_to_path(state, path_list, cx); + } + Operation::DeleteThread { index } => { + let session_id = state.remove_thread(index); + cx.update(|_, cx| { + SidebarThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.delete(session_id, cx)); + }); + } + Operation::ToggleAgentPanel => { + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let panel_open = sidebar.read_with(cx, |sidebar, _cx| sidebar.agent_panel_visible); + workspace.update_in(cx, |workspace, window, cx| { + if panel_open { + workspace.close_panel::(window, cx); + } else { + workspace.open_panel::(window, cx); + } + }); + } + Operation::AddWorkspace => { + let path = state.next_workspace_path(); + state + .fs + .insert_tree( + &path, + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + let project = project::Project::test( + state.fs.clone() as Arc, + [path.as_ref()], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + let workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project.clone(), window, cx) + }); + add_agent_panel(&workspace, &project, cx); + let new_index = state.workspace_paths.len(); + state.workspace_paths.push(path); + state.main_repo_indices.push(new_index); + } + Operation::OpenWorktreeAsWorkspace { worktree_index } => { + let worktree = state.unopened_worktrees.remove(worktree_index); + let project = project::Project::test( + state.fs.clone() as Arc, + [worktree.path.as_ref()], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + let workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project.clone(), window, cx) + }); + add_agent_panel(&workspace, &project, cx); + state.workspace_paths.push(worktree.path); + } + Operation::RemoveWorkspace { index } => { + let removed = multi_workspace + .update_in(cx, |mw, window, cx| mw.remove_workspace(index, window, cx)); + if removed.is_some() { + state.workspace_paths.remove(index); + state.main_repo_indices.retain(|i| *i != index); + for i in &mut state.main_repo_indices { + if *i > index { + *i -= 1; + } + } + } + } + Operation::SwitchWorkspace { index } => { + let workspace = + multi_workspace.read_with(cx, |mw, _| mw.workspaces()[index].clone()); + multi_workspace.update_in(cx, |mw, _window, cx| { + mw.activate(workspace, cx); + }); + } + Operation::AddLinkedWorktree { workspace_index } => { + let main_path = state.workspace_paths[workspace_index].clone(); + let dot_git = format!("{}/.git", main_path); + let worktree_name = state.next_worktree_name(); + let worktree_path = format!("/worktrees/{}", worktree_name); + + state.fs + .insert_tree( + &worktree_path, + serde_json::json!({ + ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name), + "src": {}, + }), + ) + .await; + + // Also create the worktree metadata dir inside the main repo's .git + state + .fs + .insert_tree( + &format!("{}/.git/worktrees/{}", main_path, worktree_name), + serde_json::json!({ + "commondir": "../../", + "HEAD": format!("ref: refs/heads/{}", worktree_name), + }), + ) + .await; + + let dot_git_path = std::path::Path::new(&dot_git); + let worktree_pathbuf = std::path::PathBuf::from(&worktree_path); + state + .fs + .with_git_state(dot_git_path, false, |git_state| { + git_state.worktrees.push(git::repository::Worktree { + path: worktree_pathbuf, + ref_name: Some(format!("refs/heads/{}", worktree_name).into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + // Re-scan the main workspace's project so it discovers the new worktree. + let main_workspace = + multi_workspace.read_with(cx, |mw, _| mw.workspaces()[workspace_index].clone()); + let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone()); + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + state.unopened_worktrees.push(UnopenedWorktree { + path: worktree_path, + }); + } + } + } + + fn update_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.collapsed_groups.clear(); + let path_lists: Vec = sidebar + .contents + .entries + .iter() + .filter_map(|entry| match entry { + ListEntry::ProjectHeader { path_list, .. } => Some(path_list.clone()), + _ => None, + }) + .collect(); + for path_list in path_lists { + sidebar.expanded_groups.insert(path_list, 10_000); + } + sidebar.update_entries(cx); + }); + } + + fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> { + verify_every_workspace_in_multiworkspace_is_shown(sidebar, cx)?; + verify_all_threads_are_shown(sidebar, cx)?; + verify_active_state_matches_current_workspace(sidebar, cx)?; + Ok(()) + } + + fn verify_every_workspace_in_multiworkspace_is_shown( + sidebar: &Sidebar, + cx: &App, + ) -> anyhow::Result<()> { + let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { + anyhow::bail!("sidebar should still have an associated multi-workspace"); + }; + + let all_workspaces: HashSet = multi_workspace + .read(cx) + .workspaces() + .iter() + .map(|ws| ws.entity_id()) + .collect(); + + let sidebar_workspaces: HashSet = sidebar + .contents + .entries + .iter() + .filter_map(|entry| entry.workspace().map(|ws| ws.entity_id())) + .collect(); + + let stray = &sidebar_workspaces - &all_workspaces; + anyhow::ensure!( + stray.is_empty(), + "sidebar references workspaces not in multi-workspace: {:?}", + stray, + ); + + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + + // A workspace may not appear directly in entries if another + // workspace in the same group is the representative. Check that + // every workspace is covered by a group that has at least one + // workspace visible in the sidebar entries. + let project_groups = ProjectGroupBuilder::from_multiworkspace(multi_workspace.read(cx), cx); + for ws in &workspaces { + if sidebar_workspaces.contains(&ws.entity_id()) { + continue; + } + let group_has_visible_member = project_groups.groups().any(|(_, group)| { + group.workspaces.contains(ws) + && group + .workspaces + .iter() + .any(|gws| sidebar_workspaces.contains(&gws.entity_id())) + }); + anyhow::ensure!( + group_has_visible_member, + "workspace {:?} (paths {:?}) is not in sidebar entries and no group member is visible", + ws.entity_id(), + workspace_path_list(ws, cx).paths(), + ); + } + Ok(()) + } + + fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> { + let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { + anyhow::bail!("sidebar should still have an associated multi-workspace"); + }; + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + let thread_store = SidebarThreadMetadataStore::global(cx); + + let sidebar_thread_ids: HashSet = sidebar + .contents + .entries + .iter() + .filter_map(|entry| entry.session_id().cloned()) + .collect(); + + let mut metadata_thread_ids: HashSet = HashSet::default(); + for workspace in &workspaces { + let path_list = workspace_path_list(workspace, cx); + if path_list.paths().is_empty() { + continue; + } + for metadata in thread_store.read(cx).entries_for_path(&path_list) { + metadata_thread_ids.insert(metadata.session_id.clone()); + } + for snapshot in root_repository_snapshots(workspace, cx) { + for linked_worktree in snapshot.linked_worktrees() { + let worktree_path_list = + PathList::new(std::slice::from_ref(&linked_worktree.path)); + for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list) { + metadata_thread_ids.insert(metadata.session_id.clone()); + } + } + } + } + + anyhow::ensure!( + sidebar_thread_ids == metadata_thread_ids, + "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}", + sidebar_thread_ids, + metadata_thread_ids, + ); + Ok(()) + } + + fn verify_active_state_matches_current_workspace( + sidebar: &Sidebar, + cx: &App, + ) -> anyhow::Result<()> { + let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { + anyhow::bail!("sidebar should still have an associated multi-workspace"); + }; + + let workspace = multi_workspace.read(cx).workspace(); + let panel_actually_visible = AgentPanel::is_visible(&workspace, cx); + let panel_active_session_id = + workspace + .read(cx) + .panel::(cx) + .and_then(|panel| { + panel + .read(cx) + .active_conversation_view() + .and_then(|cv| cv.read(cx).parent_id(cx)) + }); + + anyhow::ensure!( + sidebar.agent_panel_visible == panel_actually_visible, + "sidebar.agent_panel_visible ({}) does not match AgentPanel::is_visible ({})", + sidebar.agent_panel_visible, + panel_actually_visible, + ); + + // TODO: tighten this once focused_thread tracking is fixed + if sidebar.agent_panel_visible && !sidebar.active_thread_is_draft { + if let Some(panel_session_id) = panel_active_session_id { + anyhow::ensure!( + sidebar.focused_thread.as_ref() == Some(&panel_session_id), + "agent panel is visible with active session {:?} but sidebar focused_thread is {:?}", + panel_session_id, + sidebar.focused_thread, + ); + } + } + Ok(()) + } + + #[gpui::property_test] + async fn test_sidebar_invariants( + #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..20)] + raw_operations: Vec, + cx: &mut TestAppContext, + ) { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/my-project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = + project::Project::test(fs.clone() as Arc, ["/my-project".as_ref()], cx) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let mut state = TestState::new(fs, "/my-project".to_string()); + let mut executed: Vec = Vec::new(); + + for &raw_op in &raw_operations { + let operation = state.generate_operation(raw_op); + executed.push(format!("{:?}", operation)); + perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await; + cx.run_until_parked(); + + update_sidebar(&sidebar, cx); + cx.run_until_parked(); + + let result = + sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx)); + if let Err(err) = result { + let log = executed.join("\n "); + panic!( + "Property violation after step {}:\n{err}\n\nOperations:\n {log}", + executed.len(), + ); + } + } + } +} From fa2790d52a527834591a031e7f19f6e0ca103228 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Fri, 27 Mar 2026 17:40:57 -0700 Subject: [PATCH 52/53] Make carve outs in property test more explicit (#52610) Refactors the property test to be explicit about the exceptions to the sidebar's "properties" 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 --- crates/sidebar/src/sidebar_tests.rs | 105 +++++++++++++++++++--------- 1 file changed, 71 insertions(+), 34 deletions(-) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 04b2d129e0fadabbb3da07364d31ccbbaa965f35..8170a2956886f1bc0b90acd2f83b5a9ccd2c979b 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -4470,10 +4470,46 @@ mod property_test { anyhow::bail!("sidebar should still have an associated multi-workspace"); }; - let all_workspaces: HashSet = multi_workspace - .read(cx) - .workspaces() + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + + // For each workspace, collect the set of canonical repo paths + // (original_repo_abs_path) from its root repositories. Two + // workspaces that share a canonical repo path are in the same + // linked-worktree group. + let canonical_repos = |ws: &Entity| -> HashSet { + root_repository_snapshots(ws, cx) + .map(|snapshot| snapshot.original_repo_abs_path.to_path_buf()) + .collect::>() + }; + + // Build a map from canonical repo path → set of workspace + // EntityIds that share that repo. + let mut repo_to_workspaces: HashMap> = HashMap::new(); + for ws in &workspaces { + for repo_path in canonical_repos(ws) { + repo_to_workspaces + .entry(repo_path) + .or_default() + .insert(ws.entity_id()); + } + } + + // A workspace participates in a linked-worktree group when it + // shares a canonical repo path with at least one other workspace. + let in_linked_worktree_group = |ws: &Entity| -> bool { + canonical_repos(ws).iter().any(|repo_path| { + repo_to_workspaces + .get(repo_path) + .is_some_and(|members| members.len() > 1) + }) + }; + + // TODO + // Carve-out 1: workspaces with no root paths are not shown + // because the sidebar skips empty path lists. + let expected_workspaces: HashSet = workspaces .iter() + .filter(|ws| !workspace_path_list(ws, cx).paths().is_empty()) .map(|ws| ws.entity_id()) .collect(); @@ -4484,38 +4520,34 @@ mod property_test { .filter_map(|entry| entry.workspace().map(|ws| ws.entity_id())) .collect(); - let stray = &sidebar_workspaces - &all_workspaces; - anyhow::ensure!( - stray.is_empty(), - "sidebar references workspaces not in multi-workspace: {:?}", - stray, - ); - - let workspaces = multi_workspace.read(cx).workspaces().to_vec(); - - // A workspace may not appear directly in entries if another - // workspace in the same group is the representative. Check that - // every workspace is covered by a group that has at least one - // workspace visible in the sidebar entries. - let project_groups = ProjectGroupBuilder::from_multiworkspace(multi_workspace.read(cx), cx); - for ws in &workspaces { - if sidebar_workspaces.contains(&ws.entity_id()) { - continue; - } - let group_has_visible_member = project_groups.groups().any(|(_, group)| { - group.workspaces.contains(ws) - && group - .workspaces - .iter() - .any(|gws| sidebar_workspaces.contains(&gws.entity_id())) - }); + // Check every mismatch between the two sets. Each one must be + // explainable by a known carve-out. + let missing = &expected_workspaces - &sidebar_workspaces; + let stray = &sidebar_workspaces - &expected_workspaces; + + for entity_id in missing.iter().chain(stray.iter()) { + let Some(workspace) = workspaces.iter().find(|ws| ws.entity_id() == *entity_id) else { + anyhow::bail!("workspace {entity_id:?} not found in multi-workspace"); + }; + + // TODO + // Carve-out 2: when multiple workspaces share a linked- + // worktree group, only one representative is shown. Either + // side of the relationship (parent or linked worktree) may + // be the representative, so both can appear in the diff. anyhow::ensure!( - group_has_visible_member, - "workspace {:?} (paths {:?}) is not in sidebar entries and no group member is visible", - ws.entity_id(), - workspace_path_list(ws, cx).paths(), + in_linked_worktree_group(workspace), + "workspace {:?} (paths {:?}) is in the mismatch but does not \ + participate in a linked-worktree group.\n\ + Only in sidebar (stray): {:?}\n\ + Only in multi-workspace (missing): {:?}", + entity_id, + workspace_path_list(workspace, cx).paths(), + stray, + missing, ); } + Ok(()) } @@ -4583,6 +4615,7 @@ mod property_test { .and_then(|cv| cv.read(cx).parent_id(cx)) }); + // TODO: Remove this state entirely anyhow::ensure!( sidebar.agent_panel_visible == panel_actually_visible, "sidebar.agent_panel_visible ({}) does not match AgentPanel::is_visible ({})", @@ -4590,7 +4623,11 @@ mod property_test { panel_actually_visible, ); - // TODO: tighten this once focused_thread tracking is fixed + // TODO: Remove these checks, the focused_thread should _always_ be Some(item-in-the-list) after + // update_entries. if the activated workspace's agent panel has an active thread, this item should + // match the one in the list. There may be a slight delay, where a thread is loading so the agent panel + // returns None initially, and the focused_thread is often optimistically set to the thread the agent panel + // is going to be if sidebar.agent_panel_visible && !sidebar.active_thread_is_draft { if let Some(panel_session_id) = panel_active_session_id { anyhow::ensure!( @@ -4606,7 +4643,7 @@ mod property_test { #[gpui::property_test] async fn test_sidebar_invariants( - #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..20)] + #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..5)] raw_operations: Vec, cx: &mut TestAppContext, ) { From 54a95e717d4ed1589c9b0e38648f65424defc74b Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Fri, 27 Mar 2026 18:06:56 -0700 Subject: [PATCH 53/53] Add VecMap::entry_ref (#52601) Thinking more on #52596, I realized the `entry` method needs a key by value, which meant we were always cloning a path list even if it was already in the map. This PR adds an `entry_ref` that takes the key by reference and delays cloning the key until we know we're going to be inserting. 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/collections/src/vecmap.rs | 73 +++++++++++++ crates/collections/src/vecmap_tests.rs | 115 ++++++++++++++++++++ crates/sidebar/src/project_group_builder.rs | 2 +- 3 files changed, 189 insertions(+), 1 deletion(-) diff --git a/crates/collections/src/vecmap.rs b/crates/collections/src/vecmap.rs index 97972e7bf9f5ae43957648b8a44b10e3e45bc32f..bec6596b924742daf4e1da3831f1182557875d61 100644 --- a/crates/collections/src/vecmap.rs +++ b/crates/collections/src/vecmap.rs @@ -45,6 +45,20 @@ impl VecMap { None => Entry::Vacant(VacantEntry { map: self, key }), } } + + /// Like [`Self::entry`] but takes its key by reference instead of by value. + /// + /// This can be helpful if you have a key where cloning is expensive, as we + /// can avoid cloning the key until a value is inserted under that entry. + pub fn entry_ref<'a, 'k>(&'a mut self, key: &'k K) -> EntryRef<'k, 'a, K, V> { + match self.keys.iter().position(|k| k == key) { + Some(index) => EntryRef::Occupied(OccupiedEntry { + key: &self.keys[index], + value: &mut self.values[index], + }), + None => EntryRef::Vacant(VacantEntryRef { map: self, key }), + } + } } pub struct Iter<'a, K, V> { @@ -117,3 +131,62 @@ pub struct VacantEntry<'a, K, V> { map: &'a mut VecMap, key: K, } + +pub enum EntryRef<'key, 'map, K, V> { + Occupied(OccupiedEntry<'map, K, V>), + Vacant(VacantEntryRef<'key, 'map, K, V>), +} + +impl<'key, 'map, K, V> EntryRef<'key, 'map, K, V> { + pub fn key(&self) -> &K { + match self { + EntryRef::Occupied(entry) => entry.key, + EntryRef::Vacant(entry) => entry.key, + } + } +} + +impl<'key, 'map, K, V> EntryRef<'key, 'map, K, V> +where + K: Clone, +{ + pub fn or_insert_with_key(self, default: F) -> &'map mut V + where + F: FnOnce(&K) -> V, + { + match self { + EntryRef::Occupied(entry) => entry.value, + EntryRef::Vacant(entry) => { + entry.map.values.push(default(entry.key)); + entry.map.keys.push(entry.key.clone()); + match entry.map.values.last_mut() { + Some(value) => value, + None => unreachable!("vec empty after pushing to it"), + } + } + } + } + + pub fn or_insert_with(self, default: F) -> &'map mut V + where + F: FnOnce() -> V, + { + self.or_insert_with_key(|_| default()) + } + + pub fn or_insert(self, value: V) -> &'map mut V { + self.or_insert_with_key(|_| value) + } + + pub fn or_insert_default(self) -> &'map mut V + where + V: Default, + { + self.or_insert_with_key(|_| Default::default()) + } +} + +pub struct VacantEntryRef<'key, 'map, K, V> { + map: &'map mut VecMap, + key: &'key K, +} diff --git a/crates/collections/src/vecmap_tests.rs b/crates/collections/src/vecmap_tests.rs index 9fc7d430f3422374a7662bb476cbd99dd09d9a43..1f698f8331cc5044f23c19603005e253f8a81ef3 100644 --- a/crates/collections/src/vecmap_tests.rs +++ b/crates/collections/src/vecmap_tests.rs @@ -94,3 +94,118 @@ fn test_multiple_entries_independent() { map.entry(1).or_insert(99); assert_eq!(map.iter().count(), 3); } + +// entry_ref tests + +use std::cell::Cell; +use std::rc::Rc; + +#[derive(PartialEq, Eq)] +struct CountedKey { + value: String, + clone_count: Rc>, +} + +impl Clone for CountedKey { + fn clone(&self) -> Self { + self.clone_count.set(self.clone_count.get() + 1); + CountedKey { + value: self.value.clone(), + clone_count: self.clone_count.clone(), + } + } +} + +#[test] +fn test_entry_ref_vacant_or_insert() { + let mut map: VecMap = VecMap::new(); + let key = "a".to_string(); + let value = map.entry_ref(&key).or_insert(1); + assert_eq!(*value, 1); + assert_eq!(map.iter().count(), 1); +} + +#[test] +fn test_entry_ref_occupied_or_insert_keeps_existing() { + let mut map: VecMap = VecMap::new(); + map.entry_ref(&"a".to_string()).or_insert(1); + let value = map.entry_ref(&"a".to_string()).or_insert(99); + assert_eq!(*value, 1); + assert_eq!(map.iter().count(), 1); +} + +#[test] +fn test_entry_ref_key_not_cloned_when_occupied() { + let clone_count = Rc::new(Cell::new(0)); + let key = CountedKey { + value: "a".to_string(), + clone_count: clone_count.clone(), + }; + + let mut map: VecMap = VecMap::new(); + map.entry_ref(&key).or_insert(1); + let clones_after_insert = clone_count.get(); + + // Looking up an existing key must not clone it. + map.entry_ref(&key).or_insert(99); + assert_eq!(clone_count.get(), clones_after_insert); +} + +#[test] +fn test_entry_ref_key_cloned_exactly_once_on_vacant_insert() { + let clone_count = Rc::new(Cell::new(0)); + let key = CountedKey { + value: "a".to_string(), + clone_count: clone_count.clone(), + }; + + let mut map: VecMap = VecMap::new(); + map.entry_ref(&key).or_insert(1); + assert_eq!(clone_count.get(), 1); +} + +#[test] +fn test_entry_ref_or_insert_with_key() { + let mut map: VecMap = VecMap::new(); + let key = "hello".to_string(); + map.entry_ref(&key).or_insert_with_key(|k| k.to_uppercase()); + assert_eq!( + map.iter().collect::>(), + vec![(&"hello".to_string(), &"HELLO".to_string())] + ); +} + +#[test] +fn test_entry_ref_or_insert_with_not_called_when_occupied() { + let mut map: VecMap = VecMap::new(); + let key = "a".to_string(); + map.entry_ref(&key).or_insert(1); + map.entry_ref(&key) + .or_insert_with(|| panic!("should not be called")); + assert_eq!(map.iter().collect::>(), vec![(&key, &1)]); +} + +#[test] +fn test_entry_ref_or_insert_default() { + let mut map: VecMap = VecMap::new(); + map.entry_ref(&"a".to_string()).or_insert_default(); + assert_eq!(map.iter().collect::>(), vec![(&"a".to_string(), &0)]); +} + +#[test] +fn test_entry_ref_key() { + let mut map: VecMap = VecMap::new(); + let key = "a".to_string(); + assert_eq!(*map.entry_ref(&key).key(), key); + map.entry_ref(&key).or_insert(1); + assert_eq!(*map.entry_ref(&key).key(), key); +} + +#[test] +fn test_entry_ref_mut_ref_can_be_updated() { + let mut map: VecMap = VecMap::new(); + let key = "a".to_string(); + let value = map.entry_ref(&key).or_insert(0); + *value = 5; + assert_eq!(map.iter().collect::>(), vec![(&key, &5)]); +} diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs index 8b4a0862dcd269310eb571f4db6703ed0e508fef..318dfac0a839e28ceb27c6036b87e6a13d9bc992 100644 --- a/crates/sidebar/src/project_group_builder.rs +++ b/crates/sidebar/src/project_group_builder.rs @@ -120,7 +120,7 @@ impl ProjectGroupBuilder { } fn project_group_entry(&mut self, name: &ProjectGroupName) -> &mut ProjectGroup { - self.project_groups.entry(name.clone()).or_insert_default() + self.project_groups.entry_ref(name).or_insert_default() } fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) {