From a1d8c52a1be83379589438f5dc9665e1f8164dac Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 5 Mar 2026 15:27:44 -0800 Subject: [PATCH 001/219] Separate the sidebar click targets (#50885) Release Notes: - N/A --- crates/sidebar/src/sidebar.rs | 104 +++++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 28 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 8c68a332162d990503bf1e4881a69611f4b31c8c..3fee8ff811a7c4207f050348056f06a8b51a70e7 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -70,6 +70,7 @@ enum ListEntry { ProjectHeader { path_list: PathList, label: SharedString, + workspace_index: usize, highlight_positions: Vec, }, Thread { @@ -539,6 +540,7 @@ impl Sidebar { entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, + workspace_index: index, highlight_positions: workspace_highlight_positions, }); entries.extend(matched_threads); @@ -546,6 +548,7 @@ impl Sidebar { entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, + workspace_index: index, highlight_positions: Vec::new(), }); @@ -652,11 +655,13 @@ impl Sidebar { ListEntry::ProjectHeader { path_list, label, + workspace_index, highlight_positions, } => self.render_project_header( ix, path_list, label, + *workspace_index, highlight_positions, is_selected, cx, @@ -706,6 +711,7 @@ impl Sidebar { ix: usize, path_list: &PathList, label: &SharedString, + workspace_index: usize, highlight_positions: &[usize], is_selected: bool, cx: &mut Context, @@ -736,6 +742,25 @@ impl Sidebar { .px_1() .py_1p5() .gap_0p5() + .child( + IconButton::new( + SharedString::from(format!("project-header-chevron-{}", ix)), + disclosure_icon, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::text(if is_collapsed { + "Expand" + } else { + "Collapse" + })) + .on_click(cx.listener( + move |this, _, window, cx| { + this.toggle_collapse(&path_list_for_toggle, window, cx); + }, + )), + ) .child(if highlight_positions.is_empty() { Label::new(label.clone()) .size(LabelSize::Small) @@ -746,14 +771,7 @@ impl Sidebar { .size(LabelSize::Small) .color(Color::Muted) .into_any_element() - }) - .child( - div().visible_on_hover(group).child( - Icon::new(disclosure_icon) - .size(IconSize::Small) - .color(Color::Muted), - ), - ), + }), ) .end_hover_slot( h_flex() @@ -787,11 +805,26 @@ impl Sidebar { ) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; - this.toggle_collapse(&path_list_for_toggle, window, cx); + this.activate_workspace(workspace_index, window, cx); })) .into_any_element() } + fn activate_workspace( + &mut self, + workspace_index: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate_index(workspace_index, window, cx); + }); + } + fn remove_workspace( &mut self, path_list: &PathList, @@ -915,9 +948,11 @@ impl Sidebar { }; match entry { - ListEntry::ProjectHeader { path_list, .. } => { - let path_list = path_list.clone(); - self.toggle_collapse(&path_list, window, cx); + ListEntry::ProjectHeader { + workspace_index, .. + } => { + let workspace_index = *workspace_index; + self.activate_workspace(workspace_index, window, cx); } ListEntry::Thread { session_info, @@ -1797,6 +1832,7 @@ mod tests { ListEntry::ProjectHeader { path_list: expanded_path.clone(), label: "expanded-project".into(), + workspace_index: 0, highlight_positions: Vec::new(), }, // Thread with default (Completed) status, not active @@ -1898,6 +1934,7 @@ mod tests { ListEntry::ProjectHeader { path_list: collapsed_path.clone(), label: "collapsed-project".into(), + workspace_index: 1, highlight_positions: Vec::new(), }, ]; @@ -2044,12 +2081,17 @@ mod tests { } #[gpui::test] - async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) { + async fn test_keyboard_confirm_on_project_header_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_workspace(window, cx); + }); + cx.run_until_parked(); + 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()); @@ -2057,29 +2099,35 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] + vec![ + "v [my-project]", + " Thread 1", + "v [Empty Workspace]", + " [+ New Thread]", + ] ); - // Focus the sidebar — focus_in selects the header (index 0) - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // Press confirm to collapse - cx.dispatch_action(Confirm); + // Switch to workspace 1 so we can verify confirm switches back. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1 ); - // Confirm again to expand + // Focus the sidebar — focus_in selects the header (index 0) + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // Press confirm on project header (workspace 0) to activate it. cx.dispatch_action(Confirm); cx.run_until_parked(); assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project] <== selected", " Thread 1",] + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 ); } @@ -2859,10 +2907,10 @@ mod tests { cx.run_until_parked(); // User focuses the sidebar and collapses the group using keyboard: - // select the header, then press Confirm to toggle collapse. + // select the header, then press CollapseSelectedEntry to collapse. open_and_focus_sidebar(&sidebar, &multi_workspace, cx); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - cx.dispatch_action(Confirm); + cx.dispatch_action(CollapseSelectedEntry); cx.run_until_parked(); assert_eq!( From da9d5481e57a72e246f096f7a2d786313bdd9f5c Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Thu, 5 Mar 2026 18:04:49 -0600 Subject: [PATCH 002/219] zeta2: Don't remove redundant excerpts on the client (#50886) Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/edit_prediction/src/edit_prediction.rs | 17 ----- crates/edit_prediction/src/mercury.rs | 2 +- crates/edit_prediction/src/zeta.rs | 6 -- .../edit_prediction_cli/src/format_prompt.rs | 2 +- crates/zeta_prompt/src/zeta_prompt.rs | 63 ++++++++++++++++--- 5 files changed, 57 insertions(+), 33 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index d73fdc9b39e350ff0697cdb5cdf1ec7d0c866a72..fe57464c7f9aa33334fcb7b719ad65a297761db6 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -2800,23 +2800,6 @@ fn merge_trailing_events_if_needed( } } -pub(crate) fn filter_redundant_excerpts( - mut related_files: Vec, - cursor_path: &Path, - cursor_row_range: Range, -) -> Vec { - for file in &mut related_files { - if file.path.as_ref() == cursor_path { - file.excerpts.retain(|excerpt| { - excerpt.row_range.start < cursor_row_range.start - || excerpt.row_range.end > cursor_row_range.end - }); - } - } - related_files.retain(|file| !file.excerpts.is_empty()); - related_files -} - #[derive(Error, Debug)] #[error( "You must update to Zed version {minimum_version} or higher to continue using edit predictions." diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index f61219e2f71d5efbb2fb67250b58b0a5a090e9a8..cbb4e027253bb4d69b684c0668ff0da60f4e6aaf 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -72,7 +72,7 @@ impl Mercury { MAX_REWRITE_TOKENS, ); - let related_files = crate::filter_redundant_excerpts( + let related_files = zeta_prompt::filter_redundant_excerpts( related_files, full_path.as_ref(), context_range.start.row..context_range.end.row, diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 3d111bfd9394a90e87a70e24ae96eb69a58afe91..355e10a743f6b778e67989a0b65b93318bfd007c 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -439,12 +439,6 @@ pub fn zeta2_prompt_input( let (full_context, full_context_offset_range, excerpt_ranges) = compute_excerpt_ranges(cursor_point, snapshot); - let related_files = crate::filter_redundant_excerpts( - related_files, - excerpt_path.as_ref(), - full_context.start.row..full_context.end.row, - ); - let full_context_start_offset = full_context_offset_range.start; let full_context_start_row = full_context.start.row; diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index f36eaf2799166d6fbd2b7b212003a1a0644b82c4..fe7dff5935aed035e803b1451c8c06df8f79b810 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -95,7 +95,7 @@ pub fn zeta2_output_for_patch( cursor_offset: Option, version: ZetaFormat, ) -> Result { - let (context, editable_range, _) = resolve_cursor_region(input, version); + let (context, editable_range, _, _) = resolve_cursor_region(input, version); let mut old_editable_region = context[editable_range].to_string(); if !old_editable_region.ends_with_newline() { diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 668367727449c4fa3f9698746f3181d9bf3cca0a..d6313cc1f4d8dc5c9675c17b007e69d3c546ee92 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -305,14 +305,37 @@ pub fn write_cursor_excerpt_section_for_format( } } +fn offset_range_to_row_range(text: &str, range: Range) -> Range { + let start_row = text[0..range.start].matches('\n').count() as u32; + let mut end_row = start_row + text[range.clone()].matches('\n').count() as u32; + if !text[..range.end].ends_with('\n') { + end_row += 1; + } + return start_row..end_row; +} + pub fn format_prompt_with_budget_for_format( input: &ZetaPromptInput, format: ZetaFormat, max_tokens: usize, ) -> String { - let (context, editable_range, cursor_offset) = resolve_cursor_region(input, format); + let (context, editable_range, context_range, cursor_offset) = + resolve_cursor_region(input, format); let path = &*input.cursor_path; + let related_files = if let Some(cursor_excerpt_start_row) = input.excerpt_start_row { + let relative_row_range = offset_range_to_row_range(context, context_range); + let row_range = relative_row_range.start + cursor_excerpt_start_row + ..relative_row_range.end + cursor_excerpt_start_row; + &filter_redundant_excerpts( + input.related_files.clone(), + input.cursor_path.as_ref(), + row_range, + ) + } else { + &input.related_files + }; + match format { ZetaFormat::V0211SeedCoder | ZetaFormat::V0304SeedNoEdits => { seed_coder::format_prompt_with_budget( @@ -321,7 +344,7 @@ pub fn format_prompt_with_budget_for_format( &editable_range, cursor_offset, &input.events, - &input.related_files, + related_files, max_tokens, ) } @@ -349,7 +372,7 @@ pub fn format_prompt_with_budget_for_format( let budget_after_edit_history = budget_after_cursor.saturating_sub(edit_history_tokens); let related_files_section = format_related_files_within_budget( - &input.related_files, + &related_files, "<|file_sep|>", "", budget_after_edit_history, @@ -364,6 +387,23 @@ pub fn format_prompt_with_budget_for_format( } } +pub fn filter_redundant_excerpts( + mut related_files: Vec, + cursor_path: &Path, + cursor_row_range: Range, +) -> Vec { + for file in &mut related_files { + if file.path.as_ref() == cursor_path { + file.excerpts.retain(|excerpt| { + excerpt.row_range.start < cursor_row_range.start + || excerpt.row_range.end > cursor_row_range.end + }); + } + } + related_files.retain(|file| !file.excerpts.is_empty()); + related_files +} + pub fn get_prefill_for_format( format: ZetaFormat, context: &str, @@ -480,19 +520,26 @@ pub fn excerpt_range_for_format( pub fn resolve_cursor_region( input: &ZetaPromptInput, format: ZetaFormat, -) -> (&str, Range, usize) { +) -> (&str, Range, Range, usize) { let (editable_range, context_range) = excerpt_range_for_format(format, &input.excerpt_ranges); let context_start = context_range.start; - let context_text = &input.cursor_excerpt[context_range]; + let context_text = &input.cursor_excerpt[context_range.clone()]; let adjusted_editable = (editable_range.start - context_start)..(editable_range.end - context_start); let adjusted_cursor = input.cursor_offset_in_excerpt - context_start; - - (context_text, adjusted_editable, adjusted_cursor) + let adjusted_context = + (context_range.start - context_start)..(context_range.end - context_start); + + ( + context_text, + adjusted_editable, + adjusted_context, + adjusted_cursor, + ) } pub fn get_prefill(input: &ZetaPromptInput, format: ZetaFormat) -> String { - let (context, editable_range, _) = resolve_cursor_region(input, format); + let (context, editable_range, _, _) = resolve_cursor_region(input, format); get_prefill_for_format(format, context, &editable_range) } From 61d969665b7f0aab0d110b8381ab7c2aa1a921e9 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 5 Mar 2026 16:23:36 -0800 Subject: [PATCH 003/219] zeta: Add variable edit format (#50850) Release Notes: - N/A --------- Co-authored-by: Jakub Konka Co-authored-by: Oleksiy Syvokon Co-authored-by: Ben Kunkle --- crates/edit_prediction/src/edit_prediction.rs | 44 - crates/edit_prediction/src/zeta.rs | 184 ++- .../edit_prediction_cli/src/parse_output.rs | 125 +- crates/zeta_prompt/src/zeta_prompt.rs | 1122 ++++++++++++++++- 4 files changed, 1173 insertions(+), 302 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index fe57464c7f9aa33334fcb7b719ad65a297761db6..1f692eff2c062cf703e72117c6fd39c7a4e1efbb 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -53,7 +53,6 @@ use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use thiserror::Error; use util::{RangeExt as _, ResultExt as _}; -use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; pub mod cursor_excerpt; pub mod example_spec; @@ -2470,49 +2469,6 @@ impl EditPredictionStore { .await } - fn handle_api_response( - this: &WeakEntity, - response: Result<(T, Option)>, - cx: &mut gpui::AsyncApp, - ) -> Result { - match response { - Ok((data, usage)) => { - if let Some(usage) = usage { - this.update(cx, |this, cx| { - this.user_store.update(cx, |user_store, cx| { - user_store.update_edit_prediction_usage(usage, cx); - }); - }) - .ok(); - } - Ok(data) - } - Err(err) => { - if err.is::() { - cx.update(|cx| { - this.update(cx, |this, _cx| { - this.update_required = true; - }) - .ok(); - - let error_message: SharedString = err.to_string().into(); - show_app_notification( - NotificationId::unique::(), - cx, - move |cx| { - cx.new(|cx| { - ErrorMessagePrompt::new(error_message.clone(), cx) - .with_link_button("Update Zed", "https://zed.dev/releases") - }) - }, - ); - }); - } - Err(err) - } - } - } - async fn send_api_request( build: impl Fn(http_client::http::request::Builder) -> Result>, client: Arc, diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 355e10a743f6b778e67989a0b65b93318bfd007c..f16239dff0ca28781f36abfcdaab9fcc3873651d 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -1,24 +1,30 @@ -use crate::cursor_excerpt::compute_excerpt_ranges; -use crate::prediction::EditPredictionResult; use crate::{ CurrentEditPrediction, DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, StoredEvent, + ZedUpdateRequiredError, cursor_excerpt::compute_excerpt_ranges, + prediction::EditPredictionResult, }; use anyhow::Result; -use cloud_llm_client::predict_edits_v3::RawCompletionRequest; -use cloud_llm_client::{AcceptEditPredictionBody, EditPredictionRejectReason}; +use cloud_llm_client::{ + AcceptEditPredictionBody, EditPredictionRejectReason, predict_edits_v3::RawCompletionRequest, +}; use edit_prediction_types::PredictedCursorPosition; -use gpui::{App, AppContext as _, Task, prelude::*}; -use language::language_settings::all_language_settings; -use language::{BufferSnapshot, ToOffset as _, ToPoint, text_diff}; +use gpui::{App, AppContext as _, Entity, Task, WeakEntity, prelude::*}; +use language::{ + Buffer, BufferSnapshot, ToOffset as _, ToPoint, language_settings::all_language_settings, + text_diff, +}; use release_channel::AppVersion; use settings::EditPredictionPromptFormat; use text::{Anchor, Bias}; +use ui::SharedString; +use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; +use zeta_prompt::ZetaPromptInput; use std::{env, ops::Range, path::Path, sync::Arc, time::Instant}; use zeta_prompt::{ - CURSOR_MARKER, ZetaFormat, clean_zeta2_model_output, format_zeta_prompt, get_prefill, - output_with_context_for_format, prompt_input_contains_special_tokens, + CURSOR_MARKER, ZetaFormat, format_zeta_prompt, get_prefill, parse_zeta2_model_output, + prompt_input_contains_special_tokens, zeta1::{self, EDITABLE_REGION_END_MARKER}, }; @@ -86,6 +92,17 @@ pub fn request_prediction_with_zeta( .map(|organization| organization.id.clone()); let app_version = AppVersion::global(cx); + struct Prediction { + prompt_input: ZetaPromptInput, + buffer: Entity, + snapshot: BufferSnapshot, + edits: Vec<(Range, Arc)>, + cursor_position: Option, + received_response_at: Instant, + editable_range_in_buffer: Range, + model_version: Option, + } + let request_task = cx.background_spawn({ async move { let zeta_version = raw_config @@ -94,7 +111,6 @@ pub fn request_prediction_with_zeta( .unwrap_or(ZetaFormat::default()); let cursor_offset = position.to_offset(&snapshot); - let editable_range_in_excerpt: Range; let (full_context_offset_range, prompt_input) = zeta2_prompt_input( &snapshot, related_files, @@ -108,7 +124,7 @@ pub fn request_prediction_with_zeta( ); if prompt_input_contains_special_tokens(&prompt_input, zeta_version) { - return Ok((None, None)); + return Err(anyhow::anyhow!("prompt contains special tokens")); } if let Some(debug_tx) = &debug_tx { @@ -126,19 +142,19 @@ pub fn request_prediction_with_zeta( log::trace!("Sending edit prediction request"); - let (request_id, output_text, model_version, usage) = + let (request_id, output, model_version, usage) = if let Some(custom_settings) = &custom_server_settings { let max_tokens = custom_settings.max_output_tokens * 4; match custom_settings.prompt_format { EditPredictionPromptFormat::Zeta => { let ranges = &prompt_input.excerpt_ranges; + let editable_range_in_excerpt = ranges.editable_350.clone(); let prompt = zeta1::format_zeta1_from_input( &prompt_input, - ranges.editable_350.clone(), + editable_range_in_excerpt.clone(), ranges.editable_350_context_150.clone(), ); - editable_range_in_excerpt = ranges.editable_350.clone(); let stop_tokens = vec![ EDITABLE_REGION_END_MARKER.to_string(), format!("{EDITABLE_REGION_END_MARKER}\n"), @@ -160,19 +176,18 @@ pub fn request_prediction_with_zeta( let request_id = EditPredictionId(request_id.into()); let output_text = zeta1::clean_zeta1_model_output(&response_text); - (request_id, output_text, None, None) + ( + request_id, + Some(editable_range_in_excerpt).zip(output_text), + None, + None, + ) } EditPredictionPromptFormat::Zeta2 => { let prompt = format_zeta_prompt(&prompt_input, zeta_version); let prefill = get_prefill(&prompt_input, zeta_version); let prompt = format!("{prompt}{prefill}"); - editable_range_in_excerpt = zeta_prompt::excerpt_range_for_format( - zeta_version, - &prompt_input.excerpt_ranges, - ) - .0; - let (response_text, request_id) = send_custom_server_request( provider, custom_settings, @@ -189,7 +204,11 @@ pub fn request_prediction_with_zeta( None } else { let output = format!("{prefill}{response_text}"); - Some(clean_zeta2_model_output(&output, zeta_version).to_string()) + Some(parse_zeta2_model_output( + &output, + zeta_version, + &prompt_input, + )?) }; (request_id, output_text, None, None) @@ -213,12 +232,6 @@ pub fn request_prediction_with_zeta( environment, }; - editable_range_in_excerpt = zeta_prompt::excerpt_range_for_format( - config.format, - &prompt_input.excerpt_ranges, - ) - .1; - let (mut response, usage) = EditPredictionStore::send_raw_llm_request( request, client, @@ -230,13 +243,19 @@ pub fn request_prediction_with_zeta( .await?; let request_id = EditPredictionId(response.id.clone().into()); - let output_text = response.choices.pop().map(|choice| { + let output = if let Some(choice) = response.choices.pop() { let response = &choice.text; let output = format!("{prefill}{response}"); - clean_zeta2_model_output(&output, config.format).to_string() - }); + Some(parse_zeta2_model_output( + &output, + config.format, + &prompt_input, + )?) + } else { + None + }; - (request_id, output_text, None, usage) + (request_id, output, None, usage) } else { // Use V3 endpoint - server handles model/version selection and suffix stripping let (response, usage) = EditPredictionStore::send_v3_request( @@ -250,23 +269,23 @@ pub fn request_prediction_with_zeta( .await?; let request_id = EditPredictionId(response.request_id.into()); - let output_text = if response.output.is_empty() { - None - } else { - Some(response.output) - }; - editable_range_in_excerpt = response.editable_range; + let output_text = Some(response.output).filter(|s| !s.is_empty()); let model_version = response.model_version; - (request_id, output_text, model_version, usage) + ( + request_id, + Some(response.editable_range).zip(output_text), + model_version, + usage, + ) }; let received_response_at = Instant::now(); log::trace!("Got edit prediction response"); - let Some(mut output_text) = output_text else { - return Ok((Some((request_id, None, model_version)), usage)); + let Some((editable_range_in_excerpt, mut output_text)) = output else { + return Ok(((request_id, None), None)); }; let editable_range_in_buffer = editable_range_in_excerpt.start @@ -277,17 +296,6 @@ pub fn request_prediction_with_zeta( .text_for_range(editable_range_in_buffer.clone()) .collect::(); - // For the hashline format, the model may return <|set|>/<|insert|> - // edit commands instead of a full replacement. Apply them against - // the original editable region to produce the full replacement text. - // This must happen before cursor marker stripping because the cursor - // marker is embedded inside edit command content. - if let Some(rewritten_output) = - output_with_context_for_format(zeta_version, &old_text, &output_text)? - { - output_text = rewritten_output; - } - // Client-side cursor marker processing (applies to both raw and v3 responses) let cursor_offset_in_output = output_text.find(CURSOR_MARKER); if let Some(offset) = cursor_offset_in_output { @@ -323,40 +331,37 @@ pub fn request_prediction_with_zeta( ); anyhow::Ok(( - Some(( + ( request_id, - Some(( + Some(Prediction { prompt_input, buffer, - snapshot.clone(), + snapshot: snapshot.clone(), edits, cursor_position, received_response_at, editable_range_in_buffer, - )), - model_version, - )), + model_version, + }), + ), usage, )) } }); cx.spawn(async move |this, cx| { - let Some((id, prediction, model_version)) = - EditPredictionStore::handle_api_response(&this, request_task.await, cx)? - else { - return Ok(None); - }; + let (id, prediction) = handle_api_response(&this, request_task.await, cx)?; - let Some(( - inputs, - edited_buffer, - edited_buffer_snapshot, + let Some(Prediction { + prompt_input: inputs, + buffer: edited_buffer, + snapshot: edited_buffer_snapshot, edits, cursor_position, received_response_at, editable_range_in_buffer, - )) = prediction + model_version, + }) = prediction else { return Ok(Some(EditPredictionResult { id, @@ -423,6 +428,49 @@ pub fn request_prediction_with_zeta( }) } +fn handle_api_response( + this: &WeakEntity, + response: Result<(T, Option)>, + cx: &mut gpui::AsyncApp, +) -> Result { + match response { + Ok((data, usage)) => { + if let Some(usage) = usage { + this.update(cx, |this, cx| { + this.user_store.update(cx, |user_store, cx| { + user_store.update_edit_prediction_usage(usage, cx); + }); + }) + .ok(); + } + Ok(data) + } + Err(err) => { + if err.is::() { + cx.update(|cx| { + this.update(cx, |this, _cx| { + this.update_required = true; + }) + .ok(); + + let error_message: SharedString = err.to_string().into(); + show_app_notification( + NotificationId::unique::(), + cx, + move |cx| { + cx.new(|cx| { + ErrorMessagePrompt::new(error_message.clone(), cx) + .with_link_button("Update Zed", "https://zed.dev/releases") + }) + }, + ); + }); + } + Err(err) + } + } +} + pub fn zeta2_prompt_input( snapshot: &language::BufferSnapshot, related_files: Vec, diff --git a/crates/edit_prediction_cli/src/parse_output.rs b/crates/edit_prediction_cli/src/parse_output.rs index 2c066b8b32b3eaab54ad6e3b3bcb0796ff27f950..041c57c36e958df45dd000f48c33e00b05c751f3 100644 --- a/crates/edit_prediction_cli/src/parse_output.rs +++ b/crates/edit_prediction_cli/src/parse_output.rs @@ -6,11 +6,7 @@ use crate::{ }; use anyhow::{Context as _, Result}; use edit_prediction::example_spec::encode_cursor_in_patch; -use zeta_prompt::{ - CURSOR_MARKER, ZetaFormat, clean_extracted_region_for_format, - current_region_markers_for_format, output_end_marker_for_format, - output_with_context_for_format, -}; +use zeta_prompt::{CURSOR_MARKER, ZetaFormat, output_end_marker_for_format, resolve_cursor_region}; pub fn run_parse_output(example: &mut Example) -> Result<()> { example @@ -54,43 +50,20 @@ pub fn parse_prediction_output( } } -fn extract_zeta2_current_region(prompt: &str, format: ZetaFormat) -> Result { - let (current_marker, end_marker) = current_region_markers_for_format(format); - - let start = prompt.find(current_marker).with_context(|| { - format!( - "missing current marker '{}' in prompt", - current_marker.trim() - ) - })? + current_marker.len(); - - let end = prompt[start..] - .find(end_marker) - .with_context(|| format!("missing end marker '{}' in prompt", end_marker.trim()))? - + start; - - let region = &prompt[start..end]; - let region = region.replace(CURSOR_MARKER, ""); - Ok(clean_extracted_region_for_format(format, ®ion)) -} - fn parse_zeta2_output( example: &Example, actual_output: &str, format: ZetaFormat, ) -> Result<(String, Option)> { - let prompt = &example.prompt.as_ref().context("prompt required")?.input; let prompt_inputs = example .prompt_inputs .as_ref() .context("prompt_inputs required")?; - let old_text = extract_zeta2_current_region(prompt, format)?; + let (context, editable_range, _, _) = resolve_cursor_region(prompt_inputs, format); + let old_text = context[editable_range].to_string(); let mut new_text = actual_output.to_string(); - if let Some(transformed) = output_with_context_for_format(format, &old_text, &new_text)? { - new_text = transformed; - } let cursor_offset = if let Some(offset) = new_text.find(CURSOR_MARKER) { new_text.replace_range(offset..offset + CURSOR_MARKER.len(), ""); Some(offset) @@ -157,95 +130,3 @@ fn parse_zeta2_output( Ok((formatted_diff, actual_cursor)) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_zeta2_current_region_v0113() { - let prompt = indoc::indoc! {" - <|file_sep|>src/main.rs - <|fim_prefix|> - fn main() { - <|fim_middle|>current - println!(\"hello\"); - <|fim_suffix|> - } - <|fim_middle|>updated - "}; - - let region = extract_zeta2_current_region(prompt, ZetaFormat::V0113Ordered).unwrap(); - assert_eq!(region, "println!(\"hello\");\n"); - } - - #[test] - fn test_extract_zeta2_current_region_v0112() { - let prompt = indoc::indoc! {" - <|file_sep|>src/main.rs - <|fim_prefix|> - fn main() { - <|fim_suffix|> - } - <|fim_middle|>current - println!(\"hello\"); - <|fim_middle|>updated - "}; - - let region = extract_zeta2_current_region(prompt, ZetaFormat::V0112MiddleAtEnd).unwrap(); - assert_eq!(region, "println!(\"hello\");\n"); - } - - #[test] - fn test_extract_zeta2_current_region_with_cursor_marker() { - let prompt = indoc::indoc! {" - <|file_sep|>src/main.rs - <|fim_prefix|> - fn main() { - <|fim_middle|>current - print<|user_cursor|>ln!(\"hello\"); - <|fim_suffix|> - } - <|fim_middle|>updated - "}; - - let region = extract_zeta2_current_region(prompt, ZetaFormat::V0113Ordered).unwrap(); - assert_eq!(region, "println!(\"hello\");\n"); - } - - #[test] - fn test_extract_zeta2_current_region_v0120_git_merge_markers() { - let prompt = indoc::indoc! {" - <|file_sep|>src/main.rs - <|fim_prefix|> - fn main() { - <|fim_suffix|> - } - <|fim_middle|><<<<<<< CURRENT - println!(\"hello\"); - ======= - "}; - - let region = - extract_zeta2_current_region(prompt, ZetaFormat::V0120GitMergeMarkers).unwrap(); - assert_eq!(region, "println!(\"hello\");\n"); - } - - #[test] - fn test_extract_zeta2_current_region_v0120_with_cursor_marker() { - let prompt = indoc::indoc! {" - <|file_sep|>src/main.rs - <|fim_prefix|> - fn main() { - <|fim_suffix|> - } - <|fim_middle|><<<<<<< CURRENT - print<|user_cursor|>ln!(\"hello\"); - ======= - "}; - - let region = - extract_zeta2_current_region(prompt, ZetaFormat::V0120GitMergeMarkers).unwrap(); - assert_eq!(region, "println!(\"hello\");\n"); - } -} diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index d6313cc1f4d8dc5c9675c17b007e69d3c546ee92..52cda41ac07c52711bd381b8bebe9d8a172d0d09 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Result, anyhow}; use serde::{Deserialize, Serialize}; use std::fmt::Write; use std::ops::Range; @@ -89,6 +89,7 @@ pub enum ZetaFormat { V0211Prefill, V0211SeedCoder, v0226Hashline, + V0304VariableEdit, V0304SeedNoEdits, } @@ -216,6 +217,7 @@ pub fn special_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] ZetaFormat::V0211Prefill => v0211_prefill::special_tokens(), ZetaFormat::V0211SeedCoder => seed_coder::special_tokens(), ZetaFormat::v0226Hashline => hashline::special_tokens(), + ZetaFormat::V0304VariableEdit => v0304_variable_edit::special_tokens(), ZetaFormat::V0304SeedNoEdits => seed_coder::special_tokens(), } } @@ -242,6 +244,13 @@ pub fn excerpt_ranges_for_format( ranges.editable_350.clone(), ranges.editable_350_context_150.clone(), ), + ZetaFormat::V0304VariableEdit => { + let context = ranges + .context_8192 + .clone() + .unwrap_or_else(|| ranges.editable_350_context_150.clone()); + (context.clone(), context) + } } } @@ -302,6 +311,9 @@ pub fn write_cursor_excerpt_section_for_format( editable_range, cursor_offset, ), + ZetaFormat::V0304VariableEdit => { + v0304_variable_edit::write_cursor_excerpt_section(prompt, path, context, cursor_offset) + } } } @@ -418,7 +430,8 @@ pub fn get_prefill_for_format( | ZetaFormat::V0131GitMergeMarkersPrefix | ZetaFormat::V0211SeedCoder | ZetaFormat::v0226Hashline - | ZetaFormat::V0304SeedNoEdits => String::new(), + | ZetaFormat::V0304VariableEdit => String::new(), + ZetaFormat::V0304SeedNoEdits => String::new(), } } @@ -431,32 +444,8 @@ pub fn output_end_marker_for_format(format: ZetaFormat) -> Option<&'static str> ZetaFormat::V0112MiddleAtEnd | ZetaFormat::V0113Ordered | ZetaFormat::V0114180EditableRegion - | ZetaFormat::v0226Hashline => None, - } -} - -pub fn current_region_markers_for_format(format: ZetaFormat) -> (&'static str, &'static str) { - match format { - ZetaFormat::V0112MiddleAtEnd => ("<|fim_middle|>current\n", "<|fim_middle|>updated"), - ZetaFormat::V0113Ordered - | ZetaFormat::V0114180EditableRegion - | ZetaFormat::v0226Hashline => ("<|fim_middle|>current\n", "<|fim_suffix|>"), - ZetaFormat::V0120GitMergeMarkers - | ZetaFormat::V0131GitMergeMarkersPrefix - | ZetaFormat::V0211Prefill => ( - v0120_git_merge_markers::START_MARKER, - v0120_git_merge_markers::SEPARATOR, - ), - ZetaFormat::V0211SeedCoder | ZetaFormat::V0304SeedNoEdits => { - (seed_coder::START_MARKER, seed_coder::SEPARATOR) - } - } -} - -pub fn clean_extracted_region_for_format(format: ZetaFormat, region: &str) -> String { - match format { - ZetaFormat::v0226Hashline => hashline::strip_hashline_prefixes(region), - _ => region.to_string(), + | ZetaFormat::v0226Hashline + | ZetaFormat::V0304VariableEdit => None, } } @@ -470,43 +459,52 @@ pub fn encode_patch_as_output_for_format( ZetaFormat::v0226Hashline => { hashline::patch_to_edit_commands(old_editable_region, patch, cursor_offset).map(Some) } + ZetaFormat::V0304VariableEdit => v0304_variable_edit::patch_to_variable_edit_output( + old_editable_region, + patch, + cursor_offset, + ) + .map(Some), ZetaFormat::V0304SeedNoEdits => Ok(seed_coder::no_edits(patch)), _ => Ok(None), } } -pub fn output_with_context_for_format( - format: ZetaFormat, - old_editable_region: &str, +/// Parse model output for the given zeta format +pub fn parse_zeta2_model_output( output: &str, -) -> Result> { + format: ZetaFormat, + prompt_inputs: &ZetaPromptInput, +) -> Result<(Range, String)> { + let output = match output_end_marker_for_format(format) { + Some(marker) => output.strip_suffix(marker).unwrap_or(output), + None => output, + }; + + let (context, editable_range, _, _) = resolve_cursor_region(prompt_inputs, format); + let old_editable_region = &context[editable_range.clone()]; + match format { - ZetaFormat::v0226Hashline => { + ZetaFormat::v0226Hashline => Ok(( + editable_range, if hashline::output_has_edit_commands(output) { - Ok(Some(hashline::apply_edit_commands( - old_editable_region, - output, - ))) + hashline::apply_edit_commands(old_editable_region, output) } else { - Ok(None) - } + output.to_string() + }, + )), + ZetaFormat::V0304VariableEdit => { + v0304_variable_edit::apply_variable_edit(old_editable_region, output) } - ZetaFormat::V0304SeedNoEdits => { + ZetaFormat::V0304SeedNoEdits => Ok(( + editable_range, if output.starts_with(seed_coder::NO_EDITS) { - Ok(Some(old_editable_region.to_owned())) + old_editable_region.to_string() } else { - Ok(None) - } - } - _ => Ok(None), - } -} - -/// Post-processes model output for the given zeta format by stripping format-specific suffixes. -pub fn clean_zeta2_model_output(output: &str, format: ZetaFormat) -> &str { - match output_end_marker_for_format(format) { - Some(marker) => output.strip_suffix(marker).unwrap_or(output), - None => output, + output.to_string() + }, + )), + _ => Ok((editable_range, output.to_string())), } } @@ -2565,6 +2563,1009 @@ pub mod seed_coder { } } +pub mod v0304_variable_edit { + //! A prompt format with no fixed editable region. The entire context is shown + //! to the model, and it chooses which text to replace by outputting surrounding + //! context lines with `<|fim_middle|>` and `<|fim_suffix|>` delimiting the new + //! text. + //! + //! Example prompt: + //! + //! <|file_sep|>path/to/file.py + //! zero + //! one + //! two + //! three<|user_cursor|> + //! four + //! five + //! <|fim_prefix|> + // + //! Expected output (model generates): + //! + //! two + //! <|fim_middle|> + //! THREE + //! <|fim_suffix|> + //! four + //! + //! The output means: find "two\n...\nfour" in the context, and replace + //! everything between "two\n" and "four" with "THREE\n". + + use super::*; + + pub fn special_tokens() -> &'static [&'static str] { + &[ + "<|fim_prefix|>", + "<|fim_suffix|>", + "<|fim_middle|>", + "<|file_sep|>", + CURSOR_MARKER, + ] + } + + pub fn write_cursor_excerpt_section( + prompt: &mut String, + path: &Path, + context: &str, + cursor_offset: usize, + ) { + let path_str = path.to_string_lossy(); + write!(prompt, "<|file_sep|>{}\n", path_str).ok(); + + prompt.push_str(&context[..cursor_offset]); + prompt.push_str(CURSOR_MARKER); + prompt.push_str(&context[cursor_offset..]); + if !prompt.ends_with('\n') { + prompt.push('\n'); + } + prompt.push_str("<|fim_prefix|>\n") + } + + /// Apply a variable-edit model output to the original context text. + /// + /// The model output has the form: + /// + /// - prefix context lines + /// - `<|fim_middle|>` + /// - new text + /// - `<|fim_suffix|>` + /// - suffix context lines + /// + /// We locate the prefix/suffix context lines in the original text and replace + /// everything between them with the new text. + pub fn apply_variable_edit( + context: &str, + model_output: &str, + ) -> Result<(Range, String)> { + let (prefix_context, rest) = model_output + .split_once("<|fim_middle|>\n") + .or_else(|| model_output.split_once("<|fim_middle|>")) + .ok_or_else(|| anyhow::anyhow!("missing <|fim_middle|> in model output"))?; + + let (new_text, suffix_context) = rest + .split_once("<|fim_suffix|>\n") + .or_else(|| rest.split_once("<|fim_suffix|>")) + .unwrap_or((rest, "")); + + let suffix_context = if prefix_context.is_empty() && !suffix_context.is_empty() { + suffix_context.strip_prefix('\n').unwrap_or(suffix_context) + } else { + suffix_context + }; + + let prefix_offset = find_substring_at_line_boundary(context, prefix_context) + .ok_or_else(|| anyhow!("could not locate prefix lines"))? + + prefix_context.len(); + let suffix_offset = if suffix_context.is_empty() { + context.len() + } else { + find_substring_at_line_boundary(&context[prefix_offset..], suffix_context) + .ok_or_else(|| anyhow!("could not locate suffix lines"))? + + prefix_offset + }; + + let edit_range = prefix_offset..suffix_offset; + return Ok((edit_range, new_text.to_string())); + } + + fn find_substring_at_line_boundary(haystack: &str, needle: &str) -> Option { + if needle.is_empty() { + return Some(0); + } + + haystack.match_indices(needle).find_map(|(offset, _)| { + let matched_line_start = offset == 0 || haystack[..offset].ends_with('\n'); + matched_line_start.then_some(offset) + }) + } + + /// Convert a unified diff patch into the variable-edit output format. + /// + /// Parses `patch` as a unified diff against `old_text` and produces model + /// output with context lines surrounding `<|fim_middle|>` / `<|fim_suffix|>` + /// delimiters. The diff is resolved by content matching rather than line + /// numbers. + pub fn patch_to_variable_edit_output( + old_text: &str, + patch: &str, + cursor_offset: Option, + ) -> Result { + // Parse the unified diff into hunks. Each hunk has an `old_context` + // string (context + deleted lines interleaved in order) and a list of + // edits expressed as byte ranges within that context plus replacement + // text. + let hunks = parse_hunks(patch); + if hunks.is_empty() { + return Ok(String::new()); + } + + // Apply each hunk by finding its old_context in the text and + // performing the edits. We search forward from where the previous + // hunk ended so that hunks are applied in order. + let mut new_text = old_text.to_string(); + let mut search_from: usize = 0; + let mut first_hunk_pos: Option = None; + + for hunk in &hunks { + let context_pos = new_text[search_from..] + .find(&hunk.old_context) + .map(|pos| pos + search_from) + .ok_or_else(|| anyhow::anyhow!("could not locate hunk context in text"))?; + + if first_hunk_pos.is_none() { + first_hunk_pos = Some(context_pos); + } + + // Apply edits in reverse order so byte offsets remain valid. + for edit in hunk.edits.iter().rev() { + let abs_start = context_pos + edit.range.start; + let abs_end = context_pos + edit.range.end; + new_text.replace_range(abs_start..abs_end, &edit.text); + } + + // Advance past this hunk's region in the (now modified) text. + let new_region_len: usize = + hunk.edits.iter().fold(hunk.old_context.len(), |len, edit| { + len + edit.text.len() - (edit.range.end - edit.range.start) + }); + search_from = context_pos + new_region_len; + } + + // Now we have old_text and new_text. Find the changed line range by + // comparing them. + let old_lines: Vec<&str> = old_text.lines().collect(); + let new_lines: Vec<&str> = new_text.lines().collect(); + + // Find first differing line. + let first_changed_row = old_lines + .iter() + .zip(new_lines.iter()) + .position(|(a, b)| a != b) + .unwrap_or_else(|| old_lines.len().min(new_lines.len())); + + // Find last differing line (from the end). + let max_suffix = old_lines.len().min(new_lines.len()) - first_changed_row; + let common_suffix = old_lines + .iter() + .rev() + .zip(new_lines.iter().rev()) + .take(max_suffix) + .take_while(|(a, b)| a == b) + .count(); + + let old_end = old_lines.len() - common_suffix; + let new_end = new_lines.len() - common_suffix; + + if first_changed_row == old_end && first_changed_row == new_end { + return Ok(String::new()); + } + + // Build the replacement text from new_lines[first_diff..new_end]. + let mut merged_new_text = String::new(); + for line in &new_lines[first_changed_row..new_end] { + merged_new_text.push_str(line); + merged_new_text.push('\n'); + } + + // cursor_offset is relative to the first hunk's new content in + // new_text. Translate it to an offset within merged_new_text, which + // only contains lines first_diff..new_end of new_text. + if let Some(hunk_offset) = cursor_offset { + let hunk_start = first_hunk_pos.unwrap_or(0); + let absolute_pos = hunk_start + hunk_offset; + + // Byte offset where first_diff starts in new_text. + let merged_start: usize = new_lines[..first_changed_row] + .iter() + .map(|line| line.len() + 1) + .sum(); + + if absolute_pos >= merged_start { + let relative_offset = absolute_pos - merged_start; + if relative_offset <= merged_new_text.len() { + merged_new_text.insert_str(relative_offset, CURSOR_MARKER); + } + } + } + + // Build output with 2 lines of context above and below. + let context_lines_count = 2; + let mut prefix_start = first_changed_row.saturating_sub(context_lines_count); + let mut suffix_end = (old_end + context_lines_count).min(old_lines.len()); + + fn count_matches(line_range: Range, lines: &[&str]) -> usize { + let pattern = &lines[line_range]; + let pattern_len = pattern.len(); + + let mut count = 0; + for offset in 0..=lines.len() - pattern_len { + if &lines[offset..offset + pattern_len] == pattern { + count += 1; + } + } + count + } + + // Expand prefix and suffix until they are unique + while prefix_start > 0 { + if count_matches(prefix_start..first_changed_row, &old_lines) > 1 { + prefix_start -= 1; + } else { + break; + } + } + while suffix_end < old_lines.len() { + if count_matches(old_end..suffix_end, &old_lines) > 1 { + suffix_end += 1; + } else { + break; + } + } + + let mut output = String::new(); + for line in &old_lines[prefix_start..first_changed_row] { + output.push_str(line); + output.push('\n'); + } + output.push_str("<|fim_middle|>\n"); + output.push_str(&merged_new_text); + output.push_str("<|fim_suffix|>\n"); + for line in &old_lines[old_end..suffix_end] { + output.push_str(line); + output.push('\n'); + } + + Ok(output) + } + + struct ParsedHunk { + old_context: String, + edits: Vec, + } + + struct ParsedEdit { + range: Range, + text: String, + } + + /// Parse a unified diff into content-based hunks. Each hunk contains an + /// `old_context` string (context lines + deleted lines, which together + /// form the text that should be found in the original) and a list of edits + /// expressed as byte ranges within that context. + fn parse_hunks(patch: &str) -> Vec { + let mut hunks = Vec::new(); + let mut current: Option = None; + + for line in patch.lines() { + if line.starts_with("@@") { + if let Some(hunk) = current.take() { + if !hunk.old_context.is_empty() || !hunk.edits.is_empty() { + hunks.push(hunk); + } + } + current = Some(ParsedHunk { + old_context: String::new(), + edits: Vec::new(), + }); + } else if line.starts_with("---") || line.starts_with("+++") { + continue; + } else if let Some(hunk) = &mut current { + if let Some(added) = line.strip_prefix('+') { + let pos = hunk.old_context.len(); + if let Some(last_edit) = hunk.edits.last_mut() { + if last_edit.range.end == pos { + writeln!(&mut last_edit.text, "{added}").ok(); + continue; + } + } + hunk.edits.push(ParsedEdit { + range: pos..pos, + text: format!("{added}\n"), + }); + } else if let Some(removed) = line.strip_prefix('-') { + let start = hunk.old_context.len(); + writeln!(&mut hunk.old_context, "{removed}").ok(); + let end = hunk.old_context.len(); + if let Some(last_edit) = hunk.edits.last_mut() { + if last_edit.range.end == start { + last_edit.range.end = end; + continue; + } + } + hunk.edits.push(ParsedEdit { + range: start..end, + text: String::new(), + }); + } else { + let ctx = line.strip_prefix(' ').unwrap_or(line); + writeln!(&mut hunk.old_context, "{ctx}").ok(); + } + } + } + + if let Some(hunk) = current { + if !hunk.old_context.is_empty() || !hunk.edits.is_empty() { + hunks.push(hunk); + } + } + + hunks + } + + #[cfg(test)] + mod tests { + use super::*; + use indoc::indoc; + + #[test] + fn test_apply_variable_edit() { + struct Case { + name: &'static str, + original: &'static str, + model_output: &'static str, + expected: &'static str, + } + + let cases = [ + Case { + name: "simple_single_line_replacement", + original: indoc! {" + zero + one + two + three + four + five + "}, + model_output: indoc! {" + two + <|fim_middle|> + THREE + <|fim_suffix|> + four + "}, + expected: indoc! {" + zero + one + two + THREE + four + five + "}, + }, + Case { + name: "multi_line_replacement", + original: indoc! {" + a + b + c + d + e + "}, + model_output: indoc! {" + a + <|fim_middle|> + B + C + D + <|fim_suffix|> + e + "}, + expected: indoc! {" + a + B + C + D + e + "}, + }, + Case { + name: "insertion_between_existing_lines", + original: indoc! {" + a + b + c + "}, + model_output: indoc! {" + a + <|fim_middle|> + X + <|fim_suffix|> + b + "}, + expected: indoc! {" + a + X + b + c + "}, + }, + Case { + name: "deletion", + original: indoc! {" + a + b + c + d + "}, + model_output: indoc! {" + a + <|fim_middle|> + <|fim_suffix|> + c + "}, + expected: indoc! {" + a + c + d + "}, + }, + Case { + name: "replacement_at_start_no_prefix_context", + original: indoc! {" + a + b + c + "}, + model_output: indoc! {" + <|fim_middle|> + X + <|fim_suffix|> + b + "}, + expected: indoc! {" + X + b + c + "}, + }, + Case { + name: "replacement_at_end_no_suffix_context", + original: indoc! {" + a + b + c + "}, + model_output: indoc! {" + b + <|fim_middle|> + Z + <|fim_suffix|> + "}, + expected: indoc! {" + a + b + Z + "}, + }, + Case { + name: "context_with_trailing_newline_is_preserved", + original: indoc! {" + a + b + c + "}, + model_output: indoc! {" + a + <|fim_middle|> + B + <|fim_suffix|> + c + "}, + expected: indoc! {" + a + B + c + "}, + }, + Case { + name: "cursor_marker_passes_through_untouched", + original: indoc! {" + a + b + c + "}, + model_output: indoc! {" + a + <|fim_middle|> + B<|user_cursor|>B + <|fim_suffix|> + c + "}, + expected: indoc! {" + a + B<|user_cursor|>B + c + "}, + }, + Case { + name: "multiple_prefix_context_lines", + original: indoc! {" + a + b + c + d + e + "}, + model_output: indoc! {" + b + c + <|fim_middle|> + D + <|fim_suffix|> + e + "}, + expected: indoc! {" + a + b + c + D + e + "}, + }, + ]; + + for case in cases { + let (edit_range, replacement) = + apply_variable_edit(case.original, case.model_output).unwrap(); + let mut edited = case.original.to_string(); + edited.replace_range(edit_range, &replacement); + assert_eq!(edited, case.expected, "{}", case.name); + } + } + + #[test] + fn test_patch_to_variable_edit() { + struct Case { + name: &'static str, + old: &'static str, + patch: &'static str, + cursor_offset: Option, + expected_variable_edit: &'static str, + expected_after_apply: &'static str, + } + + let cases = [ + Case { + name: "simple_replacement", + old: indoc! {" + zero + one + two + three + four + five + "}, + patch: indoc! {" + @@ -3,3 +3,3 @@ + two + -three + +THREE + four + "}, + cursor_offset: None, + expected_variable_edit: indoc! {" + one + two + <|fim_middle|> + THREE + <|fim_suffix|> + four + five + "}, + expected_after_apply: indoc! {" + zero + one + two + THREE + four + five + "}, + }, + Case { + name: "insertion", + old: indoc! {" + a + b + c + d + e + "}, + patch: indoc! {" + @@ -2,0 +3,1 @@ + b + +X + c + "}, + cursor_offset: None, + expected_variable_edit: indoc! {" + a + b + <|fim_middle|> + X + <|fim_suffix|> + c + d + "}, + expected_after_apply: indoc! {" + a + b + X + c + d + e + "}, + }, + Case { + name: "deletion", + old: indoc! {" + a + b + c + d + e + "}, + patch: indoc! {" + @@ -2,3 +2,2 @@ + b + -c + d + "}, + cursor_offset: None, + expected_variable_edit: indoc! {" + a + b + <|fim_middle|> + <|fim_suffix|> + d + e + "}, + expected_after_apply: indoc! {" + a + b + d + e + "}, + }, + Case { + name: "edit_near_start", + old: indoc! {" + first + second + third + fourth + "}, + patch: indoc! {" + @@ -1,1 +1,1 @@ + -first + +FIRST + "}, + cursor_offset: None, + expected_variable_edit: indoc! {" + <|fim_middle|> + FIRST + <|fim_suffix|> + second + third + "}, + expected_after_apply: indoc! {" + FIRST + second + third + fourth + "}, + }, + Case { + name: "edit_near_end", + old: indoc! {" + first + second + third + fourth + "}, + patch: indoc! {" + @@ -4,1 +4,1 @@ + -fourth + +FOURTH + "}, + cursor_offset: None, + expected_variable_edit: indoc! {" + second + third + <|fim_middle|> + FOURTH + <|fim_suffix|> + "}, + expected_after_apply: indoc! {" + first + second + third + FOURTH + "}, + }, + Case { + name: "cursor_at_start_of_replacement", + old: indoc! {" + zero + one + two + three + four + five + "}, + patch: indoc! {" + @@ -3,3 +3,3 @@ + two + -three + +THREE + four + "}, + cursor_offset: Some(4), + expected_variable_edit: indoc! {" + one + two + <|fim_middle|> + <|user_cursor|>THREE + <|fim_suffix|> + four + five + "}, + expected_after_apply: indoc! {" + zero + one + two + <|user_cursor|>THREE + four + five + "}, + }, + Case { + name: "cursor_in_middle_of_replacement", + old: indoc! {" + zero + one + two + three + four + five + "}, + patch: indoc! {" + @@ -3,3 +3,3 @@ + two + -three + +THREE + four + "}, + cursor_offset: Some(6), + expected_variable_edit: indoc! {" + one + two + <|fim_middle|> + TH<|user_cursor|>REE + <|fim_suffix|> + four + five + "}, + expected_after_apply: indoc! {" + zero + one + two + TH<|user_cursor|>REE + four + five + "}, + }, + Case { + name: "expands_context_when_two_lines_not_unique_before_and_after", + old: indoc! {" + one + a + b + c + d + two + a + b + c + d + three + a + b + c + d + four + "}, + patch: indoc! {" + @@ -4,5 +4,5 @@ + two + a + b + -c + +C + d + three + "}, + cursor_offset: None, + expected_variable_edit: indoc! {" + two + a + b + <|fim_middle|> + C + <|fim_suffix|> + d + three + "}, + expected_after_apply: indoc! {" + one + a + b + c + d + two + a + b + C + d + three + a + b + c + d + four + "}, + }, + Case { + name: "expands_context_when_two_lines_not_unique_before_and_after", + old: indoc! {" + { + { + one(); + } + } + { + { + two(); + } + } + { + { + three(); + } + } + { + { + four(); + } + } + "}, + patch: indoc! {" + @@ -4,5 +4,5 @@ + { + - two(); + + TWO(); + } + "}, + cursor_offset: None, + expected_variable_edit: indoc! {" + one(); + } + } + { + { + <|fim_middle|> + TWO(); + <|fim_suffix|> + } + } + { + { + three(); + "}, + expected_after_apply: indoc! {" + { + { + one(); + } + } + { + { + TWO(); + } + } + { + { + three(); + } + } + { + { + four(); + } + } + "}, + }, + ]; + + for case in cases { + let output = + patch_to_variable_edit_output(case.old, case.patch, case.cursor_offset) + .unwrap_or_else(|error| { + panic!("failed converting patch for {}: {error}", case.name) + }); + assert_eq!( + output, case.expected_variable_edit, + "patch->variable_edit mismatch for {}", + case.name + ); + + let (edit_range, replacement) = apply_variable_edit(case.old, &output) + .unwrap_or_else(|error| { + panic!("failed applying variable_edit for {}: {error}", case.name) + }); + let mut edited_by_variable_edit = case.old.to_string(); + edited_by_variable_edit.replace_range(edit_range, &replacement); + assert_eq!( + edited_by_variable_edit, case.expected_after_apply, + "variable_edit apply mismatch for {}", + case.name + ); + + let (expected_edit_range, expected_replacement) = + apply_variable_edit(case.old, case.expected_variable_edit).unwrap_or_else( + |error| { + panic!( + "failed applying expected variable_edit for {}: {error}", + case.name + ) + }, + ); + let mut edited_by_expected_variable_edit = case.old.to_string(); + edited_by_expected_variable_edit + .replace_range(expected_edit_range, &expected_replacement); + assert_eq!( + edited_by_expected_variable_edit, case.expected_after_apply, + "expected variable_edit apply mismatch for {}", + case.name + ); + } + } + + #[test] + fn test_write_cursor_excerpt_section() { + let path = Path::new("test.rs"); + let context = "fn main() {\n hello();\n}\n"; + let cursor_offset = 17; + let mut prompt = String::new(); + write_cursor_excerpt_section(&mut prompt, path, context, cursor_offset); + assert_eq!( + prompt, + "<|file_sep|>test.rs\nfn main() {\n h<|user_cursor|>ello();\n}\n<|fim_prefix|>\n" + ); + } + } +} + /// The zeta1 prompt format pub mod zeta1 { use super::*; @@ -3356,21 +4357,6 @@ mod tests { ); } - #[test] - fn test_seed_coder_clean_output() { - let output_with_marker = "new code\n>>>>>>> UPDATED\n"; - let output_without_marker = "new code\n"; - - assert_eq!( - clean_zeta2_model_output(output_with_marker, ZetaFormat::V0211SeedCoder), - "new code\n" - ); - assert_eq!( - clean_zeta2_model_output(output_without_marker, ZetaFormat::V0211SeedCoder), - "new code\n" - ); - } - #[test] fn test_format_zeta1_from_input_basic() { let excerpt = "fn before() {}\nfn foo() {\n let x = 1;\n}\nfn after() {}\n"; From ca5027c4d6c43cdf21f2af9731369cd8cb64570e Mon Sep 17 00:00:00 2001 From: John Tur Date: Thu, 5 Mar 2026 21:06:27 -0500 Subject: [PATCH 004/219] Fix OpenGL initialization on Intel HD 4000 (#50891) I think we might just get it working this time Release Notes: - N/A --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4dbd905beb51bda4f5ff061179d144d9cd255e9a..ec376710159b3117bb883ddaa0ba2a4a539293bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10731,7 +10731,7 @@ dependencies = [ [[package]] name = "naga" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=9459e95113c5bd116b2cc2c87e8424b28059e17c#9459e95113c5bd116b2cc2c87e8424b28059e17c" +source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" dependencies = [ "arrayvec", "bit-set", @@ -19924,7 +19924,7 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=9459e95113c5bd116b2cc2c87e8424b28059e17c#9459e95113c5bd116b2cc2c87e8424b28059e17c" +source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" dependencies = [ "arrayvec", "bitflags 2.10.0", @@ -19953,7 +19953,7 @@ dependencies = [ [[package]] name = "wgpu-core" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=9459e95113c5bd116b2cc2c87e8424b28059e17c#9459e95113c5bd116b2cc2c87e8424b28059e17c" +source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" dependencies = [ "arrayvec", "bit-set", @@ -19984,7 +19984,7 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=9459e95113c5bd116b2cc2c87e8424b28059e17c#9459e95113c5bd116b2cc2c87e8424b28059e17c" +source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" dependencies = [ "wgpu-hal", ] @@ -19992,7 +19992,7 @@ dependencies = [ [[package]] name = "wgpu-core-deps-emscripten" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=9459e95113c5bd116b2cc2c87e8424b28059e17c#9459e95113c5bd116b2cc2c87e8424b28059e17c" +source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" dependencies = [ "wgpu-hal", ] @@ -20000,7 +20000,7 @@ dependencies = [ [[package]] name = "wgpu-core-deps-windows-linux-android" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=9459e95113c5bd116b2cc2c87e8424b28059e17c#9459e95113c5bd116b2cc2c87e8424b28059e17c" +source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" dependencies = [ "wgpu-hal", ] @@ -20008,7 +20008,7 @@ dependencies = [ [[package]] name = "wgpu-hal" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=9459e95113c5bd116b2cc2c87e8424b28059e17c#9459e95113c5bd116b2cc2c87e8424b28059e17c" +source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" dependencies = [ "android_system_properties", "arrayvec", @@ -20055,7 +20055,7 @@ dependencies = [ [[package]] name = "wgpu-types" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=9459e95113c5bd116b2cc2c87e8424b28059e17c#9459e95113c5bd116b2cc2c87e8424b28059e17c" +source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" dependencies = [ "bitflags 2.10.0", "bytemuck", diff --git a/Cargo.toml b/Cargo.toml index b8e57bda7e46ea45451fedd6759268235c7d71ab..497bdd203d958f3ad7d33cd98f5ff1e9b2e34655 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -779,7 +779,7 @@ wax = "0.7" which = "6.0.0" wasm-bindgen = "0.2.113" web-time = "1.1.0" -wgpu = { git = "https://github.com/zed-industries/wgpu", rev = "9459e95113c5bd116b2cc2c87e8424b28059e17c" } +wgpu = { git = "https://github.com/zed-industries/wgpu", rev = "0343151f535c8386df3c1db014cd42f44470e4c0" } windows-core = "0.61" yawc = "0.2.5" zeroize = "1.8" From ab824a0b9d328bc83490b3f0ea4e2d3a87af112c Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 5 Mar 2026 18:59:58 -0800 Subject: [PATCH 005/219] Use excerpt coordinates consistently in parse_zeta_model_output (#50894) Fixes a bug introduced in https://github.com/zed-industries/zed/pull/50850, where we used incorrect coordinates for the editable range. Release Notes: - N/A --- crates/zeta_prompt/src/zeta_prompt.rs | 136 ++++++++++++++++++++++---- 1 file changed, 118 insertions(+), 18 deletions(-) diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 52cda41ac07c52711bd381b8bebe9d8a172d0d09..9469c056468ed91fe9c95aa5e5cd2edf3590b8bd 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -336,7 +336,7 @@ pub fn format_prompt_with_budget_for_format( let path = &*input.cursor_path; let related_files = if let Some(cursor_excerpt_start_row) = input.excerpt_start_row { - let relative_row_range = offset_range_to_row_range(context, context_range); + let relative_row_range = offset_range_to_row_range(&input.cursor_excerpt, context_range); let row_range = relative_row_range.start + cursor_excerpt_start_row ..relative_row_range.end + cursor_excerpt_start_row; &filter_redundant_excerpts( @@ -481,31 +481,35 @@ pub fn parse_zeta2_model_output( None => output, }; - let (context, editable_range, _, _) = resolve_cursor_region(prompt_inputs, format); - let old_editable_region = &context[editable_range.clone()]; + let (context, editable_range_in_context, context_range, _) = + resolve_cursor_region(prompt_inputs, format); + let context_start = context_range.start; + let old_editable_region = &context[editable_range_in_context.clone()]; - match format { - ZetaFormat::v0226Hashline => Ok(( - editable_range, + let (range_in_context, output) = match format { + ZetaFormat::v0226Hashline => ( + editable_range_in_context, if hashline::output_has_edit_commands(output) { hashline::apply_edit_commands(old_editable_region, output) } else { output.to_string() }, - )), - ZetaFormat::V0304VariableEdit => { - v0304_variable_edit::apply_variable_edit(old_editable_region, output) - } - ZetaFormat::V0304SeedNoEdits => Ok(( - editable_range, + ), + ZetaFormat::V0304VariableEdit => v0304_variable_edit::apply_variable_edit(context, output)?, + ZetaFormat::V0304SeedNoEdits => ( + editable_range_in_context, if output.starts_with(seed_coder::NO_EDITS) { old_editable_region.to_string() } else { output.to_string() }, - )), - _ => Ok((editable_range, output.to_string())), - } + ), + _ => (editable_range_in_context, output.to_string()), + }; + + let range_in_excerpt = + range_in_context.start + context_start..range_in_context.end + context_start; + Ok((range_in_excerpt, output)) } pub fn excerpt_range_for_format( @@ -525,13 +529,11 @@ pub fn resolve_cursor_region( let adjusted_editable = (editable_range.start - context_start)..(editable_range.end - context_start); let adjusted_cursor = input.cursor_offset_in_excerpt - context_start; - let adjusted_context = - (context_range.start - context_start)..(context_range.end - context_start); ( context_text, adjusted_editable, - adjusted_context, + context_range, adjusted_cursor, ) } @@ -3800,6 +3802,35 @@ mod tests { } } + fn make_input_with_context_range( + excerpt: &str, + editable_range: Range, + context_range: Range, + cursor_offset: usize, + ) -> ZetaPromptInput { + ZetaPromptInput { + cursor_path: Path::new("test.rs").into(), + cursor_excerpt: excerpt.into(), + cursor_offset_in_excerpt: cursor_offset, + excerpt_start_row: None, + events: vec![], + related_files: vec![], + excerpt_ranges: ExcerptRanges { + editable_150: editable_range.clone(), + editable_180: editable_range.clone(), + editable_350: editable_range, + editable_150_context_350: context_range.clone(), + editable_180_context_350: context_range.clone(), + editable_350_context_150: context_range, + ..Default::default() + }, + experiment: None, + in_open_source_repo: false, + can_collect_data: false, + repo_url: None, + } + } + fn make_event(path: &str, diff: &str) -> Event { Event::BufferChange { path: Path::new(path).into(), @@ -4580,4 +4611,73 @@ mod tests { let cleaned = zeta1::clean_zeta1_model_output(output).unwrap(); assert_eq!(cleaned, ""); } + + fn apply_edit(excerpt: &str, range: &Range, new_text: &str) -> String { + let mut result = excerpt.to_string(); + result.replace_range(range.clone(), new_text); + result + } + + #[test] + fn test_parse_zeta2_model_output() { + let excerpt = "before ctx\nctx start\neditable old\nctx end\nafter ctx\n"; + let context_start = excerpt.find("ctx start").unwrap(); + let context_end = excerpt.find("after ctx").unwrap(); + let editable_start = excerpt.find("editable old").unwrap(); + let editable_end = editable_start + "editable old\n".len(); + let input = make_input_with_context_range( + excerpt, + editable_start..editable_end, + context_start..context_end, + editable_start, + ); + + let (range, text) = parse_zeta2_model_output( + "editable new\n>>>>>>> UPDATED\n", + ZetaFormat::V0131GitMergeMarkersPrefix, + &input, + ) + .unwrap(); + + assert_eq!( + apply_edit(excerpt, &range, &text), + "before ctx\nctx start\neditable new\nctx end\nafter ctx\n" + ); + } + + #[test] + fn test_parse_zeta2_model_output_identity() { + let excerpt = "aaa\nbbb\nccc\nddd\neee\n"; + let editable_start = excerpt.find("bbb").unwrap(); + let editable_end = excerpt.find("ddd").unwrap(); + let input = make_input_with_context_range( + excerpt, + editable_start..editable_end, + 0..excerpt.len(), + editable_start, + ); + + let format = ZetaFormat::V0131GitMergeMarkersPrefix; + let (range, text) = + parse_zeta2_model_output("bbb\nccc\n>>>>>>> UPDATED\n", format, &input).unwrap(); + + assert_eq!(apply_edit(excerpt, &range, &text), excerpt); + } + + #[test] + fn test_parse_zeta2_model_output_strips_end_marker() { + let excerpt = "hello\nworld\n"; + let input = make_input_with_context_range(excerpt, 0..excerpt.len(), 0..excerpt.len(), 0); + + let format = ZetaFormat::V0131GitMergeMarkersPrefix; + let (range1, text1) = + parse_zeta2_model_output("new content\n>>>>>>> UPDATED\n", format, &input).unwrap(); + let (range2, text2) = parse_zeta2_model_output("new content\n", format, &input).unwrap(); + + assert_eq!( + apply_edit(excerpt, &range1, &text1), + apply_edit(excerpt, &range2, &text2) + ); + assert_eq!(apply_edit(excerpt, &range1, &text1), "new content\n"); + } } From 3b3ffc022e1886bc323f6ba83409980c875a87ff Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 5 Mar 2026 23:40:03 -0500 Subject: [PATCH 006/219] Add GPT-5.4 and GPT-5.4-pro BYOK models (#50858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GPT-5.4 and GPT-5.4-pro as Bring Your Own Key model options for the OpenAI provider. **GPT-5.4** (`gpt-5.4`): - 1,050,000 token context window, 128K max output - Supports chat completions, images, parallel tool calls - Default reasoning effort: none **GPT-5.4-pro** (`gpt-5.4-pro`): - 1,050,000 token context window, 128K max output - Responses API only (no chat completions) - Default reasoning effort: medium (supports medium/high/xhigh) Also fixes context window sizes for GPT-5 mini and GPT-5 nano (272K → 400K) to match current OpenAI docs. Closes AI-78 Release Notes: - Added GPT-5.4 and GPT-5.4-pro as available models when using your own OpenAI API key. --- .../language_models/src/provider/open_ai.rs | 10 ++++--- crates/open_ai/src/open_ai.rs | 27 ++++++++++++++++--- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index f807a0dcb852e0ed3eaf7aec0860faed5834b2f4..9f4c6b4c5409406e6606250a847037a8543feb20 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -310,6 +310,8 @@ impl LanguageModel for OpenAiLanguageModel { | Model::FivePointTwo | Model::FivePointTwoCodex | Model::FivePointThreeCodex + | Model::FivePointFour + | Model::FivePointFourPro | Model::O1 | Model::O3 => true, Model::ThreePointFiveTurbo @@ -1217,13 +1219,13 @@ pub fn count_open_ai_tokens( | Model::FiveCodex | Model::FiveMini | Model::FiveNano => tiktoken_rs::num_tokens_from_messages(model.id(), &messages), - // GPT-5.1, 5.2, 5.2-codex, and 5.3-codex don't have dedicated tiktoken support; use gpt-5 tokenizer + // GPT-5.1, 5.2, 5.2-codex, 5.3-codex, 5.4, and 5.4-pro don't have dedicated tiktoken support; use gpt-5 tokenizer Model::FivePointOne | Model::FivePointTwo | Model::FivePointTwoCodex - | Model::FivePointThreeCodex => { - tiktoken_rs::num_tokens_from_messages("gpt-5", &messages) - } + | Model::FivePointThreeCodex + | Model::FivePointFour + | Model::FivePointFourPro => tiktoken_rs::num_tokens_from_messages("gpt-5", &messages), } .map(|tokens| tokens as u64) }) diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index e6145e409058a3fe453c4557b2a32cccf6baf16c..25946591e320df4e2d58e8dd0341d7f27451cc89 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -90,6 +90,10 @@ pub enum Model { FivePointTwoCodex, #[serde(rename = "gpt-5.3-codex")] FivePointThreeCodex, + #[serde(rename = "gpt-5.4")] + FivePointFour, + #[serde(rename = "gpt-5.4-pro")] + FivePointFourPro, #[serde(rename = "custom")] Custom { name: String, @@ -131,6 +135,8 @@ impl Model { "gpt-5.2" => Ok(Self::FivePointTwo), "gpt-5.2-codex" => Ok(Self::FivePointTwoCodex), "gpt-5.3-codex" => Ok(Self::FivePointThreeCodex), + "gpt-5.4" => Ok(Self::FivePointFour), + "gpt-5.4-pro" => Ok(Self::FivePointFourPro), invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"), } } @@ -153,6 +159,8 @@ impl Model { Self::FivePointTwo => "gpt-5.2", Self::FivePointTwoCodex => "gpt-5.2-codex", Self::FivePointThreeCodex => "gpt-5.3-codex", + Self::FivePointFour => "gpt-5.4", + Self::FivePointFourPro => "gpt-5.4-pro", Self::Custom { name, .. } => name, } } @@ -175,6 +183,8 @@ impl Model { Self::FivePointTwo => "gpt-5.2", Self::FivePointTwoCodex => "gpt-5.2-codex", Self::FivePointThreeCodex => "gpt-5.3-codex", + Self::FivePointFour => "gpt-5.4", + Self::FivePointFourPro => "gpt-5.4-pro", Self::Custom { display_name, .. } => display_name.as_deref().unwrap_or(&self.id()), } } @@ -191,12 +201,14 @@ impl Model { Self::O3 => 200_000, Self::Five => 272_000, Self::FiveCodex => 272_000, - Self::FiveMini => 272_000, - Self::FiveNano => 272_000, + Self::FiveMini => 400_000, + Self::FiveNano => 400_000, Self::FivePointOne => 400_000, Self::FivePointTwo => 400_000, Self::FivePointTwoCodex => 400_000, Self::FivePointThreeCodex => 400_000, + Self::FivePointFour => 1_050_000, + Self::FivePointFourPro => 1_050_000, Self::Custom { max_tokens, .. } => *max_tokens, } } @@ -222,6 +234,8 @@ impl Model { Self::FivePointTwo => Some(128_000), Self::FivePointTwoCodex => Some(128_000), Self::FivePointThreeCodex => Some(128_000), + Self::FivePointFour => Some(128_000), + Self::FivePointFourPro => Some(128_000), } } @@ -230,7 +244,7 @@ impl Model { Self::Custom { reasoning_effort, .. } => reasoning_effort.to_owned(), - Self::FivePointThreeCodex => Some(ReasoningEffort::Medium), + Self::FivePointThreeCodex | Self::FivePointFourPro => Some(ReasoningEffort::Medium), _ => None, } } @@ -241,7 +255,10 @@ impl Model { supports_chat_completions, .. } => *supports_chat_completions, - Self::FiveCodex | Self::FivePointTwoCodex | Self::FivePointThreeCodex => false, + Self::FiveCodex + | Self::FivePointTwoCodex + | Self::FivePointThreeCodex + | Self::FivePointFourPro => false, _ => true, } } @@ -263,6 +280,8 @@ impl Model { | Self::FivePointTwo | Self::FivePointTwoCodex | Self::FivePointThreeCodex + | Self::FivePointFour + | Self::FivePointFourPro | Self::FiveNano => true, Self::O1 | Self::O3 | Self::O3Mini | Model::Custom { .. } => false, } From 9acb32fd1f093cb52d44ae8ae4337bbb96f69939 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 5 Mar 2026 22:59:48 -0700 Subject: [PATCH 007/219] Linux: Handle device lost with wgpu (#50898) Release Notes: - Linux: Handle GPU device loss gracefully --- crates/gpui_linux/src/linux/wayland/client.rs | 8 +- crates/gpui_linux/src/linux/wayland/window.rs | 42 +- crates/gpui_linux/src/linux/x11/client.rs | 10 +- crates/gpui_linux/src/linux/x11/window.rs | 63 ++- crates/gpui_wgpu/src/gpui_wgpu.rs | 3 +- crates/gpui_wgpu/src/wgpu_atlas.rs | 11 + crates/gpui_wgpu/src/wgpu_context.rs | 29 +- crates/gpui_wgpu/src/wgpu_renderer.rs | 463 +++++++++++++----- crates/zed/src/main.rs | 2 +- 9 files changed, 465 insertions(+), 166 deletions(-) diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index b49e269a72459d52c13c21b8d1a474ab310dbffd..02da0190b3b198f8ae04761f1d159872627309d5 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -95,7 +95,7 @@ use gpui::{ ScrollDelta, ScrollWheelEvent, SharedString, Size, TaskTiming, TouchPhase, WindowParams, point, profiler, px, size, }; -use gpui_wgpu::{CompositorGpuHint, WgpuContext}; +use gpui_wgpu::{CompositorGpuHint, GpuContext}; use wayland_protocols::wp::linux_dmabuf::zv1::client::{ zwp_linux_dmabuf_feedback_v1, zwp_linux_dmabuf_v1, }; @@ -204,7 +204,7 @@ pub struct Output { pub(crate) struct WaylandClientState { serial_tracker: SerialTracker, globals: Globals, - pub gpu_context: Option, + pub gpu_context: GpuContext, pub compositor_gpu: Option, wl_seat: wl_seat::WlSeat, // TODO: Multi seat support wl_pointer: Option, @@ -520,7 +520,7 @@ impl WaylandClient { .unwrap(); let compositor_gpu = detect_compositor_gpu(); - let gpu_context = None; + let gpu_context = Rc::new(RefCell::new(None)); let seat = seat.unwrap(); let globals = Globals::new( @@ -725,7 +725,7 @@ impl LinuxClient for WaylandClient { let (window, surface_id) = WaylandWindow::new( handle, state.globals.clone(), - &mut state.gpu_context, + state.gpu_context.clone(), compositor_gpu, WaylandClientStatePtr(Rc::downgrade(&self.0)), params, diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 4c0dbae530ee254f5232eaead187b93d10b0b8e3..201ce7d2dc07f33faabf30e32094d9f3a135711c 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -34,7 +34,7 @@ use gpui::{ WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, px, size, }; -use gpui_wgpu::{CompositorGpuHint, WgpuContext, WgpuRenderer, WgpuSurfaceConfig}; +use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; #[derive(Default)] pub(crate) struct Callbacks { @@ -317,7 +317,7 @@ impl WaylandWindowState { viewport: Option, client: WaylandClientStatePtr, globals: Globals, - gpu_context: &mut Option, + gpu_context: gpui_wgpu::GpuContext, compositor_gpu: Option, options: WindowParams, parent: Option, @@ -488,7 +488,7 @@ impl WaylandWindow { pub fn new( handle: AnyWindowHandle, globals: Globals, - gpu_context: &mut Option, + gpu_context: gpui_wgpu::GpuContext, compositor_gpu: Option, client: WaylandClientStatePtr, params: WindowParams, @@ -1251,6 +1251,7 @@ impl PlatformWindow for WaylandWindow { let state = client.borrow(); state .gpu_context + .borrow() .as_ref() .is_some_and(|ctx| ctx.supports_dual_source_blending()) } @@ -1328,6 +1329,41 @@ impl PlatformWindow for WaylandWindow { fn draw(&self, scene: &Scene) { let mut state = self.borrow_mut(); + + if state.renderer.device_lost() { + let raw_window = RawWindow { + window: state.surface.id().as_ptr().cast::(), + display: state + .surface + .backend() + .upgrade() + .unwrap() + .display_ptr() + .cast::(), + }; + let display_handle = rwh::HasDisplayHandle::display_handle(&raw_window) + .unwrap() + .as_raw(); + let window_handle = rwh::HasWindowHandle::window_handle(&raw_window) + .unwrap() + .as_raw(); + + state + .renderer + .recover(display_handle, window_handle) + .unwrap_or_else(|err| { + panic!( + "GPU device lost and recovery failed. \ + This may happen after system suspend/resume. \ + Please restart the application.\n\nError: {err}" + ) + }); + + // The current scene references atlas textures that were cleared during recovery. + // Skip this frame and let the next frame rebuild the scene with fresh textures. + return; + } + state.renderer.draw(scene); } diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 3a970d9f72e1dc82215fc0d11297d222835df431..1f8db390029d67d8cdc17da7800a0f8e1d5e1af9 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -64,7 +64,7 @@ use gpui::{ PlatformKeyboardLayout, PlatformWindow, Point, RequestFrameOptions, ScrollDelta, Size, TouchPhase, WindowParams, point, px, }; -use gpui_wgpu::{CompositorGpuHint, WgpuContext}; +use gpui_wgpu::{CompositorGpuHint, GpuContext}; /// Value for DeviceId parameters which selects all devices. pub(crate) const XINPUT_ALL_DEVICES: xinput::DeviceId = 0; @@ -177,7 +177,7 @@ pub struct X11ClientState { pub(crate) last_location: Point, pub(crate) current_count: usize, - pub(crate) gpu_context: Option, + pub(crate) gpu_context: GpuContext, pub(crate) compositor_gpu: Option, pub(crate) scale_factor: f32, @@ -295,7 +295,7 @@ impl X11ClientStatePtr { } #[derive(Clone)] -pub(crate) struct X11Client(Rc>); +pub(crate) struct X11Client(pub(crate) Rc>); impl X11Client { pub(crate) fn new() -> anyhow::Result { @@ -493,7 +493,7 @@ impl X11Client { last_mouse_button: None, last_location: Point::new(px(0.0), px(0.0)), current_count: 0, - gpu_context: None, + gpu_context: Rc::new(RefCell::new(None)), compositor_gpu, scale_factor, @@ -1524,7 +1524,7 @@ impl LinuxClient for X11Client { handle, X11ClientStatePtr(Rc::downgrade(&self.0)), state.common.foreground_executor.clone(), - &mut state.gpu_context, + state.gpu_context.clone(), compositor_gpu, params, &xcb_connection, diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index a7cdc67ecd908becd22f799767f482754527fa51..57600103ce9ec1a67abb4abc373b0ed4c26cb077 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -9,7 +9,7 @@ use gpui::{ Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowDecorations, WindowKind, WindowParams, px, }; -use gpui_wgpu::{CompositorGpuHint, WgpuContext, WgpuRenderer, WgpuSurfaceConfig}; +use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; use collections::FxHashSet; use raw_window_handle as rwh; @@ -259,6 +259,8 @@ pub struct X11WindowState { executor: ForegroundExecutor, atoms: XcbAtoms, x_root_window: xproto::Window, + x_screen_index: usize, + visual_id: u32, pub(crate) counter_id: sync::Counter, pub(crate) last_sync_counter: Option, bounds: Bounds, @@ -407,7 +409,7 @@ impl X11WindowState { handle: AnyWindowHandle, client: X11ClientStatePtr, executor: ForegroundExecutor, - gpu_context: &mut Option, + gpu_context: gpui_wgpu::GpuContext, compositor_gpu: Option, params: WindowParams, xcb: &Rc, @@ -727,6 +729,8 @@ impl X11WindowState { executor, display, x_root_window: visual_set.root, + x_screen_index, + visual_id: visual.id, bounds: bounds.to_pixels(scale_factor), scale_factor, renderer, @@ -819,7 +823,7 @@ impl X11Window { handle: AnyWindowHandle, client: X11ClientStatePtr, executor: ForegroundExecutor, - gpu_context: &mut Option, + gpu_context: gpui_wgpu::GpuContext, compositor_gpu: Option, params: WindowParams, xcb: &Rc, @@ -1173,13 +1177,11 @@ impl X11WindowStatePtr { } pub fn set_bounds(&self, bounds: Bounds) -> anyhow::Result<()> { - let mut resize_args = None; - let is_resize; - { + let (is_resize, content_size, scale_factor) = { let mut state = self.state.borrow_mut(); let bounds = bounds.map(|f| px(f as f32 / state.scale_factor)); - is_resize = bounds.size.width != state.bounds.size.width + let is_resize = bounds.size.width != state.bounds.size.width || bounds.size.height != state.bounds.size.height; // If it's a resize event (only width/height changed), we ignore `bounds.origin` @@ -1191,22 +1193,19 @@ impl X11WindowStatePtr { } let gpu_size = query_render_extent(&self.xcb, self.x_window)?; - if true { - state.renderer.update_drawable_size(gpu_size); - resize_args = Some((state.content_size(), state.scale_factor)); - } + state.renderer.update_drawable_size(gpu_size); + let result = (is_resize, state.content_size(), state.scale_factor); if let Some(value) = state.last_sync_counter.take() { check_reply( || "X11 sync SetCounter failed.", sync::set_counter(&self.xcb, state.counter_id, value), )?; } - } + result + }; let mut callbacks = self.callbacks.borrow_mut(); - if let Some((content_size, scale_factor)) = resize_args - && let Some(ref mut fun) = callbacks.resize - { + if let Some(ref mut fun) = callbacks.resize { fun(content_size, scale_factor) } @@ -1499,6 +1498,7 @@ impl PlatformWindow for X11Window { let state = ref_cell.borrow(); state .gpu_context + .borrow() .as_ref() .is_some_and(|ctx| ctx.supports_dual_source_blending()) }) @@ -1593,6 +1593,39 @@ impl PlatformWindow for X11Window { fn draw(&self, scene: &Scene) { let mut inner = self.0.state.borrow_mut(); + + if inner.renderer.device_lost() { + let raw_window = RawWindow { + connection: as_raw_xcb_connection::AsRawXcbConnection::as_raw_xcb_connection( + &*self.0.xcb, + ) as *mut _, + screen_id: inner.x_screen_index, + window_id: self.0.x_window, + visual_id: inner.visual_id, + }; + let display_handle = rwh::HasDisplayHandle::display_handle(&raw_window) + .unwrap() + .as_raw(); + let window_handle = rwh::HasWindowHandle::window_handle(&raw_window) + .unwrap() + .as_raw(); + + inner + .renderer + .recover(display_handle, window_handle) + .unwrap_or_else(|err| { + panic!( + "GPU device lost and recovery failed. \ + This may happen after system suspend/resume. \ + Please restart the application.\n\nError: {err}" + ) + }); + + // The current scene references atlas textures that were cleared during recovery. + // Skip this frame and let the next frame rebuild the scene with fresh textures. + return; + } + inner.renderer.draw(scene); } diff --git a/crates/gpui_wgpu/src/gpui_wgpu.rs b/crates/gpui_wgpu/src/gpui_wgpu.rs index a306a9d4cac2251a46cd1115462bdcbe4b368759..452c3c03f51282c34368527dd503b90b92193586 100644 --- a/crates/gpui_wgpu/src/gpui_wgpu.rs +++ b/crates/gpui_wgpu/src/gpui_wgpu.rs @@ -4,6 +4,7 @@ mod wgpu_context; mod wgpu_renderer; pub use cosmic_text_system::*; +pub use wgpu; pub use wgpu_atlas::*; pub use wgpu_context::*; -pub use wgpu_renderer::*; +pub use wgpu_renderer::{GpuContext, WgpuRenderer, WgpuSurfaceConfig}; diff --git a/crates/gpui_wgpu/src/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs index ffef3a65398c3f03639a8551506463f91a862c33..3eba5c533f80d727425cc87ae89b754afa8722b1 100644 --- a/crates/gpui_wgpu/src/wgpu_atlas.rs +++ b/crates/gpui_wgpu/src/wgpu_atlas.rs @@ -65,6 +65,17 @@ impl WgpuAtlas { view: texture.view.clone(), } } + + /// Handles device lost by clearing all textures and cached tiles. + /// The atlas will lazily recreate textures as needed on subsequent frames. + pub fn handle_device_lost(&self, device: Arc, queue: Arc) { + let mut lock = self.0.lock(); + lock.device = device; + lock.queue = queue; + lock.storage = WgpuAtlasStorage::default(); + lock.tiles_by_key.clear(); + lock.pending_uploads.clear(); + } } impl PlatformAtlas for WgpuAtlas { diff --git a/crates/gpui_wgpu/src/wgpu_context.rs b/crates/gpui_wgpu/src/wgpu_context.rs index b7883a6910261da8dc3f1df6414c5e38e1c46cd2..6df2e6fa8aa9d7f529b500e4691c649c21c1fdb1 100644 --- a/crates/gpui_wgpu/src/wgpu_context.rs +++ b/crates/gpui_wgpu/src/wgpu_context.rs @@ -3,6 +3,7 @@ use anyhow::Context as _; #[cfg(not(target_family = "wasm"))] use gpui_util::ResultExt; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; pub struct WgpuContext { pub instance: wgpu::Instance, @@ -10,9 +11,10 @@ pub struct WgpuContext { pub device: Arc, pub queue: Arc, dual_source_blending: bool, + device_lost: Arc, } -#[cfg(not(target_family = "wasm"))] +#[derive(Clone, Copy)] pub struct CompositorGpuHint { pub vendor_id: u32, pub device_id: u32, @@ -47,6 +49,17 @@ impl WgpuContext { compositor_gpu.as_ref(), ))?; + let device_lost = Arc::new(AtomicBool::new(false)); + device.set_device_lost_callback({ + let device_lost = Arc::clone(&device_lost); + move |reason, message| { + log::error!("wgpu device lost: reason={reason:?}, message={message}"); + if reason != wgpu::DeviceLostReason::Destroyed { + device_lost.store(true, Ordering::Relaxed); + } + } + }); + log::info!( "Selected GPU adapter: {:?} ({:?})", adapter.get_info().name, @@ -59,6 +72,7 @@ impl WgpuContext { device: Arc::new(device), queue: Arc::new(queue), dual_source_blending, + device_lost, }) } @@ -86,6 +100,7 @@ impl WgpuContext { adapter.get_info().backend ); + let device_lost = Arc::new(AtomicBool::new(false)); let (device, queue, dual_source_blending) = Self::create_device(&adapter).await?; Ok(Self { @@ -94,6 +109,7 @@ impl WgpuContext { device: Arc::new(device), queue: Arc::new(queue), dual_source_blending, + device_lost, }) } @@ -320,6 +336,17 @@ impl WgpuContext { pub fn supports_dual_source_blending(&self) -> bool { self.dual_source_blending } + + /// Returns true if the GPU device was lost (e.g., due to driver crash, suspend/resume). + /// When this returns true, the context should be recreated. + pub fn device_lost(&self) -> bool { + self.device_lost.load(Ordering::Relaxed) + } + + /// Returns a clone of the device_lost flag for sharing with renderers. + pub(crate) fn device_lost_flag(&self) -> Arc { + Arc::clone(&self.device_lost) + } } #[cfg(not(target_family = "wasm"))] diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index 2fd83b7b065e7ce4fe0ba9ec017f39264a33bee3..da94747f3b4debcc65723c8a0ca031d59d9ae03c 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -1,6 +1,4 @@ -#[cfg(not(target_family = "wasm"))] -use crate::CompositorGpuHint; -use crate::{WgpuAtlas, WgpuContext}; +use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext}; use bytemuck::{Pod, Zeroable}; use gpui::{ AtlasTextureId, Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, Path, Point, @@ -10,7 +8,9 @@ use gpui::{ use log::warn; #[cfg(not(target_family = "wasm"))] use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; +use std::cell::RefCell; use std::num::NonZeroU64; +use std::rc::Rc; use std::sync::{Arc, Mutex}; #[repr(C)] @@ -93,28 +93,42 @@ struct WgpuBindGroupLayouts { surfaces: wgpu::BindGroupLayout, } -pub struct WgpuRenderer { +/// Shared GPU context reference, used to coordinate device recovery across multiple windows. +pub type GpuContext = Rc>>; + +/// GPU resources that must be dropped together during device recovery. +struct WgpuResources { device: Arc, queue: Arc, surface: wgpu::Surface<'static>, - surface_config: wgpu::SurfaceConfiguration, pipelines: WgpuPipelines, bind_group_layouts: WgpuBindGroupLayouts, - atlas: Arc, atlas_sampler: wgpu::Sampler, globals_buffer: wgpu::Buffer, - path_globals_offset: u64, - gamma_offset: u64, globals_bind_group: wgpu::BindGroup, path_globals_bind_group: wgpu::BindGroup, instance_buffer: wgpu::Buffer, - instance_buffer_capacity: u64, - max_buffer_size: u64, - storage_buffer_alignment: u64, path_intermediate_texture: Option, path_intermediate_view: Option, path_msaa_texture: Option, path_msaa_view: Option, +} + +pub struct WgpuRenderer { + /// Shared GPU context for device recovery coordination (unused on WASM). + #[allow(dead_code)] + context: Option, + /// Compositor GPU hint for adapter selection (unused on WASM). + #[allow(dead_code)] + compositor_gpu: Option, + resources: Option, + surface_config: wgpu::SurfaceConfiguration, + atlas: Arc, + path_globals_offset: u64, + gamma_offset: u64, + instance_buffer_capacity: u64, + max_buffer_size: u64, + storage_buffer_alignment: u64, rendering_params: RenderingParameters, dual_source_blending: bool, adapter_info: wgpu::AdapterInfo, @@ -123,17 +137,34 @@ pub struct WgpuRenderer { max_texture_size: u32, last_error: Arc>>, failed_frame_count: u32, + device_lost: std::sync::Arc, } impl WgpuRenderer { + fn resources(&self) -> &WgpuResources { + self.resources + .as_ref() + .expect("GPU resources not available") + } + + fn resources_mut(&mut self) -> &mut WgpuResources { + self.resources + .as_mut() + .expect("GPU resources not available") + } + /// Creates a new WgpuRenderer from raw window handles. /// + /// The `gpu_context` is a shared reference that coordinates GPU context across + /// multiple windows. The first window to create a renderer will initialize the + /// context; subsequent windows will share it. + /// /// # Safety /// The caller must ensure that the window handle remains valid for the lifetime /// of the returned renderer. #[cfg(not(target_family = "wasm"))] pub fn new( - gpu_context: &mut Option, + gpu_context: GpuContext, window: &W, config: WgpuSurfaceConfig, compositor_gpu: Option, @@ -154,6 +185,7 @@ impl WgpuRenderer { // The surface must be created with the same instance that will be used for // adapter selection, otherwise wgpu will panic. let instance = gpu_context + .borrow() .as_ref() .map(|ctx| ctx.instance.clone()) .unwrap_or_else(WgpuContext::instance); @@ -167,15 +199,28 @@ impl WgpuRenderer { .map_err(|e| anyhow::anyhow!("Failed to create surface: {e}"))? }; - let context = match gpu_context { + let mut ctx_ref = gpu_context.borrow_mut(); + let context = match ctx_ref.as_mut() { Some(context) => { context.check_compatible_with_surface(&surface)?; context } - None => gpu_context.insert(WgpuContext::new(instance, &surface, compositor_gpu)?), + None => ctx_ref.insert(WgpuContext::new(instance, &surface, compositor_gpu)?), }; - Self::new_with_surface(context, surface, config) + let atlas = Arc::new(WgpuAtlas::new( + Arc::clone(&context.device), + Arc::clone(&context.queue), + )); + + Self::new_internal( + Some(Rc::clone(&gpu_context)), + context, + surface, + config, + compositor_gpu, + atlas, + ) } #[cfg(target_family = "wasm")] @@ -188,13 +233,22 @@ impl WgpuRenderer { .instance .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone())) .map_err(|e| anyhow::anyhow!("Failed to create surface: {e}"))?; - Self::new_with_surface(context, surface, config) + + let atlas = Arc::new(WgpuAtlas::new( + Arc::clone(&context.device), + Arc::clone(&context.queue), + )); + + Self::new_internal(None, context, surface, config, None, atlas) } - fn new_with_surface( + fn new_internal( + gpu_context: Option, context: &WgpuContext, surface: wgpu::Surface<'static>, config: WgpuSurfaceConfig, + compositor_gpu: Option, + atlas: Arc, ) -> anyhow::Result { let surface_caps = surface.get_capabilities(&context.adapter); let preferred_formats = [ @@ -289,7 +343,6 @@ impl WgpuRenderer { dual_source_blending, ); - let atlas = Arc::new(WgpuAtlas::new(Arc::clone(&device), Arc::clone(&queue))); let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("atlas_sampler"), mag_filter: wgpu::FilterMode::Linear, @@ -375,30 +428,36 @@ impl WgpuRenderer { *guard = Some(error.to_string()); })); - Ok(Self { + let resources = WgpuResources { device, queue, surface, - surface_config, pipelines, bind_group_layouts, - atlas, atlas_sampler, globals_buffer, - path_globals_offset, - gamma_offset, globals_bind_group, path_globals_bind_group, instance_buffer, - instance_buffer_capacity: initial_instance_buffer_capacity, - max_buffer_size, - storage_buffer_alignment, // Defer intermediate texture creation to first draw call via ensure_intermediate_textures(). // This avoids panics when the device/surface is in an invalid state during initialization. path_intermediate_texture: None, path_intermediate_view: None, path_msaa_texture: None, path_msaa_view: None, + }; + + Ok(Self { + context: gpu_context, + compositor_gpu, + resources: Some(resources), + surface_config, + atlas, + path_globals_offset, + gamma_offset, + instance_buffer_capacity: initial_instance_buffer_capacity, + max_buffer_size, + storage_buffer_alignment, rendering_params, dual_source_blending, adapter_info, @@ -407,6 +466,7 @@ impl WgpuRenderer { max_texture_size, last_error, failed_frame_count: 0, + device_lost: context.device_lost_flag(), }) } @@ -855,8 +915,14 @@ impl WgpuRenderer { ); } + self.surface_config.width = clamped_width.max(1); + self.surface_config.height = clamped_height.max(1); + let surface_config = self.surface_config.clone(); + + let resources = self.resources_mut(); + // Wait for any in-flight GPU work to complete before destroying textures - if let Err(e) = self.device.poll(wgpu::PollType::Wait { + if let Err(e) = resources.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None, }) { @@ -864,55 +930,53 @@ impl WgpuRenderer { } // Destroy old textures before allocating new ones to avoid GPU memory spikes - if let Some(ref texture) = self.path_intermediate_texture { + if let Some(ref texture) = resources.path_intermediate_texture { texture.destroy(); } - if let Some(ref texture) = self.path_msaa_texture { + if let Some(ref texture) = resources.path_msaa_texture { texture.destroy(); } - self.surface_config.width = clamped_width.max(1); - self.surface_config.height = clamped_height.max(1); - self.surface.configure(&self.device, &self.surface_config); + resources + .surface + .configure(&resources.device, &surface_config); // Invalidate intermediate textures - they will be lazily recreated // in draw() after we confirm the surface is healthy. This avoids // panics when the device/surface is in an invalid state during resize. - self.path_intermediate_texture = None; - self.path_intermediate_view = None; - self.path_msaa_texture = None; - self.path_msaa_view = None; + resources.path_intermediate_texture = None; + resources.path_intermediate_view = None; + resources.path_msaa_texture = None; + resources.path_msaa_view = None; } } fn ensure_intermediate_textures(&mut self) { - if self.path_intermediate_texture.is_some() { + if self.resources().path_intermediate_texture.is_some() { return; } - let (path_intermediate_texture, path_intermediate_view) = { - let (t, v) = Self::create_path_intermediate( - &self.device, - self.surface_config.format, - self.surface_config.width, - self.surface_config.height, - ); - (Some(t), Some(v)) - }; - self.path_intermediate_texture = path_intermediate_texture; - self.path_intermediate_view = path_intermediate_view; + let format = self.surface_config.format; + let width = self.surface_config.width; + let height = self.surface_config.height; + let path_sample_count = self.rendering_params.path_sample_count; + let resources = self.resources_mut(); + + let (t, v) = Self::create_path_intermediate(&resources.device, format, width, height); + resources.path_intermediate_texture = Some(t); + resources.path_intermediate_view = Some(v); let (path_msaa_texture, path_msaa_view) = Self::create_msaa_if_needed( - &self.device, - self.surface_config.format, - self.surface_config.width, - self.surface_config.height, - self.rendering_params.path_sample_count, + &resources.device, + format, + width, + height, + path_sample_count, ) .map(|(t, v)| (Some(t), Some(v))) .unwrap_or((None, None)); - self.path_msaa_texture = path_msaa_texture; - self.path_msaa_view = path_msaa_view; + resources.path_msaa_texture = path_msaa_texture; + resources.path_msaa_view = path_msaa_view; } pub fn update_transparency(&mut self, transparent: bool) { @@ -924,14 +988,20 @@ impl WgpuRenderer { if new_alpha_mode != self.surface_config.alpha_mode { self.surface_config.alpha_mode = new_alpha_mode; - self.surface.configure(&self.device, &self.surface_config); - self.pipelines = Self::create_pipelines( - &self.device, - &self.bind_group_layouts, - self.surface_config.format, - self.surface_config.alpha_mode, - self.rendering_params.path_sample_count, - self.dual_source_blending, + let surface_config = self.surface_config.clone(); + let path_sample_count = self.rendering_params.path_sample_count; + let dual_source_blending = self.dual_source_blending; + let resources = self.resources_mut(); + resources + .surface + .configure(&resources.device, &surface_config); + resources.pipelines = Self::create_pipelines( + &resources.device, + &resources.bind_group_layouts, + surface_config.format, + surface_config.alpha_mode, + path_sample_count, + dual_source_blending, ); } } @@ -982,14 +1052,20 @@ impl WgpuRenderer { self.atlas.before_frame(); - let frame = match self.surface.get_current_texture() { + let texture_result = self.resources().surface.get_current_texture(); + let frame = match texture_result { Ok(frame) => frame, Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - self.surface.configure(&self.device, &self.surface_config); + let surface_config = self.surface_config.clone(); + let resources = self.resources_mut(); + resources + .surface + .configure(&resources.device, &surface_config); return; } Err(e) => { - log::error!("Failed to acquire surface texture: {e}"); + *self.last_error.lock().unwrap() = + Some(format!("Failed to acquire surface texture: {e}")); return; } }; @@ -1028,28 +1104,35 @@ impl WgpuRenderer { ..globals }; - self.queue - .write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals)); - self.queue.write_buffer( - &self.globals_buffer, - self.path_globals_offset, - bytemuck::bytes_of(&path_globals), - ); - self.queue.write_buffer( - &self.globals_buffer, - self.gamma_offset, - bytemuck::bytes_of(&gamma_params), - ); + { + let resources = self.resources(); + resources.queue.write_buffer( + &resources.globals_buffer, + 0, + bytemuck::bytes_of(&globals), + ); + resources.queue.write_buffer( + &resources.globals_buffer, + self.path_globals_offset, + bytemuck::bytes_of(&path_globals), + ); + resources.queue.write_buffer( + &resources.globals_buffer, + self.gamma_offset, + bytemuck::bytes_of(&gamma_params), + ); + } loop { let mut instance_offset: u64 = 0; let mut overflow = false; - let mut encoder = self - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("main_encoder"), - }); + let mut encoder = + self.resources() + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("main_encoder"), + }); { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -1169,7 +1252,9 @@ impl WgpuRenderer { continue; } - self.queue.submit(std::iter::once(encoder.finish())); + self.resources() + .queue + .submit(std::iter::once(encoder.finish())); frame.present(); return; } @@ -1185,7 +1270,7 @@ impl WgpuRenderer { self.draw_instances( data, quads.len() as u32, - &self.pipelines.quads, + &self.resources().pipelines.quads, instance_offset, pass, ) @@ -1201,7 +1286,7 @@ impl WgpuRenderer { self.draw_instances( data, shadows.len() as u32, - &self.pipelines.shadows, + &self.resources().pipelines.shadows, instance_offset, pass, ) @@ -1217,7 +1302,7 @@ impl WgpuRenderer { self.draw_instances( data, underlines.len() as u32, - &self.pipelines.underlines, + &self.resources().pipelines.underlines, instance_offset, pass, ) @@ -1236,7 +1321,7 @@ impl WgpuRenderer { data, sprites.len() as u32, &tex_info.view, - &self.pipelines.mono_sprites, + &self.resources().pipelines.mono_sprites, instance_offset, pass, ) @@ -1251,11 +1336,12 @@ impl WgpuRenderer { ) -> bool { let tex_info = self.atlas.get_texture_info(texture_id); let data = unsafe { Self::instance_bytes(sprites) }; - let pipeline = self + let resources = self.resources(); + let pipeline = resources .pipelines .subpixel_sprites .as_ref() - .unwrap_or(&self.pipelines.mono_sprites); + .unwrap_or(&resources.pipelines.mono_sprites); self.draw_instances_with_texture( data, sprites.len() as u32, @@ -1279,7 +1365,7 @@ impl WgpuRenderer { data, sprites.len() as u32, &tex_info.view, - &self.pipelines.poly_sprites, + &self.resources().pipelines.poly_sprites, instance_offset, pass, ) @@ -1299,16 +1385,19 @@ impl WgpuRenderer { let Some((offset, size)) = self.write_to_instance_buffer(instance_offset, data) else { return false; }; - let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &self.bind_group_layouts.instances, - entries: &[wgpu::BindGroupEntry { - binding: 0, - resource: self.instance_binding(offset, size), - }], - }); + let resources = self.resources(); + let bind_group = resources + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &resources.bind_group_layouts.instances, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: self.instance_binding(offset, size), + }], + }); pass.set_pipeline(pipeline); - pass.set_bind_group(0, &self.globals_bind_group, &[]); + pass.set_bind_group(0, &resources.globals_bind_group, &[]); pass.set_bind_group(1, &bind_group, &[]); pass.draw(0..4, 0..instance_count); true @@ -1329,26 +1418,29 @@ impl WgpuRenderer { let Some((offset, size)) = self.write_to_instance_buffer(instance_offset, data) else { return false; }; - let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &self.bind_group_layouts.instances_with_texture, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: self.instance_binding(offset, size), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(texture_view), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::Sampler(&self.atlas_sampler), - }, - ], - }); + let resources = self.resources(); + let bind_group = resources + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &resources.bind_group_layouts.instances_with_texture, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.instance_binding(offset, size), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(texture_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&resources.atlas_sampler), + }, + ], + }); pass.set_pipeline(pipeline); - pass.set_bind_group(0, &self.globals_bind_group, &[]); + pass.set_bind_group(0, &resources.globals_bind_group, &[]); pass.set_bind_group(1, &bind_group, &[]); pass.draw(0..4, 0..instance_count); true @@ -1386,7 +1478,8 @@ impl WgpuRenderer { vec![PathSprite { bounds }] }; - let Some(path_intermediate_view) = self.path_intermediate_view.as_ref() else { + let resources = self.resources(); + let Some(path_intermediate_view) = resources.path_intermediate_view.as_ref() else { return true; }; @@ -1395,7 +1488,7 @@ impl WgpuRenderer { sprite_data, sprites.len() as u32, path_intermediate_view, - &self.pipelines.paths, + &resources.pipelines.paths, instance_offset, pass, ) @@ -1429,20 +1522,23 @@ impl WgpuRenderer { return false; }; - let data_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("path_rasterization_bind_group"), - layout: &self.bind_group_layouts.instances, - entries: &[wgpu::BindGroupEntry { - binding: 0, - resource: self.instance_binding(vertex_offset, vertex_size), - }], - }); + let resources = self.resources(); + let data_bind_group = resources + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("path_rasterization_bind_group"), + layout: &resources.bind_group_layouts.instances, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: self.instance_binding(vertex_offset, vertex_size), + }], + }); - let Some(path_intermediate_view) = self.path_intermediate_view.as_ref() else { + let Some(path_intermediate_view) = resources.path_intermediate_view.as_ref() else { return true; }; - let (target_view, resolve_target) = if let Some(ref msaa_view) = self.path_msaa_view { + let (target_view, resolve_target) = if let Some(ref msaa_view) = resources.path_msaa_view { (msaa_view, Some(path_intermediate_view)) } else { (path_intermediate_view, None) @@ -1464,8 +1560,8 @@ impl WgpuRenderer { ..Default::default() }); - pass.set_pipeline(&self.pipelines.path_rasterization); - pass.set_bind_group(0, &self.path_globals_bind_group, &[]); + pass.set_pipeline(&resources.pipelines.path_rasterization); + pass.set_bind_group(0, &resources.path_globals_bind_group, &[]); pass.set_bind_group(1, &data_bind_group, &[]); pass.draw(0..vertices.len() as u32, 0..1); } @@ -1476,7 +1572,8 @@ impl WgpuRenderer { fn grow_instance_buffer(&mut self) { let new_capacity = (self.instance_buffer_capacity * 2).min(self.max_buffer_size); log::info!("increased instance buffer size to {}", new_capacity); - self.instance_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + let resources = self.resources_mut(); + resources.instance_buffer = resources.device.create_buffer(&wgpu::BufferDescriptor { label: Some("instance_buffer"), size: new_capacity, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, @@ -1495,14 +1592,17 @@ impl WgpuRenderer { if offset + size > self.instance_buffer_capacity { return None; } - self.queue.write_buffer(&self.instance_buffer, offset, data); + let resources = self.resources(); + resources + .queue + .write_buffer(&resources.instance_buffer, offset, data); *instance_offset = offset + size; Some((offset, NonZeroU64::new(size).expect("size is at least 16"))) } fn instance_binding(&self, offset: u64, size: NonZeroU64) -> wgpu::BindingResource<'_> { wgpu::BindingResource::Buffer(wgpu::BufferBinding { - buffer: &self.instance_buffer, + buffer: &self.resources().instance_buffer, offset, size: Some(size), }) @@ -1511,6 +1611,97 @@ impl WgpuRenderer { pub fn destroy(&mut self) { // wgpu resources are automatically cleaned up when dropped } + + /// Returns true if the GPU device was lost and recovery is needed. + pub fn device_lost(&self) -> bool { + self.device_lost.load(std::sync::atomic::Ordering::SeqCst) + } + + /// Recovers from a lost GPU device by recreating the renderer with a new context. + /// + /// Call this after detecting `device_lost()` returns true. + /// + /// This method coordinates recovery across multiple windows: + /// - The first window to call this will recreate the shared context + /// - Subsequent windows will adopt the already-recovered context + #[cfg(not(target_family = "wasm"))] + pub fn recover( + &mut self, + raw_display_handle: raw_window_handle::RawDisplayHandle, + raw_window_handle: raw_window_handle::RawWindowHandle, + ) -> anyhow::Result<()> { + let gpu_context = self.context.as_ref().expect("recover requires gpu_context"); + + // Check if another window already recovered the context + let needs_new_context = gpu_context + .borrow() + .as_ref() + .is_none_or(|ctx| ctx.device_lost()); + + let surface = if needs_new_context { + log::warn!("GPU device lost, recreating context..."); + + // Drop old resources to release Arc/Arc and GPU resources + self.resources = None; + *gpu_context.borrow_mut() = None; + + // Wait for GPU driver to stabilize (350ms copied from windows :shrug:) + std::thread::sleep(std::time::Duration::from_millis(350)); + + let instance = WgpuContext::instance(); + let surface = create_surface(&instance, raw_display_handle, raw_window_handle)?; + let new_context = WgpuContext::new(instance, &surface, self.compositor_gpu)?; + *gpu_context.borrow_mut() = Some(new_context); + surface + } else { + let ctx_ref = gpu_context.borrow(); + let instance = &ctx_ref.as_ref().unwrap().instance; + create_surface(instance, raw_display_handle, raw_window_handle)? + }; + + let config = WgpuSurfaceConfig { + size: gpui::Size { + width: gpui::DevicePixels(self.surface_config.width as i32), + height: gpui::DevicePixels(self.surface_config.height as i32), + }, + transparent: self.surface_config.alpha_mode != wgpu::CompositeAlphaMode::Opaque, + }; + let gpu_context = Rc::clone(gpu_context); + let ctx_ref = gpu_context.borrow(); + let context = ctx_ref.as_ref().expect("context should exist"); + + self.resources = None; + self.atlas + .handle_device_lost(Arc::clone(&context.device), Arc::clone(&context.queue)); + + *self = Self::new_internal( + Some(gpu_context.clone()), + context, + surface, + config, + self.compositor_gpu, + self.atlas.clone(), + )?; + + log::info!("GPU recovery complete"); + Ok(()) + } +} + +#[cfg(not(target_family = "wasm"))] +fn create_surface( + instance: &wgpu::Instance, + raw_display_handle: raw_window_handle::RawDisplayHandle, + raw_window_handle: raw_window_handle::RawWindowHandle, +) -> anyhow::Result> { + unsafe { + instance + .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle, + raw_window_handle, + }) + .map_err(|e| anyhow::anyhow!("{e}")) + } } struct RenderingParameters { diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 0d50339f6c9d42ffa653e5c7565ae6e22441bdca..4c188042482443ea0df59096884194f0740fcda1 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -276,7 +276,7 @@ fn main() { zlog::init(); - if stdout_is_a_pty() { + if true { zlog::init_output_stdout(); } else { let result = zlog::init_output_file(paths::log_file(), Some(paths::old_log_file())); From a0ba509838a47ecce970a0110221ab277adf6293 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 5 Mar 2026 22:45:50 -0800 Subject: [PATCH 008/219] Fix provisional thread title (#50905) Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 92 ++++++++++++++++++- .../src/connection_view/thread_view.rs | 7 +- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 1b9271918884dc020986577926d9578e3a6f049c..bffddde099c05438bb81c8bbbe99e3c77a5113e6 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -954,6 +954,7 @@ struct RunningTurn { pub struct AcpThread { parent_session_id: Option, title: SharedString, + provisional_title: Option, entries: Vec, plan: Plan, project: Entity, @@ -1199,6 +1200,7 @@ impl AcpThread { entries: Default::default(), plan: Default::default(), title: title.into(), + provisional_title: None, project, running_turn: None, turn_id: 0, @@ -1253,7 +1255,9 @@ impl AcpThread { } pub fn title(&self) -> SharedString { - self.title.clone() + self.provisional_title + .clone() + .unwrap_or_else(|| self.title.clone()) } pub fn entries(&self) -> &[AgentThreadEntry] { @@ -1505,16 +1509,29 @@ impl AcpThread { } pub fn set_title(&mut self, title: SharedString, cx: &mut Context) -> Task> { + let had_provisional = self.provisional_title.take().is_some(); if title != self.title { self.title = title.clone(); cx.emit(AcpThreadEvent::TitleUpdated); if let Some(set_title) = self.connection.set_title(&self.session_id, cx) { return set_title.run(title, cx); } + } else if had_provisional { + cx.emit(AcpThreadEvent::TitleUpdated); } Task::ready(Ok(())) } + /// Sets a provisional display title without propagating back to the + /// underlying agent connection. This is used for quick preview titles + /// (e.g. first 20 chars of the user message) that should be shown + /// immediately but replaced once the LLM generates a proper title via + /// `set_title`. + pub fn set_provisional_title(&mut self, title: SharedString, cx: &mut Context) { + self.provisional_title = Some(title); + cx.emit(AcpThreadEvent::TitleUpdated); + } + pub fn subagent_spawned(&mut self, session_id: acp::SessionId, cx: &mut Context) { cx.emit(AcpThreadEvent::SubagentSpawned(session_id)); } @@ -3916,6 +3933,7 @@ mod tests { struct FakeAgentConnection { auth_methods: Vec, sessions: Arc>>>, + set_title_calls: Rc>>, on_user_message: Option< Rc< dyn Fn( @@ -3934,6 +3952,7 @@ mod tests { auth_methods: Vec::new(), on_user_message: None, sessions: Arc::default(), + set_title_calls: Default::default(), } } @@ -4038,11 +4057,32 @@ mod tests { })) } + fn set_title( + &self, + _session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { + Some(Rc::new(FakeAgentSessionSetTitle { + calls: self.set_title_calls.clone(), + })) + } + fn into_any(self: Rc) -> Rc { self } } + struct FakeAgentSessionSetTitle { + calls: Rc>>, + } + + impl AgentSessionSetTitle for FakeAgentSessionSetTitle { + fn run(&self, title: SharedString, _cx: &mut App) -> Task> { + self.calls.borrow_mut().push(title); + Task::ready(Ok(())) + } + } + struct FakeAgentSessionEditor { _session_id: acp::SessionId, } @@ -4634,4 +4674,54 @@ mod tests { ); }); } + + #[gpui::test] + async fn test_provisional_title_replaced_by_real_title(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let set_title_calls = connection.set_title_calls.clone(); + + let thread = cx + .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .await + .unwrap(); + + // Initial title is the default. + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title().as_ref(), "Test"); + }); + + // Setting a provisional title updates the display title. + thread.update(cx, |thread, cx| { + thread.set_provisional_title("Hello, can you help…".into(), cx); + }); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title().as_ref(), "Hello, can you help…"); + }); + + // The provisional title should NOT have propagated to the connection. + assert_eq!( + set_title_calls.borrow().len(), + 0, + "provisional title should not propagate to the connection" + ); + + // When the real title arrives via set_title, it replaces the + // provisional title and propagates to the connection. + let task = thread.update(cx, |thread, cx| { + thread.set_title("Helping with Rust question".into(), cx) + }); + task.await.expect("set_title should succeed"); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title().as_ref(), "Helping with Rust question"); + }); + assert_eq!( + set_title_calls.borrow().as_slice(), + &[SharedString::from("Helping with Rust question")], + "real title should propagate to the connection" + ); + } } diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 64a0f61345b1a48dcfec5229d5e699fed8fee2bd..32c9f29cd6b9e60b498974cd7230a4f18a4b0f8e 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -990,10 +990,9 @@ impl ThreadView { let text = text.lines().next().unwrap_or("").trim(); if !text.is_empty() { let title: SharedString = util::truncate_and_trailoff(text, 20).into(); - thread - .update(cx, |thread, cx| thread.set_title(title, cx))? - .await - .log_err(); + thread.update(cx, |thread, cx| { + thread.set_provisional_title(title, cx); + })?; } } From 70404840577dabf33cacddf5360873ad9ade3a30 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 5 Mar 2026 22:51:26 -0800 Subject: [PATCH 009/219] Fix sidebar selections (#50900) Release Notes: - N/A --------- Co-authored-by: Eric Holk --- crates/agent_ui/src/agent_panel.rs | 35 +- crates/agent_ui/src/connection_view.rs | 67 +- crates/gpui/examples/active_state_bug.rs | 47 ++ crates/gpui/src/elements/div.rs | 12 +- crates/sidebar/Cargo.toml | 1 + crates/sidebar/src/sidebar.rs | 778 +++++++++++++++-------- crates/workspace/src/multi_workspace.rs | 26 +- crates/workspace/src/workspace.rs | 10 +- crates/zed/src/zed.rs | 21 +- 9 files changed, 701 insertions(+), 296 deletions(-) create mode 100644 crates/gpui/examples/active_state_bug.rs diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 0a216ad4bd39e0ea3949eca95f8f7461271ba8de..1937b2693e3923e46efc59ab959a7939b733cbdd 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -208,7 +208,7 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &OpenAgentDiff, window, cx| { let thread = workspace .panel::(cx) - .and_then(|panel| panel.read(cx).active_thread_view().cloned()) + .and_then(|panel| panel.read(cx).active_connection_view().cloned()) .and_then(|thread_view| { thread_view .read(cx) @@ -570,6 +570,7 @@ pub struct AgentPanel { start_thread_in: StartThreadIn, worktree_creation_status: Option, _thread_view_subscription: Option, + _active_thread_focus_subscription: Option, _worktree_creation_task: Option>, show_trust_workspace_message: bool, last_configuration_error_telemetry: Option, @@ -898,6 +899,7 @@ impl AgentPanel { start_thread_in: StartThreadIn::default(), worktree_creation_status: None, _thread_view_subscription: None, + _active_thread_focus_subscription: None, _worktree_creation_task: None, show_trust_workspace_message: false, last_configuration_error_telemetry: None, @@ -988,7 +990,7 @@ impl AgentPanel { .unwrap_or(false) } - pub(crate) fn active_thread_view(&self) -> Option<&Entity> { + pub fn active_connection_view(&self) -> Option<&Entity> { match &self.active_view { ActiveView::AgentThread { server_view, .. } => Some(server_view), ActiveView::Uninitialized @@ -1173,7 +1175,7 @@ impl AgentPanel { } fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context) { - let Some(thread_view) = self.active_thread_view() else { + let Some(thread_view) = self.active_connection_view() else { return; }; @@ -1432,7 +1434,7 @@ impl AgentPanel { cx: &mut Context, ) { if let Some(workspace) = self.workspace.upgrade() - && let Some(thread_view) = self.active_thread_view() + && let Some(thread_view) = self.active_connection_view() && let Some(active_thread) = thread_view.read(cx).active_thread().cloned() { active_thread.update(cx, |thread, cx| { @@ -1763,6 +1765,12 @@ impl AgentPanel { ActiveView::AgentThread { server_view } => { self._thread_view_subscription = Self::subscribe_to_active_thread_view(server_view, window, cx); + let focus_handle = server_view.focus_handle(cx); + self._active_thread_focus_subscription = + Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| { + cx.emit(AgentPanelEvent::ThreadFocused); + cx.notify(); + })); Some( cx.observe_in(server_view, window, |this, server_view, window, cx| { this._thread_view_subscription = @@ -1775,6 +1783,7 @@ impl AgentPanel { } _ => { self._thread_view_subscription = None; + self._active_thread_focus_subscription = None; None } }; @@ -2035,6 +2044,7 @@ impl AgentPanel { .map(|t| t.read(cx).id.clone()) == Some(session_id.clone()) { + cx.emit(AgentPanelEvent::ActiveViewChanged); return; } } @@ -2642,6 +2652,7 @@ fn agent_panel_dock_position(cx: &App) -> DockPosition { pub enum AgentPanelEvent { ActiveViewChanged, + ThreadFocused, BackgroundThreadChanged, } @@ -3523,7 +3534,7 @@ impl AgentPanel { }); let is_thread_loading = self - .active_thread_view() + .active_connection_view() .map(|thread| thread.read(cx).is_loading()) .unwrap_or(false); @@ -4077,7 +4088,7 @@ impl Render for AgentPanel { .on_action(cx.listener(Self::reset_font_size)) .on_action(cx.listener(Self::toggle_zoom)) .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| { - if let Some(thread_view) = this.active_thread_view() { + if let Some(thread_view) = this.active_connection_view() { thread_view.update(cx, |thread_view, cx| thread_view.reauthenticate(window, cx)) } })) @@ -4263,7 +4274,7 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { // Wait to create a new context until the workspace is no longer // being updated. cx.defer_in(window, move |panel, window, cx| { - if let Some(thread_view) = panel.active_thread_view() { + if let Some(thread_view) = panel.active_connection_view() { thread_view.update(cx, |thread_view, cx| { thread_view.insert_selections(window, cx); }); @@ -4301,7 +4312,7 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { // Wait to create a new context until the workspace is no longer // being updated. cx.defer_in(window, move |panel, window, cx| { - if let Some(thread_view) = panel.active_thread_view() { + if let Some(thread_view) = panel.active_connection_view() { thread_view.update(cx, |thread_view, cx| { thread_view.insert_terminal_text(text, window, cx); }); @@ -4367,7 +4378,7 @@ impl AgentPanel { /// This is a test-only accessor that exposes the private `active_thread_view()` /// method for test assertions. Not compiled into production builds. pub fn active_thread_view_for_tests(&self) -> Option<&Entity> { - self.active_thread_view() + self.active_connection_view() } /// Sets the start_thread_in value directly, bypassing validation. @@ -4552,7 +4563,7 @@ mod tests { "workspace A agent type should be restored" ); assert!( - panel.active_thread_view().is_some(), + panel.active_connection_view().is_some(), "workspace A should have its active thread restored" ); }); @@ -4572,7 +4583,7 @@ mod tests { "workspace B agent type should be restored" ); assert!( - panel.active_thread_view().is_none(), + panel.active_connection_view().is_none(), "workspace B should have no active thread" ); }); @@ -4709,7 +4720,7 @@ mod tests { send_message(&panel, &mut cx); let weak_view_a = panel.read_with(&cx, |panel, _cx| { - panel.active_thread_view().unwrap().downgrade() + panel.active_connection_view().unwrap().downgrade() }); // Thread A should be idle (auto-completed via set_next_prompt_updates). diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index e7e9403e052f6578ab20982fbb27c7c6a29d1a80..6ba0f94934300d02c5a921af797a62f1a8756d76 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -399,7 +399,10 @@ impl ConnectionView { enum ServerState { Loading(Entity), - LoadError(LoadError), + LoadError { + error: LoadError, + session_id: Option, + }, Connected(ConnectedServerState), } @@ -430,6 +433,7 @@ impl AuthState { } struct LoadingView { + session_id: Option, title: SharedString, _load_task: Task<()>, _update_title_task: Task>, @@ -572,12 +576,18 @@ impl ConnectionView { window: &mut Window, cx: &mut Context, ) -> ServerState { + let session_id = resume_thread + .as_ref() + .map(|thread| thread.session_id.clone()); if project.read(cx).is_via_collab() && agent.clone().downcast::().is_none() { - return ServerState::LoadError(LoadError::Other( - "External agents are not yet supported in shared projects.".into(), - )); + return ServerState::LoadError { + error: LoadError::Other( + "External agents are not yet supported in shared projects.".into(), + ), + session_id, + }; } let mut worktrees = project.read(cx).visible_worktrees(cx).collect::>(); // Pick the first non-single-file worktree for the root directory if there are any, @@ -633,17 +643,18 @@ impl ConnectionView { ); let connect_task = agent.connect(delegate, cx); + let load_session_id = session_id.clone(); let load_task = cx.spawn_in(window, async move |this, cx| { let connection = match connect_task.await { Ok(connection) => connection, Err(err) => { this.update_in(cx, |this, window, cx| { if err.downcast_ref::().is_some() { - this.handle_load_error(err, window, cx); + this.handle_load_error(load_session_id.clone(), err, window, cx); } else if let Some(active) = this.active_thread() { active.update(cx, |active, cx| active.handle_thread_error(err, cx)); } else { - this.handle_load_error(err, window, cx); + this.handle_load_error(load_session_id.clone(), err, window, cx); } cx.notify(); }) @@ -756,7 +767,7 @@ impl ConnectionView { ); } Err(err) => { - this.handle_load_error(err, window, cx); + this.handle_load_error(load_session_id.clone(), err, window, cx); } }; }) @@ -792,6 +803,7 @@ impl ConnectionView { }); LoadingView { + session_id, title: "Loading…".into(), _load_task: load_task, _update_title_task: update_title_task, @@ -1086,6 +1098,7 @@ impl ConnectionView { fn handle_load_error( &mut self, + session_id: Option, err: anyhow::Error, window: &mut Window, cx: &mut Context, @@ -1106,7 +1119,13 @@ impl ConnectionView { LoadError::Other(format!("{:#}", err).into()) }; self.emit_load_error_telemetry(&load_error); - self.set_server_state(ServerState::LoadError(load_error), cx); + self.set_server_state( + ServerState::LoadError { + error: load_error, + session_id, + }, + cx, + ); } fn handle_agent_servers_updated( @@ -1121,7 +1140,7 @@ impl ConnectionView { // This handles the case where a thread is restored before authentication completes. let should_retry = match &self.server_state { ServerState::Loading(_) => false, - ServerState::LoadError(_) => true, + ServerState::LoadError { .. } => true, ServerState::Connected(connected) => { connected.auth_state.is_ok() && connected.has_thread_error(cx) } @@ -1145,7 +1164,7 @@ impl ConnectionView { match &self.server_state { ServerState::Connected(_) => "New Thread".into(), ServerState::Loading(loading_view) => loading_view.read(cx).title.clone(), - ServerState::LoadError(error) => match error { + ServerState::LoadError { error, .. } => match error { LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(), LoadError::FailedToInstall(_) => { format!("Failed to Install {}", self.agent.name()).into() @@ -1164,6 +1183,17 @@ impl ConnectionView { } } + // The parent ID is None if we haven't created a thread yet + pub fn parent_id(&self, cx: &App) -> Option { + match &self.server_state { + ServerState::Connected(_) => self + .parent_thread(cx) + .map(|thread| thread.read(cx).id.clone()), + ServerState::Loading(loading) => loading.read(cx).session_id.clone(), + ServerState::LoadError { session_id, .. } => session_id.clone(), + } + } + pub fn is_loading(&self) -> bool { matches!(self.server_state, ServerState::Loading { .. }) } @@ -1361,7 +1391,13 @@ impl ConnectionView { self.focus_handle.focus(window, cx) } } - self.set_server_state(ServerState::LoadError(error.clone()), cx); + self.set_server_state( + ServerState::LoadError { + error: error.clone(), + session_id: Some(thread_id), + }, + cx, + ); } AcpThreadEvent::TitleUpdated => { let title = thread.read(cx).title(); @@ -2635,7 +2671,7 @@ impl Render for ConnectionView { .flex_1() // .child(self.render_recent_history(cx)) .into_any(), - ServerState::LoadError(e) => v_flex() + ServerState::LoadError { error: e, .. } => v_flex() .flex_1() .size_full() .items_center() @@ -3126,7 +3162,10 @@ pub(crate) mod tests { "Tab title should show the agent name with an error prefix" ); match &view.server_state { - ServerState::LoadError(LoadError::Other(msg)) => { + ServerState::LoadError { + error: LoadError::Other(msg), + .. + } => { assert!( msg.contains("Invalid gzip header"), "Error callout should contain the underlying extraction error, got: {msg}" @@ -3136,7 +3175,7 @@ pub(crate) mod tests { "Expected LoadError::Other, got: {}", match other { ServerState::Loading(_) => "Loading (stuck!)", - ServerState::LoadError(_) => "LoadError (wrong variant)", + ServerState::LoadError { .. } => "LoadError (wrong variant)", ServerState::Connected(_) => "Connected", } ), diff --git a/crates/gpui/examples/active_state_bug.rs b/crates/gpui/examples/active_state_bug.rs new file mode 100644 index 0000000000000000000000000000000000000000..f767ed27e456ec65858b72a4df89fab65e7fd1f3 --- /dev/null +++ b/crates/gpui/examples/active_state_bug.rs @@ -0,0 +1,47 @@ +/// Click the button — the `.active()` background gets stuck on every other click. +use gpui::*; +use gpui_platform::application; + +struct Example; + +impl Render for Example { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + // Colors from Zed's default dark theme + let bg = hsla(215. / 360., 0.12, 0.15, 1.); + let text = hsla(221. / 360., 0.11, 0.86, 1.); + let hover = hsla(225. / 360., 0.118, 0.267, 1.); + let active = hsla(220. / 360., 0.118, 0.20, 1.); + + div().bg(bg).size_full().p_1().child( + div() + .id("button") + .px_2() + .py_0p5() + .rounded_md() + .text_sm() + .text_color(text) + .hover(|s| s.bg(hover)) + .active(|s| s.bg(active)) + .on_click(|_, _, _| {}) + .child("Click me"), + ) + } +} + +fn main() { + application().run(|cx: &mut App| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::centered( + None, + size(px(200.), px(60.)), + cx, + ))), + ..Default::default() + }, + |_, cx| cx.new(|_| Example), + ) + .unwrap(); + cx.activate(true); + }); +} diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 58f11a7fa1fb876ef4b4ef80fedf1948423a24f5..3599affc3c792f3c93b3b94cfc44740d7c38caf7 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2517,18 +2517,24 @@ impl Interactivity { ); } + // We unconditionally bind both the mouse up and mouse down active state handlers + // Because we might not get a chance to render a frame before the mouse up event arrives. let active_state = element_state .clicked_state .get_or_insert_with(Default::default) .clone(); - if active_state.borrow().is_clicked() { + + { + let active_state = active_state.clone(); window.on_mouse_event(move |_: &MouseUpEvent, phase, window, _cx| { - if phase == DispatchPhase::Capture { + if phase == DispatchPhase::Capture && active_state.borrow().is_clicked() { *active_state.borrow_mut() = ElementClickedState::default(); window.refresh(); } }); - } else { + } + + { let active_group_hitbox = self .group_active_style .as_ref() diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index f0722a5791f6eecf873703bc5337890329d310c8..602fe12255bc3b8c5cee3445b96795475fcd7026 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -21,6 +21,7 @@ agent-client-protocol.workspace = true agent_ui.workspace = true chrono.workspace = true editor.workspace = true +feature_flags.workspace = true fs.workspace = true gpui.workspace = true menu.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 3fee8ff811a7c4207f050348056f06a8b51a70e7..1a44724b5532ec3a6f644adc16925a3dcf942c88 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -4,10 +4,11 @@ use agent_client_protocol as acp; use agent_ui::{AgentPanel, AgentPanelEvent, NewThread}; use chrono::Utc; use editor::{Editor, EditorElement, EditorStyle}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState, - Pixels, Render, SharedString, Subscription, TextStyle, WeakEntity, Window, actions, list, - prelude::*, px, relative, rems, + Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px, + relative, rems, }; use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::Event as ProjectEvent; @@ -22,8 +23,8 @@ use ui::{ }; use util::path_list::PathList; use workspace::{ - FocusWorkspaceSidebar, MultiWorkspace, Sidebar as WorkspaceSidebar, SidebarEvent, - ToggleWorkspaceSidebar, Workspace, + FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar, + SidebarEvent, ToggleWorkspaceSidebar, Workspace, }; use zed_actions::editor::{MoveDown, MoveUp}; @@ -70,7 +71,7 @@ enum ListEntry { ProjectHeader { path_list: PathList, label: SharedString, - workspace_index: usize, + workspace: Entity, highlight_positions: Vec, }, Thread { @@ -79,7 +80,7 @@ enum ListEntry { icon_from_external_svg: Option, status: AgentThreadStatus, diff_stats: Option<(usize, usize)>, - workspace_index: usize, + workspace: Entity, is_live: bool, is_background: bool, highlight_positions: Vec, @@ -90,6 +91,7 @@ enum ListEntry { }, NewThread { path_list: PathList, + workspace: Entity, }, } @@ -157,20 +159,6 @@ fn workspace_path_list_and_label( (PathList::new(&paths), label) } -fn workspace_index_for_path_list( - workspaces: &[Entity], - path_list: &PathList, - cx: &App, -) -> Option { - workspaces - .iter() - .enumerate() - .find_map(|(index, workspace)| { - let (candidate, _) = workspace_path_list_and_label(workspace, cx); - (candidate == *path_list).then_some(index) - }) -} - pub struct Sidebar { multi_workspace: WeakEntity, width: Pixels, @@ -178,13 +166,14 @@ pub struct Sidebar { filter_editor: Entity, list_state: ListState, contents: SidebarContents, + /// The index of the list item that currently has the keyboard focus + /// + /// Note: This is NOT the same as the active item. selection: Option, + focused_thread: Option, + active_entry_index: Option, collapsed_groups: HashSet, expanded_groups: HashSet, - _subscriptions: Vec, - _project_subscriptions: Vec, - _agent_panel_subscriptions: Vec, - _thread_store_subscription: Option, } impl EventEmitter for Sidebar {} @@ -205,22 +194,32 @@ impl Sidebar { editor }); - let observe_subscription = cx.observe_in( + cx.subscribe_in( &multi_workspace, window, - |this, _multi_workspace, window, cx| { - this.update_entries(window, cx); + |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event { + MultiWorkspaceEvent::ActiveWorkspaceChanged => { + this.focused_thread = None; + this.update_entries(cx); + } + MultiWorkspaceEvent::WorkspaceAdded(workspace) => { + this.subscribe_to_workspace(workspace, window, cx); + this.update_entries(cx); + } + MultiWorkspaceEvent::WorkspaceRemoved(_) => { + this.update_entries(cx); + } }, - ); + ) + .detach(); - let filter_subscription = cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| { + cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| { if let editor::EditorEvent::BufferEdited = event { let query = this.filter_editor.read(cx).text(cx); if !query.is_empty() { this.selection.take(); } - this.rebuild_contents(cx); - this.list_state.reset(this.contents.entries.len()); + this.update_entries(cx); if !query.is_empty() { this.selection = this .contents @@ -235,11 +234,30 @@ impl Sidebar { } }); } - cx.notify(); } + }) + .detach(); + + let thread_store = ThreadStore::global(cx); + cx.observe_in(&thread_store, window, |this, _, _window, cx| { + this.update_entries(cx); + }) + .detach(); + + cx.observe_flag::(window, |_is_enabled, this, _window, cx| { + this.update_entries(cx); + }) + .detach(); + + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + cx.defer_in(window, move |this, window, cx| { + for workspace in &workspaces { + this.subscribe_to_workspace(workspace, window, cx); + } + this.update_entries(cx); }); - let mut this = Self { + Self { multi_workspace: multi_workspace.downgrade(), width: DEFAULT_WIDTH, focus_handle, @@ -247,91 +265,86 @@ impl Sidebar { list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), contents: SidebarContents::default(), selection: None, + focused_thread: None, + active_entry_index: None, collapsed_groups: HashSet::new(), expanded_groups: HashSet::new(), - _subscriptions: vec![observe_subscription, filter_subscription], - _project_subscriptions: Vec::new(), - _agent_panel_subscriptions: Vec::new(), - _thread_store_subscription: None, - }; - this.update_entries(window, cx); - this + } } - fn subscribe_to_projects( - &mut self, + fn subscribe_to_workspace( + &self, + workspace: &Entity, window: &mut Window, cx: &mut Context, - ) -> Vec { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return Vec::new(); - }; - let projects: Vec<_> = multi_workspace - .read(cx) - .workspaces() - .iter() - .map(|w| w.read(cx).project().clone()) - .collect(); + ) { + let project = workspace.read(cx).project().clone(); + cx.subscribe_in( + &project, + window, + |this, _project, event, _window, cx| match event { + ProjectEvent::WorktreeAdded(_) + | ProjectEvent::WorktreeRemoved(_) + | ProjectEvent::WorktreeOrderChanged => { + this.update_entries(cx); + } + _ => {} + }, + ) + .detach(); - projects - .iter() - .map(|project| { - cx.subscribe_in( - project, - window, - |this, _project, event, window, cx| match event { - ProjectEvent::WorktreeAdded(_) - | ProjectEvent::WorktreeRemoved(_) - | ProjectEvent::WorktreeOrderChanged => { - this.update_entries(window, cx); - } - _ => {} - }, - ) - }) - .collect() + cx.subscribe_in( + workspace, + window, + |this, _workspace, event: &workspace::Event, window, cx| { + if let workspace::Event::PanelAdded(view) = event { + if let Ok(agent_panel) = view.clone().downcast::() { + this.subscribe_to_agent_panel(&agent_panel, window, cx); + } + } + }, + ) + .detach(); + + if let Some(agent_panel) = workspace.read(cx).panel::(cx) { + self.subscribe_to_agent_panel(&agent_panel, window, cx); + } } - fn subscribe_to_agent_panels( - &mut self, + fn subscribe_to_agent_panel( + &self, + agent_panel: &Entity, window: &mut Window, cx: &mut Context, - ) -> Vec { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return Vec::new(); - }; - let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().to_vec(); - - workspaces - .iter() - .map(|workspace| { - if let Some(agent_panel) = workspace.read(cx).panel::(cx) { - cx.subscribe_in( - &agent_panel, - window, - |this, _, _event: &AgentPanelEvent, window, cx| { - this.update_entries(window, cx); - }, - ) - } else { - cx.observe_in(workspace, window, |this, _, window, cx| { - this.update_entries(window, cx); - }) + ) { + cx.subscribe_in( + agent_panel, + window, + |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event { + AgentPanelEvent::ActiveViewChanged => { + if let Some(thread) = agent_panel.read(cx).active_connection_view() + && let Some(session_id) = thread.read(cx).parent_id(cx) + { + this.focused_thread = Some(session_id); + } + this.update_entries(cx); } - }) - .collect() - } - - fn subscribe_to_thread_store(&mut self, window: &mut Window, cx: &mut Context) { - if self._thread_store_subscription.is_some() { - return; - } - if let Some(thread_store) = ThreadStore::try_global(cx) { - self._thread_store_subscription = - Some(cx.observe_in(&thread_store, window, |this, _, window, cx| { - this.update_entries(window, cx); - })); - } + AgentPanelEvent::ThreadFocused => { + let new_focused = agent_panel + .read(cx) + .active_connection_view() + .and_then(|thread| thread.read(cx).parent_id(cx)); + if new_focused != this.focused_thread { + this.focused_thread = new_focused; + this.update_entries(cx); + } + } + AgentPanelEvent::BackgroundThreadChanged => { + this.update_entries(cx); + } + }, + ) + .detach(); } fn all_thread_infos_for_workspace( @@ -386,13 +399,6 @@ impl Sidebar { let mw = multi_workspace.read(cx); let workspaces = mw.workspaces().to_vec(); let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); - let active_workspace_index = active_workspace - .and_then(|active| { - workspaces - .iter() - .position(|w| w.entity_id() == active.entity_id()) - }) - .unwrap_or(0); let thread_store = ThreadStore::try_global(cx); let query = self.filter_editor.read(cx).text(cx); @@ -416,7 +422,7 @@ impl Sidebar { let mut entries = Vec::new(); let mut notified_threads = previous.notified_threads; - for (index, workspace) in workspaces.iter().enumerate() { + for workspace in workspaces.iter() { let (path_list, label) = workspace_path_list_and_label(workspace, cx); let is_collapsed = self.collapsed_groups.contains(&path_list); @@ -433,7 +439,7 @@ impl Sidebar { icon_from_external_svg: None, status: AgentThreadStatus::default(), diff_stats: None, - workspace_index: index, + workspace: workspace.clone(), is_live: false, is_background: false, highlight_positions: Vec::new(), @@ -455,7 +461,7 @@ impl Sidebar { status, icon, icon_from_external_svg, - workspace_index: _, + workspace: _, is_live, is_background, .. @@ -473,7 +479,7 @@ impl Sidebar { // Update notification state for live threads. for thread in &threads { if let ListEntry::Thread { - workspace_index, + workspace: thread_workspace, session_info, status, is_background, @@ -484,13 +490,19 @@ impl Sidebar { if *is_background && *status == AgentThreadStatus::Completed { notified_threads.insert(session_id.clone()); } else if *status == AgentThreadStatus::Completed - && *workspace_index != active_workspace_index + && active_workspace + .as_ref() + .is_none_or(|active| active != thread_workspace) && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running) { notified_threads.insert(session_id.clone()); } - if *workspace_index == active_workspace_index && !*is_background { + if active_workspace + .as_ref() + .is_some_and(|active| active == thread_workspace) + && !*is_background + { notified_threads.remove(session_id); } } @@ -540,7 +552,7 @@ impl Sidebar { entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, - workspace_index: index, + workspace: workspace.clone(), highlight_positions: workspace_highlight_positions, }); entries.extend(matched_threads); @@ -548,7 +560,7 @@ impl Sidebar { entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, - workspace_index: index, + workspace: workspace.clone(), highlight_positions: Vec::new(), }); @@ -578,6 +590,7 @@ impl Sidebar { if total == 0 { entries.push(ListEntry::NewThread { path_list: path_list.clone(), + workspace: workspace.clone(), }); } } @@ -599,40 +612,46 @@ impl Sidebar { }; } - fn update_entries(&mut self, window: &mut Window, cx: &mut Context) { - let multi_workspace = self.multi_workspace.clone(); - cx.defer_in(window, move |this, window, cx| { - let Some(multi_workspace) = multi_workspace.upgrade() else { - return; - }; - if !multi_workspace.read(cx).multi_workspace_enabled(cx) { - return; - } - - this._project_subscriptions = this.subscribe_to_projects(window, cx); - this._agent_panel_subscriptions = this.subscribe_to_agent_panels(window, cx); - this.subscribe_to_thread_store(window, cx); + fn update_entries(&mut self, cx: &mut Context) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + if !multi_workspace.read(cx).multi_workspace_enabled(cx) { + return; + } - let had_notifications = this.has_notifications(cx); + let had_notifications = self.has_notifications(cx); - this.rebuild_contents(cx); + self.rebuild_contents(cx); + self.recompute_active_entry_index(cx); - this.list_state.reset(this.contents.entries.len()); + self.list_state.reset(self.contents.entries.len()); - if let Some(selection) = this.selection { - if selection >= this.contents.entries.len() { - this.selection = this.contents.entries.len().checked_sub(1); - } - } + if had_notifications != self.has_notifications(cx) { + multi_workspace.update(cx, |_, cx| { + cx.notify(); + }); + } - if had_notifications != this.has_notifications(cx) { - multi_workspace.update(cx, |_, cx| { - cx.notify(); - }); - } + cx.notify(); + } - cx.notify(); - }); + fn recompute_active_entry_index(&mut self, cx: &App) { + self.active_entry_index = if let Some(session_id) = &self.focused_thread { + self.contents.entries.iter().position(|entry| { + matches!(entry, ListEntry::Thread { session_info, .. } if &session_info.session_id == session_id) + }) + } else { + let active_workspace = self + .multi_workspace + .upgrade() + .map(|mw| mw.read(cx).workspace().clone()); + active_workspace.and_then(|active| { + self.contents.entries.iter().position(|entry| { + matches!(entry, ListEntry::ProjectHeader { workspace, .. } if workspace == &active) + }) + }) + }; } fn render_list_entry( @@ -646,6 +665,7 @@ impl Sidebar { }; let is_focused = self.focus_handle.is_focused(window) || self.filter_editor.focus_handle(cx).is_focused(window); + // is_selected means the keyboard selector is here. let is_selected = is_focused && self.selection == Some(ix); let is_group_header_after_first = @@ -655,23 +675,17 @@ impl Sidebar { ListEntry::ProjectHeader { path_list, label, - workspace_index, - highlight_positions, - } => self.render_project_header( - ix, - path_list, - label, - *workspace_index, + workspace, highlight_positions, - is_selected, - cx, - ), + } => { + self.render_project_header(ix, path_list, label, workspace, highlight_positions, cx) + } ListEntry::Thread { session_info, icon, icon_from_external_svg, status, - workspace_index, + workspace, highlight_positions, .. } => self.render_thread( @@ -680,7 +694,7 @@ impl Sidebar { *icon, icon_from_external_svg.clone(), *status, - *workspace_index, + workspace, highlight_positions, is_selected, cx, @@ -689,11 +703,14 @@ impl Sidebar { path_list, remaining_count, } => self.render_view_more(ix, path_list, *remaining_count, is_selected, cx), - ListEntry::NewThread { path_list } => { - self.render_new_thread(ix, path_list, is_selected, cx) - } + ListEntry::NewThread { + path_list, + workspace, + } => self.render_new_thread(ix, path_list, workspace, is_selected, cx), }; + // add the blue border here, not in the sub methods + if is_group_header_after_first { v_flex() .w_full() @@ -711,9 +728,8 @@ impl Sidebar { ix: usize, path_list: &PathList, label: &SharedString, - workspace_index: usize, + workspace: &Entity, highlight_positions: &[usize], - is_selected: bool, cx: &mut Context, ) -> AnyElement { let id = SharedString::from(format!("project-header-{}", ix)); @@ -726,17 +742,24 @@ impl Sidebar { } else { IconName::ChevronDown }; - let path_list_for_new_thread = path_list.clone(); - let path_list_for_remove = path_list.clone(); + let workspace_for_new_thread = workspace.clone(); + let workspace_for_remove = workspace.clone(); + let workspace_for_activate = workspace.clone(); let path_list_for_toggle = path_list.clone(); - let workspace_count = self - .multi_workspace - .upgrade() + let multi_workspace = self.multi_workspace.upgrade(); + let workspace_count = multi_workspace + .as_ref() .map_or(0, |mw| mw.read(cx).workspaces().len()); + let is_active_workspace = self.focused_thread.is_none() + && multi_workspace + .as_ref() + .is_some_and(|mw| mw.read(cx).workspace() == workspace); + + // TODO: if is_selected, draw a blue border around the item. ListItem::new(id) .group_name(&group) - .toggle_state(is_selected) + .toggle_state(is_active_workspace) .child( h_flex() .px_1() @@ -783,7 +806,7 @@ impl Sidebar { .tooltip(Tooltip::text("New Thread")) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; - this.create_new_thread(&path_list_for_new_thread, window, cx); + this.create_new_thread(&workspace_for_new_thread, window, cx); })), ) .when(workspace_count > 1, |this| { @@ -797,7 +820,7 @@ impl Sidebar { .tooltip(Tooltip::text("Remove Project")) .on_click(cx.listener( move |this, _, window, cx| { - this.remove_workspace(&path_list_for_remove, window, cx); + this.remove_workspace(&workspace_for_remove, window, cx); }, )), ) @@ -805,14 +828,14 @@ impl Sidebar { ) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; - this.activate_workspace(workspace_index, window, cx); + this.activate_workspace(&workspace_for_activate, window, cx); })) .into_any_element() } fn activate_workspace( &mut self, - workspace_index: usize, + workspace: &Entity, window: &mut Window, cx: &mut Context, ) { @@ -820,36 +843,43 @@ impl Sidebar { return; }; + self.focused_thread = None; + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), cx); + }); + multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate_index(workspace_index, window, cx); + multi_workspace.focus_active_workspace(window, cx); }); } fn remove_workspace( &mut self, - path_list: &PathList, + workspace: &Entity, 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(); - - let Some(workspace_index) = workspace_index_for_path_list(&workspaces, path_list, cx) - else { - return; - }; multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.remove_workspace(workspace_index, window, 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, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { if self.collapsed_groups.contains(path_list) { @@ -857,7 +887,7 @@ impl Sidebar { } else { self.collapsed_groups.insert(path_list.clone()); } - self.update_entries(window, cx); + self.update_entries(cx); } fn focus_in(&mut self, _window: &mut Window, cx: &mut Context) { @@ -869,7 +899,7 @@ impl Sidebar { fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { if self.reset_filter_editor_text(window, cx) { - self.update_entries(window, cx); + self.update_entries(cx); } else { self.focus_handle.focus(window, cx); } @@ -948,29 +978,27 @@ impl Sidebar { }; match entry { - ListEntry::ProjectHeader { - workspace_index, .. - } => { - let workspace_index = *workspace_index; - self.activate_workspace(workspace_index, window, cx); + ListEntry::ProjectHeader { workspace, .. } => { + let workspace = workspace.clone(); + self.activate_workspace(&workspace, window, cx); } ListEntry::Thread { session_info, - workspace_index, + workspace, .. } => { let session_info = session_info.clone(); - let workspace_index = *workspace_index; - self.activate_thread(session_info, workspace_index, window, cx); + let workspace = workspace.clone(); + self.activate_thread(session_info, &workspace, window, cx); } ListEntry::ViewMore { path_list, .. } => { let path_list = path_list.clone(); self.expanded_groups.insert(path_list); - self.update_entries(window, cx); + self.update_entries(cx); } - ListEntry::NewThread { path_list } => { - let path_list = path_list.clone(); - self.create_new_thread(&path_list, window, cx); + ListEntry::NewThread { workspace, .. } => { + let workspace = workspace.clone(); + self.create_new_thread(&workspace, window, cx); } } } @@ -978,7 +1006,7 @@ impl Sidebar { fn activate_thread( &mut self, session_info: acp_thread::AgentSessionInfo, - workspace_index: usize, + workspace: &Entity, window: &mut Window, cx: &mut Context, ) { @@ -987,22 +1015,24 @@ impl Sidebar { }; multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate_index(workspace_index, window, cx); + multi_workspace.activate(workspace.clone(), cx); }); - let workspaces = multi_workspace.read(cx).workspaces().to_vec(); - if let Some(workspace) = workspaces.get(workspace_index) { - if let Some(agent_panel) = workspace.read(cx).panel::(cx) { - agent_panel.update(cx, |panel, cx| { - panel.load_agent_thread(session_info, window, cx); - }); - } + + workspace.update(cx, |workspace, cx| { + workspace.open_panel::(window, cx); + }); + + if let Some(agent_panel) = workspace.read(cx).panel::(cx) { + agent_panel.update(cx, |panel, cx| { + panel.load_agent_thread(session_info, window, cx); + }); } } fn expand_selected_entry( &mut self, _: &ExpandSelectedEntry, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { let Some(ix) = self.selection else { return }; @@ -1012,7 +1042,7 @@ impl Sidebar { if self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); self.collapsed_groups.remove(&path_list); - self.update_entries(window, cx); + self.update_entries(cx); } else if ix + 1 < self.contents.entries.len() { self.selection = Some(ix + 1); self.list_state.scroll_to_reveal_item(ix + 1); @@ -1026,7 +1056,7 @@ impl Sidebar { fn collapse_selected_entry( &mut self, _: &CollapseSelectedEntry, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { let Some(ix) = self.selection else { return }; @@ -1036,7 +1066,7 @@ impl Sidebar { if !self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); self.collapsed_groups.insert(path_list); - self.update_entries(window, cx); + self.update_entries(cx); } } Some( @@ -1049,7 +1079,7 @@ impl Sidebar { let path_list = path_list.clone(); self.selection = Some(i); self.collapsed_groups.insert(path_list); - self.update_entries(window, cx); + self.update_entries(cx); break; } } @@ -1065,9 +1095,9 @@ impl Sidebar { icon: IconName, icon_from_external_svg: Option, status: AgentThreadStatus, - workspace_index: usize, + workspace: &Entity, highlight_positions: &[usize], - is_selected: bool, + _is_selected: bool, cx: &mut Context, ) -> AnyElement { let has_notification = self.contents.is_thread_notified(&session_info.session_id); @@ -1077,6 +1107,7 @@ impl Sidebar { .clone() .unwrap_or_else(|| "Untitled".into()); let session_info = session_info.clone(); + let workspace = workspace.clone(); let id = SharedString::from(format!("thread-entry-{}", ix)); ThreadItem::new(id, title) @@ -1087,10 +1118,10 @@ impl Sidebar { .highlight_positions(highlight_positions.to_vec()) .status(status) .notified(has_notification) - .selected(is_selected) + .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; - this.activate_thread(session_info.clone(), workspace_index, window, cx); + this.activate_thread(session_info.clone(), &workspace, window, cx); })) .into_any_element() } @@ -1147,55 +1178,47 @@ impl Sidebar { .child(Label::new("View More")) .child(Label::new(count).color(Color::Muted).size(LabelSize::Small)), ) - .on_click(cx.listener(move |this, _, window, cx| { + .on_click(cx.listener(move |this, _, _window, cx| { this.selection = None; this.expanded_groups.insert(path_list.clone()); - this.update_entries(window, cx); + this.update_entries(cx); })) .into_any_element() } fn create_new_thread( &mut self, - path_list: &PathList, + workspace: &Entity, 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(); - - let workspace_index = workspace_index_for_path_list(&workspaces, path_list, cx); - - let Some(workspace_index) = workspace_index else { - return; - }; multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate_index(workspace_index, window, cx); + multi_workspace.activate(workspace.clone(), cx); }); - if let Some(workspace) = workspaces.get(workspace_index) { - workspace.update(cx, |workspace, cx| { - if let Some(agent_panel) = workspace.panel::(cx) { - agent_panel.update(cx, |panel, cx| { - panel.new_thread(&NewThread, window, cx); - }); - } - workspace.focus_panel::(window, cx); - }); - } + workspace.update(cx, |workspace, cx| { + if let Some(agent_panel) = workspace.panel::(cx) { + agent_panel.update(cx, |panel, cx| { + panel.new_thread(&NewThread, window, cx); + }); + } + workspace.focus_panel::(window, cx); + }); } fn render_new_thread( &self, ix: usize, - path_list: &PathList, + _path_list: &PathList, + workspace: &Entity, is_selected: bool, cx: &mut Context, ) -> AnyElement { - let path_list = path_list.clone(); + let workspace = workspace.clone(); div() .w_full() @@ -1214,7 +1237,7 @@ impl Sidebar { .toggle_state(is_selected) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; - this.create_new_thread(&path_list, window, cx); + this.create_new_thread(&workspace, window, cx); })), ) .into_any_element() @@ -1390,7 +1413,7 @@ impl Render for Sidebar { .tooltip(Tooltip::text("Clear Search")) .on_click(cx.listener(|this, _, window, cx| { this.reset_filter_editor_text(window, cx); - this.update_entries(window, cx); + this.update_entries(cx); })), ) }), @@ -1475,10 +1498,9 @@ mod tests { multi_workspace: &Entity, cx: &mut gpui::VisualTestContext, ) -> Entity { - let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { - let mw_handle = cx.entity(); - cx.new(|cx| Sidebar::new(mw_handle, window, cx)) - }); + 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_in(cx, |mw, window, cx| { mw.register_sidebar(sidebar.clone(), window, cx); }); @@ -1819,6 +1841,7 @@ mod tests { 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")]); @@ -1832,7 +1855,7 @@ mod tests { ListEntry::ProjectHeader { path_list: expanded_path.clone(), label: "expanded-project".into(), - workspace_index: 0, + workspace: workspace.clone(), highlight_positions: Vec::new(), }, // Thread with default (Completed) status, not active @@ -1848,7 +1871,7 @@ mod tests { icon_from_external_svg: None, status: AgentThreadStatus::Completed, diff_stats: None, - workspace_index: 0, + workspace: workspace.clone(), is_live: false, is_background: false, highlight_positions: Vec::new(), @@ -1866,7 +1889,7 @@ mod tests { icon_from_external_svg: None, status: AgentThreadStatus::Running, diff_stats: None, - workspace_index: 0, + workspace: workspace.clone(), is_live: true, is_background: false, highlight_positions: Vec::new(), @@ -1884,7 +1907,7 @@ mod tests { icon_from_external_svg: None, status: AgentThreadStatus::Error, diff_stats: None, - workspace_index: 1, + workspace: workspace.clone(), is_live: true, is_background: false, highlight_positions: Vec::new(), @@ -1902,7 +1925,7 @@ mod tests { icon_from_external_svg: None, status: AgentThreadStatus::WaitingForConfirmation, diff_stats: None, - workspace_index: 0, + workspace: workspace.clone(), is_live: false, is_background: false, highlight_positions: Vec::new(), @@ -1920,7 +1943,7 @@ mod tests { icon_from_external_svg: None, status: AgentThreadStatus::Completed, diff_stats: None, - workspace_index: 1, + workspace: workspace.clone(), is_live: true, is_background: true, highlight_positions: Vec::new(), @@ -1934,7 +1957,7 @@ mod tests { ListEntry::ProjectHeader { path_list: collapsed_path.clone(), label: "collapsed-project".into(), - workspace_index: 1, + workspace: workspace.clone(), highlight_positions: Vec::new(), }, ]; @@ -2129,6 +2152,16 @@ mod tests { multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), 0 ); + + // Focus should have moved out of the sidebar to the workspace center. + let workspace_0 = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); + workspace_0.update_in(cx, |workspace, window, cx| { + let pane_focus = workspace.active_pane().read(cx).focus_handle(cx); + assert!( + pane_focus.contains_focused(window, cx), + "Confirming a project header should focus the workspace center pane" + ); + }); } #[gpui::test] @@ -3045,9 +3078,9 @@ mod tests { ); // Confirm on the historical (non-live) thread at index 1. - // Before the fix, workspace_index was Option and historical - // threads had None, so activate_thread early-returned without - // switching the workspace. + // 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); @@ -3181,4 +3214,235 @@ mod tests { 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_thread_to_store(&session_id_a, &path_list_a, 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: no focused thread ────────────────────────────── + // Workspace B is active (just added), so its header is the active entry. + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread, None, + "Initially no thread should be focused" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), + "Active entry should be the active workspace header" + ); + }); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_thread( + acp_thread::AgentSessionInfo { + session_id: session_id_a.clone(), + cwd: None, + title: Some("Test".into()), + updated_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" + ); + let active_entry = sidebar.active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_a), + "Active entry should be the clicked thread" + ); + }); + + 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_thread_to_store(&session_id_b, &path_list_b, 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( + acp_thread::AgentSessionInfo { + session_id: session_id_b.clone(), + cwd: None, + title: Some("Thread B".into()), + updated_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" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_b), + "Active entry should be the cross-workspace thread" + ); + }); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_next_workspace(window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread, None, + "External workspace switch should clear focused_thread" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), + "Active entry should be the workspace header after external switch" + ); + }); + + let connection_b2 = StubAgentConnection::new(); + connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("New thread".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_thread_to_store(&session_id_b2, &path_list_b, cx).await; + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Opening a thread externally should set 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_b2), + "Defocusing the sidebar should not clear focused_thread" + ); + }); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_workspace(&workspace_b, window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread, None, + "Clicking a workspace header should clear focused_thread" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), + "Active entry should be the workspace header" + ); + }); + + // ── 8. Focusing the agent panel thread restores focused_thread ──── + // Workspace B still has session_id_b2 loaded in the agent panel. + // Clicking into the thread (simulated by focusing its view) should + // set focused_thread via the ThreadFocused event. + panel_b.update_in(cx, |panel, window, cx| { + if let Some(thread_view) = panel.active_connection_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" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_b2), + "Active entry should be the focused thread" + ); + }); + } } diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index cd77f4fe30461b5f726c3bcd2f5f78b561e4d415..3f5981178fe118f41196538e1a22960bd55644d0 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -35,6 +35,12 @@ actions!( ] ); +pub enum MultiWorkspaceEvent { + ActiveWorkspaceChanged, + WorkspaceAdded(Entity), + WorkspaceRemoved(EntityId), +} + pub enum SidebarEvent { Open, Close, @@ -109,6 +115,8 @@ pub struct MultiWorkspace { _subscriptions: Vec, } +impl EventEmitter for MultiWorkspace {} + impl MultiWorkspace { pub fn new(workspace: Entity, window: &mut Window, cx: &mut Context) -> Self { let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| { @@ -304,6 +312,7 @@ impl MultiWorkspace { if !self.multi_workspace_enabled(cx) { self.workspaces[0] = workspace; self.active_workspace_index = 0; + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); cx.notify(); return; } @@ -321,7 +330,11 @@ impl MultiWorkspace { cx: &mut Context, ) -> usize { let index = self.add_workspace(workspace, cx); + let changed = self.active_workspace_index != index; self.active_workspace_index = index; + if changed { + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); + } cx.notify(); index } @@ -338,7 +351,8 @@ impl MultiWorkspace { }); } Self::subscribe_to_workspace(&workspace, cx); - self.workspaces.push(workspace); + self.workspaces.push(workspace.clone()); + cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); cx.notify(); self.workspaces.len() - 1 } @@ -349,9 +363,13 @@ impl MultiWorkspace { index < self.workspaces.len(), "workspace index out of bounds" ); + let changed = self.active_workspace_index != index; self.active_workspace_index = index; self.serialize(cx); self.focus_active_workspace(window, cx); + if changed { + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); + } cx.notify(); } @@ -406,7 +424,7 @@ impl MultiWorkspace { } } - fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) { + pub fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) { // If a dock panel is zoomed, focus it instead of the center pane. // Otherwise, focusing the center pane triggers dismiss_zoomed_items_to_reveal // which closes the zoomed dock. @@ -633,6 +651,10 @@ impl MultiWorkspace { self.serialize(cx); self.focus_active_workspace(window, cx); + cx.emit(MultiWorkspaceEvent::WorkspaceRemoved( + removed_workspace.entity_id(), + )); + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); cx.notify(); } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index aba2fc9d98ed6e2178a925029ae7e040004cb102..b94a7b1091c664502fc7dcad0f753b71951ec423 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -27,9 +27,9 @@ mod workspace_settings; pub use crate::notifications::NotificationFrame; pub use dock::Panel; pub use multi_workspace::{ - DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, NewWorkspaceInWindow, - NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent, SidebarHandle, - ToggleWorkspaceSidebar, + DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, + NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent, + SidebarHandle, ToggleWorkspaceSidebar, }; pub use path_list::{PathList, SerializedPathList}; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; @@ -1230,6 +1230,7 @@ pub enum Event { ZoomChanged, ModalOpened, Activate, + PanelAdded(AnyView), } #[derive(Debug, Clone)] @@ -2129,10 +2130,13 @@ impl Workspace { let dock_position = panel.position(window, cx); let dock = self.dock_at_position(dock_position); + let any_panel = panel.to_any(); dock.update(cx, |dock, cx| { dock.add_panel(panel, self.weak_self.clone(), window, cx) }); + + cx.emit(Event::PanelAdded(any_panel)); } pub fn remove_panel( diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index c1e00b817abc8817cc81dc528c66901011f134aa..af7c6df1f83c6621715fbbab3f665f1fdbc18c65 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -371,15 +371,12 @@ pub fn initialize_workspace( }) .detach(); - cx.observe_new(|multi_workspace: &mut MultiWorkspace, window, cx| { + cx.observe_new(|_multi_workspace: &mut MultiWorkspace, window, cx| { let Some(window) = window else { return; }; - let multi_workspace_handle = cx.entity(); - let sidebar = cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx)); - multi_workspace.register_sidebar(sidebar, window, cx); - let multi_workspace_handle = multi_workspace_handle.downgrade(); + let multi_workspace_handle = cx.entity().downgrade(); window.on_window_should_close(cx, move |window, cx| { multi_workspace_handle .update(cx, |multi_workspace, cx| { @@ -389,6 +386,20 @@ pub fn initialize_workspace( }) .unwrap_or(true) }); + + let window_handle = window.window_handle(); + let multi_workspace_handle = cx.entity(); + cx.defer(move |cx| { + window_handle + .update(cx, |_, window, cx| { + let sidebar = + cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx)); + multi_workspace_handle.update(cx, |multi_workspace, cx| { + multi_workspace.register_sidebar(sidebar, window, cx); + }); + }) + .ok(); + }); }) .detach(); From a70c295658be39e27ada5969a89cab20e94d05f3 Mon Sep 17 00:00:00 2001 From: Xin Zhao Date: Fri, 6 Mar 2026 15:51:30 +0800 Subject: [PATCH 010/219] python: Fix conda environment not auto-activated in remote terminal (#50895) Closes #50619 In the conda activation script building procedure, Zed currently performs a file check for the conda executable on the client side. When in remote development, this check always fails because the file exists on the remote host, not the local machine. Since `pet` already handles existence checks, removing this redundant check allows the activation to proceed. It is also better to let any potential issues (like permissions) show up in the terminal rather than silently skipping the activation. This addresses the root cause for remote development, which is different from the approach in #50715 that focuses on shell hooks. **The video recording** https://github.com/user-attachments/assets/62967351-e3c5-4814-aec4-b2332940e7e3 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 conda environment not auto-activating in the terminal during remote development sessions. --- crates/languages/src/python.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 722f4bb795ea857a9d399ef5b291beb8503f1c92..95bfc798414f5d3629e1ea46f54d14a7ed58a8d4 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -1378,12 +1378,9 @@ impl ToolchainLister for PythonToolchainProvider { match toolchain.environment.kind { Some(PythonEnvironmentKind::Conda) => { - let Some(manager_info) = &toolchain.environment.manager else { + if toolchain.environment.manager.is_none() { return vec![]; }; - if smol::fs::metadata(&manager_info.executable).await.is_err() { - return vec![]; - } let manager = match conda_manager { settings::CondaManager::Conda => "conda", From e9ffef0bfa1ed1e668b550b0889c993fbcbe89ac Mon Sep 17 00:00:00 2001 From: Karthik Nishanth <7759435+nishanthkarthik@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:53:24 -0800 Subject: [PATCH 011/219] editor: Hide hover links when mouse cursor is not visible (#50424) When I am in the middle of editing, pressing Ctrl would counter-intuitively highlight links even when the mouse cursor is hidden. This change considers the state of the mouse cursor before painting links on hover. Before: Modifier pressed, cursor hidden, link visible image After: Modifier pressed, cursor hidden (red dot indicates current cursor position) image 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 spurious link highlighting when mouse cursor is hidden Fixes #50776 --- crates/editor/src/hover_links.rs | 80 +++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index d4877a5f1986685bea37f243edf4ac8bbdfdf9f5..659a383d6b20129909b4c3f2d7bdbfbe5e580f4e 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -119,7 +119,7 @@ impl Editor { cx: &mut Context, ) { let hovered_link_modifier = Editor::is_cmd_or_ctrl_pressed(&modifiers, cx); - if !hovered_link_modifier || self.has_pending_selection() { + if !hovered_link_modifier || self.has_pending_selection() || self.mouse_cursor_hidden { self.hide_hovered_link(cx); return; } @@ -782,7 +782,7 @@ fn surrounding_filename( mod tests { use super::*; use crate::{ - DisplayPoint, + DisplayPoint, HideMouseCursorOrigin, display_map::ToDisplayPoint, editor_tests::init_test, inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels}, @@ -1362,6 +1362,82 @@ mod tests { ); } + #[gpui::test] + async fn test_hover_preconditions(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + ..Default::default() + }, + cx, + ) + .await; + + macro_rules! assert_no_highlight { + ($cx:expr) => { + // No highlight + $cx.update_editor(|editor, window, cx| { + assert!( + editor + .snapshot(window, cx) + .text_highlight_ranges(HighlightKey::HoveredLinkState) + .unwrap_or_default() + .1 + .is_empty() + ); + }); + }; + } + + // No link + cx.set_state(indoc! {" + Let's test a [complex](https://zed.dev/channel/) caseˇ. + "}); + assert_no_highlight!(cx); + + // No modifier + let screen_coord = cx.pixel_position(indoc! {" + Let's test a [complex](https://zed.dev/channel/ˇ) case. + "}); + cx.simulate_mouse_move(screen_coord, None, Modifiers::none()); + assert_no_highlight!(cx); + + // Modifier active + let screen_coord = cx.pixel_position(indoc! {" + Let's test a [complex](https://zed.dev/channeˇl/) case. + "}); + cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key()); + cx.assert_editor_text_highlights( + HighlightKey::HoveredLinkState, + indoc! {" + Let's test a [complex](«https://zed.dev/channel/ˇ») case. + "}, + ); + + // Cursor hidden with secondary key + let screen_coord = cx.pixel_position(indoc! {" + Let's test a [complex](https://zed.dev/ˇchannel/) case. + "}); + cx.simulate_mouse_move(screen_coord, None, Modifiers::none()); + cx.update_editor(|editor, _, cx| { + editor.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + }); + cx.simulate_modifiers_change(Modifiers::secondary_key()); + assert_no_highlight!(cx); + + // Cursor active again + let screen_coord = cx.pixel_position(indoc! {" + Let's test a [complex](https://ˇzed.dev/channel/) case. + "}); + cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key()); + cx.assert_editor_text_highlights( + HighlightKey::HoveredLinkState, + indoc! {" + Let's test a [complex](«https://zed.dev/channel/ˇ») case. + "}, + ); + } + #[gpui::test] async fn test_urls_at_beginning_of_buffer(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); From d2952be519471d2609a3ad4fdc4132c58f4b818a Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Fri, 6 Mar 2026 15:01:18 +0530 Subject: [PATCH 012/219] zed: Fix shared agent thread links not opening (#50915) Release Notes: - Fixed an issue where shared agent thread URLs would not open. --- crates/zed/src/zed/open_listener.rs | 120 +++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index a7d1da663b3da6848d3552707f261fe02beba56b..cec4da4cf819943345f66544575565a03955bfba 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -110,8 +110,6 @@ impl OpenRequest { this.kind = Some(OpenRequestKind::Extension { extension_id: extension_id.to_string(), }); - } else if let Some(agent_path) = url.strip_prefix("zed://agent") { - this.parse_agent_url(agent_path) } else if let Some(session_id_str) = url.strip_prefix("zed://agent/shared/") { if uuid::Uuid::parse_str(session_id_str).is_ok() { this.kind = Some(OpenRequestKind::SharedAgentThread { @@ -120,6 +118,8 @@ impl OpenRequest { } else { log::error!("Invalid session ID in URL: {}", session_id_str); } + } else if let Some(agent_path) = url.strip_prefix("zed://agent") { + this.parse_agent_url(agent_path) } else if let Some(schema_path) = url.strip_prefix("zed://schemas/") { this.kind = Some(OpenRequestKind::BuiltinJsonSchema { schema_path: schema_path.to_string(), @@ -772,6 +772,122 @@ mod tests { assert_eq!(request.open_paths, vec!["/"]); } + #[gpui::test] + fn test_parse_agent_url(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://agent".into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind { + Some(OpenRequestKind::AgentPanel { initial_prompt }) => { + assert_eq!(initial_prompt, None); + } + _ => panic!("Expected AgentPanel kind"), + } + } + + #[gpui::test] + fn test_parse_agent_url_with_prompt(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://agent?prompt=Write%20me%20a%20script%0AThanks".into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind { + Some(OpenRequestKind::AgentPanel { initial_prompt }) => { + assert_eq!( + initial_prompt, + Some("Write me a script\nThanks".to_string()) + ); + } + _ => panic!("Expected AgentPanel kind"), + } + } + + #[gpui::test] + fn test_parse_agent_url_with_empty_prompt(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://agent?prompt=".into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind { + Some(OpenRequestKind::AgentPanel { initial_prompt }) => { + assert_eq!(initial_prompt, None); + } + _ => panic!("Expected AgentPanel kind"), + } + } + + #[gpui::test] + fn test_parse_shared_agent_thread_url(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + let session_id = "123e4567-e89b-12d3-a456-426614174000"; + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec![format!("zed://agent/shared/{session_id}")], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind { + Some(OpenRequestKind::SharedAgentThread { + session_id: parsed_session_id, + }) => { + assert_eq!(parsed_session_id, session_id); + } + _ => panic!("Expected SharedAgentThread kind"), + } + } + + #[gpui::test] + fn test_parse_shared_agent_thread_url_with_invalid_uuid(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://agent/shared/not-a-uuid".into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + assert!(request.kind.is_none()); + } + #[gpui::test] fn test_parse_git_commit_url(cx: &mut TestAppContext) { let _app_state = init_test(cx); From e3d0a35b73d93e51ca54571009ca7577bf2eefd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Soares?= <37777652+Dnreikronos@users.noreply.github.com> Date: Fri, 6 Mar 2026 06:31:33 -0300 Subject: [PATCH 013/219] Fix `formatter: "auto"` to skip language servers that can't format (#50661) Closes #50631 Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed `formatter: "auto"` silently doing nothing when the first language server for a language doesn't support formatting (e.g., Dependi before Tombi for TOML). --- crates/editor/src/editor_tests.rs | 90 +++++++++++++++++++++++++++++++ crates/project/src/lsp_store.rs | 15 ++++-- 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index e3d5e698153e39fd4de04893b50a804dc2105b99..8b866563636f6fe494bdd8d941458defa786c0da 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -12915,6 +12915,96 @@ async fn test_document_format_during_save(cx: &mut TestAppContext) { } } +#[gpui::test] +async fn test_auto_formatter_skips_server_without_formatting(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), Default::default()).await; + + let project = Project::test(fs, [path!("/file.rs").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + // First server: no formatting capability + let mut no_format_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "no-format-server", + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions::default()), + ..Default::default() + }, + ..Default::default() + }, + ); + + // Second server: has formatting capability + let mut format_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "format-server", + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + + let _no_format_server = no_format_servers.next().await.unwrap(); + let format_server = format_servers.next().await.unwrap(); + + format_server.set_request_handler::( + move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() + ); + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), + ", ".to_string(), + )])) + }, + ); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one, two\nthree\n" + ); +} + #[gpui::test] async fn test_redo_after_noop_format(cx: &mut TestAppContext) { init_test(cx, |settings| { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 75f9702e12cf31ce4f555940d7d1918884bbc22a..7573af1dc69f33586199c6f9e5e4d2a59f6d2d6f 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -1778,9 +1778,10 @@ impl LocalLspStore { } }) } - settings::LanguageServerFormatterSpecifier::Current => { - adapters_and_servers.first().map(|e| e.1.clone()) - } + settings::LanguageServerFormatterSpecifier::Current => adapters_and_servers + .iter() + .find(|(_, server)| Self::server_supports_formatting(server)) + .map(|(_, server)| server.clone()), }; let Some(language_server) = language_server else { @@ -2285,6 +2286,14 @@ impl LocalLspStore { } } + fn server_supports_formatting(server: &Arc) -> bool { + let capabilities = server.capabilities(); + let formatting = capabilities.document_formatting_provider.as_ref(); + let range_formatting = capabilities.document_range_formatting_provider.as_ref(); + matches!(formatting, Some(p) if *p != OneOf::Left(false)) + || matches!(range_formatting, Some(p) if *p != OneOf::Left(false)) + } + async fn format_via_lsp( this: &WeakEntity, buffer: &Entity, From 49ef20585df19bf0dbcbcbc8f5b3c40163dcfc43 Mon Sep 17 00:00:00 2001 From: claire <28279548+claiwe@users.noreply.github.com> Date: Fri, 6 Mar 2026 03:37:14 -0600 Subject: [PATCH 014/219] terminal: Fix drag-and-drop in vertical terminal panels (#49825) Closes #49800 Adds `handle_drop` to Item & ItemHandle, which allows an active item in a pane to consume drop events before the pane does. Release Notes: - terminal: Fix drag-and-drop not working in vertical terminal panels --- crates/debugger_ui/src/persistence.rs | 37 +- crates/debugger_ui/src/session/running.rs | 328 +++++++++------ crates/terminal/src/terminal.rs | 15 + crates/terminal_view/Cargo.toml | 1 + crates/terminal_view/src/terminal_panel.rs | 147 +------ crates/terminal_view/src/terminal_view.rs | 448 +++++++++++++++++++-- crates/workspace/src/item.rs | 33 ++ crates/workspace/src/pane.rs | 222 +++++++--- 8 files changed, 889 insertions(+), 342 deletions(-) diff --git a/crates/debugger_ui/src/persistence.rs b/crates/debugger_ui/src/persistence.rs index ab68fea1154182fe266bb150d762f8be0995d733..7b0fba39e70012cdeb19408d22ce21e3b6c9621f 100644 --- a/crates/debugger_ui/src/persistence.rs +++ b/crates/debugger_ui/src/persistence.rs @@ -265,49 +265,72 @@ pub(crate) fn deserialize_pane_layout( pane.entity_id(), cx.subscribe_in(&pane, window, RunningState::handle_pane_event), ); + let running_state = cx.weak_entity(); + let pane_handle = pane.downgrade(); let sub_views: Vec<_> = serialized_pane .children .iter() .map(|child| match child { - DebuggerPaneItem::Frames => { - Box::new(SubView::stack_frame_list(stack_frame_list.clone(), cx)) - } + DebuggerPaneItem::Frames => Box::new(SubView::stack_frame_list( + stack_frame_list.clone(), + running_state.clone(), + pane_handle.clone(), + cx, + )), DebuggerPaneItem::Variables => Box::new(SubView::new( variable_list.focus_handle(cx), variable_list.clone().into(), DebuggerPaneItem::Variables, + running_state.clone(), + pane_handle.clone(), + cx, + )), + DebuggerPaneItem::BreakpointList => Box::new(SubView::breakpoint_list( + breakpoint_list.clone(), + running_state.clone(), + pane_handle.clone(), cx, )), - DebuggerPaneItem::BreakpointList => { - Box::new(SubView::breakpoint_list(breakpoint_list.clone(), cx)) - } DebuggerPaneItem::Modules => Box::new(SubView::new( module_list.focus_handle(cx), module_list.clone().into(), DebuggerPaneItem::Modules, + running_state.clone(), + pane_handle.clone(), cx, )), DebuggerPaneItem::LoadedSources => Box::new(SubView::new( loaded_sources.focus_handle(cx), loaded_sources.clone().into(), DebuggerPaneItem::LoadedSources, + running_state.clone(), + pane_handle.clone(), cx, )), DebuggerPaneItem::Console => { - let view = SubView::console(console.clone(), cx); + let view = SubView::console( + console.clone(), + running_state.clone(), + pane_handle.clone(), + cx, + ); Box::new(view) } DebuggerPaneItem::Terminal => Box::new(SubView::new( terminal.focus_handle(cx), terminal.clone().into(), DebuggerPaneItem::Terminal, + running_state.clone(), + pane_handle.clone(), cx, )), DebuggerPaneItem::MemoryView => Box::new(SubView::new( memory_view.focus_handle(cx), memory_view.clone().into(), DebuggerPaneItem::MemoryView, + running_state.clone(), + pane_handle.clone(), cx, )), }) diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index 59e7226f596f1266fdeb3c5f3b60e1f97b81c850..1df442ef88fada109b6b7ad6e3bb5cf63f0ea453 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -7,7 +7,6 @@ pub mod stack_frame_list; pub mod variable_list; use std::{ any::Any, - ops::ControlFlow, path::PathBuf, sync::{Arc, LazyLock}, time::Duration, @@ -72,6 +71,7 @@ pub struct RunningState { focus_handle: FocusHandle, _remote_id: Option, workspace: WeakEntity, + project: WeakEntity, session_id: SessionId, variable_list: Entity, _subscriptions: Vec, @@ -144,6 +144,8 @@ pub(crate) struct SubView { inner: AnyView, item_focus_handle: FocusHandle, kind: DebuggerPaneItem, + running_state: WeakEntity, + host_pane: WeakEntity, show_indicator: Box bool>, actions: Option AnyElement>>, hovered: bool, @@ -154,12 +156,16 @@ impl SubView { item_focus_handle: FocusHandle, view: AnyView, kind: DebuggerPaneItem, + running_state: WeakEntity, + host_pane: WeakEntity, cx: &mut App, ) -> Entity { cx.new(|_| Self { kind, inner: view, item_focus_handle, + running_state, + host_pane, show_indicator: Box::new(|_| false), actions: None, hovered: false, @@ -168,6 +174,8 @@ impl SubView { pub(crate) fn stack_frame_list( stack_frame_list: Entity, + running_state: WeakEntity, + host_pane: WeakEntity, cx: &mut App, ) -> Entity { let weak_list = stack_frame_list.downgrade(); @@ -175,6 +183,8 @@ impl SubView { stack_frame_list.focus_handle(cx), stack_frame_list.into(), DebuggerPaneItem::Frames, + running_state, + host_pane, cx, ); @@ -189,12 +199,19 @@ impl SubView { this } - pub(crate) fn console(console: Entity, cx: &mut App) -> Entity { + pub(crate) fn console( + console: Entity, + running_state: WeakEntity, + host_pane: WeakEntity, + cx: &mut App, + ) -> Entity { let weak_console = console.downgrade(); let this = Self::new( console.focus_handle(cx), console.into(), DebuggerPaneItem::Console, + running_state, + host_pane, cx, ); this.update(cx, |this, _| { @@ -207,13 +224,20 @@ impl SubView { this } - pub(crate) fn breakpoint_list(list: Entity, cx: &mut App) -> Entity { + pub(crate) fn breakpoint_list( + list: Entity, + running_state: WeakEntity, + host_pane: WeakEntity, + cx: &mut App, + ) -> Entity { let weak_list = list.downgrade(); let focus_handle = list.focus_handle(cx); let this = Self::new( focus_handle, list.into(), DebuggerPaneItem::BreakpointList, + running_state, + host_pane, cx, ); @@ -239,6 +263,10 @@ impl SubView { ) { self.actions = Some(actions); } + + fn set_host_pane(&mut self, host_pane: WeakEntity) { + self.host_pane = host_pane; + } } impl Focusable for SubView { fn focus_handle(&self, _: &App) -> FocusHandle { @@ -281,6 +309,75 @@ impl Item for SubView { label.into_any_element() } + + fn handle_drop( + &self, + active_pane: &Pane, + dropped: &dyn Any, + window: &mut Window, + cx: &mut App, + ) -> bool { + let Some(tab) = dropped.downcast_ref::() else { + return true; + }; + let Some(this_pane) = self.host_pane.upgrade() else { + return true; + }; + let item = if tab.pane == this_pane { + active_pane.item_for_index(tab.ix) + } else { + tab.pane.read(cx).item_for_index(tab.ix) + }; + let Some(item) = item.filter(|item| item.downcast::().is_some()) else { + return true; + }; + let Some(split_direction) = active_pane.drag_split_direction() else { + return false; + }; + + let source = tab.pane.clone(); + let item_id_to_move = item.item_id(); + let weak_running = self.running_state.clone(); + + // Source pane may be the one currently updated, so defer the move. + window.defer(cx, move |window, cx| { + let new_pane = weak_running.update(cx, |running, cx| { + let Some(project) = running.project.upgrade() else { + return Err(anyhow!("Debugger project has been dropped")); + }; + + let new_pane = new_debugger_pane(running.workspace.clone(), project, window, cx); + let _previous_subscription = running.pane_close_subscriptions.insert( + new_pane.entity_id(), + cx.subscribe_in(&new_pane, window, RunningState::handle_pane_event), + ); + debug_assert!(_previous_subscription.is_none()); + running + .panes + .split(&this_pane, &new_pane, split_direction, cx); + anyhow::Ok(new_pane) + }); + + match new_pane.and_then(|result| result) { + Ok(new_pane) => { + move_item( + &source, + &new_pane, + item_id_to_move, + new_pane.read(cx).active_item_index(), + true, + window, + cx, + ); + } + Err(err) => { + log::error!("{err:?}"); + } + } + }); + + true + } } impl Render for SubView { @@ -311,83 +408,18 @@ pub(crate) fn new_debugger_pane( cx: &mut Context, ) -> Entity { let weak_running = cx.weak_entity(); - let custom_drop_handle = { - let workspace = workspace.clone(); - let project = project.downgrade(); - let weak_running = weak_running.clone(); - move |pane: &mut Pane, any: &dyn Any, window: &mut Window, cx: &mut Context| { - let Some(tab) = any.downcast_ref::() else { - return ControlFlow::Break(()); - }; - let Some(project) = project.upgrade() else { - return ControlFlow::Break(()); - }; - let this_pane = cx.entity(); - let item = if tab.pane == this_pane { - pane.item_for_index(tab.ix) - } else { - tab.pane.read(cx).item_for_index(tab.ix) - }; - let Some(item) = item.filter(|item| item.downcast::().is_some()) else { - return ControlFlow::Break(()); - }; - - let source = tab.pane.clone(); - let item_id_to_move = item.item_id(); - - let Some(split_direction) = pane.drag_split_direction() else { - // If we drop into existing pane or current pane, - // regular pane drop handler will take care of it, - // using the right tab index for the operation. - return ControlFlow::Continue(()); - }; - - let workspace = workspace.clone(); - let weak_running = weak_running.clone(); - // Source pane may be the one currently updated, so defer the move. - window.defer(cx, move |window, cx| { - let new_pane = weak_running.update(cx, |running, cx| { - let new_pane = - new_debugger_pane(workspace.clone(), project.clone(), window, cx); - let _previous_subscription = running.pane_close_subscriptions.insert( - new_pane.entity_id(), - cx.subscribe_in(&new_pane, window, RunningState::handle_pane_event), - ); - debug_assert!(_previous_subscription.is_none()); - running - .panes - .split(&this_pane, &new_pane, split_direction, cx); - new_pane - }); - - match new_pane { - Ok(new_pane) => { - move_item( - &source, - &new_pane, - item_id_to_move, - new_pane.read(cx).active_item_index(), - true, - window, - cx, - ); - } - Err(err) => { - log::error!("{err:?}"); - } - }; - }); - - ControlFlow::Break(()) - } - }; cx.new(move |cx| { + let can_drop_predicate: Arc bool> = + Arc::new(|any, _window, _cx| { + any.downcast_ref::() + .is_some_and(|dragged_tab| dragged_tab.item.downcast::().is_some()) + }); let mut pane = Pane::new( workspace.clone(), project.clone(), Default::default(), - None, + Some(can_drop_predicate), NoAction.boxed_clone(), true, window, @@ -426,7 +458,6 @@ pub(crate) fn new_debugger_pane( }))); pane.set_can_toggle_zoom(false, cx); pane.display_nav_history_buttons(None); - pane.set_custom_drop_handle(cx, custom_drop_handle); pane.set_should_display_tab_bar(|_, _| true); pane.set_render_tab_bar_buttons(cx, |_, _, _| (None, None)); pane.set_render_tab_bar(cx, { @@ -466,8 +497,17 @@ pub(crate) fn new_debugger_pane( }) .on_drop(cx.listener( move |this, dragged_tab: &DraggedTab, window, cx| { + if dragged_tab.item.downcast::().is_none() { + return; + } this.drag_split_direction = None; - this.handle_tab_drop(dragged_tab, this.items_len(), window, cx) + this.handle_tab_drop( + dragged_tab, + this.items_len(), + false, + window, + cx, + ) }, )) .children(pane.items().enumerate().map(|(ix, item)| { @@ -516,8 +556,11 @@ pub(crate) fn new_debugger_pane( )) .on_drop(cx.listener( move |this, dragged_tab: &DraggedTab, window, cx| { + if dragged_tab.item.downcast::().is_none() { + return; + } this.drag_split_direction = None; - this.handle_tab_drop(dragged_tab, ix, window, cx) + this.handle_tab_drop(dragged_tab, ix, false, window, cx) }, )) .on_drag( @@ -729,6 +772,7 @@ impl RunningState { ) -> Self { let focus_handle = cx.focus_handle(); let session_id = session.read(cx).session_id(); + let weak_project = project.downgrade(); let weak_state = cx.weak_entity(); let stack_frame_list = cx.new(|cx| { StackFrameList::new( @@ -904,6 +948,7 @@ impl RunningState { memory_view, session, workspace, + project: weak_project, focus_handle, variable_list, _subscriptions, @@ -1304,48 +1349,71 @@ impl RunningState { fn create_sub_view( &self, item_kind: DebuggerPaneItem, - _pane: &Entity, + pane: &Entity, cx: &mut Context, ) -> Box { + let running_state = cx.weak_entity(); + let host_pane = pane.downgrade(); + match item_kind { - DebuggerPaneItem::Console => Box::new(SubView::console(self.console.clone(), cx)), + DebuggerPaneItem::Console => Box::new(SubView::console( + self.console.clone(), + running_state, + host_pane, + cx, + )), DebuggerPaneItem::Variables => Box::new(SubView::new( self.variable_list.focus_handle(cx), self.variable_list.clone().into(), item_kind, + running_state, + host_pane, + cx, + )), + DebuggerPaneItem::BreakpointList => Box::new(SubView::breakpoint_list( + self.breakpoint_list.clone(), + running_state, + host_pane, cx, )), - DebuggerPaneItem::BreakpointList => { - Box::new(SubView::breakpoint_list(self.breakpoint_list.clone(), cx)) - } DebuggerPaneItem::Frames => Box::new(SubView::new( self.stack_frame_list.focus_handle(cx), self.stack_frame_list.clone().into(), item_kind, + running_state, + host_pane, cx, )), DebuggerPaneItem::Modules => Box::new(SubView::new( self.module_list.focus_handle(cx), self.module_list.clone().into(), item_kind, + running_state, + host_pane, cx, )), DebuggerPaneItem::LoadedSources => Box::new(SubView::new( self.loaded_sources_list.focus_handle(cx), self.loaded_sources_list.clone().into(), item_kind, + running_state, + host_pane, cx, )), DebuggerPaneItem::Terminal => Box::new(SubView::new( self.debug_terminal.focus_handle(cx), self.debug_terminal.clone().into(), item_kind, + running_state, + host_pane, cx, )), DebuggerPaneItem::MemoryView => Box::new(SubView::new( self.memory_view.focus_handle(cx), self.memory_view.clone().into(), item_kind, + running_state, + host_pane, cx, )), } @@ -1454,6 +1522,13 @@ impl RunningState { ) { this.serialize_layout(window, cx); match event { + Event::AddItem { item } => { + if let Some(sub_view) = item.downcast::() { + sub_view.update(cx, |sub_view, _| { + sub_view.set_host_pane(source_pane.downgrade()); + }); + } + } Event::Remove { .. } => { let _did_find_pane = this.panes.remove(source_pane, cx).is_ok(); debug_assert!(_did_find_pane); @@ -1795,23 +1870,28 @@ impl RunningState { window: &mut Window, cx: &mut Context<'_, RunningState>, ) -> Member { + let running_state = cx.weak_entity(); + let leftmost_pane = new_debugger_pane(workspace.clone(), project.clone(), window, cx); + let leftmost_pane_handle = leftmost_pane.downgrade(); + let leftmost_frames = SubView::new( + stack_frame_list.focus_handle(cx), + stack_frame_list.clone().into(), + DebuggerPaneItem::Frames, + running_state.clone(), + leftmost_pane_handle.clone(), + cx, + ); + let leftmost_breakpoints = SubView::breakpoint_list( + breakpoints.clone(), + running_state.clone(), + leftmost_pane_handle, + cx, + ); leftmost_pane.update(cx, |this, cx| { + this.add_item(Box::new(leftmost_frames), true, false, None, window, cx); this.add_item( - Box::new(SubView::new( - this.focus_handle(cx), - stack_frame_list.clone().into(), - DebuggerPaneItem::Frames, - cx, - )), - true, - false, - None, - window, - cx, - ); - this.add_item( - Box::new(SubView::breakpoint_list(breakpoints.clone(), cx)), + Box::new(leftmost_breakpoints), true, false, None, @@ -1820,44 +1900,42 @@ impl RunningState { ); this.activate_item(0, false, false, window, cx); }); + let center_pane = new_debugger_pane(workspace.clone(), project.clone(), window, cx); + let center_pane_handle = center_pane.downgrade(); + let center_console = SubView::console( + console.clone(), + running_state.clone(), + center_pane_handle.clone(), + cx, + ); + let center_variables = SubView::new( + variable_list.focus_handle(cx), + variable_list.clone().into(), + DebuggerPaneItem::Variables, + running_state.clone(), + center_pane_handle, + cx, + ); center_pane.update(cx, |this, cx| { - let view = SubView::console(console.clone(), cx); + this.add_item(Box::new(center_console), true, false, None, window, cx); - this.add_item(Box::new(view), true, false, None, window, cx); - - this.add_item( - Box::new(SubView::new( - variable_list.focus_handle(cx), - variable_list.clone().into(), - DebuggerPaneItem::Variables, - cx, - )), - true, - false, - None, - window, - cx, - ); + this.add_item(Box::new(center_variables), true, false, None, window, cx); this.activate_item(0, false, false, window, cx); }); let rightmost_pane = new_debugger_pane(workspace.clone(), project, window, cx); + let rightmost_terminal = SubView::new( + debug_terminal.focus_handle(cx), + debug_terminal.clone().into(), + DebuggerPaneItem::Terminal, + running_state, + rightmost_pane.downgrade(), + cx, + ); rightmost_pane.update(cx, |this, cx| { - this.add_item( - Box::new(SubView::new( - debug_terminal.focus_handle(cx), - debug_terminal.clone().into(), - DebuggerPaneItem::Terminal, - cx, - )), - false, - false, - None, - window, - cx, - ); + this.add_item(Box::new(rightmost_terminal), false, false, None, window, cx); }); subscriptions.extend( diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 0fa3b37e1501ed6407d18b07e0b2188ce5e77cf7..56cca7cb40195298ed0479fc43c8b13b6c577249 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -415,6 +415,8 @@ impl TerminalBuilder { event_loop_task: Task::ready(Ok(())), background_executor: background_executor.clone(), path_style, + #[cfg(any(test, feature = "test-support"))] + input_log: Vec::new(), }; Ok(TerminalBuilder { @@ -646,6 +648,8 @@ impl TerminalBuilder { event_loop_task: Task::ready(Ok(())), background_executor, path_style, + #[cfg(any(test, feature = "test-support"))] + input_log: Vec::new(), }; if !activation_script.is_empty() && no_task { @@ -870,6 +874,8 @@ pub struct Terminal { event_loop_task: Task>, background_executor: BackgroundExecutor, path_style: PathStyle, + #[cfg(any(test, feature = "test-support"))] + input_log: Vec>, } struct CopyTemplate { @@ -1451,9 +1457,18 @@ impl Terminal { .push_back(InternalEvent::Scroll(AlacScroll::Bottom)); self.events.push_back(InternalEvent::SetSelection(None)); + let input = input.into(); + #[cfg(any(test, feature = "test-support"))] + self.input_log.push(input.to_vec()); + self.write_to_pty(input); } + #[cfg(any(test, feature = "test-support"))] + pub fn take_input_log(&mut self) -> Vec> { + std::mem::take(&mut self.input_log) + } + pub fn toggle_vi_mode(&mut self) { self.events.push_back(InternalEvent::ToggleViMode); } diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index ef31480341ddc873e00612b471217899836a3bd1..08ffbf36263d11d4b73f02c212e571c7c11d29b8 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -53,6 +53,7 @@ editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } rand.workspace = true +terminal = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } [package.metadata.cargo-machete] diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 88bde3c771f72a0771a405cfbf123ac4e2286ad9..93b9e651191e791da8bbda35600c3db001b46d90 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1,4 +1,4 @@ -use std::{cmp, ops::ControlFlow, path::PathBuf, process::ExitStatus, sync::Arc, time::Duration}; +use std::{cmp, path::PathBuf, process::ExitStatus, sync::Arc, time::Duration}; use crate::{ TerminalView, default_working_directory, @@ -12,11 +12,11 @@ use db::kvp::KEY_VALUE_STORE; use futures::{channel::oneshot, future::join_all}; use gpui::{ Action, AnyView, App, AsyncApp, AsyncWindowContext, Context, Corner, Entity, EventEmitter, - ExternalPaths, FocusHandle, Focusable, IntoElement, ParentElement, Pixels, Render, Styled, - Task, WeakEntity, Window, actions, + FocusHandle, Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Task, WeakEntity, + Window, actions, }; use itertools::Itertools; -use project::{Fs, Project, ProjectEntryId}; +use project::{Fs, Project}; use settings::{Settings, TerminalDockPosition}; use task::{RevealStrategy, RevealTarget, Shell, ShellBuilder, SpawnInTerminal, TaskId}; @@ -28,13 +28,13 @@ use ui::{ use util::{ResultExt, TryFutureExt}; use workspace::{ ActivateNextPane, ActivatePane, ActivatePaneDown, ActivatePaneLeft, ActivatePaneRight, - ActivatePaneUp, ActivatePreviousPane, DraggedSelection, DraggedTab, ItemId, MoveItemToPane, + ActivatePaneUp, ActivatePreviousPane, DraggedTab, ItemId, MoveItemToPane, MoveItemToPaneInDirection, MovePaneDown, MovePaneLeft, MovePaneRight, MovePaneUp, Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitMode, SplitRight, SplitUp, SwapPaneDown, SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace, dock::{DockPosition, Panel, PanelEvent, PanelHandle}, item::SerializableItem, - move_active_item, move_item, pane, + move_active_item, pane, }; use anyhow::{Result, anyhow}; @@ -133,7 +133,11 @@ impl TerminalPanel { } } - fn apply_tab_bar_buttons(&self, terminal_pane: &Entity, cx: &mut Context) { + pub(crate) fn apply_tab_bar_buttons( + &self, + terminal_pane: &Entity, + cx: &mut Context, + ) { let assistant_tab_bar_button = self.assistant_tab_bar_button.clone(); terminal_pane.update(cx, |pane, cx| { pane.set_render_tab_bar_buttons(cx, move |pane, window, cx| { @@ -1187,7 +1191,6 @@ pub fn new_terminal_pane( window: &mut Window, cx: &mut Context, ) -> Entity { - let is_local = project.read(cx).is_local(); let terminal_panel = cx.entity(); let pane = cx.new(|cx| { let mut pane = Pane::new( @@ -1245,113 +1248,6 @@ pub fn new_terminal_pane( toolbar.add_item(breadcrumbs, window, cx); }); - let drop_closure_project = project.downgrade(); - let drop_closure_terminal_panel = terminal_panel.downgrade(); - pane.set_custom_drop_handle(cx, move |pane, dropped_item, window, cx| { - let Some(project) = drop_closure_project.upgrade() else { - return ControlFlow::Break(()); - }; - if let Some(tab) = dropped_item.downcast_ref::() { - let this_pane = cx.entity(); - let item = if tab.pane == this_pane { - pane.item_for_index(tab.ix) - } else { - tab.pane.read(cx).item_for_index(tab.ix) - }; - if let Some(item) = item { - if item.downcast::().is_some() { - let source = tab.pane.clone(); - let item_id_to_move = item.item_id(); - - // If no split direction, let the regular pane drop handler take care of it - let Some(split_direction) = pane.drag_split_direction() else { - return ControlFlow::Continue(()); - }; - - // Gather data synchronously before deferring - let is_zoomed = drop_closure_terminal_panel - .upgrade() - .map(|terminal_panel| { - let terminal_panel = terminal_panel.read(cx); - if terminal_panel.active_pane == this_pane { - pane.is_zoomed() - } else { - terminal_panel.active_pane.read(cx).is_zoomed() - } - }) - .unwrap_or(false); - - let workspace = workspace.clone(); - let terminal_panel = drop_closure_terminal_panel.clone(); - - // Defer the split operation to avoid re-entrancy panic. - // The pane may be the one currently being updated, so we cannot - // call mark_positions (via split) synchronously. - cx.spawn_in(window, async move |_, cx| { - cx.update(|window, cx| { - let Ok(new_pane) = - terminal_panel.update(cx, |terminal_panel, cx| { - let new_pane = new_terminal_pane( - workspace, project, is_zoomed, window, cx, - ); - terminal_panel.apply_tab_bar_buttons(&new_pane, cx); - terminal_panel.center.split( - &this_pane, - &new_pane, - split_direction, - cx, - ); - new_pane - }) - else { - return; - }; - - move_item( - &source, - &new_pane, - item_id_to_move, - new_pane.read(cx).active_item_index(), - true, - window, - cx, - ); - }) - .ok(); - }) - .detach(); - } else if let Some(project_path) = item.project_path(cx) - && let Some(entry_path) = project.read(cx).absolute_path(&project_path, cx) - { - add_paths_to_terminal(pane, &[entry_path], window, cx); - } - } - } else if let Some(selection) = dropped_item.downcast_ref::() { - let project = project.read(cx); - let paths_to_add = selection - .items() - .map(|selected_entry| selected_entry.entry_id) - .filter_map(|entry_id| project.path_for_entry(entry_id, cx)) - .filter_map(|project_path| project.absolute_path(&project_path, cx)) - .collect::>(); - if !paths_to_add.is_empty() { - add_paths_to_terminal(pane, &paths_to_add, window, cx); - } - } else if let Some(&entry_id) = dropped_item.downcast_ref::() { - if let Some(entry_path) = project - .read(cx) - .path_for_entry(entry_id, cx) - .and_then(|project_path| project.read(cx).absolute_path(&project_path, cx)) - { - add_paths_to_terminal(pane, &[entry_path], window, cx); - } - } else if is_local && let Some(paths) = dropped_item.downcast_ref::() { - add_paths_to_terminal(pane, paths.paths(), window, cx); - } - - ControlFlow::Break(()) - }); - pane }); @@ -1376,27 +1272,6 @@ async fn wait_for_terminals_tasks( join_all(pending_tasks).await; } -fn add_paths_to_terminal( - pane: &mut Pane, - paths: &[PathBuf], - window: &mut Window, - cx: &mut Context, -) { - if let Some(terminal_view) = pane - .active_item() - .and_then(|item| item.downcast::()) - { - window.focus(&terminal_view.focus_handle(cx), cx); - let mut new_text = paths.iter().map(|path| format!(" {path:?}")).join(""); - new_text.push(' '); - terminal_view.update(cx, |terminal_view, cx| { - terminal_view.terminal().update(cx, |terminal, _| { - terminal.paste(&new_text); - }); - }); - } -} - struct FailedToSpawnTerminal { error: String, focus_handle: FocusHandle, diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index eaba1f22682a759d8cfce42e555ca692cee9ada6..e4ed410ef79897770d2a27aaef10017b1d284390 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -8,18 +8,20 @@ mod terminal_slash_command; use assistant_slash_command::SlashCommandRegistry; use editor::{Editor, EditorSettings, actions::SelectAll, blink_manager::BlinkManager}; use gpui::{ - Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, FocusHandle, - Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Point, - Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, - div, + Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, ExternalPaths, + FocusHandle, Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, + Pixels, Point, Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, + anchored, deferred, div, }; +use itertools::Itertools; use menu; use persistence::TERMINAL_DB; -use project::{Project, search::SearchQuery}; +use project::{Project, ProjectEntryId, search::SearchQuery}; use schemars::JsonSchema; use serde::Deserialize; use settings::{Settings, SettingsStore, TerminalBlink, WorkingDirectory}; use std::{ + any::Any, cmp, ops::{Range, RangeInclusive}, path::{Path, PathBuf}, @@ -50,8 +52,8 @@ use ui::{ }; use util::ResultExt; use workspace::{ - CloseActiveItem, NewCenterTerminal, NewTerminal, ToolbarItemLocation, Workspace, WorkspaceId, - delete_unloaded_items, + CloseActiveItem, DraggedSelection, DraggedTab, NewCenterTerminal, NewTerminal, Pane, + ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items, item::{ BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent, }, @@ -833,6 +835,15 @@ impl TerminalView { }); } + fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) { + let mut text = paths.iter().map(|path| format!(" {path:?}")).join(""); + text.push(' '); + window.focus(&self.focus_handle(cx), cx); + self.terminal.update(cx, |terminal, _| { + terminal.paste(&text); + }); + } + fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context) { self.clear_bell(cx); self.terminal.update(cx, |term, _| { @@ -1412,6 +1423,154 @@ impl Item for TerminalView { None } + fn handle_drop( + &self, + active_pane: &Pane, + dropped: &dyn Any, + window: &mut Window, + cx: &mut App, + ) -> bool { + let Some(project) = self.project.upgrade() else { + return false; + }; + + if let Some(paths) = dropped.downcast_ref::() { + let is_local = project.read(cx).is_local(); + if is_local { + self.add_paths_to_terminal(paths.paths(), window, cx); + return true; + } + + return false; + } else if let Some(tab) = dropped.downcast_ref::() { + let Some(self_handle) = self.self_handle.upgrade() else { + return false; + }; + + let Some(workspace) = self.workspace.upgrade() else { + return false; + }; + + let Some(this_pane) = workspace.read(cx).pane_for(&self_handle) else { + return false; + }; + + let item = if tab.pane == this_pane { + active_pane.item_for_index(tab.ix) + } else { + tab.pane.read(cx).item_for_index(tab.ix) + }; + + let Some(item) = item else { + return false; + }; + + if item.downcast::().is_some() { + let Some(split_direction) = active_pane.drag_split_direction() else { + return false; + }; + + let Some(terminal_panel) = workspace.read(cx).panel::(cx) else { + return false; + }; + + if !terminal_panel.read(cx).center.panes().contains(&&this_pane) { + return false; + } + + let source = tab.pane.clone(); + let item_id_to_move = item.item_id(); + let is_zoomed = { + let terminal_panel = terminal_panel.read(cx); + if terminal_panel.active_pane == this_pane { + active_pane.is_zoomed() + } else { + terminal_panel.active_pane.read(cx).is_zoomed() + } + }; + + let workspace = workspace.downgrade(); + let terminal_panel = terminal_panel.downgrade(); + // Defer the split operation to avoid re-entrancy panic. + // The pane may be the one currently being updated, so we cannot + // call mark_positions (via split) synchronously. + window + .spawn(cx, async move |cx| { + cx.update(|window, cx| { + let Ok(new_pane) = terminal_panel.update(cx, |terminal_panel, cx| { + let new_pane = terminal_panel::new_terminal_pane( + workspace, project, is_zoomed, window, cx, + ); + terminal_panel.apply_tab_bar_buttons(&new_pane, cx); + terminal_panel.center.split( + &this_pane, + &new_pane, + split_direction, + cx, + ); + anyhow::Ok(new_pane) + }) else { + return; + }; + + let Some(new_pane) = new_pane.log_err() else { + return; + }; + + workspace::move_item( + &source, + &new_pane, + item_id_to_move, + new_pane.read(cx).active_item_index(), + true, + window, + cx, + ); + }) + .ok(); + }) + .detach(); + + return true; + } else { + if let Some(project_path) = item.project_path(cx) + && let Some(path) = project.read(cx).absolute_path(&project_path, cx) + { + self.add_paths_to_terminal(&[path], window, cx); + return true; + } + } + + return false; + } else if let Some(selection) = dropped.downcast_ref::() { + let project = project.read(cx); + let paths = selection + .items() + .map(|selected_entry| selected_entry.entry_id) + .filter_map(|entry_id| project.path_for_entry(entry_id, cx)) + .filter_map(|project_path| project.absolute_path(&project_path, cx)) + .collect::>(); + + if !paths.is_empty() { + self.add_paths_to_terminal(&paths, window, cx); + } + + return true; + } else if let Some(&entry_id) = dropped.downcast_ref::() { + let project = project.read(cx); + if let Some(path) = project + .path_for_entry(entry_id, cx) + .and_then(|project_path| project.absolute_path(&project_path, cx)) + { + self.add_paths_to_terminal(&[path], window, cx); + } + + return true; + } + + false + } + fn tab_extra_context_menu_actions( &self, _window: &mut Window, @@ -1840,10 +1999,46 @@ mod tests { use super::*; use gpui::TestAppContext; use project::{Entry, Project, ProjectPath, Worktree}; - use std::path::Path; + use std::path::{Path, PathBuf}; use util::paths::PathStyle; use util::rel_path::RelPath; - use workspace::{AppState, MultiWorkspace}; + use workspace::item::test::{TestItem, TestProjectItem}; + use workspace::{AppState, MultiWorkspace, SelectedEntry}; + + fn expected_drop_text(paths: &[PathBuf]) -> String { + let mut text = String::new(); + for path in paths { + text.push(' '); + text.push_str(&format!("{path:?}")); + } + text.push(' '); + text + } + + fn assert_drop_writes_to_terminal( + pane: &Entity, + terminal_view_index: usize, + terminal: &Entity, + dropped: &dyn Any, + expected_text: &str, + window: &mut Window, + cx: &mut Context, + ) { + let _ = terminal.update(cx, |terminal, _| terminal.take_input_log()); + + let handled = pane.update(cx, |pane, cx| { + pane.item_for_index(terminal_view_index) + .unwrap() + .handle_drop(pane, dropped, window, cx) + }); + assert!(handled, "handle_drop should return true for {:?}", dropped); + + let mut input_log = terminal.update(cx, |terminal, _| terminal.take_input_log()); + assert_eq!(input_log.len(), 1, "expected exactly one write to terminal"); + let written = + String::from_utf8(input_log.remove(0)).expect("terminal write should be valid UTF-8"); + assert_eq!(written, expected_text); + } // Working directory calculation tests @@ -1972,24 +2167,7 @@ mod tests { let (project, _workspace) = init_test(cx).await; let (wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await; - let entry = cx - .update(|cx| { - wt.update(cx, |wt, cx| { - wt.create_entry( - RelPath::new(Path::new("src/main.rs"), PathStyle::local()) - .unwrap() - .as_ref() - .into(), - false, - None, - cx, - ) - }) - }) - .await - .unwrap() - .into_included() - .unwrap(); + let entry = create_file_in_worktree(wt.clone(), "src/main.rs", cx).await; insert_active_entry_for(wt, entry, project.clone(), cx); cx.update(|cx| { @@ -2014,6 +2192,18 @@ mod tests { /// Creates a worktree with 1 file: /root.txt pub async fn init_test(cx: &mut TestAppContext) -> (Entity, Entity) { + let (project, workspace, _) = init_test_with_window(cx).await; + (project, workspace) + } + + /// Creates a worktree with 1 file /root.txt and returns the project, workspace, and window handle. + async fn init_test_with_window( + cx: &mut TestAppContext, + ) -> ( + Entity, + Entity, + gpui::WindowHandle, + ) { let params = cx.update(AppState::test); cx.update(|cx| { theme::init(theme::LoadThemes::JustBase, cx); @@ -2026,7 +2216,32 @@ mod tests { .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); - (project, workspace) + (project, workspace, window_handle) + } + + /// Creates a file in the given worktree and returns its entry. + async fn create_file_in_worktree( + worktree: Entity, + relative_path: impl AsRef, + cx: &mut TestAppContext, + ) -> Entry { + cx.update(|cx| { + worktree.update(cx, |worktree, cx| { + worktree.create_entry( + RelPath::new(relative_path.as_ref(), PathStyle::local()) + .unwrap() + .as_ref() + .into(), + false, + None, + cx, + ) + }) + }) + .await + .unwrap() + .into_included() + .unwrap() } /// Creates a worktree with 1 folder: /root{suffix}/ @@ -2089,6 +2304,183 @@ mod tests { }); } + // Terminal drag/drop test + + #[gpui::test] + async fn test_handle_drop_writes_paths_for_all_drop_types(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + + let (worktree, _) = create_folder_wt(project.clone(), "/root/", cx).await; + let first_entry = create_file_in_worktree(worktree.clone(), "first.txt", cx).await; + let second_entry = create_file_in_worktree(worktree.clone(), "second.txt", cx).await; + + let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); + let first_path = project + .read_with(cx, |project, cx| { + project.absolute_path( + &ProjectPath { + worktree_id, + path: first_entry.path.clone(), + }, + cx, + ) + }) + .unwrap(); + let second_path = project + .read_with(cx, |project, cx| { + project.absolute_path( + &ProjectPath { + worktree_id, + path: second_entry.path.clone(), + }, + cx, + ) + }) + .unwrap(); + + let (active_pane, terminal, terminal_view, tab_item) = window_handle + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + let active_pane = workspace.read(cx).active_pane().clone(); + + let terminal = cx.new(|cx| { + terminal::TerminalBuilder::new_display_only( + CursorShape::default(), + terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .unwrap() + .subscribe(cx) + }); + let terminal_view = cx.new(|cx| { + TerminalView::new( + terminal.clone(), + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }); + + active_pane.update(cx, |pane, cx| { + pane.add_item( + Box::new(terminal_view.clone()), + true, + false, + None, + window, + cx, + ); + }); + + let tab_project_item = cx.new(|_| TestProjectItem { + entry_id: Some(second_entry.id), + project_path: Some(ProjectPath { + worktree_id, + path: second_entry.path.clone(), + }), + is_dirty: false, + }); + let tab_item = + cx.new(|cx| TestItem::new(cx).with_project_items(&[tab_project_item])); + active_pane.update(cx, |pane, cx| { + pane.add_item(Box::new(tab_item.clone()), true, false, None, window, cx); + }); + + (active_pane, terminal, terminal_view, tab_item) + }) + .unwrap(); + + cx.run_until_parked(); + + window_handle + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + let terminal_view_index = + active_pane.read(cx).index_for_item(&terminal_view).unwrap(); + let dragged_tab_index = active_pane.read(cx).index_for_item(&tab_item).unwrap(); + + assert!( + workspace.read(cx).pane_for(&terminal_view).is_some(), + "terminal view not registered with workspace after run_until_parked" + ); + + // Dragging an external file should write its path to the terminal + let external_paths = ExternalPaths(vec![first_path.clone()].into()); + assert_drop_writes_to_terminal( + &active_pane, + terminal_view_index, + &terminal, + &external_paths, + &expected_drop_text(std::slice::from_ref(&first_path)), + window, + cx, + ); + + // Dragging a tab should write the path of the tab's item to the terminal + let dragged_tab = DraggedTab { + pane: active_pane.clone(), + item: Box::new(tab_item.clone()), + ix: dragged_tab_index, + detail: 0, + is_active: false, + }; + assert_drop_writes_to_terminal( + &active_pane, + terminal_view_index, + &terminal, + &dragged_tab, + &expected_drop_text(std::slice::from_ref(&second_path)), + window, + cx, + ); + + // Dragging multiple selections should write both paths to the terminal + let dragged_selection = DraggedSelection { + active_selection: SelectedEntry { + worktree_id, + entry_id: first_entry.id, + }, + marked_selections: Arc::from([ + SelectedEntry { + worktree_id, + entry_id: first_entry.id, + }, + SelectedEntry { + worktree_id, + entry_id: second_entry.id, + }, + ]), + }; + assert_drop_writes_to_terminal( + &active_pane, + terminal_view_index, + &terminal, + &dragged_selection, + &expected_drop_text(&[first_path.clone(), second_path.clone()]), + window, + cx, + ); + + // Dropping a project entry should write the entry's path to the terminal + let dropped_entry_id = first_entry.id; + assert_drop_writes_to_terminal( + &active_pane, + terminal_view_index, + &terminal, + &dropped_entry_id, + &expected_drop_text(&[first_path]), + window, + cx, + ); + }) + .unwrap(); + } + // Terminal rename tests #[gpui::test] diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 97a52b606ec951ca015b62f301ba9b898af3d254..09c99c230a0c7a9710e2976ac0673b639d8e36c4 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -366,6 +366,18 @@ pub trait Item: Focusable + EventEmitter + Render + Sized { true } + /// Called when the containing pane receives a drop on the item or the item's tab. + /// Returns `true` to consume it and suppress the pane's default drop behavior. + fn handle_drop( + &self, + _active_pane: &Pane, + _dropped: &dyn Any, + _window: &mut Window, + _cx: &mut App, + ) -> bool { + false + } + /// Returns additional actions to add to the tab's context menu. /// Each entry is a label and an action to dispatch. fn tab_extra_context_menu_actions( @@ -545,6 +557,13 @@ pub trait ItemHandle: 'static + Send { fn preserve_preview(&self, cx: &App) -> bool; fn include_in_nav_history(&self) -> bool; fn relay_action(&self, action: Box, window: &mut Window, cx: &mut App); + fn handle_drop( + &self, + active_pane: &Pane, + dropped: &dyn Any, + window: &mut Window, + cx: &mut App, + ) -> bool; fn tab_extra_context_menu_actions( &self, window: &mut Window, @@ -1110,6 +1129,20 @@ impl ItemHandle for Entity { }) } + /// Called when the containing pane receives a drop on the item or the item's tab. + /// Returns `true` if the item handled it and the pane should skip its default drop behavior. + fn handle_drop( + &self, + active_pane: &Pane, + dropped: &dyn Any, + window: &mut Window, + cx: &mut App, + ) -> bool { + self.update(cx, |this, cx| { + this.handle_drop(active_pane, dropped, window, cx) + }) + } + fn tab_extra_context_menu_actions( &self, window: &mut Window, diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 81283427e83afb820b113250545d90f787030e25..5f1177e58d5dcb0e8617ac1eb6068b7a9858685c 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -34,7 +34,6 @@ use std::{ any::Any, cmp, fmt, mem, num::NonZeroUsize, - ops::ControlFlow, path::PathBuf, rc::Rc, sync::{ @@ -382,9 +381,6 @@ pub struct Pane { project: WeakEntity, pub drag_split_direction: Option, can_drop_predicate: Option bool>>, - custom_drop_handle: Option< - Arc) -> ControlFlow<(), ()>>, - >, can_split_predicate: Option) -> bool>>, can_toggle_zoom: bool, @@ -567,7 +563,6 @@ impl Pane { workspace, project: project.downgrade(), can_drop_predicate, - custom_drop_handle: None, can_split_predicate: None, can_toggle_zoom: true, should_display_tab_bar: Rc::new(|_, cx| TabBarSettings::get_global(cx).show), @@ -846,15 +841,6 @@ impl Pane { cx.notify(); } - pub fn set_custom_drop_handle(&mut self, cx: &mut Context, handle: F) - where - F: 'static - + Fn(&mut Pane, &dyn Any, &mut Window, &mut Context) -> ControlFlow<(), ()>, - { - self.custom_drop_handle = Some(Arc::new(handle)); - cx.notify(); - } - pub fn nav_history_for_item(&self, item: &Entity) -> ItemNavHistory { ItemNavHistory { history: self.nav_history.clone(), @@ -2901,7 +2887,7 @@ impl Pane { .on_drop( cx.listener(move |this, dragged_tab: &DraggedTab, window, cx| { this.drag_split_direction = None; - this.handle_tab_drop(dragged_tab, ix, window, cx) + this.handle_tab_drop(dragged_tab, ix, false, window, cx) }), ) .on_drop( @@ -3550,7 +3536,7 @@ impl Pane { .on_drop( cx.listener(move |this, dragged_tab: &DraggedTab, window, cx| { this.drag_split_direction = None; - this.handle_tab_drop(dragged_tab, this.items.len(), window, cx) + this.handle_tab_drop(dragged_tab, this.items.len(), false, window, cx) }), ) .on_drop( @@ -3691,14 +3677,18 @@ impl Pane { &mut self, dragged_tab: &DraggedTab, ix: usize, + is_pane_target: bool, window: &mut Window, cx: &mut Context, ) { - if let Some(custom_drop_handle) = self.custom_drop_handle.clone() - && let ControlFlow::Break(()) = custom_drop_handle(self, dragged_tab, window, cx) + if is_pane_target + && ix == self.active_item_index + && let Some(active_item) = self.active_item() + && active_item.handle_drop(self, dragged_tab, window, cx) { return; } + let mut to_pane = cx.entity(); let split_direction = self.drag_split_direction; let item_id = dragged_tab.item.item_id(); @@ -3791,7 +3781,7 @@ impl Pane { let item_id = dragged_tab.item.item_id(); let pinned_count = self.pinned_tab_count; - self.handle_tab_drop(dragged_tab, pinned_count, window, cx); + self.handle_tab_drop(dragged_tab, pinned_count, false, window, cx); let to_pane = cx.entity(); @@ -3843,11 +3833,12 @@ impl Pane { window: &mut Window, cx: &mut Context, ) { - if let Some(custom_drop_handle) = self.custom_drop_handle.clone() - && let ControlFlow::Break(()) = custom_drop_handle(self, dragged_selection, window, cx) + if let Some(active_item) = self.active_item() + && active_item.handle_drop(self, dragged_selection, window, cx) { return; } + self.handle_project_entry_drop( &dragged_selection.active_selection.entry_id, dragged_onto, @@ -3863,11 +3854,12 @@ impl Pane { window: &mut Window, cx: &mut Context, ) { - if let Some(custom_drop_handle) = self.custom_drop_handle.clone() - && let ControlFlow::Break(()) = custom_drop_handle(self, project_entry_id, window, cx) + if let Some(active_item) = self.active_item() + && active_item.handle_drop(self, project_entry_id, window, cx) { return; } + let mut to_pane = cx.entity(); let split_direction = self.drag_split_direction; let project_entry_id = *project_entry_id; @@ -3939,11 +3931,12 @@ impl Pane { window: &mut Window, cx: &mut Context, ) { - if let Some(custom_drop_handle) = self.custom_drop_handle.clone() - && let ControlFlow::Break(()) = custom_drop_handle(self, paths, window, cx) + if let Some(active_item) = self.active_item() + && active_item.handle_drop(self, paths, window, cx) { return; } + let mut to_pane = cx.entity(); let mut split_direction = self.drag_split_direction; let paths = paths.paths().to_vec(); @@ -4424,6 +4417,7 @@ impl Render for Pane { this.handle_tab_drop( dragged_tab, this.active_item_index(), + true, window, cx, ) @@ -4826,7 +4820,7 @@ impl Render for DraggedTab { #[cfg(test)] mod tests { - use std::{iter::zip, num::NonZero}; + use std::{cell::Cell, iter::zip, num::NonZero}; use super::*; use crate::{ @@ -4839,6 +4833,65 @@ mod tests { use theme::LoadThemes; use util::TryFutureExt; + // drop_call_count is a Cell here because `handle_drop` takes &self, not &mut self. + struct CustomDropHandlingItem { + focus_handle: gpui::FocusHandle, + drop_call_count: Cell, + } + + impl CustomDropHandlingItem { + fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + drop_call_count: Cell::new(0), + } + } + + fn drop_call_count(&self) -> usize { + self.drop_call_count.get() + } + } + + impl EventEmitter<()> for CustomDropHandlingItem {} + + impl Focusable for CustomDropHandlingItem { + fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } + } + + impl Render for CustomDropHandlingItem { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl gpui::IntoElement { + gpui::Empty + } + } + + impl Item for CustomDropHandlingItem { + type Event = (); + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> gpui::SharedString { + "custom_drop_handling_item".into() + } + + fn handle_drop( + &self, + _active_pane: &Pane, + dropped: &dyn std::any::Any, + _window: &mut Window, + _cx: &mut App, + ) -> bool { + let is_dragged_tab = dropped.downcast_ref::().is_some(); + if is_dragged_tab { + self.drop_call_count.set(self.drop_call_count.get() + 1); + } + is_dragged_tab + } + } + #[gpui::test] async fn test_add_item_capped_to_max_tabs(cx: &mut TestAppContext) { init_test(cx); @@ -5664,6 +5717,83 @@ mod tests { assert_item_labels(&pane, ["C", "A", "B*"], cx); } + #[gpui::test] + async fn test_handle_tab_drop_respects_is_pane_target(cx: &mut TestAppContext) { + 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 source_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + let item_a = add_labeled_item(&source_pane, "A", false, cx); + let item_b = add_labeled_item(&source_pane, "B", false, cx); + + let target_pane = workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane(source_pane.clone(), SplitDirection::Right, window, cx) + }); + + let custom_item = target_pane.update_in(cx, |pane, window, cx| { + let custom_item = Box::new(cx.new(CustomDropHandlingItem::new)); + pane.add_item(custom_item.clone(), true, true, None, window, cx); + custom_item + }); + + let moved_item_id = item_a.item_id(); + let other_item_id = item_b.item_id(); + let custom_item_id = custom_item.item_id(); + + let pane_item_ids = |pane: &Entity, cx: &mut VisualTestContext| { + pane.read_with(cx, |pane, _| { + pane.items().map(|item| item.item_id()).collect::>() + }) + }; + + let source_before_item_ids = pane_item_ids(&source_pane, cx); + assert_eq!(source_before_item_ids, vec![moved_item_id, other_item_id]); + + let target_before_item_ids = pane_item_ids(&target_pane, cx); + assert_eq!(target_before_item_ids, vec![custom_item_id]); + + let dragged_tab = DraggedTab { + pane: source_pane.clone(), + item: item_a.boxed_clone(), + ix: 0, + detail: 0, + is_active: true, + }; + + // Dropping item_a onto the target pane itself means the + // custom item handles the drop and no tab move should occur + target_pane.update_in(cx, |pane, window, cx| { + pane.handle_tab_drop(&dragged_tab, pane.active_item_index(), true, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + custom_item.read_with(cx, |item, _| item.drop_call_count()), + 1 + ); + assert_eq!(pane_item_ids(&source_pane, cx), source_before_item_ids); + assert_eq!(pane_item_ids(&target_pane, cx), target_before_item_ids); + + // Dropping item_a onto the tab target means the custom handler + // should be skipped and the pane's default tab drop behavior should run. + target_pane.update_in(cx, |pane, window, cx| { + pane.handle_tab_drop(&dragged_tab, pane.active_item_index(), false, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + custom_item.read_with(cx, |item, _| item.drop_call_count()), + 1 + ); + assert_eq!(pane_item_ids(&source_pane, cx), vec![other_item_id]); + + let target_item_ids = pane_item_ids(&target_pane, cx); + assert_eq!(target_item_ids, vec![moved_item_id, custom_item_id]); + } + #[gpui::test] async fn test_drag_unpinned_tab_to_split_creates_pane_with_unpinned_tab( cx: &mut TestAppContext, @@ -5699,7 +5829,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 0, window, cx); + pane.handle_tab_drop(&dragged_tab, 0, true, window, cx); }); // A should be moved to new pane. B should remain pinned, A should not be pinned @@ -5748,7 +5878,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 0, window, cx); + pane.handle_tab_drop(&dragged_tab, 0, true, window, cx); }); // A should be moved to new pane. Both A and B should still be pinned @@ -5798,7 +5928,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 0, window, cx); + pane.handle_tab_drop(&dragged_tab, 0, false, window, cx); }); // A should stay pinned @@ -5846,7 +5976,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 1, window, cx); + pane.handle_tab_drop(&dragged_tab, 1, false, window, cx); }); // A should become pinned @@ -5890,7 +6020,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 0, window, cx); + pane.handle_tab_drop(&dragged_tab, 0, false, window, cx); }); // A should stay pinned @@ -5952,7 +6082,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 0, window, cx); + pane.handle_tab_drop(&dragged_tab, 0, false, window, cx); }); // E (unpinned) should be closed, leaving 3 pinned items @@ -5987,7 +6117,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 1, window, cx); + pane.handle_tab_drop(&dragged_tab, 1, false, window, cx); }); // A should still be pinned and active @@ -6027,7 +6157,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 2, window, cx); + pane.handle_tab_drop(&dragged_tab, 2, false, window, cx); }); // A stays pinned @@ -6064,7 +6194,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 1, window, cx); + pane.handle_tab_drop(&dragged_tab, 1, false, window, cx); }); // Neither are pinned @@ -6101,7 +6231,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 2, window, cx); + pane.handle_tab_drop(&dragged_tab, 2, false, window, cx); }); // A becomes unpinned @@ -6138,7 +6268,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 0, window, cx); + pane.handle_tab_drop(&dragged_tab, 0, false, window, cx); }); // A becomes unpinned @@ -6174,7 +6304,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 1, window, cx); + pane.handle_tab_drop(&dragged_tab, 1, false, window, cx); }); // A stays pinned, B and C remain unpinned @@ -6215,7 +6345,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 0, window, cx); + pane.handle_tab_drop(&dragged_tab, 0, false, window, cx); }); // A should become pinned since it was dropped in the pinned region @@ -6257,7 +6387,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 1, window, cx); + pane.handle_tab_drop(&dragged_tab, 1, true, window, cx); }); // A should remain unpinned since it was dropped outside the pinned region @@ -6304,7 +6434,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 1, window, cx); + pane.handle_tab_drop(&dragged_tab, 1, false, window, cx); }); // A should be after B and all are pinned @@ -6319,7 +6449,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 2, window, cx); + pane.handle_tab_drop(&dragged_tab, 2, false, window, cx); }); // A should be after C and all are pinned @@ -6334,7 +6464,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 1, window, cx); + pane.handle_tab_drop(&dragged_tab, 1, false, window, cx); }); // A should be before C and all are pinned @@ -6349,7 +6479,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 0, window, cx); + pane.handle_tab_drop(&dragged_tab, 0, false, window, cx); }); // A should be before B and all are pinned @@ -6381,7 +6511,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 2, window, cx); + pane.handle_tab_drop(&dragged_tab, 2, false, window, cx); }); // A should be at the end @@ -6413,7 +6543,7 @@ mod tests { detail: 0, is_active: true, }; - pane.handle_tab_drop(&dragged_tab, 0, window, cx); + pane.handle_tab_drop(&dragged_tab, 0, false, window, cx); }); // C should be at the beginning From 5289bea46e32fdb247a9a9ef495f763240bd762e Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 6 Mar 2026 11:53:14 +0100 Subject: [PATCH 015/219] nix: Coerce rel path to cargo wrapper script into abs path (#50919) This allows you to now re-use our Zed flake in different side projects with cargo wrapper script having its path correctly resolved. Say you have this dir structure: ``` $HOME/dev/zed $HOME/dev/something ``` Then this now works: ``` $ cd $HOME/dev/something $ nix develop ../zed $ cargo version cargo 1.93.0 (083ac5135 2025-12-15) ``` Release Notes: - N/A --- nix/modules/devshells.nix | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/nix/modules/devshells.nix b/nix/modules/devshells.nix index cfc0e48b871e71d87f9f794b35c16fed714ed4a9..ab58d37fff2dcaa64885effa5526db7bd365586b 100644 --- a/nix/modules/devshells.nix +++ b/nix/modules/devshells.nix @@ -22,10 +22,14 @@ # Cargo build timings wrapper script wrappedCargo = pkgs.writeShellApplication { name = "cargo"; - runtimeInputs = [pkgs.nodejs]; - text = '' - NIX_WRAPPER=1 CARGO=${rustToolchain}/bin/cargo ./script/cargo "$@" - ''; + runtimeInputs = [ pkgs.nodejs ]; + text = + let + pathToCargoScript = ./. + "/../../script/cargo"; + in + '' + NIX_WRAPPER=1 CARGO=${rustToolchain}/bin/cargo ${pathToCargoScript} "$@" + ''; }; in { @@ -34,7 +38,7 @@ inputsFrom = [ zed-editor ]; packages = with pkgs; [ - wrappedCargo # must be first, to shadow the `cargo` provided by `rustToolchain` + wrappedCargo # must be first, to shadow the `cargo` provided by `rustToolchain` rustToolchain # cargo, rustc, and rust-toolchain.toml components included cargo-nextest cargo-hakari From 2457e27437b355f030793f2085cda51bbc39b642 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 6 Mar 2026 12:12:38 +0100 Subject: [PATCH 016/219] eval: Add eval_cli crate (#50922) Very much wip Release Notes: - N/A --- Cargo.lock | 41 ++ Cargo.toml | 1 + crates/eval_cli/.gitignore | 3 + crates/eval_cli/Cargo.toml | 50 +++ crates/eval_cli/Dockerfile | 62 +++ crates/eval_cli/Dockerfile.dockerignore | 21 + crates/eval_cli/LICENSE-GPL | 1 + crates/eval_cli/README.md | 108 +++++ crates/eval_cli/build.rs | 15 + crates/eval_cli/script/build-linux | 57 +++ crates/eval_cli/src/headless.rs | 131 ++++++ crates/eval_cli/src/main.rs | 550 ++++++++++++++++++++++++ crates/eval_cli/zed_eval/__init__.py | 3 + crates/eval_cli/zed_eval/agent.py | 161 +++++++ crates/eval_cli/zed_eval/install.sh.j2 | 49 +++ crates/eval_cli/zed_eval/pyproject.toml | 10 + 16 files changed, 1263 insertions(+) create mode 100644 crates/eval_cli/.gitignore create mode 100644 crates/eval_cli/Cargo.toml create mode 100644 crates/eval_cli/Dockerfile create mode 100644 crates/eval_cli/Dockerfile.dockerignore create mode 120000 crates/eval_cli/LICENSE-GPL create mode 100644 crates/eval_cli/README.md create mode 100644 crates/eval_cli/build.rs create mode 100755 crates/eval_cli/script/build-linux create mode 100644 crates/eval_cli/src/headless.rs create mode 100644 crates/eval_cli/src/main.rs create mode 100644 crates/eval_cli/zed_eval/__init__.py create mode 100644 crates/eval_cli/zed_eval/agent.py create mode 100644 crates/eval_cli/zed_eval/install.sh.j2 create mode 100644 crates/eval_cli/zed_eval/pyproject.toml diff --git a/Cargo.lock b/Cargo.lock index ec376710159b3117bb883ddaa0ba2a4a539293bc..b147a39663d567bee029ed8b6c6694f0c6b41e85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5892,6 +5892,47 @@ dependencies = [ "watch", ] +[[package]] +name = "eval_cli" +version = "0.1.0" +dependencies = [ + "acp_thread", + "agent", + "agent-client-protocol", + "agent_ui", + "anyhow", + "clap", + "client", + "ctrlc", + "debug_adapter_extension", + "env_logger 0.11.8", + "extension", + "feature_flags", + "fs", + "futures 0.3.31", + "gpui", + "gpui_platform", + "gpui_tokio", + "language", + "language_extension", + "language_model", + "language_models", + "languages", + "node_runtime", + "paths", + "project", + "prompt_store", + "release_channel", + "reqwest_client", + "serde", + "serde_json", + "settings", + "shellexpand 2.1.2", + "terminal_view", + "util", + "watch", +] + [[package]] name = "eval_utils" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 497bdd203d958f3ad7d33cd98f5ff1e9b2e34655..597a5f2a207c27154dcf1a55c85d97271604f83f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,7 @@ members = [ "crates/encoding_selector", "crates/etw_tracing", "crates/eval", + "crates/eval_cli", "crates/eval_utils", "crates/explorer_command_injector", "crates/extension", diff --git a/crates/eval_cli/.gitignore b/crates/eval_cli/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..083ef6e3d354cb335e59916071199149d11965be --- /dev/null +++ b/crates/eval_cli/.gitignore @@ -0,0 +1,3 @@ +**/jobs +**/*.egg-info +**/__pycache__ diff --git a/crates/eval_cli/Cargo.toml b/crates/eval_cli/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..d8f52992e2ae9512e694bb11c491fd8b60c0c947 --- /dev/null +++ b/crates/eval_cli/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "eval_cli" +version = "0.1.0" +publish.workspace = true +edition.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[[bin]] +name = "eval-cli" +path = "src/main.rs" + +[dependencies] +acp_thread.workspace = true +agent.workspace = true +agent-client-protocol.workspace = true +agent_ui.workspace = true +anyhow.workspace = true +clap.workspace = true +client.workspace = true +ctrlc = { version = "3.5", features = ["termination"] } +debug_adapter_extension.workspace = true +env_logger.workspace = true +extension.workspace = true +feature_flags.workspace = true +fs.workspace = true +futures.workspace = true +gpui.workspace = true +gpui_platform.workspace = true +gpui_tokio.workspace = true +language.workspace = true +language_extension.workspace = true +language_model.workspace = true +language_models.workspace = true +languages = { workspace = true, features = ["load-grammars"] } +node_runtime.workspace = true +paths.workspace = true +project.workspace = true +prompt_store.workspace = true +release_channel.workspace = true +reqwest_client.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +shellexpand.workspace = true +terminal_view.workspace = true +util.workspace = true +watch.workspace = true diff --git a/crates/eval_cli/Dockerfile b/crates/eval_cli/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..7b91a7adf991428670fac43ad745a6e9998c9c38 --- /dev/null +++ b/crates/eval_cli/Dockerfile @@ -0,0 +1,62 @@ +# Build eval-cli for Linux. +# +# Usage (from the zed repo root): +# docker build --platform linux/amd64 -f crates/eval_cli/Dockerfile -t eval-cli-builder . +# docker cp "$(docker create eval-cli-builder)":/eval-cli ./target/eval-cli +# +# Or use the helper script: +# crates/eval_cli/script/build-linux + +FROM rust:1.93.1-bookworm AS builder + +WORKDIR /app + +# Install build dependencies (subset of script/linux needed for headless GPUI). +RUN apt-get update && apt-get install -y --no-install-recommends \ + cmake \ + clang \ + g++ \ + libasound2-dev \ + libfontconfig-dev \ + libgit2-dev \ + libglib2.0-dev \ + libssl-dev \ + libwayland-dev \ + libx11-xcb-dev \ + libxkbcommon-x11-dev \ + libzstd-dev \ + libsqlite3-dev \ + build-essential \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install wild linker for faster linking (built from source to match bookworm's glibc). +RUN cargo install --locked wild-linker --version 0.8.0 --root /usr/local + +# Download WASI SDK (needed by some dependencies). +ARG TARGETARCH +RUN mkdir -p /app/target && \ + WASI_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "x86_64") && \ + curl -L "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-${WASI_ARCH}-linux.tar.gz" \ + | tar -xz -C /app/target && \ + mv /app/target/wasi-sdk-25.0-${WASI_ARCH}-linux /app/target/wasi-sdk + +# Pre-install the toolchain specified in rust-toolchain.toml so it is cached. +RUN rustup toolchain install 1.93 --profile minimal \ + --component rustfmt --component clippy --component rust-analyzer --component rust-src \ + --target wasm32-wasip2 --target wasm32-unknown-unknown --target x86_64-unknown-linux-musl + +COPY . . + +ENV CC=clang CXX=clang++ +ENV RUSTFLAGS="-C linker=clang -C link-arg=--ld-path=wild" + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/app/target \ + cargo build --release --package eval_cli && \ + cp /app/target/release/eval-cli /eval-cli && \ + strip /eval-cli + +FROM scratch +COPY --from=builder /eval-cli /eval-cli diff --git a/crates/eval_cli/Dockerfile.dockerignore b/crates/eval_cli/Dockerfile.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..419f92f9c9b6dad52f04c9ad39e031a7405f2a4b --- /dev/null +++ b/crates/eval_cli/Dockerfile.dockerignore @@ -0,0 +1,21 @@ +.git +.github +**/.gitignore +**/.gitkeep +.gitattributes +.mailmap +**/target +zed.xcworkspace +.DS_Store +compose.yml +plugins/bin +script/node_modules +styles/node_modules +crates/collab/static/styles.css +vendor/bin +assets/themes/ +**/jobs + +**/*.egg-info +**/__pycache__ +**/.venv diff --git a/crates/eval_cli/LICENSE-GPL b/crates/eval_cli/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/eval_cli/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/eval_cli/README.md b/crates/eval_cli/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a9952bbf4fe1066a78acaad15bfab10d0cee098d --- /dev/null +++ b/crates/eval_cli/README.md @@ -0,0 +1,108 @@ +# eval-cli + +Headless CLI binary for running Zed's agent in evaluation/benchmark +environments. Designed to work inside containerized environments like +[Harbor](https://harborframework.com/) where the repository is already +checked out and API keys are provided via environment variables. + +Uses the same `NativeAgent` + `AcpThread` pipeline as the production Zed +editor — full agentic loop with tool calls, subagents, and retries, just +without a GUI. + +## Building + +### Native (for local testing on the same OS) + +``` +cargo build --release -p eval_cli +``` + +### Cross-compile for Linux x86_64 (from macOS or other hosts) + +Harbor containers run Linux x86_64. Use the Docker-based build script: + +``` +crates/eval_cli/script/build-linux +``` + +This produces `target/eval-cli` (an x86_64 Linux ELF binary). You can +also specify a custom output path: + +``` +crates/eval_cli/script/build-linux --output ~/bin/eval-cli-linux +``` + +## Standalone usage + +``` +eval-cli \ + --workdir /testbed \ + --model anthropic/claude-sonnet-4-6-latest \ + --instruction "Fix the bug described in..." \ + --timeout 600 \ + --output-dir /logs/agent +``` + +Reads API keys from environment variables (`ANTHROPIC_API_KEY`, +`OPENAI_API_KEY`, etc.). Writes `result.json`, `thread.md`, and +`thread.json` to the output directory. + +### Exit codes + +| Code | Meaning | +| ---- | ---------------------------------- | +| 0 | Agent finished | +| 1 | Error (model/auth/runtime failure) | +| 2 | Timeout | +| 3 | Interrupted (SIGTERM/SIGINT) | + +## Harbor integration + +The `zed_eval/` directory contains a Python package that +implements Harbor's `BaseInstalledAgent` interface, allowing eval-cli to +be used with `--agent-import-path` without modifying Harbor's source code. + +### Setup + +``` +pip install -e crates/eval_cli/harbor/ +``` + +### Running with a local binary + +Build for Linux first, then pass the binary path: + +``` +crates/eval_cli/script/build-linux + +harbor run -d "swebench_verified@latest" \ + --agent-import-path zed_eval.agent:ZedAgent \ + --ae binary_path=target/eval-cli \ + -m anthropic/claude-sonnet-4-6-latest +``` + +The agent uploads the binary into the container during setup — no +download URL needed during local iteration. + +### Running with a download URL + +For CI or when the binary is hosted somewhere: + +``` +harbor run -d "swebench_verified@latest" \ + --agent-import-path zed_eval.agent:ZedAgent \ + --ak download_url=https://example.com/eval-cli \ + -m anthropic/claude-sonnet-4-6-latest +``` + +### Setting a timeout + +Pass `EVAL_CLI_TIMEOUT` via `--ae`: + +``` +harbor run -d "swebench_verified@latest" \ + --agent-import-path zed_eval.agent:ZedAgent \ + --ak binary_path=target/eval-cli \ + --ae EVAL_CLI_TIMEOUT=600 \ + -m anthropic/claude-sonnet-4-6-latest +``` diff --git a/crates/eval_cli/build.rs b/crates/eval_cli/build.rs new file mode 100644 index 0000000000000000000000000000000000000000..0180e9036fbd049ba5a9e5b455ec1c017cd700e3 --- /dev/null +++ b/crates/eval_cli/build.rs @@ -0,0 +1,15 @@ +fn main() { + let cargo_toml = + std::fs::read_to_string("../zed/Cargo.toml").expect("Failed to read crates/zed/Cargo.toml"); + let version = cargo_toml + .lines() + .find(|line| line.starts_with("version = ")) + .expect("Version not found in crates/zed/Cargo.toml") + .split('=') + .nth(1) + .expect("Invalid version format") + .trim() + .trim_matches('"'); + println!("cargo:rerun-if-changed=../zed/Cargo.toml"); + println!("cargo:rustc-env=ZED_PKG_VERSION={}", version); +} diff --git a/crates/eval_cli/script/build-linux b/crates/eval_cli/script/build-linux new file mode 100755 index 0000000000000000000000000000000000000000..9c710668de2aa5e956efff727e6ef8eb2c5ed627 --- /dev/null +++ b/crates/eval_cli/script/build-linux @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# Build eval-cli for x86_64 Linux from any host (macOS, Linux, etc.) +# using Docker. The resulting binary is placed at the path printed on +# completion (default: target/eval-cli). +# +# Usage: +# crates/eval_cli/script/build-linux [--output PATH] +# +# Examples: +# crates/eval_cli/script/build-linux +# crates/eval_cli/script/build-linux --output ~/bin/eval-cli +# +# Prerequisites: Docker must be installed and running. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +OUTPUT="${REPO_ROOT}/target/eval-cli" + +while [[ $# -gt 0 ]]; do + case $1 in + --output) + OUTPUT="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +cd "$REPO_ROOT" + +IMAGE_TAG="eval-cli-builder" + +echo "Building eval-cli for x86_64-unknown-linux-gnu..." +echo " Repo root: $REPO_ROOT" +echo " Output: $OUTPUT" +echo "" + +docker build \ + --platform linux/amd64 \ + -f crates/eval_cli/Dockerfile \ + -t "$IMAGE_TAG" \ + . + +CONTAINER_ID=$(docker create "$IMAGE_TAG" /eval-cli) +mkdir -p "$(dirname "$OUTPUT")" +docker cp "$CONTAINER_ID":/eval-cli "$OUTPUT" +docker rm "$CONTAINER_ID" > /dev/null + +echo "" +echo "Built successfully: $OUTPUT" +echo " $(file "$OUTPUT")" diff --git a/crates/eval_cli/src/headless.rs b/crates/eval_cli/src/headless.rs new file mode 100644 index 0000000000000000000000000000000000000000..1448cbeb7a724b2b4dfdb1cbba430dcc3cdfd5b5 --- /dev/null +++ b/crates/eval_cli/src/headless.rs @@ -0,0 +1,131 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use client::{Client, ProxySettings, UserStore}; +use extension::ExtensionHostProxy; +use fs::RealFs; +use gpui::http_client::read_proxy_from_env; +use gpui::{App, AppContext as _, Entity}; +use gpui_tokio::Tokio; +use language::LanguageRegistry; +use language_extension::LspAccess; +use node_runtime::{NodeBinaryOptions, NodeRuntime}; +use project::project_settings::ProjectSettings; +use prompt_store::PromptBuilder; +use release_channel::{AppCommitSha, AppVersion}; +use reqwest_client::ReqwestClient; +use settings::{Settings, SettingsStore}; +use util::ResultExt as _; + +pub struct AgentCliAppState { + pub languages: Arc, + pub client: Arc, + pub user_store: Entity, + pub fs: Arc, + pub node_runtime: NodeRuntime, +} + +pub fn init(cx: &mut App) -> Arc { + let app_commit_sha = option_env!("ZED_COMMIT_SHA").map(|s| AppCommitSha::new(s.to_owned())); + + let app_version = AppVersion::load( + env!("ZED_PKG_VERSION"), + option_env!("ZED_BUILD_ID"), + app_commit_sha, + ); + + release_channel::init(app_version.clone(), cx); + gpui_tokio::init(cx); + + let settings_store = SettingsStore::new(cx, &settings::default_settings()); + cx.set_global(settings_store); + + let user_agent = format!( + "Zed Agent CLI/{} ({}; {})", + app_version, + std::env::consts::OS, + std::env::consts::ARCH + ); + let proxy_str = ProxySettings::get_global(cx).proxy.to_owned(); + let proxy_url = proxy_str + .as_ref() + .and_then(|input| input.parse().ok()) + .or_else(read_proxy_from_env); + let http = { + let _guard = Tokio::handle(cx).enter(); + ReqwestClient::proxy_and_user_agent(proxy_url, &user_agent) + .expect("could not start HTTP client") + }; + cx.set_http_client(Arc::new(http)); + + let client = Client::production(cx); + cx.set_http_client(client.http_client()); + + let git_binary_path = None; + let fs = Arc::new(RealFs::new( + git_binary_path, + cx.background_executor().clone(), + )); + + let mut languages = LanguageRegistry::new(cx.background_executor().clone()); + languages.set_language_server_download_dir(paths::languages_dir().clone()); + let languages = Arc::new(languages); + + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + + extension::init(cx); + + let (mut node_options_tx, node_options_rx) = watch::channel(None); + cx.observe_global::(move |cx| { + let settings = &ProjectSettings::get_global(cx).node; + let options = NodeBinaryOptions { + allow_path_lookup: !settings.ignore_system_version, + allow_binary_download: true, + use_paths: settings.path.as_ref().map(|node_path| { + let node_path = PathBuf::from(shellexpand::tilde(node_path).as_ref()); + let npm_path = settings + .npm_path + .as_ref() + .map(|path| PathBuf::from(shellexpand::tilde(&path).as_ref())); + ( + node_path.clone(), + npm_path.unwrap_or_else(|| { + let base_path = PathBuf::new(); + node_path.parent().unwrap_or(&base_path).join("npm") + }), + ) + }), + }; + node_options_tx.send(Some(options)).log_err(); + }) + .detach(); + let node_runtime = NodeRuntime::new(client.http_client(), None, node_options_rx); + + let extension_host_proxy = ExtensionHostProxy::global(cx); + debug_adapter_extension::init(extension_host_proxy.clone(), cx); + language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone()); + language_model::init(client.clone(), cx); + language_models::init(user_store.clone(), client.clone(), cx); + languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx); + prompt_store::init(cx); + terminal_view::init(cx); + + let stdout_is_a_pty = false; + let prompt_builder = PromptBuilder::load(fs.clone(), stdout_is_a_pty, cx); + agent_ui::init( + fs.clone(), + client.clone(), + prompt_builder, + languages.clone(), + true, + cx, + ); + + Arc::new(AgentCliAppState { + languages, + client, + user_store, + fs, + node_runtime, + }) +} diff --git a/crates/eval_cli/src/main.rs b/crates/eval_cli/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..0f8dbed7ba12cee934e7631dc7068c83db1dc293 --- /dev/null +++ b/crates/eval_cli/src/main.rs @@ -0,0 +1,550 @@ +//! Headless CLI binary for running Zed's agent in evaluation/benchmark environments. +//! +//! Designed to work inside containerized environments (like Harbor/termbench) where: +//! - The repository is already checked out at the working directory +//! - The model API key is provided via environment variables +//! - Results are written to an output directory (default: `/logs/agent/`) +//! +//! ## Usage +//! +//! ```text +//! eval-cli --workdir /testbed --model anthropic/claude-sonnet-4-6-latest \ +//! --instruction "Fix the bug described in..." --timeout 600 +//! ``` +//! +//! ## Output +//! +//! Writes to `--output-dir` (default `/logs/agent/`): +//! - `result.json` — structured result with status, timing, and token usage +//! - `thread.md` — full conversation as markdown +//! - `thread.json` — raw thread state as JSON +//! +//! ## Exit codes +//! +//! | Code | Meaning | +//! |------|---------| +//! | 0 | Agent finished | +//! | 1 | Error (model/auth/runtime failure) | +//! | 2 | Timeout | +//! | 3 | Interrupted (SIGTERM/SIGINT) | + +mod headless; + +use std::path::PathBuf; +use std::process; +use std::rc::Rc; +use std::str::FromStr; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use acp_thread::AgentConnection as _; +use agent::{NativeAgent, NativeAgentConnection, Templates, ThreadStore}; +use agent_client_protocol as acp; +use anyhow::{Context, Result}; +use clap::Parser; +use feature_flags::FeatureFlagAppExt as _; + +use futures::{FutureExt, select_biased}; +use gpui::{AppContext as _, AsyncApp, Entity, UpdateGlobal}; +use language_model::{LanguageModelRegistry, SelectedModel}; +use project::Project; +use settings::SettingsStore; + +use crate::headless::AgentCliAppState; + +#[derive(Parser, Debug)] +#[command( + name = "eval-cli", + about = "Run Zed's agent headlessly in evaluation/benchmark environments" +)] +struct Args { + /// Output current environment variables as JSON to stdout. + /// Used internally by Zed's shell environment capture. + #[arg(long, hide = true)] + printenv: bool, + + /// Path to the repository working directory. Defaults to the current directory. + #[arg(long, default_value = ".")] + workdir: PathBuf, + + /// Instruction/prompt text. If omitted, read from --instruction-file or stdin. + #[arg(long)] + instruction: Option, + + /// Language model to use, in `provider/model` format. + #[arg(long, default_value = "anthropic/claude-sonnet-4-6-latest")] + model: String, + + /// Maximum wall-clock time in seconds for the agent run. + #[arg(long)] + timeout: Option, + + /// Directory for output artifacts (result.json, thread.md, thread.json). + #[arg(long, default_value = "/logs/agent")] + output_dir: PathBuf, +} + +enum AgentOutcome { + Completed, + Timeout { seconds: u64 }, + Interrupted, +} + +#[derive(serde::Serialize)] +struct EvalResult { + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + duration_secs: f64, + #[serde(skip_serializing_if = "Option::is_none")] + timeout_secs: Option, + model: String, + #[serde(skip_serializing_if = "Option::is_none")] + input_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cache_creation_input_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cache_read_input_tokens: Option, +} + +const EXIT_OK: i32 = 0; +const EXIT_ERROR: i32 = 1; +const EXIT_TIMEOUT: i32 = 2; +const EXIT_INTERRUPTED: i32 = 3; + +static TERMINATED: AtomicBool = AtomicBool::new(false); + +fn main() { + let args = Args::parse(); + + if args.printenv { + util::shell_env::print_env(); + return; + } + + env_logger::init(); + + ctrlc::set_handler(|| { + TERMINATED.store(true, Ordering::SeqCst); + }) + .expect("failed to set signal handler"); + + let instruction = read_instruction(&args).unwrap_or_else(|e| { + eprintln!("Error reading instruction: {e}"); + process::exit(EXIT_ERROR); + }); + + let workdir = args.workdir.canonicalize().unwrap_or_else(|e| { + eprintln!("Invalid --workdir {:?}: {e}", args.workdir); + process::exit(EXIT_ERROR); + }); + + let output_dir = args.output_dir.clone(); + if let Err(e) = std::fs::create_dir_all(&output_dir) { + eprintln!("Error creating output dir {}: {e}", output_dir.display()); + process::exit(EXIT_ERROR); + } + + let http_client = Arc::new(reqwest_client::ReqwestClient::new()); + let app = gpui_platform::headless().with_http_client(http_client); + + app.run(move |cx| { + let app_state = headless::init(cx); + cx.set_staff(true); + + let auth_tasks = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry + .providers() + .iter() + .map(|p| p.authenticate(cx)) + .collect::>() + }); + + let model_name = args.model.clone(); + let timeout = args.timeout; + + cx.spawn(async move |cx| { + futures::future::join_all(auth_tasks).await; + + let start = Instant::now(); + + let (outcome, token_usage) = run_agent( + &app_state, + &workdir, + &instruction, + &model_name, + timeout, + Some(&output_dir), + cx, + ) + .await; + + let duration = start.elapsed(); + + let (status, error, exit_code) = match &outcome { + Ok(AgentOutcome::Completed) => ("completed".to_string(), None, EXIT_OK), + Ok(AgentOutcome::Timeout { seconds }) => { + eprintln!("Timeout: agent exceeded {seconds}s time limit"); + ("timeout".to_string(), None, EXIT_TIMEOUT) + } + Ok(AgentOutcome::Interrupted) => { + eprintln!("Interrupted: received SIGTERM, saved partial output"); + ("interrupted".to_string(), None, EXIT_INTERRUPTED) + } + Err(e) => { + eprintln!("Error: {e:#}"); + ("error".to_string(), Some(format!("{e:#}")), EXIT_ERROR) + } + }; + + let result = EvalResult { + status, + error, + duration_secs: duration.as_secs_f64(), + timeout_secs: timeout, + model: model_name.clone(), + input_tokens: token_usage.as_ref().map(|u| u.input_tokens), + output_tokens: token_usage.as_ref().map(|u| u.output_tokens), + cache_creation_input_tokens: token_usage + .as_ref() + .filter(|u| u.cache_creation_input_tokens > 0) + .map(|u| u.cache_creation_input_tokens), + cache_read_input_tokens: token_usage + .as_ref() + .filter(|u| u.cache_read_input_tokens > 0) + .map(|u| u.cache_read_input_tokens), + }; + + match serde_json::to_string_pretty(&result) { + Ok(json) => { + if let Err(e) = std::fs::write(output_dir.join("result.json"), &json) { + eprintln!("Error writing result.json: {e:#}"); + } + eprintln!("[eval-cli] result: {json}"); + } + Err(e) => eprintln!("Error serializing result: {e:#}"), + } + + cx.update(|cx| cx.quit()); + process::exit(exit_code); + }) + .detach(); + }); +} + +fn read_instruction(args: &Args) -> Result { + let text = if let Some(text) = &args.instruction { + text.clone() + } else { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .context("reading instruction from stdin")?; + buf + }; + anyhow::ensure!(!text.trim().is_empty(), "instruction is empty"); + Ok(text) +} + +async fn run_agent( + app_state: &Arc, + workdir: &std::path::Path, + instruction: &str, + model_name: &str, + timeout: Option, + output_dir: Option<&std::path::Path>, + cx: &mut AsyncApp, +) -> (Result, Option) { + let setup_result: Result<()> = cx.update(|cx| { + let selected = SelectedModel::from_str(model_name).map_err(|e| anyhow::anyhow!("{e}"))?; + let registry = LanguageModelRegistry::global(cx); + let model = registry + .read(cx) + .available_models(cx) + .find(|m| m.id() == selected.model && m.provider_id() == selected.provider) + .ok_or_else(|| { + let available = registry + .read(cx) + .available_models(cx) + .map(|m| format!("{}/{}", m.provider_id().0, m.id().0)) + .collect::>() + .join(", "); + anyhow::anyhow!("Model {model_name} not found. Available: {available}") + })?; + + let supports_thinking = model.supports_thinking(); + + registry.update(cx, |registry, cx| { + registry.set_default_model( + Some(language_model::ConfiguredModel { + provider: registry + .provider(&model.provider_id()) + .context("Provider not found")?, + model, + }), + cx, + ); + anyhow::Ok(()) + })?; + + let (enable_thinking, effort) = if supports_thinking { + (true, "\"high\"") + } else { + (false, "null") + }; + let provider_id = selected.provider.0.to_string(); + let model_id = selected.model.0.to_string(); + SettingsStore::update_global(cx, |store, cx| { + let settings = format!( + r#"{{ + "agent": {{ + "tool_permissions": {{"default": "allow"}}, + "default_model": {{ + "provider": "{provider_id}", + "model": "{model_id}", + "enable_thinking": {enable_thinking}, + "effort": {effort} + }} + }}, + "autosave": "off", + "format_on_save": "off" + }}" + "# + ); + store.set_user_settings(&settings, cx).ok(); + }); + + anyhow::Ok(()) + }); + + if let Err(e) = setup_result { + return (Err(e), None); + } + + let project = cx.update(|cx| { + Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + project::LocalProjectFlags { + init_worktree_trust: false, + ..Default::default() + }, + cx, + ) + }); + + let worktree = project.update(cx, |project, cx| project.create_worktree(workdir, true, cx)); + let worktree = match worktree.await { + Ok(w) => w, + Err(e) => return (Err(e).context("creating worktree"), None), + }; + + let scan_result = worktree.update(cx, |tree, _cx| { + tree.as_local() + .context("expected local worktree") + .map(|local| local.scan_complete()) + }); + match scan_result { + Ok(future) => future.await, + Err(e) => return (Err(e), None), + }; + + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let agent = match NativeAgent::new( + project.clone(), + thread_store, + Templates::new(), + None, + app_state.fs.clone(), + cx, + ) + .await + { + Ok(a) => a, + Err(e) => return (Err(e).context("creating agent"), None), + }; + + let connection = Rc::new(NativeAgentConnection(agent.clone())); + let acp_thread = match cx + .update(|cx| connection.clone().new_session(project, workdir, cx)) + .await + { + Ok(t) => t, + Err(e) => return (Err(e).context("creating ACP session"), None), + }; + + let _subscription = cx.subscribe(&acp_thread, |acp_thread, event, cx| { + log_acp_thread_event(&acp_thread, event, cx); + }); + + let message = vec![acp::ContentBlock::Text(acp::TextContent::new( + instruction.to_string(), + ))]; + + let send_future = acp_thread.update(cx, |acp_thread: &mut acp_thread::AcpThread, cx| { + acp_thread.send(message, cx) + }); + + let timeout_future = if let Some(timeout_secs) = timeout { + futures::future::Either::Left( + cx.background_executor() + .timer(Duration::from_secs(timeout_secs)), + ) + } else { + futures::future::Either::Right(futures::future::pending::<()>()) + }; + + let sigterm_future = { + let executor = cx.background_executor().clone(); + async move { + while !TERMINATED.load(Ordering::Relaxed) { + executor.timer(Duration::from_millis(100)).await; + } + } + }; + + let outcome = select_biased! { + result = send_future.fuse() => match result { + Ok(Some(response)) => { + eprintln!("[eval-cli] stopped: {:?}", response.stop_reason); + if response.stop_reason == acp::StopReason::MaxTokens { + Err(anyhow::anyhow!("Model hit maximum token limit")) + } else { + Ok(AgentOutcome::Completed) + } + } + Ok(None) => { + eprintln!("[eval-cli] completed (no response)"); + Ok(AgentOutcome::Completed) + } + Err(e) => Err(e).context("agent run failed"), + }, + _ = sigterm_future.fuse() => { + eprintln!("[eval-cli] received SIGTERM, cancelling..."); + acp_thread.update(cx, |t: &mut acp_thread::AcpThread, cx| t.cancel(cx)).await; + Ok(AgentOutcome::Interrupted) + }, + _ = timeout_future.fuse() => { + acp_thread.update(cx, |t: &mut acp_thread::AcpThread, cx| t.cancel(cx)).await; + Ok(AgentOutcome::Timeout { seconds: timeout.unwrap_or(0) }) + } + }; + + let thread = cx.update(|cx| { + let session_id = acp_thread.read(cx).session_id().clone(); + connection.thread(&session_id, cx) + }); + + let cumulative_usage = if let Some(thread) = &thread { + let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx)); + let db_thread = db_thread.await; + let usage = db_thread.cumulative_token_usage; + if usage.input_tokens > 0 || usage.output_tokens > 0 { + Some(usage) + } else { + None + } + } else { + None + }; + + let acp_usage = cx.update(|cx| { + acp_thread + .read(cx) + .token_usage() + .map(|usage| language_model::TokenUsage { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + ..Default::default() + }) + }); + + let final_usage = cumulative_usage.or(acp_usage); + + if let (Some(thread), Some(dir)) = (&thread, output_dir) { + let markdown = thread.read_with(cx, |thread, _cx| thread.to_markdown()); + if let Err(e) = std::fs::write(dir.join("thread.md"), markdown) { + eprintln!("Error writing thread.md: {e:#}"); + } + + let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx)); + let db_thread = db_thread.await; + match serde_json::to_string_pretty(&db_thread) { + Ok(json) => { + if let Err(e) = std::fs::write(dir.join("thread.json"), json) { + eprintln!("Error writing thread.json: {e:#}"); + } + } + Err(e) => eprintln!("Error serializing thread.json: {e:#}"), + } + } + + (outcome, final_usage) +} + +fn log_acp_thread_event( + acp_thread: &Entity, + event: &acp_thread::AcpThreadEvent, + cx: &mut gpui::App, +) { + match event { + acp_thread::AcpThreadEvent::NewEntry => { + let entries = acp_thread.read(cx).entries(); + if let Some(acp_thread::AgentThreadEntry::AssistantMessage(message)) = entries.last() { + for chunk in &message.chunks { + if let acp_thread::AssistantMessageChunk::Message { block } = chunk { + if let acp_thread::ContentBlock::Markdown { markdown } = block { + let text = markdown.read(cx).source().to_string(); + if !text.is_empty() { + eprint!("{text}"); + } + } + } + } + } + } + acp_thread::AcpThreadEvent::EntryUpdated(index) => { + let entries = acp_thread.read(cx).entries(); + if let Some(acp_thread::AgentThreadEntry::ToolCall(tool_call)) = entries.get(*index) { + if let Some(name) = &tool_call.tool_name { + match &tool_call.status { + acp_thread::ToolCallStatus::Completed => { + eprintln!("[tool] {name} ✓"); + } + acp_thread::ToolCallStatus::Failed => { + eprintln!("[tool] {name} ✗"); + } + acp_thread::ToolCallStatus::Rejected => { + eprintln!("[tool] {name} rejected"); + } + acp_thread::ToolCallStatus::Canceled => { + eprintln!("[tool] {name} canceled"); + } + _ => {} + } + } + } + } + acp_thread::AcpThreadEvent::Stopped(reason) => { + eprintln!("\n[eval-cli] stopped: {reason:?}"); + } + acp_thread::AcpThreadEvent::Error => { + eprintln!("[eval-cli] error event"); + } + acp_thread::AcpThreadEvent::Retry(status) => { + eprintln!("[eval-cli] retry: {status:?}"); + } + acp_thread::AcpThreadEvent::SubagentSpawned(session_id) => { + eprintln!("[eval-cli] subagent spawned: {session_id}"); + } + _ => {} + } +} diff --git a/crates/eval_cli/zed_eval/__init__.py b/crates/eval_cli/zed_eval/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8cf07a06883a70660eb4bb3ca5a20ae304e6871b --- /dev/null +++ b/crates/eval_cli/zed_eval/__init__.py @@ -0,0 +1,3 @@ +from zed_eval.agent import ZedAgent + +__all__ = ["ZedAgent"] diff --git a/crates/eval_cli/zed_eval/agent.py b/crates/eval_cli/zed_eval/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..6214ff18d784dd9620f404a00ba1b48ce96b5707 --- /dev/null +++ b/crates/eval_cli/zed_eval/agent.py @@ -0,0 +1,161 @@ +"""Harbor agent wrapper for Zed's eval-cli binary. + +Usage: + # Build eval-cli locally first: + cargo build --release -p eval_cli + + # Run via Harbor with a local binary: + harbor run -d "dataset@version" \ + --agent-import-path zed_eval.agent:ZedAgent \ + --ae binary_path=/path/to/target/release/eval-cli \ + --agent-model anthropic/claude-sonnet-4-6-latest + + # Or with a download URL (for CI): + harbor run -d "dataset@version" \ + --agent-import-path zed_eval.agent:ZedAgent \ + --ae download_url=https://example.com/eval-cli \ + --agent-model anthropic/claude-sonnet-4-6-latest +""" + +import json +import os +import shlex +from pathlib import Path + +from harbor.agents.installed.base import BaseInstalledAgent, ExecInput +from harbor.environments.base import BaseEnvironment +from harbor.models.agent.context import AgentContext + + +class ZedAgent(BaseInstalledAgent): + """Runs Zed's headless AI agent (eval-cli) to solve tasks. + + The eval-cli binary boots a headless GPUI application and uses the same + NativeAgent + AcpThread pipeline as the production Zed editor, driving + the full agentic loop (tool calls, subagents, retries) without a GUI. + """ + + def __init__( + self, + logs_dir: Path, + binary_path: str | None = None, + download_url: str | None = None, + *args, + **kwargs, + ): + super().__init__(logs_dir, *args, **kwargs) + self._binary_path = binary_path + self._download_url = download_url or os.environ.get("EVAL_CLI_DOWNLOAD_URL") + + @staticmethod + def name() -> str: + return "zed" + + @property + def _install_agent_template_path(self) -> Path: + return Path(__file__).parent / "install.sh.j2" + + async def setup(self, environment: BaseEnvironment) -> None: + await environment.exec(command="mkdir -p /installed-agent") + + if self._binary_path: + binary = Path(self._binary_path) + if not binary.exists(): + raise FileNotFoundError( + f"eval-cli binary not found at {binary}. " + "Build it with: cargo build --release -p eval_cli" + ) + await environment.upload_file( + source_path=binary, + target_path="/usr/local/bin/eval-cli", + ) + await environment.exec(command="chmod +x /usr/local/bin/eval-cli") + + await super().setup(environment) + + @property + def _template_variables(self) -> dict[str, str]: + variables = super()._template_variables + if self._binary_path: + variables["binary_uploaded"] = "true" + if self._download_url: + variables["download_url"] = self._download_url + return variables + + def populate_context_post_run(self, context: AgentContext) -> None: + result_data = None + for json_file in self.logs_dir.rglob("result.json"): + try: + result_data = json.loads(json_file.read_text()) + break + except (json.JSONDecodeError, OSError): + continue + + if result_data is None: + self.logger.warning("Could not find or parse result.json from eval-cli") + return + + if result_data.get("input_tokens") is not None: + context.n_input_tokens = result_data["input_tokens"] + if result_data.get("output_tokens") is not None: + context.n_output_tokens = result_data["output_tokens"] + if result_data.get("cache_read_input_tokens") is not None: + context.n_cache_tokens = result_data["cache_read_input_tokens"] + + context.metadata = { + "status": result_data.get("status"), + "duration_secs": result_data.get("duration_secs"), + "model": result_data.get("model"), + } + + def _get_api_env(self) -> dict[str, str]: + env: dict[str, str] = {} + if not self.model_name or "/" not in self.model_name: + return env + + provider = self.model_name.split("/", 1)[0] + provider_env_map = { + "anthropic": "ANTHROPIC_API_KEY", + "openai": "OPENAI_API_KEY", + "google": "GEMINI_API_KEY", + "gemini": "GEMINI_API_KEY", + "deepseek": "DEEPSEEK_API_KEY", + "mistral": "MISTRAL_API_KEY", + } + + env_var = provider_env_map.get(provider) + if env_var: + api_key = os.environ.get(env_var, "") + if api_key: + env[env_var] = api_key + + return env + + def create_run_agent_commands(self, instruction: str) -> list[ExecInput]: + escaped_instruction = shlex.quote(instruction) + env = self._get_api_env() + + parts = ["eval-cli", "--workdir /testbed", "--output-dir /logs/agent"] + + if self.model_name: + parts.append(f"--model {self.model_name}") + + timeout = self._extra_env.get("EVAL_CLI_TIMEOUT") + if timeout: + parts.append(f"--timeout {timeout}") + + parts.append(f"--instruction {escaped_instruction}") + + eval_cli_command = " ".join(parts) + " 2>&1 | stdbuf -oL tee /logs/agent/eval-cli.txt" + + patch_command = ( + "cd /testbed && " + "git add -A && " + "git diff --cached HEAD > /logs/agent/patch.diff && " + "echo \"Patch size: $(wc -c < /logs/agent/patch.diff) bytes\"" + ) + + return [ + ExecInput(command=eval_cli_command, env=env), + ExecInput(command=patch_command), + ] diff --git a/crates/eval_cli/zed_eval/install.sh.j2 b/crates/eval_cli/zed_eval/install.sh.j2 new file mode 100644 index 0000000000000000000000000000000000000000..f7ebbe028216a1a7a0fd606e50a2f707db34c5ce --- /dev/null +++ b/crates/eval_cli/zed_eval/install.sh.j2 @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail + +# Install runtime dependencies needed by the eval-cli binary (dynamically linked +# against glibc + these shared libraries from its GPUI/terminal/language stacks). +apt-get update +apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + libasound2 \ + libfontconfig1 \ + libglib2.0-0 \ + libsqlite3-0 \ + libssl3 \ + libwayland-client0 \ + libx11-xcb1 \ + libxkbcommon-x11-0 \ + libzstd1 + +# Install Node.js 22 LTS (needed by language servers like basedpyright). +curl -fsSL https://deb.nodesource.com/setup_22.x | bash - +apt-get install -y --no-install-recommends nodejs + +# Install uv (needed for running Python tests in SWE-bench tasks). +curl -LsSf https://astral.sh/uv/install.sh | sh +. "$HOME/.local/bin/env" +ln -sf "$HOME/.local/bin/uv" /usr/local/bin/uv +ln -sf "$HOME/.local/bin/uvx" /usr/local/bin/uvx + +{% if binary_uploaded is defined %} +# Binary was uploaded directly via setup() — just verify it works. +eval-cli --help +{% elif download_url is defined %} +curl -fsSL "{{ download_url }}" -o /usr/local/bin/eval-cli +chmod +x /usr/local/bin/eval-cli +eval-cli --help +{% else %} +echo "ERROR: No eval-cli binary provided." +echo "" +echo "Either pass binary_path= to upload a local build:" +echo " --ae binary_path=/path/to/target/release/eval-cli" +echo "" +echo "Or set download_url= / EVAL_CLI_DOWNLOAD_URL:" +echo " --ae download_url=https://example.com/eval-cli" +exit 1 +{% endif %} + +echo "INSTALL_SUCCESS" diff --git a/crates/eval_cli/zed_eval/pyproject.toml b/crates/eval_cli/zed_eval/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..416c025826eaf99ad029c914b609aa28abd56f00 --- /dev/null +++ b/crates/eval_cli/zed_eval/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "zed-eval" +version = "0.1.0" +description = "Harbor agent wrapper for Zed's eval-cli" +requires-python = ">=3.12" +dependencies = ["harbor"] + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" From 77fa02889e535c751a78fa6090180975880b6daf Mon Sep 17 00:00:00 2001 From: Daniel Strobusch <1847260+dastrobu@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:34:58 +0100 Subject: [PATCH 017/219] Ensure consistent newline behavior in auto-height editors with JetBrains keymap (#47595) Add an explicit `Editor && mode == auto_height` context block. This ensures that `Shift+Enter` and `Ctrl+Enter` correctly insert a newline at the cursor position in editors like the AI Agent Panel, preventing them from inheriting conflicting overrides (e.g., JetBrains mapping `Shift+Enter` to `editor::NewlineBelow`). Closes #47269 Release Notes: - Fixed an issue where `Shift+Enter` would insert a newline at the end of the text instead of the cursor position in the Agent Panel when using certain keymaps. --- assets/keymaps/linux/jetbrains.json | 7 +++++++ assets/keymaps/macos/jetbrains.json | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index bdf3949b3f9203220978ff599e0187513d6a976f..98d5cf93106f35e488ab70a60468fa2239cb08c0 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -81,6 +81,13 @@ "ctrl-\\": "assistant::InlineAssist", }, }, + { + "context": "Editor && mode == auto_height", + "bindings": { + "shift-enter": "editor::Newline", + "ctrl-shift-enter": "editor::NewlineBelow", + }, + }, { "context": "BufferSearchBar", "bindings": { diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index c9106e4d49671f16917b1322824c2edfcd0e7700..8612e07c4719dfdbf67762c89505cc2da0cfa000 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -79,6 +79,13 @@ "cmd-\\": "assistant::InlineAssist", }, }, + { + "context": "Editor && mode == auto_height", + "bindings": { + "shift-enter": "editor::Newline", + "ctrl-shift-enter": "editor::NewlineBelow", + }, + }, { "context": "BufferSearchBar", "bindings": { From 61e7032e542c8ce5b9253f4a708b78ac70c5ec6b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:20:29 -0300 Subject: [PATCH 018/219] agent_ui: Adjust panel toolbar design for v2 (#50927) Adding some adjustments so the toolbar looks more like the designs we've been working on. All that changes here are only valid for the v2 feature flag. Haven't changed anything for today's production version. Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 774 ++++++++++++++++------------- 1 file changed, 436 insertions(+), 338 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 1937b2693e3923e46efc59ab959a7939b733cbdd..76636e64ad30d507e5320c54e158a155dde63383 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -73,8 +73,9 @@ use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; use theme::ThemeSettings; use ui::{ - Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu, - PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize, + Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, + PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*, + utils::WithRemSize, }; use util::ResultExt as _; use workspace::{ @@ -416,17 +417,10 @@ impl From for AgentType { impl StartThreadIn { fn label(&self) -> SharedString { match self { - Self::LocalProject => "Local Project".into(), + Self::LocalProject => "Current Project".into(), Self::NewWorktree => "New Worktree".into(), } } - - fn icon(&self) -> IconName { - match self { - Self::LocalProject => IconName::Screen, - Self::NewWorktree => IconName::GitBranchPlus, - } - } } #[derive(Clone, Debug)] @@ -3114,12 +3108,11 @@ impl AgentPanel { }; let trigger_button = Button::new("thread-target-trigger", trigger_label) - .label_size(LabelSize::Small) - .color(Color::Muted) .icon(icon) .icon_size(IconSize::XSmall) .icon_position(IconPosition::End) .icon_color(Color::Muted) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .disabled(is_creating); let dock_position = AgentSettings::get_global(cx).dock; @@ -3132,21 +3125,16 @@ impl AgentPanel { PopoverMenu::new("thread-target-selector") .trigger(trigger_button) - .anchor(gpui::Corner::BottomRight) - .with_handle(self.start_thread_in_menu_handle.clone()) .menu(move |window, cx| { - let current_target = current_target; - Some(ContextMenu::build(window, cx, move |menu, _window, _cx| { - let is_local_selected = current_target == StartThreadIn::LocalProject; - let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree; + let is_local_selected = current_target == StartThreadIn::LocalProject; + let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree; + Some(ContextMenu::build(window, cx, move |menu, _window, _cx| { let new_worktree_disabled = !has_git_repo || is_via_collab; menu.header("Start Thread In…") .item( - ContextMenuEntry::new("Local Project") - .icon(StartThreadIn::LocalProject.icon()) - .icon_color(Color::Muted) + ContextMenuEntry::new("Current Project") .toggleable(IconPosition::End, is_local_selected) .handler(|window, cx| { window @@ -3155,8 +3143,6 @@ impl AgentPanel { ) .item({ let entry = ContextMenuEntry::new("New Worktree") - .icon(StartThreadIn::NewWorktree.icon()) - .icon_color(Color::Muted) .toggleable(IconPosition::End, is_new_worktree_selected) .disabled(new_worktree_disabled) .handler(|window, cx| { @@ -3182,6 +3168,12 @@ impl AgentPanel { }) })) }) + .with_handle(self.start_thread_in_menu_handle.clone()) + .anchor(Corner::TopLeft) + .offset(gpui::Point { + x: px(1.0), + y: px(1.0), + }) } fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -3209,77 +3201,179 @@ impl AgentPanel { | ActiveView::Configuration => None, }; - let new_thread_menu = PopoverMenu::new("new_thread_menu") - .trigger_with_tooltip( - IconButton::new("new_thread_menu_btn", IconName::Plus).icon_size(IconSize::Small), - { - let focus_handle = focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "New Thread…", - &ToggleNewThreadMenu, - &focus_handle, - cx, + let new_thread_menu_builder: Rc< + dyn Fn(&mut Window, &mut App) -> Option>, + > = { + let selected_agent = self.selected_agent.clone(); + let is_agent_selected = move |agent_type: AgentType| selected_agent == agent_type; + + let workspace = self.workspace.clone(); + let is_via_collab = workspace + .update(cx, |workspace, cx| { + workspace.project().read(cx).is_via_collab() + }) + .unwrap_or_default(); + + let focus_handle = focus_handle.clone(); + let agent_server_store = agent_server_store; + + Rc::new(move |window, cx| { + telemetry::event!("New Thread Clicked"); + + let active_thread = active_thread.clone(); + Some(ContextMenu::build(window, cx, |menu, _window, cx| { + menu.context(focus_handle.clone()) + .when_some(active_thread, |this, active_thread| { + let thread = active_thread.read(cx); + + if !thread.is_empty() { + let session_id = thread.id().clone(); + this.item( + ContextMenuEntry::new("New From Summary") + .icon(IconName::ThreadFromSummary) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + Box::new(NewNativeAgentThreadFromSummary { + from_session_id: session_id.clone(), + }), + cx, + ); + }), + ) + } else { + this + } + }) + .item( + ContextMenuEntry::new("Zed Agent") + .when( + is_agent_selected(AgentType::NativeAgent) + | is_agent_selected(AgentType::TextThread), + |this| { + this.action(Box::new(NewExternalAgentThread { + agent: None, + })) + }, + ) + .icon(IconName::ZedAgent) + .icon_color(Color::Muted) + .handler({ + let workspace = workspace.clone(); + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = + workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.new_agent_thread( + AgentType::NativeAgent, + window, + cx, + ); + }); + } + }); + } + } + }), ) - } - }, - ) - .anchor(Corner::TopRight) - .with_handle(self.new_thread_menu_handle.clone()) - .menu({ - let selected_agent = self.selected_agent.clone(); - let is_agent_selected = move |agent_type: AgentType| selected_agent == agent_type; + .item( + ContextMenuEntry::new("Text Thread") + .action(NewTextThread.boxed_clone()) + .icon(IconName::TextThread) + .icon_color(Color::Muted) + .handler({ + let workspace = workspace.clone(); + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = + workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.new_agent_thread( + AgentType::TextThread, + window, + cx, + ); + }); + } + }); + } + } + }), + ) + .separator() + .header("External Agents") + .map(|mut menu| { + let agent_server_store = agent_server_store.read(cx); + let registry_store = + project::AgentRegistryStore::try_global(cx); + let registry_store_ref = + registry_store.as_ref().map(|s| s.read(cx)); + + struct AgentMenuItem { + id: ExternalAgentServerName, + display_name: SharedString, + } - let workspace = self.workspace.clone(); - let is_via_collab = workspace - .update(cx, |workspace, cx| { - workspace.project().read(cx).is_via_collab() - }) - .unwrap_or_default(); + let agent_items = agent_server_store + .external_agents() + .map(|name| { + let display_name = agent_server_store + .agent_display_name(name) + .or_else(|| { + registry_store_ref + .as_ref() + .and_then(|store| store.agent(name.0.as_ref())) + .map(|a| a.name().clone()) + }) + .unwrap_or_else(|| name.0.clone()); + AgentMenuItem { + id: name.clone(), + display_name, + } + }) + .sorted_unstable_by_key(|e| e.display_name.to_lowercase()) + .collect::>(); - move |window, cx| { - telemetry::event!("New Thread Clicked"); - - let active_thread = active_thread.clone(); - Some(ContextMenu::build(window, cx, |menu, _window, cx| { - menu.context(focus_handle.clone()) - .when_some(active_thread, |this, active_thread| { - let thread = active_thread.read(cx); - - if !thread.is_empty() { - let session_id = thread.id().clone(); - this.item( - ContextMenuEntry::new("New From Summary") - .icon(IconName::ThreadFromSummary) - .icon_color(Color::Muted) - .handler(move |window, cx| { - window.dispatch_action( - Box::new(NewNativeAgentThreadFromSummary { - from_session_id: session_id.clone(), - }), - cx, - ); - }), - ) + for item in &agent_items { + let mut entry = + ContextMenuEntry::new(item.display_name.clone()); + + let icon_path = agent_server_store + .agent_icon(&item.id) + .or_else(|| { + registry_store_ref + .as_ref() + .and_then(|store| store.agent(item.id.0.as_str())) + .and_then(|a| a.icon_path().cloned()) + }); + + if let Some(icon_path) = icon_path { + entry = entry.custom_icon_svg(icon_path); } else { - this + entry = entry.icon(IconName::Sparkle); } - }) - .item( - ContextMenuEntry::new("Zed Agent") + + entry = entry .when( - is_agent_selected(AgentType::NativeAgent) - | is_agent_selected(AgentType::TextThread), + is_agent_selected(AgentType::Custom { + name: item.id.0.clone(), + }), |this| { - this.action(Box::new(NewExternalAgentThread { - agent: None, - })) + this.action(Box::new( + NewExternalAgentThread { agent: None }, + )) }, ) - .icon(IconName::ZedAgent) .icon_color(Color::Muted) + .disabled(is_via_collab) .handler({ let workspace = workspace.clone(); + let agent_id = item.id.clone(); move |window, cx| { if let Some(workspace) = workspace.upgrade() { workspace.update(cx, |workspace, cx| { @@ -3288,7 +3382,9 @@ impl AgentPanel { { panel.update(cx, |panel, cx| { panel.new_agent_thread( - AgentType::NativeAgent, + AgentType::Custom { + name: agent_id.0.clone(), + }, window, cx, ); @@ -3297,16 +3393,84 @@ impl AgentPanel { }); } } - }), - ) - .item( - ContextMenuEntry::new("Text Thread") - .action(NewTextThread.boxed_clone()) - .icon(IconName::TextThread) + }); + + menu = menu.item(entry); + } + + menu + }) + .separator() + .map(|mut menu| { + let agent_server_store = agent_server_store.read(cx); + let registry_store = + project::AgentRegistryStore::try_global(cx); + let registry_store_ref = + registry_store.as_ref().map(|s| s.read(cx)); + + let previous_built_in_ids: &[ExternalAgentServerName] = + &[CLAUDE_AGENT_NAME.into(), CODEX_NAME.into(), GEMINI_NAME.into()]; + + let promoted_items = previous_built_in_ids + .iter() + .filter(|id| { + !agent_server_store.external_agents.contains_key(*id) + }) + .filter_map(|name| { + let display_name = registry_store_ref + .as_ref() + .and_then(|store| store.agent(name.0.as_ref())) + .map(|a| a.name().clone())?; + Some((name.clone(), display_name)) + }) + .sorted_unstable_by_key(|(_, display_name)| display_name.to_lowercase()) + .collect::>(); + + for (agent_id, display_name) in &promoted_items { + let mut entry = + ContextMenuEntry::new(display_name.clone()); + + let icon_path = registry_store_ref + .as_ref() + .and_then(|store| store.agent(agent_id.0.as_str())) + .and_then(|a| a.icon_path().cloned()); + + if let Some(icon_path) = icon_path { + entry = entry.custom_icon_svg(icon_path); + } else { + entry = entry.icon(IconName::Sparkle); + } + + entry = entry .icon_color(Color::Muted) + .disabled(is_via_collab) .handler({ let workspace = workspace.clone(); + let agent_id = agent_id.clone(); move |window, cx| { + let fs = ::global(cx); + let agent_id_string = + agent_id.to_string(); + settings::update_settings_file( + fs, + cx, + move |settings, _| { + let agent_servers = settings + .agent_servers + .get_or_insert_default(); + agent_servers.entry(agent_id_string).or_insert_with(|| { + settings::CustomAgentServerSettings::Registry { + default_mode: None, + default_model: None, + env: Default::default(), + favorite_models: Vec::new(), + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), + } + }); + }, + ); + if let Some(workspace) = workspace.upgrade() { workspace.update(cx, |workspace, cx| { if let Some(panel) = @@ -3314,7 +3478,9 @@ impl AgentPanel { { panel.update(cx, |panel, cx| { panel.new_agent_thread( - AgentType::TextThread, + AgentType::Custom { + name: agent_id.0.clone(), + }, window, cx, ); @@ -3323,215 +3489,29 @@ impl AgentPanel { }); } } - }), - ) - .separator() - .header("External Agents") - .map(|mut menu| { - let agent_server_store = agent_server_store.read(cx); - let registry_store = - project::AgentRegistryStore::try_global(cx); - let registry_store_ref = - registry_store.as_ref().map(|s| s.read(cx)); - - struct AgentMenuItem { - id: ExternalAgentServerName, - display_name: SharedString, - } - - let agent_items = agent_server_store - .external_agents() - .map(|name| { - let display_name = agent_server_store - .agent_display_name(name) - .or_else(|| { - registry_store_ref - .as_ref() - .and_then(|store| store.agent(name.0.as_ref())) - .map(|a| a.name().clone()) - }) - .unwrap_or_else(|| name.0.clone()); - AgentMenuItem { - id: name.clone(), - display_name, - } - }) - .sorted_unstable_by_key(|e| e.display_name.to_lowercase()) - .collect::>(); - - for item in &agent_items { - let mut entry = - ContextMenuEntry::new(item.display_name.clone()); + }); - let icon_path = agent_server_store - .agent_icon(&item.id) - .or_else(|| { - registry_store_ref - .as_ref() - .and_then(|store| store.agent(item.id.0.as_str())) - .and_then(|a| a.icon_path().cloned()) - }); - - if let Some(icon_path) = icon_path { - entry = entry.custom_icon_svg(icon_path); - } else { - entry = entry.icon(IconName::Sparkle); - } + menu = menu.item(entry); + } - entry = entry - .when( - is_agent_selected(AgentType::Custom { - name: item.id.0.clone(), - }), - |this| { - this.action(Box::new( - NewExternalAgentThread { agent: None }, - )) - }, + menu + }) + .item( + ContextMenuEntry::new("Add More Agents") + .icon(IconName::Plus) + .icon_color(Color::Muted) + .handler({ + move |window, cx| { + window.dispatch_action( + Box::new(zed_actions::AcpRegistry), + cx, ) - .icon_color(Color::Muted) - .disabled(is_via_collab) - .handler({ - let workspace = workspace.clone(); - let agent_id = item.id.clone(); - move |window, cx| { - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = - workspace.panel::(cx) - { - panel.update(cx, |panel, cx| { - panel.new_agent_thread( - AgentType::Custom { - name: agent_id.0.clone(), - }, - window, - cx, - ); - }); - } - }); - } - } - }); - - menu = menu.item(entry); - } - - menu - }) - .separator() - .map(|mut menu| { - let agent_server_store = agent_server_store.read(cx); - let registry_store = - project::AgentRegistryStore::try_global(cx); - let registry_store_ref = - registry_store.as_ref().map(|s| s.read(cx)); - - let previous_built_in_ids: &[ExternalAgentServerName] = - &[CLAUDE_AGENT_NAME.into(), CODEX_NAME.into(), GEMINI_NAME.into()]; - - let promoted_items = previous_built_in_ids - .iter() - .filter(|id| { - !agent_server_store.external_agents.contains_key(*id) - }) - .filter_map(|name| { - let display_name = registry_store_ref - .as_ref() - .and_then(|store| store.agent(name.0.as_ref())) - .map(|a| a.name().clone())?; - Some((name.clone(), display_name)) - }) - .sorted_unstable_by_key(|(_, display_name)| display_name.to_lowercase()) - .collect::>(); - - for (agent_id, display_name) in &promoted_items { - let mut entry = - ContextMenuEntry::new(display_name.clone()); - - let icon_path = registry_store_ref - .as_ref() - .and_then(|store| store.agent(agent_id.0.as_str())) - .and_then(|a| a.icon_path().cloned()); - - if let Some(icon_path) = icon_path { - entry = entry.custom_icon_svg(icon_path); - } else { - entry = entry.icon(IconName::Sparkle); } - - entry = entry - .icon_color(Color::Muted) - .disabled(is_via_collab) - .handler({ - let workspace = workspace.clone(); - let agent_id = agent_id.clone(); - move |window, cx| { - let fs = ::global(cx); - let agent_id_string = - agent_id.to_string(); - settings::update_settings_file( - fs, - cx, - move |settings, _| { - let agent_servers = settings - .agent_servers - .get_or_insert_default(); - agent_servers.entry(agent_id_string).or_insert_with(|| { - settings::CustomAgentServerSettings::Registry { - default_mode: None, - default_model: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - } - }); - }, - ); - - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = - workspace.panel::(cx) - { - panel.update(cx, |panel, cx| { - panel.new_agent_thread( - AgentType::Custom { - name: agent_id.0.clone(), - }, - window, - cx, - ); - }); - } - }); - } - } - }); - - menu = menu.item(entry); - } - - menu - }) - .item( - ContextMenuEntry::new("Add More Agents") - .icon(IconName::Plus) - .icon_color(Color::Muted) - .handler({ - move |window, cx| { - window.dispatch_action( - Box::new(zed_actions::AcpRegistry), - cx, - ) - } - }), - ) - })) - } - }); + }), + ) + })) + }) + }; let is_thread_loading = self .active_connection_view() @@ -3539,6 +3519,9 @@ impl AgentPanel { .unwrap_or(false); let has_custom_icon = selected_agent_custom_icon.is_some(); + let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone(); + let selected_agent_builtin_icon = self.selected_agent.icon(); + let selected_agent_label_for_tooltip = selected_agent_label.clone(); let selected_agent = div() .id("selected_agent_icon") @@ -3552,7 +3535,12 @@ impl AgentPanel { }) }) .tooltip(move |_, cx| { - Tooltip::with_meta(selected_agent_label.clone(), None, "Selected Agent", cx) + Tooltip::with_meta( + selected_agent_label_for_tooltip.clone(), + None, + "Selected Agent", + cx, + ) }); let selected_agent = if is_thread_loading { @@ -3571,50 +3559,160 @@ impl AgentPanel { let show_history_menu = self.history_kind_for_selected_agent(cx).is_some(); let has_v2_flag = cx.has_flag::(); + let is_empty_state = !self.active_thread_has_messages(cx); - h_flex() - .id("agent-panel-toolbar") - .h(Tab::container_height(cx)) - .max_w_full() - .flex_none() - .justify_between() - .gap_2() - .bg(cx.theme().colors().tab_bar_background) - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - h_flex() - .size_full() - .gap(DynamicSpacing::Base04.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) - .child(match &self.active_view { - ActiveView::History { .. } | ActiveView::Configuration => { - self.render_toolbar_back_button(cx).into_any_element() + let is_in_history_or_config = matches!( + &self.active_view, + ActiveView::History { .. } | ActiveView::Configuration + ); + + let use_v2_empty_toolbar = has_v2_flag && is_empty_state && !is_in_history_or_config; + + if use_v2_empty_toolbar { + let (chevron_icon, icon_color, label_color) = + if self.new_thread_menu_handle.is_deployed() { + (IconName::ChevronUp, Color::Accent, Color::Accent) + } else { + (IconName::ChevronDown, Color::Muted, Color::Default) + }; + + let agent_icon_element: AnyElement = + if let Some(icon_path) = selected_agent_custom_icon_for_button { + Icon::from_external_svg(icon_path) + .size(IconSize::Small) + .color(icon_color) + .into_any_element() + } else { + let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent); + Icon::new(icon_name) + .size(IconSize::Small) + .color(icon_color) + .into_any_element() + }; + + let agent_selector_button = ButtonLike::new("agent-selector-trigger") + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .child( + h_flex() + .gap_1() + .child(agent_icon_element) + .child(Label::new(selected_agent_label).color(label_color)) + .child( + Icon::new(chevron_icon) + .color(icon_color) + .size(IconSize::XSmall), + ), + ); + + let agent_selector_menu = PopoverMenu::new("new_thread_menu") + .trigger(agent_selector_button) + .menu({ + let builder = new_thread_menu_builder.clone(); + move |window, cx| builder(window, cx) + }) + .with_handle(self.new_thread_menu_handle.clone()) + .anchor(Corner::TopLeft) + .offset(gpui::Point { + x: px(1.0), + y: px(1.0), + }); + + h_flex() + .id("agent-panel-toolbar") + .h(Tab::container_height(cx)) + .max_w_full() + .flex_none() + .justify_between() + .gap_2() + .bg(cx.theme().colors().tab_bar_background) + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + h_flex() + .size_full() + .gap(DynamicSpacing::Base04.rems(cx)) + .pl(DynamicSpacing::Base04.rems(cx)) + .child(selected_agent) + .child(agent_selector_menu) + .child(self.render_start_thread_in_selector(cx)), + ) + .child( + h_flex() + .flex_none() + .gap(DynamicSpacing::Base02.rems(cx)) + .pl(DynamicSpacing::Base04.rems(cx)) + .pr(DynamicSpacing::Base06.rems(cx)) + .when(show_history_menu, |this| { + this.child(self.render_recent_entries_menu( + IconName::MenuAltTemp, + Corner::TopRight, + cx, + )) + }) + .child(self.render_panel_options_menu(window, cx)), + ) + .into_any_element() + } else { + let new_thread_menu = PopoverMenu::new("new_thread_menu") + .trigger_with_tooltip( + IconButton::new("new_thread_menu_btn", IconName::Plus) + .icon_size(IconSize::Small), + { + move |_window, cx| { + Tooltip::for_action_in( + "New Thread\u{2026}", + &ToggleNewThreadMenu, + &focus_handle, + cx, + ) } - _ => selected_agent.into_any_element(), - }) - .child(self.render_title_view(window, cx)), - ) - .child( - h_flex() - .flex_none() - .gap(DynamicSpacing::Base02.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) - .pr(DynamicSpacing::Base06.rems(cx)) - .when( - has_v2_flag && !self.active_thread_has_messages(cx), - |this| this.child(self.render_start_thread_in_selector(cx)), - ) - .child(new_thread_menu) - .when(show_history_menu, |this| { - this.child(self.render_recent_entries_menu( - IconName::MenuAltTemp, - Corner::TopRight, - cx, - )) - }) - .child(self.render_panel_options_menu(window, cx)), - ) + }, + ) + .anchor(Corner::TopRight) + .with_handle(self.new_thread_menu_handle.clone()) + .menu(move |window, cx| new_thread_menu_builder(window, cx)); + + h_flex() + .id("agent-panel-toolbar") + .h(Tab::container_height(cx)) + .max_w_full() + .flex_none() + .justify_between() + .gap_2() + .bg(cx.theme().colors().tab_bar_background) + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + h_flex() + .size_full() + .gap(DynamicSpacing::Base04.rems(cx)) + .pl(DynamicSpacing::Base04.rems(cx)) + .child(match &self.active_view { + ActiveView::History { .. } | ActiveView::Configuration => { + self.render_toolbar_back_button(cx).into_any_element() + } + _ => selected_agent.into_any_element(), + }) + .child(self.render_title_view(window, cx)), + ) + .child( + h_flex() + .flex_none() + .gap(DynamicSpacing::Base02.rems(cx)) + .pl(DynamicSpacing::Base04.rems(cx)) + .pr(DynamicSpacing::Base06.rems(cx)) + .child(new_thread_menu) + .when(show_history_menu, |this| { + this.child(self.render_recent_entries_menu( + IconName::MenuAltTemp, + Corner::TopRight, + cx, + )) + }) + .child(self.render_panel_options_menu(window, cx)), + ) + .into_any_element() + } } fn render_worktree_creation_status(&self, cx: &mut Context) -> Option { From e95b5c3c717a4d37d05cf19de56418681c4040e5 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:20:54 -0300 Subject: [PATCH 019/219] sidebar: Add some refinements (#50923) - Make clicking on the project header collapse/expand the group as opposed to activating the workspace - Added the "threads" label close to the sidebar toggle button - Made the open folder icon button open the file finder directly as opposed to the recent projects popover Release Notes: - N/A --- Cargo.lock | 1 - crates/sidebar/Cargo.toml | 2 - crates/sidebar/src/sidebar.rs | 230 ++++++++++++++---------------- crates/title_bar/src/title_bar.rs | 2 +- 4 files changed, 106 insertions(+), 129 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b147a39663d567bee029ed8b6c6694f0c6b41e85..7de51f99cb81a019b0c4ae58121a2b2607267a90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15831,7 +15831,6 @@ dependencies = [ "language_model", "menu", "project", - "recent_projects", "serde_json", "settings", "theme", diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 602fe12255bc3b8c5cee3445b96795475fcd7026..d835e9a602d7610eb412d8e3fc4135cb55d5a634 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -26,7 +26,6 @@ fs.workspace = true gpui.workspace = true menu.workspace = true project.workspace = true -recent_projects.workspace = true settings.workspace = true theme.workspace = true ui.workspace = true @@ -41,7 +40,6 @@ agent_ui = { workspace = true, features = ["test-support"] } assistant_text_thread = { workspace = true, features = ["test-support"] } editor.workspace = true language_model = { workspace = true, features = ["test-support"] } -recent_projects = { workspace = true, features = ["test-support"] } serde_json.workspace = true feature_flags.workspace = true fs = { workspace = true, features = ["test-support"] } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 1a44724b5532ec3a6f644adc16925a3dcf942c88..7f1512ee549ce7acc7094ee86ccc233443cd6eac 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -18,8 +18,8 @@ use std::mem; use theme::{ActiveTheme, ThemeSettings}; use ui::utils::TRAFFIC_LIGHT_PADDING; use ui::{ - AgentThreadStatus, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, PopoverMenu, Tab, - ThreadItem, Tooltip, WithScrollbar, prelude::*, + AgentThreadStatus, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, Tab, ThreadItem, + Tooltip, WithScrollbar, prelude::*, }; use util::path_list::PathList; use workspace::{ @@ -190,7 +190,7 @@ impl Sidebar { let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Search threads…", window, cx); + editor.set_placeholder_text("Search…", window, cx); editor }); @@ -744,7 +744,7 @@ impl Sidebar { }; let workspace_for_new_thread = workspace.clone(); let workspace_for_remove = workspace.clone(); - let workspace_for_activate = workspace.clone(); + // let workspace_for_activate = workspace.clone(); let path_list_for_toggle = path_list.clone(); let multi_workspace = self.multi_workspace.upgrade(); let workspace_count = multi_workspace @@ -755,60 +755,35 @@ impl Sidebar { .as_ref() .is_some_and(|mw| mw.read(cx).workspace() == workspace); + let label = if highlight_positions.is_empty() { + Label::new(label.clone()) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element() + } else { + HighlightedLabel::new(label.clone(), highlight_positions.to_vec()) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element() + }; + // TODO: if is_selected, draw a blue border around the item. ListItem::new(id) .group_name(&group) .toggle_state(is_active_workspace) .child( - h_flex() - .px_1() - .py_1p5() - .gap_0p5() - .child( - IconButton::new( - SharedString::from(format!("project-header-chevron-{}", ix)), - disclosure_icon, - ) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::text(if is_collapsed { - "Expand" - } else { - "Collapse" - })) - .on_click(cx.listener( - move |this, _, window, cx| { - this.toggle_collapse(&path_list_for_toggle, window, cx); - }, - )), - ) - .child(if highlight_positions.is_empty() { - Label::new(label.clone()) - .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element() - } else { - HighlightedLabel::new(label.clone(), highlight_positions.to_vec()) - .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element() - }), + h_flex().px_1().py_1p5().gap_0p5().child(label).child( + div().visible_on_hover(group).child( + Icon::new(disclosure_icon) + .size(IconSize::Small) + .color(Color::Muted), + ), + ), ) .end_hover_slot( h_flex() .gap_0p5() - .child( - IconButton::new(ib_id, IconName::NewThread) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("New Thread")) - .on_click(cx.listener(move |this, _, window, cx| { - this.selection = None; - this.create_new_thread(&workspace_for_new_thread, window, cx); - })), - ) .when(workspace_count > 1, |this| { this.child( IconButton::new( @@ -824,12 +799,26 @@ impl Sidebar { }, )), ) - }), + }) + .child( + IconButton::new(ib_id, IconName::NewThread) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("New Thread")) + .on_click(cx.listener(move |this, _, window, cx| { + this.selection = None; + this.create_new_thread(&workspace_for_new_thread, window, cx); + })), + ), ) .on_click(cx.listener(move |this, _, window, cx| { - this.selection = None; - this.activate_workspace(&workspace_for_activate, window, cx); + this.toggle_collapse(&path_list_for_toggle, window, cx); })) + // TODO: Decide if we really want the header to be activating different workspaces + // .on_click(cx.listener(move |this, _, window, cx| { + // this.selection = None; + // this.activate_workspace(&workspace_for_activate, window, cx); + // })) .into_any_element() } @@ -1175,7 +1164,7 @@ impl Sidebar { .size(IconSize::Small) .color(Color::Muted), ) - .child(Label::new("View More")) + .child(Label::new("View More").color(Color::Muted)) .child(Label::new(count).color(Color::Muted).size(LabelSize::Small)), ) .on_click(cx.listener(move |this, _, _window, cx| { @@ -1315,82 +1304,73 @@ impl Render for Sidebar { .justify_between() .border_b_1() .border_color(cx.theme().colors().border) - .child({ - let focus_handle_toggle = self.focus_handle.clone(); - let focus_handle_focus = self.focus_handle.clone(); - IconButton::new("close-sidebar", IconName::WorkspaceNavOpen) + .child( + h_flex() + .gap_1() + .child({ + let focus_handle_toggle = self.focus_handle.clone(); + let focus_handle_focus = self.focus_handle.clone(); + IconButton::new("close-sidebar", IconName::WorkspaceNavOpen) + .icon_size(IconSize::Small) + .tooltip(Tooltip::element(move |_, cx| { + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new("Close Sidebar")) + .child(KeyBinding::for_action_in( + &ToggleWorkspaceSidebar, + &focus_handle_toggle, + cx, + )), + ) + .child( + h_flex() + .pt_1() + .gap_2() + .border_t_1() + .border_color( + cx.theme().colors().border_variant, + ) + .justify_between() + .child(Label::new(focus_tooltip_label)) + .child(KeyBinding::for_action_in( + &FocusWorkspaceSidebar, + &focus_handle_focus, + cx, + )), + ) + .into_any_element() + })) + .on_click(cx.listener(|_this, _, _window, cx| { + cx.emit(SidebarEvent::Close); + })) + }) + .child(Label::new("Threads").size(LabelSize::Small)), + ) + .child( + IconButton::new("open-project", IconName::OpenFolder) .icon_size(IconSize::Small) - .tooltip(Tooltip::element(move |_, cx| { - v_flex() - .gap_1() - .child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new("Close Sidebar")) - .child(KeyBinding::for_action_in( - &ToggleWorkspaceSidebar, - &focus_handle_toggle, - cx, - )), - ) - .child( - h_flex() - .pt_1() - .gap_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .justify_between() - .child(Label::new(focus_tooltip_label)) - .child(KeyBinding::for_action_in( - &FocusWorkspaceSidebar, - &focus_handle_focus, - cx, - )), - ) - .into_any_element() - })) - .on_click(cx.listener(|_this, _, _window, cx| { - cx.emit(SidebarEvent::Close); - })) - }) - .child({ - let workspace = self - .multi_workspace - .upgrade() - .map(|mw| mw.read(cx).workspace().downgrade()); - let focus_handle = workspace - .as_ref() - .and_then(|w| w.upgrade()) - .map(|w| w.read(cx).focus_handle(cx)) - .unwrap_or_else(|| cx.focus_handle()); - - PopoverMenu::new("sidebar-recent-projects-menu") - .menu(move |window, cx| { - let workspace = workspace.clone()?; - Some(recent_projects::RecentProjects::popover( - workspace, - false, - focus_handle.clone(), - window, + .tooltip(|_window, cx| { + Tooltip::for_action( + "Open Project", + &workspace::Open { + create_new_window: false, + }, cx, - )) + ) }) - .trigger_with_tooltip( - IconButton::new("new-workspace", IconName::OpenFolder) - .icon_size(IconSize::Small), - |_window, cx| { - Tooltip::for_action( - "Open Recent Project", - &zed_actions::OpenRecent { - create_new_window: false, - }, - cx, - ) - }, - ) - .anchor(gpui::Corner::TopLeft) - }), + .on_click(|_event, window, cx| { + window.dispatch_action( + Box::new(workspace::Open { + create_new_window: false, + }), + cx, + ); + }), + ), ) .child( h_flex() diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index f00a71a305e306ba9201e5a4976382012ae0059e..05ede406b91e1025729b23e229046192f94d73d0 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -709,7 +709,7 @@ impl TitleBar { .indicator_border_color(Some(cx.theme().colors().title_bar_background)) }) .tooltip(move |_, cx| { - Tooltip::for_action("Open Workspace Sidebar", &ToggleWorkspaceSidebar, cx) + Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) }) .on_click(|_, window, cx| { window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); From 8a38d2d7b465bc5024bba37a21f68958926e2eb9 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:04:39 -0300 Subject: [PATCH 020/219] agent_ui: Adjust empty state for the panel for v2 (#50932) Making the message editor take all the available space, and improving the loading state for external agents a bit. Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 1 - crates/agent_ui/src/connection_view.rs | 13 ++- .../src/connection_view/thread_view.rs | 97 +++++++++++-------- 3 files changed, 66 insertions(+), 45 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 76636e64ad30d507e5320c54e158a155dde63383..45c22856c721fdfe165389e1775617afa7ffadb7 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -3632,7 +3632,6 @@ impl AgentPanel { .size_full() .gap(DynamicSpacing::Base04.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) - .child(selected_agent) .child(agent_selector_menu) .child(self.render_start_thread_in_selector(cx)), ) diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 6ba0f94934300d02c5a921af797a62f1a8756d76..84aa9e9c2b1959ba5c068e3cfa117506ac459ff0 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -2661,6 +2661,7 @@ impl ConnectionView { impl Render for ConnectionView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_queued_message_editors(window, cx); + let v2_flag = cx.has_flag::(); v_flex() .track_focus(&self.focus_handle) @@ -2669,7 +2670,17 @@ impl Render for ConnectionView { .child(match &self.server_state { ServerState::Loading { .. } => v_flex() .flex_1() - // .child(self.render_recent_history(cx)) + .when(v2_flag, |this| { + this.size_full().items_center().justify_center().child( + Label::new("Loading…").color(Color::Muted).with_animation( + "loading-agent-label", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.3, 0.7)), + |label, delta| label.alpha(delta), + ), + ) + }) .into_any(), ServerState::LoadError { error: e, .. } => v_flex() .flex_1() diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 32c9f29cd6b9e60b498974cd7230a4f18a4b0f8e..3397c619b7fc6544177ba52e9e71c887c74180cc 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -2697,6 +2697,8 @@ impl ThreadView { let focus_handle = self.message_editor.focus_handle(cx); let editor_bg_color = cx.theme().colors().editor_background; let editor_expanded = self.editor_expanded; + let has_messages = self.list_state.item_count() > 0; + let v2_empty_state = cx.has_flag::() && !has_messages; let (expand_icon, expand_tooltip) = if editor_expanded { (IconName::Minimize, "Minimize Message Editor") } else { @@ -2707,10 +2709,12 @@ impl ThreadView { .on_action(cx.listener(Self::expand_message_editor)) .p_2() .gap_2() - .border_t_1() - .border_color(cx.theme().colors().border) + .when(!v2_empty_state, |this| { + this.border_t_1().border_color(cx.theme().colors().border) + }) .bg(editor_bg_color) - .when(editor_expanded, |this| { + .when(v2_empty_state, |this| this.flex_1().size_full()) + .when(editor_expanded && !v2_empty_state, |this| { this.h(vh(0.8, window)).size_full().justify_between() }) .child( @@ -2720,36 +2724,38 @@ impl ThreadView { .pt_1() .pr_2p5() .child(self.message_editor.clone()) - .child( - h_flex() - .absolute() - .top_0() - .right_0() - .opacity(0.5) - .hover(|this| this.opacity(1.0)) - .child( - IconButton::new("toggle-height", expand_icon) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in( - expand_tooltip, + .when(!v2_empty_state, |this| { + this.child( + h_flex() + .absolute() + .top_0() + .right_0() + .opacity(0.5) + .hover(|this| this.opacity(1.0)) + .child( + IconButton::new("toggle-height", expand_icon) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + expand_tooltip, + &ExpandMessageEditor, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener(|this, _, window, cx| { + this.expand_message_editor( &ExpandMessageEditor, - &focus_handle, + window, cx, - ) - } - }) - .on_click(cx.listener(|this, _, window, cx| { - this.expand_message_editor( - &ExpandMessageEditor, - window, - cx, - ); - })), - ), - ), + ); + })), + ), + ) + }), ) .child( h_flex() @@ -7639,20 +7645,25 @@ impl ThreadView { impl Render for ThreadView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let has_messages = self.list_state.item_count() > 0; + let v2_empty_state = cx.has_flag::() && !has_messages; - let conversation = v_flex().flex_1().map(|this| { - let this = this.when(self.resumed_without_history, |this| { - this.child(Self::render_resume_notice(cx)) + let conversation = v_flex() + .when(!v2_empty_state, |this| this.flex_1()) + .map(|this| { + let this = this.when(self.resumed_without_history, |this| { + this.child(Self::render_resume_notice(cx)) + }); + if has_messages { + let list_state = self.list_state.clone(); + this.child(self.render_entries(cx)) + .vertical_scrollbar_for(&list_state, window, cx) + .into_any() + } else if v2_empty_state { + this.into_any() + } else { + this.child(self.render_recent_history(cx)).into_any() + } }); - if has_messages { - let list_state = self.list_state.clone(); - this.child(self.render_entries(cx)) - .vertical_scrollbar_for(&list_state, window, cx) - .into_any() - } else { - this.child(self.render_recent_history(cx)).into_any() - } - }); v_flex() .key_context("AcpThread") From 5db8d6d1bc88ccd8ae7f4fb254b26a12f9d67cc3 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 6 Mar 2026 14:13:23 +0100 Subject: [PATCH 021/219] agent: Only use AgentSessionInfo in history (#50933) Previously we required AgentSessionInfo all over the place, which meant there were lots of unnecessary fake ones created all over the place. Made the methods and functions only take the data they need so we only use these in history contexts now, as intended. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 172 ++++++++--------- crates/acp_thread/src/connection.rs | 9 +- crates/agent/src/agent.rs | 6 +- crates/agent_servers/src/acp.rs | 42 ++--- crates/agent_ui/src/agent_panel.rs | 117 +++++++----- crates/agent_ui/src/agent_ui.rs | 6 +- crates/agent_ui/src/completion_provider.rs | 74 +++++--- crates/agent_ui/src/connection_view.rs | 174 ++++++++++-------- .../src/connection_view/thread_view.rs | 32 +--- crates/agent_ui/src/message_editor.rs | 46 ++--- crates/agent_ui/src/thread_history.rs | 12 +- crates/agent_ui/src/ui/mention_crease.rs | 13 +- crates/sidebar/src/sidebar.rs | 8 +- crates/zed/src/main.rs | 16 +- 14 files changed, 391 insertions(+), 336 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index bffddde099c05438bb81c8bbbe99e3c77a5113e6..58252eaddca553eb1da4c960a829a88afb9eb497 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -952,6 +952,8 @@ struct RunningTurn { } pub struct AcpThread { + session_id: acp::SessionId, + cwd: Option, parent_session_id: Option, title: SharedString, provisional_title: Option, @@ -963,7 +965,6 @@ pub struct AcpThread { turn_id: u32, running_turn: Option, connection: Rc, - session_id: acp::SessionId, token_usage: Option, prompt_capabilities: acp::PromptCapabilities, _observe_prompt_capabilities: Task>, @@ -1048,87 +1049,6 @@ pub enum TerminalProviderCommand { }, } -impl AcpThread { - pub fn on_terminal_provider_event( - &mut self, - event: TerminalProviderEvent, - cx: &mut Context, - ) { - match event { - TerminalProviderEvent::Created { - terminal_id, - label, - cwd, - output_byte_limit, - terminal, - } => { - let entity = self.register_terminal_created( - terminal_id.clone(), - label, - cwd, - output_byte_limit, - terminal, - cx, - ); - - if let Some(mut chunks) = self.pending_terminal_output.remove(&terminal_id) { - for data in chunks.drain(..) { - entity.update(cx, |term, cx| { - term.inner().update(cx, |inner, cx| { - inner.write_output(&data, cx); - }) - }); - } - } - - if let Some(_status) = self.pending_terminal_exit.remove(&terminal_id) { - entity.update(cx, |_term, cx| { - cx.notify(); - }); - } - - cx.notify(); - } - TerminalProviderEvent::Output { terminal_id, data } => { - if let Some(entity) = self.terminals.get(&terminal_id) { - entity.update(cx, |term, cx| { - term.inner().update(cx, |inner, cx| { - inner.write_output(&data, cx); - }) - }); - } else { - self.pending_terminal_output - .entry(terminal_id) - .or_default() - .push(data); - } - } - TerminalProviderEvent::TitleChanged { terminal_id, title } => { - if let Some(entity) = self.terminals.get(&terminal_id) { - entity.update(cx, |term, cx| { - term.inner().update(cx, |inner, cx| { - inner.breadcrumb_text = title; - cx.emit(::terminal::Event::BreadcrumbsChanged); - }) - }); - } - } - TerminalProviderEvent::Exit { - terminal_id, - status, - } => { - if let Some(entity) = self.terminals.get(&terminal_id) { - entity.update(cx, |_term, cx| { - cx.notify(); - }); - } else { - self.pending_terminal_exit.insert(terminal_id, status); - } - } - } - } -} - #[derive(PartialEq, Eq, Debug)] pub enum ThreadStatus { Idle, @@ -1175,6 +1095,7 @@ impl AcpThread { pub fn new( parent_session_id: Option, title: impl Into, + cwd: Option, connection: Rc, project: Entity, action_log: Entity, @@ -1195,6 +1116,7 @@ impl AcpThread { Self { parent_session_id, + cwd, action_log, shared_buffers: Default::default(), entries: Default::default(), @@ -1268,6 +1190,10 @@ impl AcpThread { &self.session_id } + pub fn cwd(&self) -> Option<&PathBuf> { + self.cwd.as_ref() + } + pub fn status(&self) -> ThreadStatus { if self.running_turn.is_some() { ThreadStatus::Generating @@ -2624,6 +2550,85 @@ impl AcpThread { } } } + + pub fn on_terminal_provider_event( + &mut self, + event: TerminalProviderEvent, + cx: &mut Context, + ) { + match event { + TerminalProviderEvent::Created { + terminal_id, + label, + cwd, + output_byte_limit, + terminal, + } => { + let entity = self.register_terminal_created( + terminal_id.clone(), + label, + cwd, + output_byte_limit, + terminal, + cx, + ); + + if let Some(mut chunks) = self.pending_terminal_output.remove(&terminal_id) { + for data in chunks.drain(..) { + entity.update(cx, |term, cx| { + term.inner().update(cx, |inner, cx| { + inner.write_output(&data, cx); + }) + }); + } + } + + if let Some(_status) = self.pending_terminal_exit.remove(&terminal_id) { + entity.update(cx, |_term, cx| { + cx.notify(); + }); + } + + cx.notify(); + } + TerminalProviderEvent::Output { terminal_id, data } => { + if let Some(entity) = self.terminals.get(&terminal_id) { + entity.update(cx, |term, cx| { + term.inner().update(cx, |inner, cx| { + inner.write_output(&data, cx); + }) + }); + } else { + self.pending_terminal_output + .entry(terminal_id) + .or_default() + .push(data); + } + } + TerminalProviderEvent::TitleChanged { terminal_id, title } => { + if let Some(entity) = self.terminals.get(&terminal_id) { + entity.update(cx, |term, cx| { + term.inner().update(cx, |inner, cx| { + inner.breadcrumb_text = title; + cx.emit(::terminal::Event::BreadcrumbsChanged); + }) + }); + } + } + TerminalProviderEvent::Exit { + terminal_id, + status, + } => { + if let Some(entity) = self.terminals.get(&terminal_id) { + entity.update(cx, |_term, cx| { + cx.notify(); + }); + } else { + self.pending_terminal_exit.insert(terminal_id, status); + } + } + } + } } fn markdown_for_raw_output( @@ -3988,7 +3993,7 @@ mod tests { fn new_session( self: Rc, project: Entity, - _cwd: &Path, + cwd: &Path, cx: &mut App, ) -> Task>> { let session_id = acp::SessionId::new( @@ -4003,6 +4008,7 @@ mod tests { AcpThread::new( None, "Test", + Some(cwd.to_path_buf()), self.clone(), project, action_log, diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 773508f1c898c39d713d5779c82384caf8f190ec..644986bc15eccbe7d2be32ea5ad6e422db930541 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -45,9 +45,10 @@ pub trait AgentConnection { /// Load an existing session by ID. fn load_session( self: Rc, - _session: AgentSessionInfo, + _session_id: acp::SessionId, _project: Entity, _cwd: &Path, + _title: Option, _cx: &mut App, ) -> Task>> { Task::ready(Err(anyhow::Error::msg("Loading sessions is not supported"))) @@ -71,9 +72,10 @@ pub trait AgentConnection { /// Resume an existing session by ID without replaying previous messages. fn resume_session( self: Rc, - _session: AgentSessionInfo, + _session_id: acp::SessionId, _project: Entity, _cwd: &Path, + _title: Option, _cx: &mut App, ) -> Task>> { Task::ready(Err(anyhow::Error::msg( @@ -619,7 +621,7 @@ mod test_support { fn new_session( self: Rc, project: Entity, - _cwd: &Path, + cwd: &Path, cx: &mut gpui::App, ) -> Task>> { static NEXT_SESSION_ID: AtomicUsize = AtomicUsize::new(0); @@ -630,6 +632,7 @@ mod test_support { AcpThread::new( None, "Test", + Some(cwd.to_path_buf()), self.clone(), project, action_log, diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index a93c2d2062b7472f8ed94a6ea0947a685edd204f..d9ad55c7127983516dbb5fe0392ef135186b79f7 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -361,6 +361,7 @@ impl NativeAgent { let mut acp_thread = acp_thread::AcpThread::new( parent_session_id, title, + None, connection, project.clone(), action_log.clone(), @@ -1277,13 +1278,14 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn load_session( self: Rc, - session: AgentSessionInfo, + session_id: acp::SessionId, _project: Entity, _cwd: &Path, + _title: Option, cx: &mut App, ) -> Task>> { self.0 - .update(cx, |agent, cx| agent.open_thread(session.session_id, cx)) + .update(cx, |agent, cx| agent.open_thread(session_id, cx)) } fn supports_close_session(&self) -> bool { diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index c63e4fab2201671fa6448e9d58f6c925c2c91cd8..ceceb5b8ae02a0674b27e0fa18244a94f2b409de 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -385,7 +385,7 @@ impl AgentConnection for AcpConnection { cx.spawn(async move |cx| { let response = self.connection - .new_session(acp::NewSessionRequest::new(cwd).mcp_servers(mcp_servers)) + .new_session(acp::NewSessionRequest::new(cwd.clone()).mcp_servers(mcp_servers)) .await .map_err(map_acp_error)?; @@ -560,6 +560,7 @@ impl AgentConnection for AcpConnection { AcpThread::new( None, self.display_name.clone(), + Some(cwd), self.clone(), project, action_log, @@ -598,9 +599,10 @@ impl AgentConnection for AcpConnection { fn load_session( self: Rc, - session: AgentSessionInfo, + session_id: acp::SessionId, project: Entity, cwd: &Path, + title: Option, cx: &mut App, ) -> Task>> { if !self.agent_capabilities.load_session { @@ -612,25 +614,23 @@ impl AgentConnection for AcpConnection { let cwd = cwd.to_path_buf(); let mcp_servers = mcp_servers_for_project(&project, cx); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let title = session - .title - .clone() - .unwrap_or_else(|| self.display_name.clone()); + let title = title.unwrap_or_else(|| self.display_name.clone()); let thread: Entity = cx.new(|cx| { AcpThread::new( None, title, + Some(cwd.clone()), self.clone(), project, action_log, - session.session_id.clone(), + session_id.clone(), watch::Receiver::constant(self.agent_capabilities.prompt_capabilities.clone()), cx, ) }); self.sessions.borrow_mut().insert( - session.session_id.clone(), + session_id.clone(), AcpSession { thread: thread.downgrade(), suppress_abort_err: false, @@ -644,21 +644,20 @@ impl AgentConnection for AcpConnection { let response = match self .connection .load_session( - acp::LoadSessionRequest::new(session.session_id.clone(), cwd) - .mcp_servers(mcp_servers), + acp::LoadSessionRequest::new(session_id.clone(), cwd).mcp_servers(mcp_servers), ) .await { Ok(response) => response, Err(err) => { - self.sessions.borrow_mut().remove(&session.session_id); + self.sessions.borrow_mut().remove(&session_id); return Err(map_acp_error(err)); } }; let (modes, models, config_options) = config_state(response.modes, response.models, response.config_options); - if let Some(session) = self.sessions.borrow_mut().get_mut(&session.session_id) { + if let Some(session) = self.sessions.borrow_mut().get_mut(&session_id) { session.session_modes = modes; session.models = models; session.config_options = config_options.map(ConfigOptions::new); @@ -670,9 +669,10 @@ impl AgentConnection for AcpConnection { fn resume_session( self: Rc, - session: AgentSessionInfo, + session_id: acp::SessionId, project: Entity, cwd: &Path, + title: Option, cx: &mut App, ) -> Task>> { if self @@ -689,25 +689,23 @@ impl AgentConnection for AcpConnection { let cwd = cwd.to_path_buf(); let mcp_servers = mcp_servers_for_project(&project, cx); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let title = session - .title - .clone() - .unwrap_or_else(|| self.display_name.clone()); + let title = title.unwrap_or_else(|| self.display_name.clone()); let thread: Entity = cx.new(|cx| { AcpThread::new( None, title, + Some(cwd.clone()), self.clone(), project, action_log, - session.session_id.clone(), + session_id.clone(), watch::Receiver::constant(self.agent_capabilities.prompt_capabilities.clone()), cx, ) }); self.sessions.borrow_mut().insert( - session.session_id.clone(), + session_id.clone(), AcpSession { thread: thread.downgrade(), suppress_abort_err: false, @@ -721,21 +719,21 @@ impl AgentConnection for AcpConnection { let response = match self .connection .resume_session( - acp::ResumeSessionRequest::new(session.session_id.clone(), cwd) + acp::ResumeSessionRequest::new(session_id.clone(), cwd) .mcp_servers(mcp_servers), ) .await { Ok(response) => response, Err(err) => { - self.sessions.borrow_mut().remove(&session.session_id); + self.sessions.borrow_mut().remove(&session_id); return Err(map_acp_error(err)); } }; let (modes, models, config_options) = config_state(response.modes, response.models, response.config_options); - if let Some(session) = self.sessions.borrow_mut().get_mut(&session.session_id) { + if let Some(session) = self.sessions.borrow_mut().get_mut(&session_id) { session.session_modes = modes; session.models = models; session.config_options = config_options.map(ConfigOptions::new); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 45c22856c721fdfe165389e1775617afa7ffadb7..b53cb003969f8519f584aef1269554f4277e31f6 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -9,7 +9,7 @@ use std::{ time::Duration, }; -use acp_thread::{AcpThread, AgentSessionInfo, MentionUri, ThreadStatus}; +use acp_thread::{AcpThread, MentionUri, ThreadStatus}; use agent::{ContextServerRegistry, SharedThread, ThreadStore}; use agent_client_protocol as acp; use agent_servers::AgentServer; @@ -191,7 +191,15 @@ pub fn init(cx: &mut App) { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); panel.update(cx, |panel, cx| { - panel.external_thread(action.agent.clone(), None, None, window, cx) + panel.external_thread( + action.agent.clone(), + None, + None, + None, + None, + window, + cx, + ) }); } }) @@ -322,6 +330,8 @@ pub fn init(cx: &mut App) { panel.update(cx, |panel, cx| { panel.external_thread( + None, + None, None, None, Some(AgentInitialContent::ContentBlock { @@ -715,16 +725,9 @@ impl AgentPanel { if let Some(thread_info) = last_active_thread { let agent_type = thread_info.agent_type.clone(); - let session_info = AgentSessionInfo { - session_id: acp::SessionId::new(thread_info.session_id), - cwd: thread_info.cwd, - title: thread_info.title.map(SharedString::from), - updated_at: None, - meta: None, - }; panel.update(cx, |panel, cx| { panel.selected_agent = agent_type; - panel.load_agent_thread(session_info, window, cx); + panel.load_agent_thread(thread_info.session_id.into(), thread_info.cwd, thread_info.title.map(SharedString::from), window, cx); }); } panel @@ -761,7 +764,13 @@ impl AgentPanel { window, |this, _, event, window, cx| match event { ThreadHistoryEvent::Open(thread) => { - this.load_agent_thread(thread.clone(), window, cx); + this.load_agent_thread( + thread.session_id.clone(), + thread.cwd.clone(), + thread.title.clone(), + window, + cx, + ); } }, ) @@ -950,13 +959,17 @@ impl AgentPanel { pub fn open_thread( &mut self, - thread: AgentSessionInfo, + session_id: acp::SessionId, + cwd: Option, + title: Option, window: &mut Window, cx: &mut Context, ) { self.external_thread( Some(crate::ExternalAgent::NativeAgent), - Some(thread), + Some(session_id), + cwd, + title, None, window, cx, @@ -1015,7 +1028,12 @@ impl AgentPanel { self.external_thread( Some(ExternalAgent::NativeAgent), None, - Some(AgentInitialContent::ThreadSummary(thread)), + None, + None, + Some(AgentInitialContent::ThreadSummary { + session_id: thread.session_id, + title: thread.title, + }), window, cx, ); @@ -1067,7 +1085,9 @@ impl AgentPanel { fn external_thread( &mut self, agent_choice: Option, - resume_thread: Option, + resume_session_id: Option, + cwd: Option, + title: Option, initial_content: Option, window: &mut Window, cx: &mut Context, @@ -1129,7 +1149,9 @@ impl AgentPanel { this.update_in(cx, |agent_panel, window, cx| { agent_panel.create_external_thread( server, - resume_thread, + resume_session_id, + cwd, + title, initial_content, workspace, project, @@ -1548,16 +1570,8 @@ impl AgentPanel { }) .await?; - let thread_metadata = acp_thread::AgentSessionInfo { - session_id, - cwd: None, - title: Some(title), - updated_at: Some(chrono::Utc::now()), - meta: None, - }; - this.update_in(cx, |this, window, cx| { - this.open_thread(thread_metadata, window, cx); + this.open_thread(session_id, None, Some(title), window, cx); })?; this.update_in(cx, |_, _window, cx| { @@ -1839,7 +1853,13 @@ impl AgentPanel { let entry = entry.clone(); panel .update(cx, move |this, cx| { - this.load_agent_thread(entry.clone(), window, cx); + this.load_agent_thread( + entry.session_id.clone(), + entry.cwd.clone(), + entry.title.clone(), + window, + cx, + ); }) .ok(); } @@ -1981,6 +2001,8 @@ impl AgentPanel { cx: &mut Context, ) { self.external_thread( + None, + None, None, None, initial_text.map(|text| AgentInitialContent::ContentBlock { @@ -2006,6 +2028,8 @@ impl AgentPanel { Some(crate::ExternalAgent::NativeAgent), None, None, + None, + None, window, cx, ), @@ -2013,6 +2037,8 @@ impl AgentPanel { Some(crate::ExternalAgent::Custom { name }), None, None, + None, + None, window, cx, ), @@ -2021,11 +2047,12 @@ impl AgentPanel { pub fn load_agent_thread( &mut self, - thread: AgentSessionInfo, + session_id: acp::SessionId, + cwd: Option, + title: Option, window: &mut Window, cx: &mut Context, ) { - let session_id = thread.session_id.clone(); if let Some(server_view) = self.background_threads.remove(&session_id) { self.set_active_view(ActiveView::AgentThread { server_view }, true, window, cx); return; @@ -2059,13 +2086,15 @@ impl AgentPanel { let Some(agent) = self.selected_external_agent() else { return; }; - self.external_thread(Some(agent), Some(thread), None, window, cx); + self.external_thread(Some(agent), Some(session_id), cwd, title, None, window, cx); } pub(crate) fn create_external_thread( &mut self, server: Rc, - resume_thread: Option, + resume_session_id: Option, + cwd: Option, + title: Option, initial_content: Option, workspace: WeakEntity, project: Entity, @@ -2087,7 +2116,9 @@ impl AgentPanel { let server_view = cx.new(|cx| { crate::ConnectionView::new( server, - resume_thread, + resume_session_id, + cwd, + title, initial_content, workspace.clone(), project, @@ -2598,7 +2629,15 @@ impl AgentPanel { workspace.focus_panel::(window, cx); if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.external_thread(None, None, Some(initial_content), window, cx); + panel.external_thread( + None, + None, + None, + None, + Some(initial_content), + window, + cx, + ); }); } }); @@ -4466,7 +4505,7 @@ impl AgentPanel { }; self.create_external_thread( - server, None, None, workspace, project, ext_agent, window, cx, + server, None, None, None, None, workspace, project, ext_agent, window, cx, ); } @@ -4877,17 +4916,7 @@ mod tests { // Load thread A back via load_agent_thread — should promote from background. panel.update_in(&mut cx, |panel, window, cx| { - panel.load_agent_thread( - AgentSessionInfo { - session_id: session_id_a.clone(), - cwd: None, - title: None, - updated_at: None, - meta: None, - }, - window, - cx, - ); + panel.load_agent_thread(session_id_a.clone(), None, None, window, cx); }); // Thread A should now be the active view, promoted from background. diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index caecce3d0282e33daf8164fb17f48bd53be60b9f..eee7a61576e4f4dbdb56c98b497b50cc59c0053d 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -35,6 +35,7 @@ mod ui; use std::rc::Rc; use std::sync::Arc; +use agent_client_protocol as acp; use agent_settings::{AgentProfileId, AgentSettings}; use assistant_slash_command::SlashCommandRegistry; use client::Client; @@ -241,7 +242,10 @@ pub enum StartThreadIn { /// Content to initialize new external agent with. pub enum AgentInitialContent { - ThreadSummary(acp_thread::AgentSessionInfo), + ThreadSummary { + session_id: acp::SessionId, + title: Option, + }, ContentBlock { blocks: Vec, auto_submit: bool, diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 30778909b2c9a91dab0b20417e973b7e83ea6a17..40ad7bc729269d5dae3364ecf3e0de6e5ee5b0ec 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -5,7 +5,8 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use crate::ThreadHistory; -use acp_thread::{AgentSessionInfo, MentionUri}; +use acp_thread::MentionUri; +use agent_client_protocol as acp; use anyhow::Result; use editor::{ CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH, @@ -144,8 +145,8 @@ impl PromptContextType { pub(crate) enum Match { File(FileMatch), Symbol(SymbolMatch), - Thread(AgentSessionInfo), - RecentThread(AgentSessionInfo), + Thread(SessionMatch), + RecentThread(SessionMatch), Fetch(SharedString), Rules(RulesContextEntry), Entry(EntryMatch), @@ -165,15 +166,19 @@ impl Match { } } +#[derive(Debug, Clone)] +pub struct SessionMatch { + session_id: acp::SessionId, + title: SharedString, +} + pub struct EntryMatch { mat: Option, entry: PromptContextEntry, } -fn session_title(session: &AgentSessionInfo) -> SharedString { - session - .title - .clone() +fn session_title(title: Option) -> SharedString { + title .filter(|title| !title.is_empty()) .unwrap_or_else(|| SharedString::new_static("New Thread")) } @@ -266,7 +271,8 @@ impl PromptCompletionProvider { } fn completion_for_thread( - thread_entry: AgentSessionInfo, + session_id: acp::SessionId, + title: Option, source_range: Range, recent: bool, source: Arc, @@ -275,9 +281,9 @@ impl PromptCompletionProvider { workspace: Entity, cx: &mut App, ) -> Completion { - let title = session_title(&thread_entry); + let title = session_title(title); let uri = MentionUri::Thread { - id: thread_entry.session_id, + id: session_id, name: title.to_string(), }; @@ -841,7 +847,15 @@ impl PromptCompletionProvider { Some(PromptContextType::Thread) => { if let Some(history) = self.history.upgrade() { - let sessions = history.read(cx).sessions().to_vec(); + let sessions = history + .read(cx) + .sessions() + .iter() + .map(|session| SessionMatch { + session_id: session.session_id.clone(), + title: session_title(session.title.clone()), + }) + .collect::>(); let search_task = filter_sessions_by_query(query, cancellation_flag, sessions, cx); cx.spawn(async move |_cx| { @@ -1018,15 +1032,18 @@ impl PromptCompletionProvider { .read(cx) .sessions() .into_iter() + .map(|session| SessionMatch { + session_id: session.session_id.clone(), + title: session_title(session.title.clone()), + }) .filter(|session| { let uri = MentionUri::Thread { id: session.session_id.clone(), - name: session_title(session).to_string(), + name: session.title.to_string(), }; !mentions.contains(&uri) }) .take(RECENT_COUNT) - .cloned() .map(Match::RecentThread), ); return Task::ready(recent); @@ -1298,7 +1315,8 @@ impl CompletionProvider for PromptCompletio ) } Match::Thread(thread) => Some(Self::completion_for_thread( - thread, + thread.session_id, + Some(thread.title), source_range.clone(), false, source.clone(), @@ -1308,7 +1326,8 @@ impl CompletionProvider for PromptCompletio cx, )), Match::RecentThread(thread) => Some(Self::completion_for_thread( - thread, + thread.session_id, + Some(thread.title), source_range.clone(), true, source.clone(), @@ -1878,9 +1897,9 @@ pub(crate) fn search_symbols( fn filter_sessions_by_query( query: String, cancellation_flag: Arc, - sessions: Vec, + sessions: Vec, cx: &mut App, -) -> Task> { +) -> Task> { if query.is_empty() { return Task::ready(sessions); } @@ -1893,10 +1912,13 @@ fn filter_sessions_by_query( async fn filter_sessions( query: String, cancellation_flag: Arc, - sessions: Vec, + sessions: Vec, executor: BackgroundExecutor, -) -> Vec { - let titles = sessions.iter().map(session_title).collect::>(); +) -> Vec { + let titles = sessions + .iter() + .map(|session| session.title.clone()) + .collect::>(); let candidates = titles .iter() .enumerate() @@ -2338,10 +2360,14 @@ mod tests { #[gpui::test] async fn test_filter_sessions_by_query(cx: &mut TestAppContext) { - let mut alpha = AgentSessionInfo::new("session-alpha"); - alpha.title = Some("Alpha Session".into()); - let mut beta = AgentSessionInfo::new("session-beta"); - beta.title = Some("Beta Session".into()); + let alpha = SessionMatch { + session_id: acp::SessionId::new("session-alpha"), + title: "Alpha Session".into(), + }; + let beta = SessionMatch { + session_id: acp::SessionId::new("session-beta"), + title: "Beta Session".into(), + }; let sessions = vec![alpha.clone(), beta]; diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 84aa9e9c2b1959ba5c068e3cfa117506ac459ff0..48aa88a95ba3c1fd440c59768f9328719f02aa70 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -39,7 +39,7 @@ use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore}; use std::cell::RefCell; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; @@ -470,7 +470,9 @@ impl ConnectedServerState { impl ConnectionView { pub fn new( agent: Rc, - resume_thread: Option, + resume_session_id: Option, + cwd: Option, + title: Option, initial_content: Option, workspace: WeakEntity, project: Entity, @@ -514,7 +516,9 @@ impl ConnectionView { prompt_store, server_state: Self::initial_state( agent.clone(), - resume_thread, + resume_session_id, + cwd, + title, project, initial_content, window, @@ -540,13 +544,23 @@ impl ConnectionView { } fn reset(&mut self, window: &mut Window, cx: &mut Context) { - let resume_thread_metadata = self + let (resume_session_id, cwd, title) = self .active_thread() - .and_then(|thread| thread.read(cx).resume_thread_metadata.clone()); + .map(|thread_view| { + let thread = thread_view.read(cx).thread.read(cx); + ( + Some(thread.session_id().clone()), + thread.cwd().cloned(), + Some(thread.title()), + ) + }) + .unwrap_or((None, None, None)); let state = Self::initial_state( self.agent.clone(), - resume_thread_metadata, + resume_session_id, + cwd, + title, self.project.clone(), None, window, @@ -570,15 +584,14 @@ impl ConnectionView { fn initial_state( agent: Rc, - resume_thread: Option, + resume_session_id: Option, + cwd: Option, + title: Option, project: Entity, initial_content: Option, window: &mut Window, cx: &mut Context, ) -> ServerState { - let session_id = resume_thread - .as_ref() - .map(|thread| thread.session_id.clone()); if project.read(cx).is_via_collab() && agent.clone().downcast::().is_none() { @@ -586,7 +599,7 @@ impl ConnectionView { error: LoadError::Other( "External agents are not yet supported in shared projects.".into(), ), - session_id, + session_id: resume_session_id.clone(), }; } let mut worktrees = project.read(cx).visible_worktrees(cx).collect::>(); @@ -608,28 +621,22 @@ impl ConnectionView { } }) .collect(); - let session_cwd = resume_thread - .as_ref() - .and_then(|resume| { - resume - .cwd - .as_ref() - .filter(|cwd| { - // Validate with the normalized path (rejects `..` traversals), - // but return the original cwd to preserve its path separators. - // On Windows, `normalize_lexically` rebuilds the path with - // backslashes via `PathBuf::push`, which would corrupt - // forward-slash Linux paths used by WSL agents. - util::paths::normalize_lexically(cwd) - .ok() - .is_some_and(|normalized| { - worktree_roots - .iter() - .any(|root| normalized.starts_with(root.as_ref())) - }) + let session_cwd = cwd + .filter(|cwd| { + // Validate with the normalized path (rejects `..` traversals), + // but return the original cwd to preserve its path separators. + // On Windows, `normalize_lexically` rebuilds the path with + // backslashes via `PathBuf::push`, which would corrupt + // forward-slash Linux paths used by WSL agents. + util::paths::normalize_lexically(cwd) + .ok() + .is_some_and(|normalized| { + worktree_roots + .iter() + .any(|root| normalized.starts_with(root.as_ref())) }) - .map(|path| Arc::from(path.as_path())) }) + .map(|path| path.into()) .or_else(|| worktree_roots.first().cloned()) .unwrap_or_else(|| paths::home_dir().as_path().into()); @@ -643,7 +650,7 @@ impl ConnectionView { ); let connect_task = agent.connect(delegate, cx); - let load_session_id = session_id.clone(); + let load_session_id = resume_session_id.clone(); let load_task = cx.spawn_in(window, async move |this, cx| { let connection = match connect_task.await { Ok(connection) => connection, @@ -666,17 +673,25 @@ impl ConnectionView { telemetry::event!("Agent Thread Started", agent = connection.telemetry_id()); let mut resumed_without_history = false; - let result = if let Some(resume) = resume_thread.clone() { + let result = if let Some(session_id) = load_session_id.clone() { cx.update(|_, cx| { if connection.supports_load_session() { - connection - .clone() - .load_session(resume, project.clone(), &session_cwd, cx) + connection.clone().load_session( + session_id, + project.clone(), + &session_cwd, + title, + cx, + ) } else if connection.supports_resume_session() { resumed_without_history = true; - connection - .clone() - .resume_session(resume, project.clone(), &session_cwd, cx) + connection.clone().resume_session( + session_id, + project.clone(), + &session_cwd, + title, + cx, + ) } else { Task::ready(Err(anyhow!(LoadError::Other( "Loading or resuming sessions is not supported by this agent.".into() @@ -732,7 +747,6 @@ impl ConnectionView { thread, conversation.clone(), resumed_without_history, - resume_thread, initial_content, window, cx, @@ -803,7 +817,7 @@ impl ConnectionView { }); LoadingView { - session_id, + session_id: resume_session_id, title: "Loading…".into(), _load_task: load_task, _update_title_task: update_title_task, @@ -819,7 +833,6 @@ impl ConnectionView { thread: Entity, conversation: Entity, resumed_without_history: bool, - resume_thread: Option, initial_content: Option, window: &mut Window, cx: &mut Context, @@ -1002,7 +1015,6 @@ impl ConnectionView { prompt_capabilities, available_commands, resumed_without_history, - resume_thread, self.project.downgrade(), self.thread_store.clone(), self.history.clone(), @@ -1680,9 +1692,10 @@ impl ConnectionView { let cwd = root_dir.unwrap_or_else(|| paths::home_dir().as_path().into()); let subagent_thread_task = connected.connection.clone().load_session( - AgentSessionInfo::new(subagent_id.clone()), + subagent_id.clone(), self.project.clone(), &cwd, + None, cx, ); @@ -1704,7 +1717,6 @@ impl ConnectionView { conversation, false, None, - None, window, cx, ); @@ -2606,10 +2618,10 @@ impl ConnectionView { }) } - pub fn delete_history_entry(&mut self, entry: AgentSessionInfo, cx: &mut Context) { - let task = self.history.update(cx, |history, cx| { - history.delete_session(&entry.session_id, cx) - }); + pub fn delete_history_entry(&mut self, session_id: &acp::SessionId, cx: &mut Context) { + let task = self + .history + .update(cx, |history, cx| history.delete_session(&session_id, cx)); task.detach_and_log_err(cx); } } @@ -2856,6 +2868,8 @@ pub(crate) mod tests { Rc::new(StubAgentServer::default_response()), None, None, + None, + None, workspace.downgrade(), project, Some(thread_store), @@ -2939,7 +2953,6 @@ pub(crate) mod tests { async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) { init_test(cx); - let session = AgentSessionInfo::new(SessionId::new("resume-session")); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; let (multi_workspace, cx) = @@ -2953,7 +2966,9 @@ pub(crate) mod tests { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)), - Some(session), + Some(SessionId::new("resume-session")), + None, + None, None, workspace.downgrade(), project, @@ -2997,9 +3012,6 @@ pub(crate) mod tests { let connection = CwdCapturingConnection::new(); let captured_cwd = connection.captured_cwd.clone(); - let mut session = AgentSessionInfo::new(SessionId::new("session-1")); - session.cwd = Some(PathBuf::from("/project/subdir")); - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); @@ -3007,7 +3019,9 @@ pub(crate) mod tests { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), - Some(session), + Some(SessionId::new("session-1")), + Some(PathBuf::from("/project/subdir")), + None, None, workspace.downgrade(), project, @@ -3049,9 +3063,6 @@ pub(crate) mod tests { let connection = CwdCapturingConnection::new(); let captured_cwd = connection.captured_cwd.clone(); - let mut session = AgentSessionInfo::new(SessionId::new("session-1")); - session.cwd = Some(PathBuf::from("/some/other/path")); - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); @@ -3059,7 +3070,9 @@ pub(crate) mod tests { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), - Some(session), + Some(SessionId::new("session-1")), + Some(PathBuf::from("/some/other/path")), + None, None, workspace.downgrade(), project, @@ -3101,9 +3114,6 @@ pub(crate) mod tests { let connection = CwdCapturingConnection::new(); let captured_cwd = connection.captured_cwd.clone(); - let mut session = AgentSessionInfo::new(SessionId::new("session-1")); - session.cwd = Some(PathBuf::from("/project/../outside")); - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); @@ -3111,7 +3121,9 @@ pub(crate) mod tests { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), - Some(session), + Some(SessionId::new("session-1")), + Some(PathBuf::from("/project/../outside")), + None, None, workspace.downgrade(), project, @@ -3424,6 +3436,8 @@ pub(crate) mod tests { Rc::new(agent), None, None, + None, + None, workspace1.downgrade(), project1.clone(), Some(thread_store), @@ -3612,6 +3626,8 @@ pub(crate) mod tests { Rc::new(agent), None, None, + None, + None, workspace.downgrade(), project, Some(thread_store), @@ -3792,6 +3808,7 @@ pub(crate) mod tests { AcpThread::new( None, name, + None, connection, project, action_log, @@ -3894,18 +3911,14 @@ pub(crate) mod tests { fn resume_session( self: Rc, - session: AgentSessionInfo, + session_id: acp::SessionId, project: Entity, _cwd: &Path, + _title: Option, cx: &mut App, ) -> Task>> { - let thread = build_test_thread( - self, - project, - "ResumeOnlyAgentConnection", - session.session_id, - cx, - ); + let thread = + build_test_thread(self, project, "ResumeOnlyAgentConnection", session_id, cx); Task::ready(Ok(thread)) } @@ -3965,7 +3978,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - _cwd: &Path, + cwd: &Path, cx: &mut gpui::App, ) -> Task>> { if !*self.authenticated.lock() { @@ -3980,6 +3993,7 @@ pub(crate) mod tests { AcpThread::new( None, "AuthGatedAgent", + Some(cwd.to_path_buf()), self, project, action_log, @@ -4041,7 +4055,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - _cwd: &Path, + cwd: &Path, cx: &mut gpui::App, ) -> Task>> { Task::ready(Ok(cx.new(|cx| { @@ -4049,6 +4063,7 @@ pub(crate) mod tests { AcpThread::new( None, "SaboteurAgentConnection", + Some(cwd.to_path_buf()), self, project, action_log, @@ -4106,7 +4121,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - _cwd: &Path, + cwd: &Path, cx: &mut gpui::App, ) -> Task>> { Task::ready(Ok(cx.new(|cx| { @@ -4114,6 +4129,7 @@ pub(crate) mod tests { AcpThread::new( None, "RefusalAgentConnection", + Some(cwd.to_path_buf()), self, project, action_log, @@ -4189,6 +4205,7 @@ pub(crate) mod tests { AcpThread::new( None, "CwdCapturingConnection", + Some(cwd.to_path_buf()), self.clone(), project, action_log, @@ -4211,9 +4228,10 @@ pub(crate) mod tests { fn load_session( self: Rc, - session: AgentSessionInfo, + session_id: acp::SessionId, project: Entity, cwd: &Path, + _title: Option, cx: &mut App, ) -> Task>> { *self.captured_cwd.lock() = Some(cwd.to_path_buf()); @@ -4222,10 +4240,11 @@ pub(crate) mod tests { AcpThread::new( None, "CwdCapturingConnection", + Some(cwd.to_path_buf()), self.clone(), project, action_log, - session.session_id, + session_id, watch::Receiver::constant( acp::PromptCapabilities::new() .image(true) @@ -4327,6 +4346,8 @@ pub(crate) mod tests { Rc::new(StubAgentServer::new(connection.as_ref().clone())), None, None, + None, + None, workspace.downgrade(), project.clone(), Some(thread_store.clone()), @@ -6036,6 +6057,7 @@ pub(crate) mod tests { AcpThread::new( parent_session_id, "Test Thread", + None, connection, project, action_log, diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 3397c619b7fc6544177ba52e9e71c887c74180cc..1c3b8ba244d895ba3ab2e6473f6cbe33ddae550b 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -247,7 +247,6 @@ pub struct ThreadView { pub is_loading_contents: bool, pub new_server_version_available: Option, pub resumed_without_history: bool, - pub resume_thread_metadata: Option, pub _cancel_task: Option>, _save_task: Option>, _draft_resolve_task: Option>, @@ -307,7 +306,6 @@ impl ThreadView { prompt_capabilities: Rc>, available_commands: Rc>>, resumed_without_history: bool, - resume_thread_metadata: Option, project: WeakEntity, thread_store: Option>, history: Entity, @@ -347,8 +345,8 @@ impl ThreadView { ); if let Some(content) = initial_content { match content { - AgentInitialContent::ThreadSummary(entry) => { - editor.insert_thread_summary(entry, window, cx); + AgentInitialContent::ThreadSummary { session_id, title } => { + editor.insert_thread_summary(session_id, title, window, cx); } AgentInitialContent::ContentBlock { blocks, @@ -439,7 +437,6 @@ impl ThreadView { prompt_capabilities, available_commands, resumed_without_history, - resume_thread_metadata, _subscriptions: subscriptions, permission_dropdown_handle: PopoverMenuHandle::default(), thread_retry_status: None, @@ -1772,18 +1769,7 @@ impl ThreadView { }) .await?; - let thread_metadata = AgentSessionInfo { - session_id, - cwd: None, - title: Some(format!("🔗 {}", response.title).into()), - updated_at: Some(chrono::Utc::now()), - meta: None, - }; - - this.update_in(cx, |this, window, cx| { - this.resume_thread_metadata = Some(thread_metadata); - server_view.update(cx, |server_view, cx| server_view.reset(window, cx)); - })?; + server_view.update_in(cx, |server_view, window, cx| server_view.reset(window, cx))?; this.update_in(cx, |this, _window, cx| { if let Some(workspace) = this.workspace.upgrade() { @@ -7906,17 +7892,7 @@ pub(crate) fn open_link( MentionUri::Thread { id, name } => { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.open_thread( - AgentSessionInfo { - session_id: id, - cwd: None, - title: Some(name.into()), - updated_at: None, - meta: None, - }, - window, - cx, - ) + panel.open_thread(id, None, Some(name.into()), window, cx) }); } } diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 6ce0b7e356dc75f1c3d4db0f318d1978a37d00cc..cee6725cd15c15f4f39ad5e53be5578f5f5cc3d8 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -10,7 +10,7 @@ use crate::{ Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context, }, }; -use acp_thread::{AgentSessionInfo, MentionUri}; +use acp_thread::MentionUri; use agent::ThreadStore; use agent_client_protocol as acp; use anyhow::{Result, anyhow}; @@ -301,7 +301,8 @@ impl MessageEditor { pub fn insert_thread_summary( &mut self, - thread: AgentSessionInfo, + session_id: acp::SessionId, + title: Option, window: &mut Window, cx: &mut Context, ) { @@ -311,13 +312,11 @@ impl MessageEditor { let Some(workspace) = self.workspace.upgrade() else { return; }; - let thread_title = thread - .title - .clone() + let thread_title = title .filter(|title| !title.is_empty()) .unwrap_or_else(|| SharedString::new_static("New Thread")); let uri = MentionUri::Thread { - id: thread.session_id, + id: session_id, name: thread_title.to_string(), }; let content = format!("{}\n", uri.as_link()); @@ -1571,7 +1570,7 @@ fn find_matching_bracket(text: &str, open: char, close: char) -> Option { mod tests { use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc}; - use acp_thread::{AgentSessionInfo, MentionUri}; + use acp_thread::MentionUri; use agent::{ThreadStore, outline}; use agent_client_protocol as acp; use editor::{ @@ -2811,14 +2810,8 @@ mod tests { let history = cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); - // Create a thread metadata to insert as summary - let thread_metadata = AgentSessionInfo { - session_id: acp::SessionId::new("thread-123"), - cwd: None, - title: Some("Previous Conversation".into()), - updated_at: Some(chrono::Utc::now()), - meta: None, - }; + let session_id = acp::SessionId::new("thread-123"); + let title = Some("Previous Conversation".into()); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2839,17 +2832,17 @@ mod tests { window, cx, ); - editor.insert_thread_summary(thread_metadata.clone(), window, cx); + editor.insert_thread_summary(session_id.clone(), title.clone(), window, cx); editor }) }); // Construct expected values for verification let expected_uri = MentionUri::Thread { - id: thread_metadata.session_id.clone(), - name: thread_metadata.title.as_ref().unwrap().to_string(), + id: session_id.clone(), + name: title.as_ref().unwrap().to_string(), }; - let expected_title = thread_metadata.title.as_ref().unwrap(); + let expected_title = title.as_ref().unwrap(); let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri()); message_editor.read_with(cx, |editor, cx| { @@ -2893,14 +2886,6 @@ mod tests { let history = cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); - let thread_metadata = AgentSessionInfo { - session_id: acp::SessionId::new("thread-123"), - cwd: None, - title: Some("Previous Conversation".into()), - updated_at: Some(chrono::Utc::now()), - meta: None, - }; - let message_editor = cx.update(|window, cx| { cx.new(|cx| { let mut editor = MessageEditor::new( @@ -2920,7 +2905,12 @@ mod tests { window, cx, ); - editor.insert_thread_summary(thread_metadata, window, cx); + editor.insert_thread_summary( + acp::SessionId::new("thread-123"), + Some("Previous Conversation".into()), + window, + cx, + ); editor }) }); diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs index 8f8488cb94f94e036b37ef31c9c588740cd6cf02..6601616e9f2ef447beb448f2753460fa7c380fa6 100644 --- a/crates/agent_ui/src/thread_history.rs +++ b/crates/agent_ui/src/thread_history.rs @@ -948,12 +948,12 @@ impl RenderOnce for HistoryEntryElement { }) .on_click({ let thread_view = self.thread_view.clone(); - let entry = self.entry.clone(); + let session_id = self.entry.session_id.clone(); move |_event, _window, cx| { if let Some(thread_view) = thread_view.upgrade() { thread_view.update(cx, |thread_view, cx| { - thread_view.delete_history_entry(entry.clone(), cx); + thread_view.delete_history_entry(&session_id, cx); }); } } @@ -973,7 +973,13 @@ impl RenderOnce for HistoryEntryElement { { if let Some(panel) = workspace.read(cx).panel::(cx) { panel.update(cx, |panel, cx| { - panel.load_agent_thread(entry.clone(), window, cx); + panel.load_agent_thread( + entry.session_id.clone(), + entry.cwd.clone(), + entry.title.clone(), + window, + cx, + ); }); } } diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 0a61b8e4ef2ec69714f158a72f83cc0528cc8a8f..8b813ef7e40c2afe91b98600b9d1146d4751d48b 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -269,24 +269,13 @@ fn open_thread( cx: &mut Context, ) { use crate::AgentPanel; - use acp_thread::AgentSessionInfo; let Some(panel) = workspace.panel::(cx) else { return; }; panel.update(cx, |panel, cx| { - panel.load_agent_thread( - AgentSessionInfo { - session_id: id, - cwd: None, - title: Some(name.into()), - updated_at: None, - meta: None, - }, - window, - cx, - ) + panel.load_agent_thread(id, None, Some(name.into()), window, cx) }); } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 7f1512ee549ce7acc7094ee86ccc233443cd6eac..3bb3ea9ea44efe2cf57a4d021b0a1755ac3b3681 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -1013,7 +1013,13 @@ impl Sidebar { if let Some(agent_panel) = workspace.read(cx).panel::(cx) { agent_panel.update(cx, |panel, cx| { - panel.load_agent_thread(session_info, window, cx); + panel.load_agent_thread( + session_info.session_id, + session_info.cwd, + session_info.title, + window, + cx, + ); }); } } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 4c188042482443ea0df59096884194f0740fcda1..062d0bd1f956b959f8ff3cabc6d4a44fbd5a3a7a 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -979,21 +979,19 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut }) .await?; - let thread_metadata = acp_thread::AgentSessionInfo { - session_id, - cwd: None, - title: Some(format!("🔗 {}", response.title).into()), - updated_at: Some(chrono::Utc::now()), - meta: None, - }; - let sharer_username = response.sharer_username.clone(); multi_workspace.update(cx, |_, window, cx| { workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.open_thread(thread_metadata, window, cx); + panel.open_thread( + session_id, + None, + Some(format!("🔗 {}", response.title).into()), + window, + cx, + ); }); panel.focus_handle(cx).focus(window, cx); } From 66de3d9c0093eb24943f96b8915c97dea7327e51 Mon Sep 17 00:00:00 2001 From: MostlyK <135974627+MostlyKIGuess@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:58:10 +0530 Subject: [PATCH 022/219] repl: Treat WSL as a separate kernel type from SSH remote (#50721) Split WslRemote out of the remote_kernels bucket in the kernel picker, giving it its own "WSL Kernels" section. Use the distro name and kernelspec display name for WSL entries instead of the generic "WSL" string. In python_env_kernel_specifications, detect WSL projects via RemoteConnectionOptions and return WslRemote instead of SshRemote. Stop marking WSL worktrees as remote so global kernel specs load. Fix ark kernel stdout pollution by building the wsl.exe bash command with a quoted cd and inline env assignment, so exec replaces the shell and doesn't echo input back. Closes #50459 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: - N/A --- crates/repl/src/components/kernel_options.rs | 21 ++++++++--- crates/repl/src/kernels/mod.rs | 37 ++++++++++++++++---- crates/repl/src/kernels/wsl_kernel.rs | 30 +++++++++++++++- crates/repl/src/repl_store.rs | 11 +++++- 4 files changed, 85 insertions(+), 14 deletions(-) diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index 3b9535767b64dd3e674020035778dffad1601fc6..45e55f0d5f8a17d66a76d206216c07ba7cc36e8a 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -27,6 +27,7 @@ fn build_grouped_entries(store: &ReplStore, worktree_id: WorktreeId) -> Vec Vec { + KernelSpecification::JupyterServer(_) | KernelSpecification::SshRemote(_) => { remote_kernels.push(KernelPickerEntry::Kernel { spec: spec.clone(), is_recommended, }); } + KernelSpecification::WslRemote(_) => { + wsl_kernels.push(KernelPickerEntry::Kernel { + spec: spec.clone(), + is_recommended, + }); + } } } @@ -105,6 +110,12 @@ fn build_grouped_entries(store: &ReplStore, worktree_id: WorktreeId) -> Vec None, + KernelSpecification::WslRemote(_) => Some(spec.path().to_string()), KernelSpecification::PythonEnv(_) | KernelSpecification::JupyterServer(_) - | KernelSpecification::SshRemote(_) - | KernelSpecification::WslRemote(_) => { + | KernelSpecification::SshRemote(_) => { let env_kind = spec.environment_kind_label(); let path = spec.path(); match env_kind { diff --git a/crates/repl/src/kernels/mod.rs b/crates/repl/src/kernels/mod.rs index 9ec2ddb497f8c265b51dcfce58d0946d331d87d2..0f1ee9dabebe03b3735bfb95ab0e620a914de1e0 100644 --- a/crates/repl/src/kernels/mod.rs +++ b/crates/repl/src/kernels/mod.rs @@ -9,6 +9,7 @@ pub use native_kernel::*; mod remote_kernels; use project::{Project, ProjectPath, Toolchains, WorktreeId}; +use remote::RemoteConnectionOptions; pub use remote_kernels::*; mod ssh_kernel; @@ -238,7 +239,7 @@ impl KernelSpecification { Self::PythonEnv(spec) => spec.name.clone().into(), Self::JupyterServer(spec) => spec.name.clone().into(), Self::SshRemote(spec) => spec.name.clone().into(), - Self::WslRemote(spec) => spec.name.clone().into(), + Self::WslRemote(spec) => spec.kernelspec.display_name.clone().into(), } } @@ -262,7 +263,7 @@ impl KernelSpecification { Self::PythonEnv(spec) => spec.path.to_string_lossy().into_owned(), Self::JupyterServer(spec) => spec.url.to_string(), Self::SshRemote(spec) => spec.path.to_string(), - Self::WslRemote(_) => "WSL".to_string(), + Self::WslRemote(spec) => spec.distro.clone(), }) } @@ -348,7 +349,16 @@ pub fn python_env_kernel_specifications( ) -> impl Future>> + use<> { let python_language = LanguageName::new_static("Python"); let is_remote = project.read(cx).is_remote(); - log::info!("python_env_kernel_specifications: is_remote: {}", is_remote); + let wsl_distro = project + .read(cx) + .remote_connection_options(cx) + .and_then(|opts| { + if let RemoteConnectionOptions::Wsl(wsl) = opts { + Some(wsl.distro_name) + } else { + None + } + }); let toolchains = project.read(cx).available_toolchains( ProjectPath { @@ -383,6 +393,7 @@ pub fn python_env_kernel_specifications( .flatten() .chain(toolchains.toolchains) .map(|toolchain| { + let wsl_distro = wsl_distro.clone(); background_executor.spawn(async move { // For remote projects, we assume python is available assuming toolchain is reported. // We can skip the `ipykernel` check or run it remotely. @@ -390,10 +401,6 @@ pub fn python_env_kernel_specifications( // `new_smol_command` runs locally. We need to run remotely if `is_remote`. if is_remote { - log::info!( - "python_env_kernel_specifications: returning SshRemote for toolchain {}", - toolchain.name - ); let default_kernelspec = JupyterKernelspec { argv: vec![ toolchain.path.to_string(), @@ -409,6 +416,22 @@ pub fn python_env_kernel_specifications( env: None, }; + if let Some(distro) = wsl_distro { + log::debug!( + "python_env_kernel_specifications: returning WslRemote for toolchain {}", + toolchain.name + ); + return Some(KernelSpecification::WslRemote(WslKernelSpecification { + name: toolchain.name.to_string(), + kernelspec: default_kernelspec, + distro, + })); + } + + log::debug!( + "python_env_kernel_specifications: returning SshRemote for toolchain {}", + toolchain.name + ); return Some(KernelSpecification::SshRemote( SshRemoteKernelSpecification { name: format!("Remote {}", toolchain.name), diff --git a/crates/repl/src/kernels/wsl_kernel.rs b/crates/repl/src/kernels/wsl_kernel.rs index 34340c74feeb76cc4822a6ca5d669693cc448334..d9ac05c5fc8c2cb756898ff449d6714b78cb7997 100644 --- a/crates/repl/src/kernels/wsl_kernel.rs +++ b/crates/repl/src/kernels/wsl_kernel.rs @@ -274,7 +274,23 @@ impl WslRunningKernel { cd_command, set_env_command, arg_string, arg_string, arg_string, arg_string ) } else { - quote_posix_shell_arguments(&kernel_args)? + let args_string = quote_posix_shell_arguments(&resolved_argv)?; + + let cd_command = if let Some(wd) = wsl_working_directory.as_ref() { + let quoted_wd = shlex::try_quote(wd) + .map(|quoted| quoted.into_owned())?; + format!("cd {quoted_wd} && ") + } else { + String::new() + }; + + let env_prefix_inline = if !env_assignments.is_empty() { + format!("env {} ", env_assignments.join(" ")) + } else { + String::new() + }; + + format!("{cd_command}exec {env_prefix_inline}{args_string}") }; cmd.arg("bash") @@ -578,8 +594,20 @@ pub async fn wsl_kernel_specifications( }) }) .collect::>(); + } else if let Err(e) = + serde_json::from_str::(&json_str) + { + log::error!( + "wsl_kernel_specifications parse error: {} \nJSON: {}", + e, + json_str + ); } + } else { + log::error!("wsl_kernel_specifications command failed"); } + } else if let Err(e) = output { + log::error!("wsl_kernel_specifications command execution failed: {}", e); } Vec::new() diff --git a/crates/repl/src/repl_store.rs b/crates/repl/src/repl_store.rs index 8da94eaa7fe40e28a1d6336a648d7eae5c6767ae..ff0a2793617982e75d6c81d6c3a180d2f9b3c8ee 100644 --- a/crates/repl/src/repl_store.rs +++ b/crates/repl/src/repl_store.rs @@ -8,6 +8,7 @@ use gpui::{App, Context, Entity, EntityId, Global, SharedString, Subscription, T use jupyter_websocket_client::RemoteServer; use language::{Language, LanguageName}; use project::{Fs, Project, ProjectPath, WorktreeId}; +use remote::RemoteConnectionOptions; use settings::{Settings, SettingsStore}; use util::rel_path::RelPath; @@ -144,6 +145,14 @@ impl ReplStore { cx: &mut Context, ) -> Task> { let is_remote = project.read(cx).is_remote(); + // WSL does require access to global kernel specs, so we only exclude remote worktrees that aren't WSL. + // TODO: a better way to handle WSL vs SSH/remote projects, + let is_wsl_remote = project + .read(cx) + .remote_connection_options(cx) + .map_or(false, |opts| { + matches!(opts, RemoteConnectionOptions::Wsl(_)) + }); let kernel_specifications = python_env_kernel_specifications(project, worktree_id, cx); let active_toolchain = project.read(cx).active_toolchain( ProjectPath { @@ -168,7 +177,7 @@ impl ReplStore { this.active_python_toolchain_for_worktree .insert(worktree_id, path); } - if is_remote { + if is_remote && !is_wsl_remote { this.remote_worktrees.insert(worktree_id); } else { this.remote_worktrees.remove(&worktree_id); From 33e53019463427af991a1ccb70639935d50f2b18 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Fri, 6 Mar 2026 14:03:45 +0000 Subject: [PATCH 023/219] gpui: Add `property_test` macro (#50935) Co-authored-by: Conrad Irwin --- Cargo.lock | 313 ++++++++++++------ Cargo.toml | 3 + crates/editor/Cargo.toml | 5 + crates/editor/src/editor_tests.rs | 3 + .../editor/src/editor_tests/property_test.rs | 85 +++++ crates/gpui/Cargo.toml | 2 + crates/gpui/src/gpui.rs | 7 +- crates/gpui/src/test.rs | 33 +- crates/gpui_macros/Cargo.toml | 2 +- crates/gpui_macros/src/gpui_macros.rs | 74 +++++ crates/gpui_macros/src/property_test.rs | 199 +++++++++++ crates/sum_tree/Cargo.toml | 6 + crates/sum_tree/src/property_test.rs | 32 ++ crates/sum_tree/src/sum_tree.rs | 2 + crates/text/Cargo.toml | 1 + 15 files changed, 656 insertions(+), 111 deletions(-) create mode 100644 crates/editor/src/editor_tests/property_test.rs create mode 100644 crates/gpui_macros/src/property_test.rs create mode 100644 crates/sum_tree/src/property_test.rs diff --git a/Cargo.lock b/Cargo.lock index 7de51f99cb81a019b0c4ae58121a2b2607267a90..e5aa4d63e2b1df09c98f677a0041b057b2c891da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -731,7 +731,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -1128,7 +1128,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -1196,7 +1196,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -1226,7 +1226,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2065,7 +2065,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2083,7 +2083,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2218,7 +2218,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2247,7 +2247,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2397,7 +2397,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2482,7 +2482,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2736,7 +2736,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.106", + "syn 2.0.117", "tempfile", "toml 0.8.23", ] @@ -2965,7 +2965,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -3643,6 +3643,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "copilot" version = "0.1.0" @@ -4354,7 +4363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4431,7 +4440,7 @@ dependencies = [ "proc-macro2", "quote", "scratch", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4445,7 +4454,7 @@ dependencies = [ "indexmap", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4463,7 +4472,7 @@ dependencies = [ "indexmap", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4560,7 +4569,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4571,7 +4580,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4803,7 +4812,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4825,7 +4834,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.106", + "syn 2.0.117", "unicode-xid", ] @@ -4835,7 +4844,7 @@ version = "0.1.0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4847,7 +4856,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -5038,7 +5047,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -5101,7 +5110,7 @@ dependencies = [ "proc-macro2", "quote", "strum 0.27.2", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -5476,6 +5485,8 @@ dependencies = [ "parking_lot", "pretty_assertions", "project", + "proptest", + "proptest-derive", "rand 0.9.2", "regex", "release_channel", @@ -5652,7 +5663,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -5673,7 +5684,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -5738,7 +5749,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -6213,7 +6224,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -6512,7 +6523,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -6781,7 +6792,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -7192,7 +7203,7 @@ source = "git+https://github.com/zed-industries/gh-workflow?rev=c9eac0ed361583e1 dependencies = [ "heck 0.5.0", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -7441,7 +7452,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -7656,6 +7667,7 @@ dependencies = [ "postage", "pretty_assertions", "profiling", + "proptest", "rand 0.9.2", "raw-window-handle", "refineable", @@ -7783,7 +7795,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -8211,7 +8223,7 @@ dependencies = [ "markup5ever 0.12.1", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -8669,7 +8681,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -8745,7 +8757,7 @@ checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -8840,7 +8852,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -9032,7 +9044,7 @@ checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -10303,7 +10315,7 @@ checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11170,7 +11182,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11253,7 +11265,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11646,7 +11658,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11675,7 +11687,7 @@ checksum = "969ccca8ffc4fb105bd131a228107d5c9dd89d9d627edf3295cbe979156f9712" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11733,7 +11745,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11843,7 +11855,7 @@ dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -12106,7 +12118,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -12585,7 +12597,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -12598,7 +12610,7 @@ dependencies = [ "phf_shared 0.12.1", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -12660,7 +12672,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -12984,7 +12996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -13048,7 +13060,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -13068,7 +13080,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "version_check", "yansi", ] @@ -13099,7 +13111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -13308,6 +13320,47 @@ dependencies = [ "uuid", ] +[[package]] +name = "proptest" +version = "1.10.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.10.0", + "num-traits", + "proptest-macro", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "proptest-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c57924a81864dddafba92e1bf92f9bf82f97096c44489548a60e888e1547549b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proptest-macro" +version = "0.5.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "prost" version = "0.9.0" @@ -13365,7 +13418,7 @@ dependencies = [ "prost 0.12.6", "prost-types 0.12.6", "regex", - "syn 2.0.106", + "syn 2.0.117", "tempfile", ] @@ -13392,7 +13445,7 @@ dependencies = [ "itertools 0.12.1", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -13560,6 +13613,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -13785,6 +13844,15 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.3", +] + [[package]] name = "random_choice" version = "0.3.2" @@ -13859,7 +13927,7 @@ dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", @@ -14059,7 +14127,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -14736,7 +14804,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.106", + "syn 2.0.117", "walkdir", ] @@ -14990,6 +15058,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "rustybuzz" version = "0.20.1" @@ -15109,7 +15189,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -15170,7 +15250,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -15199,7 +15279,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -15241,7 +15321,7 @@ dependencies = [ "proc-macro2", "quote", "sea-bae", - "syn 2.0.106", + "syn 2.0.117", "unicode-ident", ] @@ -15426,7 +15506,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -15437,7 +15517,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -15495,7 +15575,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -15635,7 +15715,7 @@ version = "0.1.0" dependencies = [ "quote", "settings", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -16009,7 +16089,7 @@ checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -16187,7 +16267,7 @@ version = "0.1.0" dependencies = [ "sqlez", "sqlformat", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -16264,7 +16344,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -16287,7 +16367,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.106", + "syn 2.0.117", "tokio", "url", ] @@ -16446,7 +16526,7 @@ checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" dependencies = [ "proc-macro-error2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -16589,7 +16669,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -16605,6 +16685,7 @@ dependencies = [ "arrayvec", "ctor", "log", + "proptest", "rand 0.9.2", "rayon", "tracing", @@ -16890,9 +16971,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.106" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -16931,7 +17012,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -17371,6 +17452,7 @@ dependencies = [ "log", "parking_lot", "postage", + "proptest", "rand 0.9.2", "regex", "rope", @@ -17481,7 +17563,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -17492,7 +17574,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -17513,7 +17595,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg", ] @@ -17736,7 +17818,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -18074,7 +18156,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -18169,7 +18251,7 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -18626,7 +18708,7 @@ version = "0.1.0" dependencies = [ "component", "quote", - "syn 2.0.106", + "syn 2.0.117", "ui", ] @@ -18643,6 +18725,12 @@ dependencies = [ "workspace", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.8.1" @@ -18881,7 +18969,7 @@ version = "0.1.0" dependencies = [ "perf", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -19099,6 +19187,15 @@ dependencies = [ "serde", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "waker-fn" version = "1.2.0" @@ -19228,7 +19325,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -19524,7 +19621,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "wasmtime-component-util", "wasmtime-wit-bindgen", "wit-parser 0.229.0", @@ -19639,7 +19736,7 @@ checksum = "d0963c1438357a3d8c0efe152b4ef5259846c1cf8b864340270744fe5b3bae5e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -20177,7 +20274,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "witx", ] @@ -20189,7 +20286,7 @@ checksum = "d873bb5b59ca703b5e41562e96a4796d1af61bf4cf80bf8a7abda755a380ec1c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "wiggle-generate", ] @@ -20412,7 +20509,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -20423,7 +20520,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -20434,7 +20531,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -20445,7 +20542,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -20456,7 +20553,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -20467,7 +20564,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -21105,7 +21202,7 @@ dependencies = [ "heck 0.5.0", "indexmap", "prettyplease", - "syn 2.0.106", + "syn 2.0.117", "wasm-metadata 0.227.1", "wit-bindgen-core 0.41.0", "wit-component 0.227.1", @@ -21121,7 +21218,7 @@ dependencies = [ "heck 0.5.0", "indexmap", "prettyplease", - "syn 2.0.106", + "syn 2.0.117", "wasm-metadata 0.244.0", "wit-bindgen-core 0.51.0", "wit-component 0.244.0", @@ -21136,7 +21233,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "wit-bindgen-core 0.22.0", "wit-bindgen-rust 0.22.0", ] @@ -21151,7 +21248,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "wit-bindgen-core 0.41.0", "wit-bindgen-rust 0.41.0", ] @@ -21166,7 +21263,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "wit-bindgen-core 0.51.0", "wit-bindgen-rust 0.51.0", ] @@ -21697,7 +21794,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "synstructure", ] @@ -21709,7 +21806,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "synstructure", ] @@ -21757,7 +21854,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -22155,7 +22252,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -22175,7 +22272,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "synstructure", ] @@ -22196,7 +22293,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -22253,7 +22350,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -22415,7 +22512,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "zvariant_utils", ] @@ -22428,6 +22525,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.106", + "syn 2.0.117", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 597a5f2a207c27154dcf1a55c85d97271604f83f..1d0f29086b45029e4d9dbc3fa9ffd9dbb6135a6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -650,6 +650,9 @@ postage = { version = "0.5", features = ["futures-traits"] } pretty_assertions = { version = "1.3.0", features = ["unstable"] } proc-macro2 = "1.0.93" profiling = "1" +# replace this with main when #635 is merged +proptest = { git = "https://github.com/proptest-rs/proptest", rev = "3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b", features = ["attr-macro"] } +proptest-derive = "0.8.0" prost = "0.9" prost-build = "0.9" prost-types = "0.9" diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index b200d25f6d9ca90e862091b1b999613b0f5e2723..2a8709dea29cf1398a862216e407b973eae41004 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -26,6 +26,7 @@ test-support = [ "tree-sitter-rust", "tree-sitter-typescript", "tree-sitter-html", + "proptest", "unindent", ] @@ -63,6 +64,8 @@ ordered-float.workspace = true parking_lot.workspace = true pretty_assertions.workspace = true project.workspace = true +proptest = { workspace = true, optional = true } +proptest-derive = { workspace = true, optional = true } rand.workspace = true regex.workspace = true rpc.workspace = true @@ -110,6 +113,8 @@ lsp = { workspace = true, features = ["test-support"] } markdown = { workspace = true, features = ["test-support"] } multi_buffer = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } +proptest.workspace = true +proptest-derive.workspace = true release_channel.workspace = true rand.workspace = true semver.workspace = true diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 8b866563636f6fe494bdd8d941458defa786c0da..3cb2ac6ceec6e54b93266e2052403722651f89e3 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -76,6 +76,9 @@ fn display_ranges(editor: &Editor, cx: &mut Context<'_, Editor>) -> Vec, + ) { + match action { + TestAction::Type(text) => self.insert(&text, window, cx), + TestAction::Backspace { count } => { + for _ in 0..*count { + self.delete(&Default::default(), window, cx); + } + } + TestAction::Move { count, direction } => { + for _ in 0..*count { + match direction { + Direction::Up => self.move_up(&Default::default(), window, cx), + Direction::Down => self.move_down(&Default::default(), window, cx), + Direction::Left => self.move_left(&Default::default(), window, cx), + Direction::Right => self.move_right(&Default::default(), window, cx), + } + } + } + } + } +} + +fn test_actions() -> impl Strategy> { + proptest::collection::vec(any::(), 1..10) +} + +#[gpui::property_test(config = ProptestConfig {cases: 100, ..Default::default()})] +fn editor_property_test( + cx: &mut TestAppContext, + #[strategy = test_actions()] actions: Vec, +) { + init_test(cx, |_| {}); + + let group_interval = Duration::from_millis(1); + + let buffer = cx.new(|cx| { + let mut buf = language::Buffer::local("123456", cx); + buf.set_group_interval(group_interval); + buf + }); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); + + editor + .update(cx, |editor, window, cx| { + for action in actions { + editor.apply_test_action(&action, window, cx); + } + }) + .unwrap(); +} diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index c80f97efb6dc8bf1450c08bfe85290096b44815b..a07eb08576c31236df26787c9c9ade4186c466d6 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -24,6 +24,7 @@ test-support = [ "http_client/test-support", "wayland", "x11", + "proptest", ] inspector = ["gpui_macros/inspector"] leak-detection = ["backtrace"] @@ -64,6 +65,7 @@ num_cpus = "1.13" parking = "2.0.0" parking_lot.workspace = true postage.workspace = true +proptest = { workspace = true, optional = true } chrono.workspace = true profiling.workspace = true rand.workspace = true diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index ff36dbce500b8e7472f3d7faa31d9e5cb17e087e..6d7d801cd42c3639d7892295a660319d21b05dfa 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -54,6 +54,9 @@ mod util; mod view; mod window; +#[cfg(any(test, feature = "test-support"))] +pub use proptest; + #[cfg(doc)] pub mod _ownership_and_data_flow; @@ -86,7 +89,9 @@ pub use elements::*; pub use executor::*; pub use geometry::*; pub use global::*; -pub use gpui_macros::{AppContext, IntoElement, Render, VisualContext, register_action, test}; +pub use gpui_macros::{ + AppContext, IntoElement, Render, VisualContext, property_test, register_action, test, +}; pub use gpui_util::arc_cow::ArcCow; pub use http_client; pub use input::*; diff --git a/crates/gpui/src/test.rs b/crates/gpui/src/test.rs index 9e76d97e97e941121417d872e8c6f596cf658e20..ddcc3d27bd04d2fd82b3367a2fee6930e86ef356 100644 --- a/crates/gpui/src/test.rs +++ b/crates/gpui/src/test.rs @@ -27,12 +27,43 @@ //! ``` use crate::{Entity, Subscription, TestAppContext, TestDispatcher}; use futures::StreamExt as _; +use proptest::prelude::{Just, Strategy, any}; use std::{ env, - panic::{self, RefUnwindSafe}, + panic::{self, RefUnwindSafe, UnwindSafe}, pin::Pin, }; +/// Strategy injected into `#[gpui::property_test]` tests to control the seed +/// given to the scheduler. Doesn't shrink, since all scheduler seeds are +/// equivalent in complexity. If `$SEED` is set, it always uses that value. +pub fn seed_strategy() -> impl Strategy { + match std::env::var("SEED") { + Ok(val) => Just(val.parse().unwrap()).boxed(), + Err(_) => any::().no_shrink().boxed(), + } +} + +/// Similar to [`run_test`], but only runs the callback once, allowing +/// [`FnOnce`] callbacks. This is intended for use with the +/// `gpui::property_test` macro and generally should not be used directly. +/// +/// Doesn't support many features of [`run_test`], since these are provided by +/// proptest. +pub fn run_test_once(seed: u64, test_fn: Box) { + let result = panic::catch_unwind(|| { + let dispatcher = TestDispatcher::new(seed); + let scheduler = dispatcher.scheduler().clone(); + test_fn(dispatcher); + scheduler.end_test(); + }); + + match result { + Ok(()) => {} + Err(e) => panic::resume_unwind(e), + } +} + /// Run the given test function with the configured parameters. /// This is intended for use with the `gpui::test` macro /// and generally should not be used directly. diff --git a/crates/gpui_macros/Cargo.toml b/crates/gpui_macros/Cargo.toml index 2ee8da52fb7a013cefdd5fe79520a5d18f1e5b3f..513dd61d7b1da83aae2ca4779fb187aece3d7278 100644 --- a/crates/gpui_macros/Cargo.toml +++ b/crates/gpui_macros/Cargo.toml @@ -24,4 +24,4 @@ quote.workspace = true syn.workspace = true [dev-dependencies] -gpui = { workspace = true, features = ["inspector"] } +gpui = { workspace = true, features = ["inspector"] } \ No newline at end of file diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index 0f1365be77ec221d9061f588f84ff6acab3c32ab..e30c85e6edbee8b5307a5139c00a222e9a83bc55 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/crates/gpui_macros/src/gpui_macros.rs @@ -3,6 +3,7 @@ mod derive_app_context; mod derive_into_element; mod derive_render; mod derive_visual_context; +mod property_test; mod register_action; mod styles; mod test; @@ -188,6 +189,79 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { test::test(args, function) } +/// A variant of `#[gpui::test]` that supports property-based testing. +/// +/// A property test, much like a standard GPUI randomized test, allows testing +/// claims of the form "for any possible X, Y should hold". For example: +/// ``` +/// #[gpui::property_test] +/// fn test_arithmetic(x: i32, y: i32) { +/// assert!(x == y || x < y || x > y); +/// } +/// ``` +/// Standard GPUI randomized tests provide you with an instance of `StdRng` to +/// generate random data in a controlled manner. Property-based tests have some +/// advantages, however: +/// - Shrinking - the harness also understands a notion of the "complexity" of a +/// particular value. This allows it to find the "simplest possible value that +/// causes the test to fail". +/// - Ergonomics/clarity - the property-testing harness will automatically +/// generate values, removing the need to fill the test body with generation +/// logic. +/// - Failure persistence - if a failing seed is identified, it is stored in a +/// file, which can be checked in, and future runs will check these cases before +/// future cases. +/// +/// Property tests work best when all inputs can be generated up-front and kept +/// in a simple data structure. Sometimes, this isn't possible - for example, if +/// a test needs to make a random decision based on the current state of some +/// structure. In this case, a standard GPUI randomized test may be more +/// suitable. +/// +/// ## Customizing random values +/// +/// This macro is based on the [`#[proptest::property_test]`] macro, but handles +/// some of the same GPUI-specific arguments as `#[gpui::test]`. Specifically, +/// `&{mut,} TestAppContext` and `BackgroundExecutor` work as normal. `StdRng` +/// arguments are **explicitly forbidden**, since they break shrinking, and are +/// a common footgun. +/// +/// All other arguments are forwarded to the underlying proptest macro. +/// +/// Note: much of the following is copied from the proptest docs, specifically the +/// [`#[proptest::property_test]`] macro docs. +/// +/// Random values of type `T` are generated by a `Strategy` object. +/// Some types have a canonical `Strategy` - these types also implement +/// `Arbitrary`. Parameters to a `#[gpui::property_test]`, by default, use a +/// type's `Arbitrary` implementation. If you'd like to provide a custom +/// strategy, you can use `#[strategy = ...]` on the argument: +/// ``` +/// #[gpui::property_test] +/// fn int_test(#[strategy = 1..10] x: i32, #[strategy = "[a-zA-Z0-9]{20}"] s: String) { +/// assert!(s.len() > (x as usize)); +/// } +/// ``` +/// +/// For more information on writing custom `Strategy` and `Arbitrary` +/// implementations, see [the proptest book][book], and the [`Strategy`] trait. +/// +/// ## Scheduler +/// +/// Similar to `#[gpui::test]`, this macro will choose random seeds for the test +/// scheduler. It uses `.no_shrink()` to tell proptest that all seeds are +/// roughly equivalent in terms of "complexity". If `$SEED` is set, it will +/// affect **ONLY** the seed passed to the scheduler. To control other values, +/// use custom `Strategy`s. +/// +/// [`#[proptest::property_test]`]: https://docs.rs/proptest/latest/proptest/attr.property_test.html +/// [book]: https://proptest-rs.github.io/proptest/intro.html +/// [`Strategy`]: https://docs.rs/proptest/latest/proptest/strategy/trait.Strategy.html +#[proc_macro_attribute] +pub fn property_test(args: TokenStream, function: TokenStream) -> TokenStream { + property_test::test(args.into(), function.into()).into() +} + /// When added to a trait, `#[derive_inspector_reflection]` generates a module which provides /// enumeration and lookup by name of all methods that have the shape `fn method(self) -> Self`. /// This is used by the inspector so that it can use the builder methods in `Styled` and diff --git a/crates/gpui_macros/src/property_test.rs b/crates/gpui_macros/src/property_test.rs new file mode 100644 index 0000000000000000000000000000000000000000..6bf60eca1b63a86bce22fbf4ae771230ee34726d --- /dev/null +++ b/crates/gpui_macros/src/property_test.rs @@ -0,0 +1,199 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote, quote_spanned}; +use syn::{ + FnArg, Ident, ItemFn, Type, parse2, punctuated::Punctuated, spanned::Spanned, token::Comma, +}; + +pub fn test(args: TokenStream, item: TokenStream) -> TokenStream { + let item_span = item.span(); + let Ok(func) = parse2::(item) else { + return quote_spanned! { item_span => + compile_error!("#[gpui::property_test] must be placed on a function"); + }; + }; + + let test_name = func.sig.ident.clone(); + let inner_fn_name = format_ident!("__{test_name}"); + + let parsed_args = parse_args(func.sig.inputs, &test_name); + + let inner_body = func.block; + let inner_arg_decls = parsed_args.inner_fn_decl_args; + let asyncness = func.sig.asyncness; + + let inner_fn = quote! { + let #inner_fn_name = #asyncness move |#inner_arg_decls| #inner_body; + }; + + let arg_errors = parsed_args.errors; + let proptest_args = parsed_args.proptest_args; + let inner_args = parsed_args.inner_fn_args; + let cx_vars = parsed_args.cx_vars; + let cx_teardowns = parsed_args.cx_teardowns; + + let proptest_args = quote! { + #[strategy = ::gpui::seed_strategy()] __seed: u64, + #proptest_args + }; + + let run_test_body = match &asyncness { + None => quote! { + #cx_vars + #inner_fn_name(#inner_args); + #cx_teardowns + }, + Some(_) => quote! { + let foreground_executor = gpui::ForegroundExecutor::new(std::sync::Arc::new(dispatcher.clone())); + #cx_vars + foreground_executor.block_test(#inner_fn_name(#inner_args)); + #cx_teardowns + }, + }; + + quote! { + #arg_errors + + #[::gpui::proptest::property_test(proptest_path = "::gpui::proptest", #args)] + fn #test_name(#proptest_args) { + #inner_fn + + ::gpui::run_test_once( + __seed, + Box::new(move |dispatcher| { + #run_test_body + }), + ) + } + } +} + +#[derive(Default)] +struct ParsedArgs { + cx_vars: TokenStream, + cx_teardowns: TokenStream, + proptest_args: TokenStream, + errors: TokenStream, + + // exprs passed at the call-site + inner_fn_args: TokenStream, + // args in the declaration + inner_fn_decl_args: TokenStream, +} + +fn parse_args(args: Punctuated, test_name: &Ident) -> ParsedArgs { + let mut parsed = ParsedArgs::default(); + let mut args = args.into_iter().collect(); + + remove_cxs(&mut parsed, &mut args, test_name); + remove_std_rng(&mut parsed, &mut args); + remove_background_executor(&mut parsed, &mut args); + + // all remaining args forwarded to proptest's macro + parsed.proptest_args = quote!( #(#args),* ); + + parsed +} + +fn remove_cxs(parsed: &mut ParsedArgs, args: &mut Vec, test_name: &Ident) { + let mut ix = 0; + args.retain_mut(|arg| { + if !is_test_cx(arg) { + return true; + } + + let cx_varname = format_ident!("cx_{ix}"); + ix += 1; + + parsed.cx_vars.extend(quote!( + let mut #cx_varname = gpui::TestAppContext::build( + dispatcher.clone(), + Some(stringify!(#test_name)), + ); + )); + parsed.cx_teardowns.extend(quote!( + dispatcher.run_until_parked(); + #cx_varname.executor().forbid_parking(); + #cx_varname.quit(); + dispatcher.run_until_parked(); + )); + + parsed.inner_fn_decl_args.extend(quote!(#arg,)); + parsed.inner_fn_args.extend(quote!(&mut #cx_varname,)); + + false + }); +} + +fn remove_std_rng(parsed: &mut ParsedArgs, args: &mut Vec) { + args.retain_mut(|arg| { + if !is_std_rng(arg) { + return true; + } + + parsed.errors.extend(quote_spanned! { arg.span() => + compile_error!("`StdRng` is not allowed in a property test. Consider implementing `Arbitrary`, or implementing a custom `Strategy`. https://altsysrq.github.io/proptest-book/proptest/tutorial/strategy-basics.html"); + }); + + false + }); +} + +fn remove_background_executor(parsed: &mut ParsedArgs, args: &mut Vec) { + args.retain_mut(|arg| { + if !is_background_executor(arg) { + return true; + } + + parsed.inner_fn_decl_args.extend(quote!(#arg,)); + parsed + .inner_fn_args + .extend(quote!(gpui::BackgroundExecutor::new(std::sync::Arc::new( + dispatcher.clone() + )),)); + + false + }); +} + +// Matches `&TestAppContext` or `&foo::bar::baz::TestAppContext` +fn is_test_cx(arg: &FnArg) -> bool { + let FnArg::Typed(arg) = arg else { + return false; + }; + + let Type::Reference(ty) = &*arg.ty else { + return false; + }; + + let Type::Path(ty) = &*ty.elem else { + return false; + }; + + ty.path + .segments + .last() + .is_some_and(|seg| seg.ident == "TestAppContext") +} + +fn is_std_rng(arg: &FnArg) -> bool { + is_path_with_last_segment(arg, "StdRng") +} + +fn is_background_executor(arg: &FnArg) -> bool { + is_path_with_last_segment(arg, "BackgroundExecutor") +} + +fn is_path_with_last_segment(arg: &FnArg, last_segment: &str) -> bool { + let FnArg::Typed(arg) = arg else { + return false; + }; + + let Type::Path(ty) = &*arg.ty else { + return false; + }; + + ty.path + .segments + .last() + .is_some_and(|seg| seg.ident == last_segment) +} diff --git a/crates/sum_tree/Cargo.toml b/crates/sum_tree/Cargo.toml index 3e06ede162dad37f94017207ccbd6ee5c38f26a5..e4cf78181aa43cce4a6692cc3c6c92e03b7bf9ad 100644 --- a/crates/sum_tree/Cargo.toml +++ b/crates/sum_tree/Cargo.toml @@ -19,11 +19,17 @@ rayon.workspace = true log.workspace = true ztracing.workspace = true tracing.workspace = true +proptest = { workspace = true, optional = true } [dev-dependencies] ctor.workspace = true rand.workspace = true +proptest.workspace = true zlog.workspace = true + [package.metadata.cargo-machete] ignored = ["tracing"] + +[features] +test-support = ["proptest"] \ No newline at end of file diff --git a/crates/sum_tree/src/property_test.rs b/crates/sum_tree/src/property_test.rs new file mode 100644 index 0000000000000000000000000000000000000000..d6c6bd76f94704c60dfc6919fa02ba66c19f349d --- /dev/null +++ b/crates/sum_tree/src/property_test.rs @@ -0,0 +1,32 @@ +use core::fmt::Debug; + +use proptest::{prelude::*, sample::SizeRange}; + +use crate::{Item, SumTree, Summary}; + +impl Arbitrary for SumTree +where + T: Debug + Arbitrary + Item + 'static, + T::Summary: Debug + Summary = ()>, +{ + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + any::>() + .prop_map(|vec| SumTree::from_iter(vec, ())) + .boxed() + } +} + +/// A strategy for producing a [`SumTree`] with a given size. +/// +/// Equivalent to [`proptest::collection::vec`]. +pub fn sum_tree(values: S, size: impl Into) -> impl Strategy> +where + T: Debug + Arbitrary + Item + 'static, + T::Summary: Debug + Summary = ()>, + S: Strategy, +{ + proptest::collection::vec(values, size).prop_map(|vec| SumTree::from_iter(vec, ())) +} diff --git a/crates/sum_tree/src/sum_tree.rs b/crates/sum_tree/src/sum_tree.rs index 068bc4bce56816962a3b75d6f6497b033a9209a5..8ab9b5ccb1fdb3b28b3aa0dd93c7a732a21645cb 100644 --- a/crates/sum_tree/src/sum_tree.rs +++ b/crates/sum_tree/src/sum_tree.rs @@ -1,4 +1,6 @@ mod cursor; +#[cfg(any(test, feature = "test-support"))] +pub mod property_test; mod tree_map; use arrayvec::ArrayVec; diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index ed02381eb83db5daececd159171a90072244a340..47c1dd768d19492e43231a3e8cd8270fb648f39c 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -37,3 +37,4 @@ rand.workspace = true util = { workspace = true, features = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } zlog.workspace = true +proptest.workspace = true From 09b4140b80491eebcd5967297f885eb26f7fe019 Mon Sep 17 00:00:00 2001 From: Xin Zhao Date: Fri, 6 Mar 2026 22:11:16 +0800 Subject: [PATCH 024/219] lsp: Use correct LSP adapter for completion labels in remote development (#50697) Closes #47917 Currently, during remote development, Zed uses the first available local LSP adapter to handle all label computing. This isn't ideal and causes bugs because different adapters may expect different label formats. By leveraging the `all_capable_for_proto_request` method, we can now retrieve the specific language server name and use it as a key to find the correct LSP adapter for label population. Even if this lookup fails, we can still fall back to the first adapter, so this PR should provide more accurate label population than before. This addresses the root cause of #47917, which stems from two main issues. For example, in remote Python development, the `basedpyright` adapter might incorrectly handle labels even when the remote server is actually `ty`. The completion items returned by `ty` are slightly different from `basedpyright`: `ty` stores labels in `labelDetails.detail`, while basedpyright uses `labelDetails.description`. By matching the correct adapter, we ensure labels are populated properly for completion items. ```json // RPC message returned by `ty`, label is in `labelDetails.detail` { ... "labelDetails": { "detail": " (import pathlib)" }, ... } // RPC message returned by `basedpyright`, label is in `labelDetails.description` { ... "labelDetails": { "description": "pathlib" }, ... } ``` Additionally, adapters registered via `register_available_lsp_adapter` are lazy-loaded into the `LanguageRegistry` (which is the case for `ty` before). In remote scenarios, the adapter might be loaded on the remote server but not on the local host, making it hard to find in `lsp_adapters`. This is partially resolved in #50662, and combined with this PR, we can fully address #47917. There is still more to do, however. In some cases, we still can't find the correct local LSP adapter if the local host lacks the registry that the remote server has; this typically happens when the adapter is registered via `register_available_lsp_adapter`. I've opened a feature discussion #49178 to track this. If it's decided that this needs further refinement, I'm happy to continue working on it. Before you mark this PR as ready for review, make sure that you have: - [ ] 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 missing labels for `ty` completion items in remote development. --- crates/project/src/lsp_store.rs | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 7573af1dc69f33586199c6f9e5e4d2a59f6d2d6f..97aa03cec730c61acfb129579c77f6a5b560ee32 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -4904,7 +4904,7 @@ impl LspStore { buffer: &Entity, mut check: F, cx: &App, - ) -> Vec + ) -> Vec<(lsp::LanguageServerId, lsp::LanguageServerName)> where F: FnMut(&lsp::LanguageServerName, &lsp::ServerCapabilities) -> bool, { @@ -4934,7 +4934,7 @@ impl LspStore { .map(|c| (server_id, server_name, c)) }) .filter(|(_, server_name, capabilities)| check(server_name, capabilities)) - .map(|(server_id, _, _)| *server_id) + .map(|(server_id, server_name, _)| (*server_id, server_name.clone())) .collect() } @@ -6132,23 +6132,13 @@ impl LspStore { let language = buffer.read(cx).language().cloned(); - // In the future, we should provide project guests with the names of LSP adapters, - // so that they can use the correct LSP adapter when computing labels. For now, - // guests just use the first LSP adapter associated with the buffer's language. - let lsp_adapter = language.as_ref().and_then(|language| { - language_registry - .lsp_adapters(&language.name()) - .first() - .cloned() - }); - let buffer = buffer.clone(); cx.spawn(async move |this, cx| { let requests = join_all( capable_lsps .into_iter() - .map(|id| { + .map(|(id, server_name)| { let request = GetCompletions { position, context: context.clone(), @@ -6156,7 +6146,14 @@ impl LspStore { }; let buffer = buffer.clone(); let language = language.clone(); - let lsp_adapter = lsp_adapter.clone(); + let lsp_adapter = language.as_ref().and_then(|language| { + let adapters = language_registry.lsp_adapters(&language.name()); + adapters + .iter() + .find(|adapter| adapter.name() == server_name) + .or_else(|| adapters.first()) + .cloned() + }); let upstream_client = upstream_client.clone(); let response = this .update(cx, |this, cx| { From 84daec5d03a80f969949ee725d06dce3f8124d0b Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Fri, 6 Mar 2026 19:45:03 +0530 Subject: [PATCH 025/219] agent_ui: Add safeguards for deep link prompt injections (#50936) - Show a warning asking users to review pre-filled prompts from external links before sending. - Strip control characters and bidi control characters from pre-filled prompts. - Collapse 3+ consecutive newlines in pre-filled prompts to prevent newline padding attacks. - API changes make it impossible to auto-submit pre-filled prompts from external sources. Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 12 +- crates/agent_ui/src/agent_ui.rs | 9 + crates/agent_ui/src/connection_view.rs | 74 +++++++- .../src/connection_view/thread_view.rs | 47 +++++ crates/agent_ui/src/external_source_prompt.rs | 162 ++++++++++++++++++ crates/zed/src/main.rs | 10 +- crates/zed/src/zed/open_listener.rs | 45 +++-- 7 files changed, 335 insertions(+), 24 deletions(-) create mode 100644 crates/agent_ui/src/external_source_prompt.rs diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index b53cb003969f8519f584aef1269554f4277e31f6..bfff2e1369eb2f9441111ba48a59b3d006f3249e 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -39,7 +39,8 @@ use crate::{ ui::EndTrialUpsell, }; use crate::{ - AgentInitialContent, ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary, + AgentInitialContent, ExternalAgent, ExternalSourcePrompt, NewExternalAgentThread, + NewNativeAgentThreadFromSummary, }; use crate::{ ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent, @@ -1994,9 +1995,9 @@ impl AgentPanel { } } - pub fn new_external_thread_with_text( + pub fn new_agent_thread_with_external_source_prompt( &mut self, - initial_text: Option, + external_source_prompt: Option, window: &mut Window, cx: &mut Context, ) { @@ -2005,10 +2006,7 @@ impl AgentPanel { None, None, None, - initial_text.map(|text| AgentInitialContent::ContentBlock { - blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(text))], - auto_submit: false, - }), + external_source_prompt.map(AgentInitialContent::from), window, cx, ); diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index eee7a61576e4f4dbdb56c98b497b50cc59c0053d..e8a80597f330cb5f10f25a44fa41cb4e38d69818 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -11,6 +11,7 @@ pub(crate) mod connection_view; mod context; mod context_server_configuration; mod entry_view_state; +mod external_source_prompt; mod favorite_models; mod inline_assistant; mod inline_prompt_editor; @@ -66,6 +67,7 @@ use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; pub(crate) use connection_view::ConnectionView; +pub use external_source_prompt::ExternalSourcePrompt; pub(crate) use mode_selector::ModeSelector; pub(crate) use model_selector::ModelSelector; pub(crate) use model_selector_popover::ModelSelectorPopover; @@ -250,6 +252,13 @@ pub enum AgentInitialContent { blocks: Vec, auto_submit: bool, }, + FromExternalSource(ExternalSourcePrompt), +} + +impl From for AgentInitialContent { + fn from(prompt: ExternalSourcePrompt) -> Self { + Self::FromExternalSource(prompt) + } } /// Opens the profile management interface for configuring agent tools and settings. diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 48aa88a95ba3c1fd440c59768f9328719f02aa70..07841c42215795ffcccf9f7e5ca684f42a59b498 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -2796,6 +2796,55 @@ pub(crate) mod tests { assert!(!weak_view.is_upgradable()); } + #[gpui::test] + async fn test_external_source_prompt_requires_manual_send(cx: &mut TestAppContext) { + init_test(cx); + + let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else { + panic!("expected prompt from external source to sanitize successfully"); + }; + let initial_content = AgentInitialContent::FromExternalSource(prompt); + + let (thread_view, cx) = setup_thread_view_with_initial_content( + StubAgentServer::default_response(), + initial_content, + cx, + ) + .await; + + active_thread(&thread_view, cx).read_with(cx, |view, cx| { + assert!(view.show_external_source_prompt_warning); + assert_eq!(view.thread.read(cx).entries().len(), 0); + assert_eq!(view.message_editor.read(cx).text(cx), "Write me a script"); + }); + } + + #[gpui::test] + async fn test_external_source_prompt_warning_clears_after_send(cx: &mut TestAppContext) { + init_test(cx); + + let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else { + panic!("expected prompt from external source to sanitize successfully"); + }; + let initial_content = AgentInitialContent::FromExternalSource(prompt); + + let (thread_view, cx) = setup_thread_view_with_initial_content( + StubAgentServer::default_response(), + initial_content, + cx, + ) + .await; + + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + cx.run_until_parked(); + + active_thread(&thread_view, cx).read_with(cx, |view, cx| { + assert!(!view.show_external_source_prompt_warning); + assert_eq!(view.message_editor.read(cx).text(cx), ""); + assert_eq!(view.thread.read(cx).entries().len(), 2); + }); + } + #[gpui::test] async fn test_notification_for_stop_event(cx: &mut TestAppContext) { init_test(cx); @@ -3610,6 +3659,29 @@ pub(crate) mod tests { Entity, Entity, &mut VisualTestContext, + ) { + setup_thread_view_with_history_and_initial_content(agent, None, cx).await + } + + async fn setup_thread_view_with_initial_content( + agent: impl AgentServer + 'static, + initial_content: AgentInitialContent, + cx: &mut TestAppContext, + ) -> (Entity, &mut VisualTestContext) { + let (thread_view, _history, cx) = + setup_thread_view_with_history_and_initial_content(agent, Some(initial_content), cx) + .await; + (thread_view, cx) + } + + async fn setup_thread_view_with_history_and_initial_content( + agent: impl AgentServer + 'static, + initial_content: Option, + cx: &mut TestAppContext, + ) -> ( + Entity, + Entity, + &mut VisualTestContext, ) { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; @@ -3627,7 +3699,7 @@ pub(crate) mod tests { None, None, None, - None, + initial_content, workspace.downgrade(), project, Some(thread_store), diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 1c3b8ba244d895ba3ab2e6473f6cbe33ddae550b..0154ec920c82ccb829ad7486b3de97d5fb33e3ef 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -262,6 +262,7 @@ pub struct ThreadView { pub project: WeakEntity, pub recent_history_entries: Vec, pub hovered_recent_history_item: Option, + pub show_external_source_prompt_warning: bool, pub show_codex_windows_warning: bool, pub history: Entity, pub _history_subscription: Subscription, @@ -324,6 +325,7 @@ impl ThreadView { }); let mut should_auto_submit = false; + let mut show_external_source_prompt_warning = false; let message_editor = cx.new(|cx| { let mut editor = MessageEditor::new( @@ -355,6 +357,18 @@ impl ThreadView { should_auto_submit = auto_submit; editor.set_message(blocks, window, cx); } + AgentInitialContent::FromExternalSource(prompt) => { + show_external_source_prompt_warning = true; + // SECURITY: Be explicit about not auto submitting prompt from external source. + should_auto_submit = false; + editor.set_message( + vec![acp::ContentBlock::Text(acp::TextContent::new( + prompt.into_string(), + ))], + window, + cx, + ); + } } } else if let Some(draft) = thread.read(cx).draft_prompt() { editor.set_message(draft.to_vec(), window, cx); @@ -477,6 +491,7 @@ impl ThreadView { project, recent_history_entries, hovered_recent_history_item: None, + show_external_source_prompt_warning, history, _history_subscription: history_subscription, show_codex_windows_warning, @@ -781,6 +796,13 @@ impl ThreadView { // sending + fn clear_external_source_prompt_warning(&mut self, cx: &mut Context) { + if self.show_external_source_prompt_warning { + self.show_external_source_prompt_warning = false; + cx.notify(); + } + } + pub fn send(&mut self, window: &mut Window, cx: &mut Context) { let thread = &self.thread; @@ -862,6 +884,7 @@ impl ThreadView { .any(|command| command.name == "logout"); if can_login && !logout_supported { message_editor.update(cx, |editor, cx| editor.clear(window, cx)); + self.clear_external_source_prompt_warning(cx); let connection = self.thread.read(cx).connection().clone(); window.defer(cx, { @@ -954,6 +977,7 @@ impl ThreadView { }; let generation = this.update(cx, |this, cx| { + this.clear_external_source_prompt_warning(cx); let generation = this.start_turn(cx); this.in_flight_prompt = Some(contents.clone()); generation @@ -7445,6 +7469,26 @@ impl ThreadView { ) } + fn render_external_source_prompt_warning(&self, cx: &mut Context) -> Callout { + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title("Review before sending") + .description("This prompt was pre-filled by an external link. Read it carefully before you send it.") + .dismiss_action( + IconButton::new("dismiss-external-source-prompt-warning", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Dismiss Warning")) + .on_click(cx.listener({ + move |this, _, _, cx| { + this.show_external_source_prompt_warning = false; + cx.notify(); + } + })), + ) + } + fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context) -> Div { let server_view = self.server_view.clone(); v_flex().w_full().justify_end().child( @@ -7794,6 +7838,9 @@ impl Render for ThreadView { .children(self.render_subagent_titlebar(cx)) .child(conversation) .children(self.render_activity_bar(window, cx)) + .when(self.show_external_source_prompt_warning, |this| { + this.child(self.render_external_source_prompt_warning(cx)) + }) .when(self.show_codex_windows_warning, |this| { this.child(self.render_codex_windows_warning(cx)) }) diff --git a/crates/agent_ui/src/external_source_prompt.rs b/crates/agent_ui/src/external_source_prompt.rs new file mode 100644 index 0000000000000000000000000000000000000000..cf581c038e97a96ee580818634b8588daf227d2d --- /dev/null +++ b/crates/agent_ui/src/external_source_prompt.rs @@ -0,0 +1,162 @@ +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalSourcePrompt(String); + +impl ExternalSourcePrompt { + pub fn new(prompt: &str) -> Option { + sanitize(prompt).map(Self) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +fn sanitize(prompt: &str) -> Option { + let mut sanitized_prompt = String::with_capacity(prompt.len()); + let mut consecutive_newline_count = 0; + let mut characters = prompt.chars().peekable(); + + while let Some(character) = characters.next() { + let character = if character == '\r' { + if characters.peek() == Some(&'\n') { + characters.next(); + } + '\n' + } else { + character + }; + + if is_bidi_control_character(character) || is_disallowed_control_character(character) { + continue; + } + + if character == '\n' { + consecutive_newline_count += 1; + if consecutive_newline_count > 2 { + continue; + } + } else { + consecutive_newline_count = 0; + } + + sanitized_prompt.push(character); + } + + if sanitized_prompt.is_empty() { + None + } else { + Some(sanitized_prompt) + } +} + +fn is_disallowed_control_character(character: char) -> bool { + character.is_control() && !matches!(character, '\n' | '\t') +} + +fn is_bidi_control_character(character: char) -> bool { + matches!( + character, + '\u{061C}' // ALM + | '\u{200E}' // LRM + | '\u{200F}' // RLM + | '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO + | '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI + ) +} + +#[cfg(test)] +mod tests { + use super::ExternalSourcePrompt; + + #[test] + fn keeps_normal_prompt_text() { + let prompt = ExternalSourcePrompt::new("Write me a script\nThanks"); + + assert_eq!( + prompt.as_ref().map(ExternalSourcePrompt::as_str), + Some("Write me a script\nThanks") + ); + } + + #[test] + fn keeps_multilingual_text() { + let prompt = + ExternalSourcePrompt::new("日本語の依頼です。\n中文提示也应该保留。\nemoji 👩‍💻"); + + assert_eq!( + prompt.as_ref().map(ExternalSourcePrompt::as_str), + Some("日本語の依頼です。\n中文提示也应该保留。\nemoji 👩‍💻") + ); + } + + #[test] + fn collapses_newline_padding() { + let prompt = ExternalSourcePrompt::new( + "Review this prompt carefully.\n\nThis paragraph should stay separated.\n\n\n\n\n\n\nWrite me a script to do fizz buzz.", + ); + + assert_eq!( + prompt.as_ref().map(ExternalSourcePrompt::as_str), + Some( + "Review this prompt carefully.\n\nThis paragraph should stay separated.\n\nWrite me a script to do fizz buzz." + ) + ); + } + + #[test] + fn normalizes_carriage_returns() { + let prompt = ExternalSourcePrompt::new("Line one\r\nLine two\rLine three"); + + assert_eq!( + prompt.as_ref().map(ExternalSourcePrompt::as_str), + Some("Line one\nLine two\nLine three") + ); + } + + #[test] + fn strips_bidi_control_characters() { + let prompt = ExternalSourcePrompt::new("abc\u{202E}def\u{202C}ghi"); + + assert_eq!( + prompt.as_ref().map(ExternalSourcePrompt::as_str), + Some("abcdefghi") + ); + } + + #[test] + fn strips_other_control_characters() { + let prompt = ExternalSourcePrompt::new("safe\u{0000}\u{001B}\u{007F}text"); + + assert_eq!( + prompt.as_ref().map(ExternalSourcePrompt::as_str), + Some("safetext") + ); + } + + #[test] + fn keeps_tabs() { + let prompt = ExternalSourcePrompt::new("keep\tindentation"); + + assert_eq!( + prompt.as_ref().map(ExternalSourcePrompt::as_str), + Some("keep\tindentation") + ); + } + + #[test] + fn drops_empty_prompt() { + assert_eq!(ExternalSourcePrompt::new(""), None); + } + + #[test] + fn drops_prompt_with_only_removed_characters() { + assert_eq!( + ExternalSourcePrompt::new("\u{202E}\u{202C}\u{0000}\u{001B}"), + None + ); + } +} diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 062d0bd1f956b959f8ff3cabc6d4a44fbd5a3a7a..109b79ff06b6e6dff6334765050979f14b400d35 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -914,7 +914,9 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut }) .detach_and_log_err(cx); } - OpenRequestKind::AgentPanel { initial_prompt } => { + OpenRequestKind::AgentPanel { + external_source_prompt, + } => { cx.spawn(async move |cx| { let multi_workspace = workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; @@ -923,7 +925,11 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut multi_workspace.workspace().update(cx, |workspace, cx| { if let Some(panel) = workspace.focus_panel::(window, cx) { panel.update(cx, |panel, cx| { - panel.new_external_thread_with_text(initial_prompt, window, cx); + panel.new_agent_thread_with_external_source_prompt( + external_source_prompt, + window, + cx, + ); }); } }); diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index cec4da4cf819943345f66544575565a03955bfba..e8f8554482680c4a51fc182c58369de19184bcb0 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -1,5 +1,6 @@ use crate::handle_open_request; use crate::restore_or_create_workspace; +use agent_ui::ExternalSourcePrompt; use anyhow::{Context as _, Result, anyhow}; use cli::{CliRequest, CliResponse, ipc::IpcSender}; use cli::{IpcHandshake, ipc}; @@ -48,7 +49,7 @@ pub enum OpenRequestKind { extension_id: String, }, AgentPanel { - initial_prompt: Option, + external_source_prompt: Option, }, SharedAgentThread { session_id: String, @@ -164,13 +165,14 @@ impl OpenRequest { fn parse_agent_url(&mut self, agent_path: &str) { // Format: "" or "?prompt=" - let initial_prompt = agent_path.strip_prefix('?').and_then(|query| { + let external_source_prompt = agent_path.strip_prefix('?').and_then(|query| { url::form_urlencoded::parse(query.as_bytes()) .find_map(|(key, value)| (key == "prompt").then_some(value)) - .filter(|s| !s.is_empty()) - .map(|s| s.into_owned()) + .and_then(|prompt| ExternalSourcePrompt::new(prompt.as_ref())) + }); + self.kind = Some(OpenRequestKind::AgentPanel { + external_source_prompt, }); - self.kind = Some(OpenRequestKind::AgentPanel { initial_prompt }); } fn parse_git_clone_url(&mut self, clone_path: &str) -> Result<()> { @@ -788,21 +790,30 @@ mod tests { }); match request.kind { - Some(OpenRequestKind::AgentPanel { initial_prompt }) => { - assert_eq!(initial_prompt, None); + Some(OpenRequestKind::AgentPanel { + external_source_prompt, + }) => { + assert_eq!(external_source_prompt, None); } _ => panic!("Expected AgentPanel kind"), } } + fn agent_url_with_prompt(prompt: &str) -> String { + let mut serializer = url::form_urlencoded::Serializer::new("zed://agent?".to_string()); + serializer.append_pair("prompt", prompt); + serializer.finish() + } + #[gpui::test] fn test_parse_agent_url_with_prompt(cx: &mut TestAppContext) { let _app_state = init_test(cx); + let prompt = "Write me a script\nThanks"; let request = cx.update(|cx| { OpenRequest::parse( RawOpenRequest { - urls: vec!["zed://agent?prompt=Write%20me%20a%20script%0AThanks".into()], + urls: vec![agent_url_with_prompt(prompt)], ..Default::default() }, cx, @@ -811,10 +822,14 @@ mod tests { }); match request.kind { - Some(OpenRequestKind::AgentPanel { initial_prompt }) => { + Some(OpenRequestKind::AgentPanel { + external_source_prompt, + }) => { assert_eq!( - initial_prompt, - Some("Write me a script\nThanks".to_string()) + external_source_prompt + .as_ref() + .map(ExternalSourcePrompt::as_str), + Some("Write me a script\nThanks") ); } _ => panic!("Expected AgentPanel kind"), @@ -828,7 +843,7 @@ mod tests { let request = cx.update(|cx| { OpenRequest::parse( RawOpenRequest { - urls: vec!["zed://agent?prompt=".into()], + urls: vec![agent_url_with_prompt("")], ..Default::default() }, cx, @@ -837,8 +852,10 @@ mod tests { }); match request.kind { - Some(OpenRequestKind::AgentPanel { initial_prompt }) => { - assert_eq!(initial_prompt, None); + Some(OpenRequestKind::AgentPanel { + external_source_prompt, + }) => { + assert_eq!(external_source_prompt, None); } _ => panic!("Expected AgentPanel kind"), } From 3fe3776f5270a629d9ec81521594ff80a95e8181 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 6 Mar 2026 15:27:18 +0100 Subject: [PATCH 026/219] agent: Update `old_text` docs for `StreamingEditFileTool` (#50921) In the old edit agent tool we encouraged the LLM to respond with whole lines, which we did not do in the new edit file tool. Release Notes: - N/A --- crates/agent/src/tools/streaming_edit_file_tool.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index 81846ec282a52cc694a0f1c8e8418b5202d7e0d6..c326ed3c10170d1c45517103ba02e178bec32c36 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -108,6 +108,11 @@ pub enum StreamingEditFileMode { pub struct Edit { /// The exact text to find in the file. This will be matched using fuzzy matching /// to handle minor differences in whitespace or formatting. + /// + /// Always include complete lines. Do not start or end mid-line. + /// Be minimal with replacements: + /// - For unique lines, include only those lines + /// - For non-unique lines, include enough context to identify them pub old_text: String, /// The text to replace it with pub new_text: String, From db6b47ab1831298e82f0fdc6136574c97e534215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Soares?= <37777652+Dnreikronos@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:34:11 -0300 Subject: [PATCH 027/219] agent_ui: Fix agent panel focus stealing from modals (#50511) Closes #49336 Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) ### Video: https://drive.google.com/file/d/1qAwAoDr4wr8cs1dosvLocU-a4pngJZvr/view?usp=sharing Release Notes: - Fixed agent panel stealing keyboard focus from modals during workspace restoration --- crates/agent_ui/src/agent_panel.rs | 174 +++++++++++++++++++---------- crates/zed/src/zed.rs | 4 +- 2 files changed, 116 insertions(+), 62 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index bfff2e1369eb2f9441111ba48a59b3d006f3249e..917de98f7ebaab4c0a7f804b63ba54b2489258ee 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -198,6 +198,7 @@ pub fn init(cx: &mut App) { None, None, None, + true, window, cx, ) @@ -339,6 +340,7 @@ pub fn init(cx: &mut App) { blocks: content_blocks, auto_submit: true, }), + true, window, cx, ); @@ -728,7 +730,7 @@ impl AgentPanel { let agent_type = thread_info.agent_type.clone(); panel.update(cx, |panel, cx| { panel.selected_agent = agent_type; - panel.load_agent_thread(thread_info.session_id.into(), thread_info.cwd, thread_info.title.map(SharedString::from), window, cx); + panel.load_agent_thread_inner(thread_info.session_id.into(), thread_info.cwd, thread_info.title.map(SharedString::from), false, window, cx); }); } panel @@ -972,6 +974,7 @@ impl AgentPanel { cwd, title, None, + true, window, cx, ); @@ -1035,6 +1038,7 @@ impl AgentPanel { session_id: thread.session_id, title: thread.title, }), + true, window, cx, ); @@ -1090,6 +1094,7 @@ impl AgentPanel { cwd: Option, title: Option, initial_content: Option, + focus: bool, window: &mut Window, cx: &mut Context, ) { @@ -1107,64 +1112,75 @@ impl AgentPanel { let thread_store = self.thread_store.clone(); - cx.spawn_in(window, async move |this, cx| { - let ext_agent = match agent_choice { - Some(agent) => { - cx.background_spawn({ - let agent = agent.clone(); - async move { - if let Some(serialized) = - serde_json::to_string(&LastUsedExternalAgent { agent }).log_err() - { - KEY_VALUE_STORE - .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized) - .await - .log_err(); - } - } - }) - .detach(); - - agent - } - None => { - if is_via_collab { - ExternalAgent::NativeAgent - } else { - cx.background_spawn(async move { - KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY) - }) - .await - .log_err() - .flatten() - .and_then(|value| { - serde_json::from_str::(&value).log_err() - }) - .map(|agent| agent.agent) - .unwrap_or(ExternalAgent::NativeAgent) + if let Some(agent) = agent_choice { + cx.background_spawn({ + let agent = agent.clone(); + async move { + if let Some(serialized) = + serde_json::to_string(&LastUsedExternalAgent { agent }).log_err() + { + KEY_VALUE_STORE + .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized) + .await + .log_err(); } } - }; + }) + .detach(); - let server = ext_agent.server(fs, thread_store); - this.update_in(cx, |agent_panel, window, cx| { - agent_panel.create_external_thread( - server, - resume_session_id, - cwd, - title, - initial_content, - workspace, - project, - ext_agent, - window, - cx, - ); - })?; + let server = agent.server(fs, thread_store); + self.create_external_thread( + server, + resume_session_id, + cwd, + title, + initial_content, + workspace, + project, + agent, + focus, + window, + cx, + ); + } else { + cx.spawn_in(window, async move |this, cx| { + let ext_agent = if is_via_collab { + ExternalAgent::NativeAgent + } else { + cx.background_spawn(async move { + KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY) + }) + .await + .log_err() + .flatten() + .and_then(|value| { + serde_json::from_str::(&value).log_err() + }) + .map(|agent| agent.agent) + .unwrap_or(ExternalAgent::NativeAgent) + }; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); + let server = ext_agent.server(fs, thread_store); + this.update_in(cx, |agent_panel, window, cx| { + agent_panel.create_external_thread( + server, + resume_session_id, + cwd, + title, + initial_content, + workspace, + project, + ext_agent, + focus, + window, + cx, + ); + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } } fn deploy_rules_library( @@ -2007,6 +2023,7 @@ impl AgentPanel { None, None, external_source_prompt.map(AgentInitialContent::from), + true, window, cx, ); @@ -2017,6 +2034,16 @@ impl AgentPanel { agent: AgentType, window: &mut Window, cx: &mut Context, + ) { + self.new_agent_thread_inner(agent, true, window, cx); + } + + fn new_agent_thread_inner( + &mut self, + agent: AgentType, + focus: bool, + window: &mut Window, + cx: &mut Context, ) { match agent { AgentType::TextThread => { @@ -2028,6 +2055,7 @@ impl AgentPanel { None, None, None, + focus, window, cx, ), @@ -2037,6 +2065,7 @@ impl AgentPanel { None, None, None, + focus, window, cx, ), @@ -2050,9 +2079,21 @@ impl AgentPanel { title: Option, window: &mut Window, cx: &mut Context, + ) { + self.load_agent_thread_inner(session_id, cwd, title, true, window, cx); + } + + fn load_agent_thread_inner( + &mut self, + session_id: acp::SessionId, + cwd: Option, + title: Option, + focus: bool, + window: &mut Window, + cx: &mut Context, ) { if let Some(server_view) = self.background_threads.remove(&session_id) { - self.set_active_view(ActiveView::AgentThread { server_view }, true, window, cx); + self.set_active_view(ActiveView::AgentThread { server_view }, focus, window, cx); return; } @@ -2076,7 +2117,7 @@ impl AgentPanel { == Some(session_id.clone()) { let view = self.previous_view.take().unwrap(); - self.set_active_view(view, true, window, cx); + self.set_active_view(view, focus, window, cx); return; } } @@ -2084,7 +2125,16 @@ impl AgentPanel { let Some(agent) = self.selected_external_agent() else { return; }; - self.external_thread(Some(agent), Some(session_id), cwd, title, None, window, cx); + self.external_thread( + Some(agent), + Some(session_id), + cwd, + title, + None, + focus, + window, + cx, + ); } pub(crate) fn create_external_thread( @@ -2097,6 +2147,7 @@ impl AgentPanel { workspace: WeakEntity, project: Entity, ext_agent: ExternalAgent, + focus: bool, window: &mut Window, cx: &mut Context, ) { @@ -2142,7 +2193,7 @@ impl AgentPanel { }) .detach(); - self.set_active_view(ActiveView::AgentThread { server_view }, true, window, cx); + self.set_active_view(ActiveView::AgentThread { server_view }, focus, window, cx); } fn active_thread_has_messages(&self, cx: &App) -> bool { @@ -2633,6 +2684,7 @@ impl AgentPanel { None, None, Some(initial_content), + true, window, cx, ); @@ -2744,7 +2796,7 @@ impl Panel for AgentPanel { ) { let selected_agent = self.selected_agent.clone(); - self.new_agent_thread(selected_agent, window, cx); + self.new_agent_thread_inner(selected_agent, false, window, cx); } } @@ -4503,7 +4555,7 @@ impl AgentPanel { }; self.create_external_thread( - server, None, None, None, None, workspace, project, ext_agent, window, cx, + server, None, None, None, None, workspace, project, ext_agent, true, window, cx, ); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index af7c6df1f83c6621715fbbab3f665f1fdbc18c65..0cb93bbc4c903b4f3290d1da2cc2e1c2f38829e8 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -502,7 +502,9 @@ pub fn initialize_workspace( workspace.set_panels_task(panels_task); register_actions(app_state.clone(), workspace, window, cx); - workspace.focus_handle(cx).focus(window, cx); + if !workspace.has_active_modal(window, cx) { + workspace.focus_handle(cx).focus(window, cx); + } }) .detach(); } From f69ab1d04e53577b4fc5e873ebb0ec0bbd3283e2 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 6 Mar 2026 15:39:08 +0100 Subject: [PATCH 028/219] agent: Fail faster in case streaming tool call fails (#50834) If a streaming tool call (e.g. edit file) returns an error during streaming, we would wait until we received the whole input. Release Notes: - N/A --------- Co-authored-by: Ben Brandt --- crates/agent/src/tests/mod.rs | 198 ++++++++++++++++++++++++++- crates/agent/src/tests/test_tools.rs | 73 +++++++++- crates/agent/src/thread.rs | 85 +++++++++--- 3 files changed, 332 insertions(+), 24 deletions(-) diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 0993b43a13ced62000692bf2b0b35d3ab7fb68e7..79e8a5e24592d746675de670ca3288771e5eb5f4 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -3616,7 +3616,7 @@ async fn test_streaming_tool_completes_when_llm_stream_ends_without_final_input( let fake_model = model.as_fake(); thread.update(cx, |thread, _cx| { - thread.add_tool(StreamingEchoTool); + thread.add_tool(StreamingEchoTool::new()); }); let _events = thread @@ -3768,7 +3768,8 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { InfiniteTool::NAME: true, CancellationAwareTool::NAME: true, StreamingEchoTool::NAME: true, - (TerminalTool::NAME): true, + StreamingFailingEchoTool::NAME: true, + TerminalTool::NAME: true, } } } @@ -6335,3 +6336,196 @@ async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) { ); }); } + +#[gpui::test] +async fn test_streaming_tool_error_breaks_stream_loop_immediately(cx: &mut TestAppContext) { + init_test(cx); + always_allow_tools(cx); + + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread.update(cx, |thread, _cx| { + thread.add_tool(StreamingFailingEchoTool { + receive_chunks_until_failure: 1, + }); + }); + + let _events = thread + .update(cx, |thread, cx| { + thread.send( + UserMessageId::new(), + ["Use the streaming_failing_echo tool"], + cx, + ) + }) + .unwrap(); + cx.run_until_parked(); + + let tool_use = LanguageModelToolUse { + id: "call_1".into(), + name: StreamingFailingEchoTool::NAME.into(), + raw_input: "hello".into(), + input: json!({}), + is_input_complete: false, + thought_signature: None, + }; + + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone())); + + cx.run_until_parked(); + + let completions = fake_model.pending_completions(); + let last_completion = completions.last().unwrap(); + + assert_eq!( + last_completion.messages[1..], + vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec!["Use the streaming_failing_echo tool".into()], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![language_model::MessageContent::ToolUse(tool_use.clone())], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![language_model::MessageContent::ToolResult( + LanguageModelToolResult { + tool_use_id: tool_use.id.clone(), + tool_name: tool_use.name, + is_error: true, + content: "failed".into(), + output: Some("failed".into()), + } + )], + cache: true, + reasoning_details: None, + }, + ] + ); +} + +#[gpui::test] +async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut TestAppContext) { + init_test(cx); + always_allow_tools(cx); + + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let (complete_streaming_echo_tool_call_tx, complete_streaming_echo_tool_call_rx) = + oneshot::channel(); + + thread.update(cx, |thread, _cx| { + thread.add_tool( + StreamingEchoTool::new().with_wait_until_complete(complete_streaming_echo_tool_call_rx), + ); + thread.add_tool(StreamingFailingEchoTool { + receive_chunks_until_failure: 1, + }); + }); + + let _events = thread + .update(cx, |thread, cx| { + thread.send( + UserMessageId::new(), + ["Use the streaming_echo tool and the streaming_failing_echo tool"], + cx, + ) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "call_1".into(), + name: StreamingEchoTool::NAME.into(), + raw_input: "hello".into(), + input: json!({ "text": "hello" }), + is_input_complete: false, + thought_signature: None, + }, + )); + let first_tool_use = LanguageModelToolUse { + id: "call_1".into(), + name: StreamingEchoTool::NAME.into(), + raw_input: "hello world".into(), + input: json!({ "text": "hello world" }), + is_input_complete: true, + thought_signature: None, + }; + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + first_tool_use.clone(), + )); + let second_tool_use = LanguageModelToolUse { + name: StreamingFailingEchoTool::NAME.into(), + raw_input: "hello".into(), + input: json!({ "text": "hello" }), + is_input_complete: false, + thought_signature: None, + id: "call_2".into(), + }; + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + second_tool_use.clone(), + )); + + cx.run_until_parked(); + + complete_streaming_echo_tool_call_tx.send(()).unwrap(); + + cx.run_until_parked(); + + let completions = fake_model.pending_completions(); + let last_completion = completions.last().unwrap(); + + assert_eq!( + last_completion.messages[1..], + vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec![ + "Use the streaming_echo tool and the streaming_failing_echo tool".into() + ], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![ + language_model::MessageContent::ToolUse(first_tool_use.clone()), + language_model::MessageContent::ToolUse(second_tool_use.clone()) + ], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![ + language_model::MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: second_tool_use.id.clone(), + tool_name: second_tool_use.name, + is_error: true, + content: "failed".into(), + output: Some("failed".into()), + }), + language_model::MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: first_tool_use.id.clone(), + tool_name: first_tool_use.name, + is_error: false, + content: "hello world".into(), + output: Some("hello world".into()), + }), + ], + cache: true, + reasoning_details: None, + }, + ] + ); +} diff --git a/crates/agent/src/tests/test_tools.rs b/crates/agent/src/tests/test_tools.rs index ac179c590a93824813afa338d9deed16b4d00ebd..f36549a6c42f9e810c7794d8ec683613b6ae6933 100644 --- a/crates/agent/src/tests/test_tools.rs +++ b/crates/agent/src/tests/test_tools.rs @@ -2,6 +2,7 @@ use super::*; use agent_settings::AgentSettings; use gpui::{App, SharedString, Task}; use std::future; +use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -14,7 +15,22 @@ pub struct StreamingEchoToolInput { pub text: String, } -pub struct StreamingEchoTool; +pub struct StreamingEchoTool { + wait_until_complete_rx: Mutex>>, +} + +impl StreamingEchoTool { + pub fn new() -> Self { + Self { + wait_until_complete_rx: Mutex::new(None), + } + } + + pub fn with_wait_until_complete(mut self, receiver: oneshot::Receiver<()>) -> Self { + self.wait_until_complete_rx = Mutex::new(Some(receiver)); + self + } +} impl AgentTool for StreamingEchoTool { type Input = StreamingEchoToolInput; @@ -44,17 +60,72 @@ impl AgentTool for StreamingEchoTool { _event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { + let wait_until_complete_rx = self.wait_until_complete_rx.lock().unwrap().take(); cx.spawn(async move |_cx| { while input.recv_partial().await.is_some() {} let input = input .recv() .await .map_err(|e| format!("Failed to receive tool input: {e}"))?; + if let Some(rx) = wait_until_complete_rx { + rx.await.ok(); + } Ok(input.text) }) } } +/// A streaming tool that echoes its input, used to test streaming tool +/// lifecycle (e.g. partial delivery and cleanup when the LLM stream ends +/// before `is_input_complete`). +#[derive(JsonSchema, Serialize, Deserialize)] +pub struct StreamingFailingEchoToolInput { + /// The text to echo. + pub text: String, +} + +pub struct StreamingFailingEchoTool { + pub receive_chunks_until_failure: usize, +} + +impl AgentTool for StreamingFailingEchoTool { + type Input = StreamingFailingEchoToolInput; + + type Output = String; + + const NAME: &'static str = "streaming_failing_echo"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn supports_input_streaming() -> bool { + true + } + + fn initial_title( + &self, + _input: Result, + _cx: &mut App, + ) -> SharedString { + "echo".into() + } + + fn run( + self: Arc, + mut input: ToolInput, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + cx.spawn(async move |_cx| { + for _ in 0..self.receive_chunks_until_failure { + let _ = input.recv_partial().await; + } + Err("failed".into()) + }) + } +} + /// A tool that echoes its input #[derive(JsonSchema, Serialize, Deserialize)] pub struct EchoToolInput { diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 73102929ac58caaf96b06e6ab74ded698cbe86e3..e61a395e71f93d49d63d378355c89e44359db835 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1846,12 +1846,37 @@ impl Thread { Ok(events) => (events.fuse(), None), Err(err) => (stream::empty().boxed().fuse(), Some(err)), }; - let mut tool_results = FuturesUnordered::new(); + let mut tool_results: FuturesUnordered> = + FuturesUnordered::new(); + let mut early_tool_results: Vec = Vec::new(); let mut cancelled = false; loop { - // Race between getting the first event and cancellation + // Race between getting the first event, tool completion, and cancellation. let first_event = futures::select! { event = events.next().fuse() => event, + tool_result = futures::StreamExt::select_next_some(&mut tool_results) => { + let is_error = tool_result.is_error; + let is_still_streaming = this + .read_with(cx, |this, _cx| { + this.running_turn + .as_ref() + .and_then(|turn| turn.streaming_tool_inputs.get(&tool_result.tool_use_id)) + .map_or(false, |inputs| !inputs.has_received_final()) + }) + .unwrap_or(false); + + early_tool_results.push(tool_result); + + // Only break if the tool errored and we are still + // streaming the input of the tool. If the tool errored + // but we are no longer streaming its input (i.e. there + // are parallel tool calls) we want to continue + // processing those tool inputs. + if is_error && is_still_streaming { + break; + } + continue; + } _ = cancellation_rx.changed().fuse() => { if *cancellation_rx.borrow() { cancelled = true; @@ -1931,26 +1956,13 @@ impl Thread { } })?; - let end_turn = tool_results.is_empty(); - while let Some(tool_result) = tool_results.next().await { - log::debug!("Tool finished {:?}", tool_result); + let end_turn = tool_results.is_empty() && early_tool_results.is_empty(); - event_stream.update_tool_call_fields( - &tool_result.tool_use_id, - acp::ToolCallUpdateFields::new() - .status(if tool_result.is_error { - acp::ToolCallStatus::Failed - } else { - acp::ToolCallStatus::Completed - }) - .raw_output(tool_result.output.clone()), - None, - ); - this.update(cx, |this, _cx| { - this.pending_message() - .tool_results - .insert(tool_result.tool_use_id.clone(), tool_result); - })?; + for tool_result in early_tool_results { + Self::process_tool_result(this, event_stream, cx, tool_result)?; + } + while let Some(tool_result) = tool_results.next().await { + Self::process_tool_result(this, event_stream, cx, tool_result)?; } this.update(cx, |this, cx| { @@ -2004,6 +2016,33 @@ impl Thread { } } + fn process_tool_result( + this: &WeakEntity, + event_stream: &ThreadEventStream, + cx: &mut AsyncApp, + tool_result: LanguageModelToolResult, + ) -> Result<(), anyhow::Error> { + log::debug!("Tool finished {:?}", tool_result); + + event_stream.update_tool_call_fields( + &tool_result.tool_use_id, + acp::ToolCallUpdateFields::new() + .status(if tool_result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + }) + .raw_output(tool_result.output.clone()), + None, + ); + this.update(cx, |this, _cx| { + this.pending_message() + .tool_results + .insert(tool_result.tool_use_id.clone(), tool_result); + })?; + Ok(()) + } + fn handle_completion_error( &mut self, error: LanguageModelCompletionError, @@ -3072,6 +3111,10 @@ impl ToolInputSender { (sender, input) } + pub(crate) fn has_received_final(&self) -> bool { + self.final_tx.is_none() + } + pub(crate) fn send_partial(&self, value: serde_json::Value) { self.partial_tx.unbounded_send(value).ok(); } From 2d0bd7cd04ab7d03428b9cf03a476cefe5f7e08e Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 6 Mar 2026 10:03:21 -0500 Subject: [PATCH 029/219] Add more verbosity for panic inside `summaries_for_anchors_with_payloads` (#50940) We are seeing the timestamp comparison fail in an unexpected way, e.g. https://zed-dev.sentry.io/issues/7293482758/events/c7f7eab3f8f2463f879d4889a80d623e, where it seems like `text::Anchor::is_max` should be returning true but it apparently isn't. Add some more information when this panic happens to understand what's going on. Release Notes: - N/A Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> --- crates/text/src/anchor.rs | 4 ++-- crates/text/src/text.rs | 13 +++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/text/src/anchor.rs b/crates/text/src/anchor.rs index 63e0570e91ef08dfce02fbbca25e97ee7519dc0a..5c4cce0f11d7db7b7593631e796c0f5e3d50adab 100644 --- a/crates/text/src/anchor.rs +++ b/crates/text/src/anchor.rs @@ -15,8 +15,8 @@ pub struct Anchor { // we store the replica id and sequence number of the timestamp inline // to avoid the alignment of our fields from increasing the size of this struct // This saves 8 bytes, by allowing replica id, value and bias to occupy the padding - timestamp_replica_id: clock::ReplicaId, - timestamp_value: clock::Seq, + pub(crate) timestamp_replica_id: clock::ReplicaId, + pub(crate) timestamp_value: clock::Seq, /// The byte offset into the text inserted in the operation /// at `timestamp`. diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index a5bdbe443bbaa4723c8d3104bfed28e4c2fe8fdb..a991a72df40c502a90aa0b82191b37c54b3f8de2 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -2379,13 +2379,22 @@ impl BufferSnapshot { anchor ); }; + // TODO verbose debug because we are seeing is_max return false unexpectedly, + // remove this once that is understood and fixed assert_eq!( insertion.timestamp, anchor.timestamp(), - "invalid insertion for buffer {}@{:?} and anchor {:?}", + "invalid insertion for buffer {}@{:?}. anchor: {:?}, {:?}, {:?}, {:?}, {:?}. timestamp: {:?}, offset: {:?}, bias: {:?}", self.remote_id(), self.version, - anchor + anchor.timestamp_replica_id, + anchor.timestamp_value, + anchor.offset, + anchor.bias, + anchor.buffer_id, + anchor.timestamp() == clock::Lamport::MAX, + anchor.offset == u32::MAX, + anchor.bias == Bias::Right, ); fragment_cursor.seek_forward(&Some(&insertion.fragment_id), Bias::Left); From 2fd5c7b09d174398dc88f3493070d9d57da5f9ee Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:58:58 +0100 Subject: [PATCH 030/219] workspace: Fix dock/panel resizing (#50947) Before the panel resize wouldn't take into account the width of the sidebar and the right dock could push the left dock on resize too. Before you mark this PR as ready for review, make sure that you have: - [ ] 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) Co-authored-by: Cameron \ Release Notes: - N/A --- crates/workspace/src/workspace.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b94a7b1091c664502fc7dcad0f753b71951ec423..90f05d07a3a87a53ca25a1dc15da7663a95984a8 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -7085,7 +7085,17 @@ impl Workspace { } fn resize_left_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) { - let size = new_size.min(self.bounds.right() - RESIZE_HANDLE_SIZE); + let workspace_width = self.bounds.size.width; + let mut size = new_size.min(workspace_width - RESIZE_HANDLE_SIZE); + + self.right_dock.read_with(cx, |right_dock, cx| { + let right_dock_size = right_dock + .active_panel_size(window, cx) + .unwrap_or(Pixels::ZERO); + if right_dock_size + size > workspace_width { + size = workspace_width - right_dock_size + } + }); self.left_dock.update(cx, |left_dock, cx| { if WorkspaceSettings::get_global(cx) @@ -7100,13 +7110,14 @@ impl Workspace { } fn resize_right_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) { - let mut size = new_size.max(self.bounds.left() - RESIZE_HANDLE_SIZE); + let workspace_width = self.bounds.size.width; + let mut size = new_size.min(workspace_width - RESIZE_HANDLE_SIZE); self.left_dock.read_with(cx, |left_dock, cx| { let left_dock_size = left_dock .active_panel_size(window, cx) .unwrap_or(Pixels::ZERO); - if left_dock_size + size > self.bounds.right() { - size = self.bounds.right() - left_dock_size + if left_dock_size + size > workspace_width { + size = workspace_width - left_dock_size } }); self.right_dock.update(cx, |right_dock, cx| { @@ -7667,6 +7678,7 @@ impl Render for Workspace { { workspace.previous_dock_drag_coordinates = Some(e.event.position); + match e.drag(cx).0 { DockPosition::Left => { workspace.resize_left_dock( From a216f9d0e0c057e838b3853986ef2305b2f09d16 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 6 Mar 2026 17:06:35 +0100 Subject: [PATCH 031/219] crashes: Bump minidumper crate to 0.9 (#50937) Release Notes: - N/A --- Cargo.lock | 50 ++++++++++++++++++++++++----------- Cargo.toml | 2 +- crates/crashes/src/crashes.rs | 6 ++--- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5aa4d63e2b1df09c98f677a0041b057b2c891da..2b5cfff38787ce93619a904b04b38317cea2194b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5800,6 +5800,15 @@ dependencies = [ "libc", ] +[[package]] +name = "error-graph" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b920e777967421aa5f9bf34f842c0ab6ba19b3bdb4a082946093860f5858879" +dependencies = [ + "serde", +] + [[package]] name = "etagere" version = "0.2.15" @@ -6169,6 +6178,12 @@ dependencies = [ "zed_actions", ] +[[package]] +name = "failspot" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c942e64b20ecd39933d5ff938ca4fdb6ef0d298cc3855b231179a5ef0b24948d" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -7553,9 +7568,9 @@ dependencies = [ [[package]] name = "goblin" -version = "0.8.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +checksum = "daa0a64d21a7eb230583b4c5f4e23b7e4e57974f96620f42a7e75e08ae66d745" dependencies = [ "log", "plain", @@ -9685,9 +9700,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libdbus-sys" @@ -10549,9 +10564,9 @@ dependencies = [ [[package]] name = "minidump-common" -version = "0.21.2" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c4d14bcca0fd3ed165a03000480aaa364c6860c34e900cb2dafdf3b95340e77" +checksum = "2e16d10087ae9e375bad7a40e8ef5504bc08e808ccc6019067ff9de42a84570f" dependencies = [ "bitflags 2.10.0", "debugid", @@ -10564,14 +10579,16 @@ dependencies = [ [[package]] name = "minidump-writer" -version = "0.8.9" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abcd9c8a1e6e1e9d56ce3627851f39a17ea83e17c96bc510f29d7e43d78a7d" +checksum = "0e1fc14d6ded915b8e850801465e7096f77ed60bf87e4e85878d463720d9dc4d" dependencies = [ "bitflags 2.10.0", "byteorder", "cfg-if", "crash-context", + "error-graph", + "failspot", "goblin", "libc", "log", @@ -10579,18 +10596,20 @@ dependencies = [ "memmap2", "memoffset", "minidump-common", - "nix 0.28.0", + "nix 0.29.0", "procfs-core", "scroll", + "serde", + "serde_json", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.17", ] [[package]] name = "minidumper" -version = "0.8.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4ebc9d1f8847ec1d078f78b35ed598e0ebefa1f242d5f83cd8d7f03960a7d1" +checksum = "10d9254e42a48098d045472a5c0cb892007a42e25342eddbf2642f6978bf381a" dependencies = [ "cfg-if", "crash-context", @@ -10600,7 +10619,7 @@ dependencies = [ "parking_lot", "polling", "scroll", - "thiserror 1.0.69", + "thiserror 2.0.17", "uds", ] @@ -13087,12 +13106,13 @@ dependencies = [ [[package]] name = "procfs-core" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" +checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" dependencies = [ "bitflags 2.10.0", "hex", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1d0f29086b45029e4d9dbc3fa9ffd9dbb6135a6e..17dadab934b3066a352f28105814c8a7bc5988b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -596,7 +596,7 @@ lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "a4f410 mach2 = "0.5" markup5ever_rcdom = "0.3.0" metal = "0.33" -minidumper = "0.8" +minidumper = "0.9" moka = { version = "0.12.10", features = ["sync"] } naga = { version = "28.0", features = ["wgsl-in"] } nanoid = "0.4" diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index 0c848d759cd444f3eb6e2a9838d3005254a25b19..60af963ee5520addedcfe9abdf41941e77922867 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -1,7 +1,7 @@ use crash_handler::{CrashEventResult, CrashHandler}; use futures::future::BoxFuture; use log::info; -use minidumper::{Client, LoopAction, MinidumpBinary}; +use minidumper::{Client, LoopAction, MinidumpBinary, Server, SocketName}; use parking_lot::Mutex; use release_channel::{RELEASE_CHANNEL, ReleaseChannel}; use serde::{Deserialize, Serialize}; @@ -128,7 +128,7 @@ async fn connect_and_keepalive(crash_init: InitCrashHandler, handler: CrashHandl let retry_frequency = Duration::from_millis(100); let mut maybe_client = None; while maybe_client.is_none() { - if let Ok(client) = Client::with_name(socket_name.as_path()) { + if let Ok(client) = Client::with_name(SocketName::Path(&socket_name)) { maybe_client = Some(client); info!("connected to crash handler process after {elapsed:?}"); break; @@ -446,7 +446,7 @@ fn spawn_crash_handler_windows(exe: &Path, socket_name: &Path) { } pub fn crash_server(socket: &Path) { - let Ok(mut server) = minidumper::Server::with_name(socket) else { + let Ok(mut server) = Server::with_name(SocketName::Path(socket)) else { log::info!("Couldn't create socket, there may already be a running crash server"); return; }; From ba3aea0a5558424473bc57d08165a6e1d553aea6 Mon Sep 17 00:00:00 2001 From: Oliver Azevedo Barnes Date: Fri, 6 Mar 2026 16:21:44 +0000 Subject: [PATCH 032/219] agent: Don't connect to MCP servers when AI is globally disabled (#47857) Closes #46846 When `disable_ai: true` is set in user settings, Zed was still connecting to configured MCP (context) servers and sending initialization requests. This change adds checks for `DisableAiSettings` in `ContextServerStore` to: - Skip server connections when AI is disabled - Disconnect from running servers when AI becomes disabled - Connect to servers when AI is re-enabled - Prevent registry changes from triggering connections while AI is disabled The fix tracks `ai_disabled` state to detect transitions and properly manage server connections when AI is toggled. Release Notes: - Fixed Zed connecting to MCP servers when AI is disabled. --------- Co-authored-by: Bennet Bo Fenner --- crates/project/src/context_server_store.rs | 31 ++++- .../tests/integration/context_server_store.rs | 113 +++++++++++++++++- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index 88dc64fcbe8795ae4826dcaa2813744f525b9258..ed8d31ea79cc8cb8537f8cff2edbf2a899794d19 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -222,6 +222,7 @@ pub struct ContextServerStore { update_servers_task: Option>>, context_server_factory: Option, needs_server_update: bool, + ai_disabled: bool, _subscriptions: Vec, } @@ -377,23 +378,42 @@ impl ContextServerStore { cx: &mut Context, ) -> Self { let mut subscriptions = vec![cx.observe_global::(move |this, cx| { + let ai_disabled = DisableAiSettings::get_global(cx).disable_ai; + let ai_was_disabled = this.ai_disabled; + this.ai_disabled = ai_disabled; + let settings = &Self::resolve_project_settings(&this.worktree_store, cx).context_servers; - if &this.context_server_settings == settings { + let settings_changed = &this.context_server_settings != settings; + + if settings_changed { + this.context_server_settings = settings.clone(); + } + + // When AI is disabled, stop all running servers + if ai_disabled { + let server_ids: Vec<_> = this.servers.keys().cloned().collect(); + for id in server_ids { + this.stop_server(&id, cx).log_err(); + } return; } - this.context_server_settings = settings.clone(); - if maintain_server_loop { + + // Trigger updates if AI was re-enabled or settings changed + if maintain_server_loop && (ai_was_disabled || settings_changed) { this.available_context_servers_changed(cx); } })]; if maintain_server_loop { subscriptions.push(cx.observe(®istry, |this, _registry, cx| { - this.available_context_servers_changed(cx); + if !DisableAiSettings::get_global(cx).disable_ai { + this.available_context_servers_changed(cx); + } })); } + let ai_disabled = DisableAiSettings::get_global(cx).disable_ai; let mut this = Self { state, _subscriptions: subscriptions, @@ -404,12 +424,13 @@ impl ContextServerStore { project: weak_project, registry, needs_server_update: false, + ai_disabled, servers: HashMap::default(), server_ids: Default::default(), update_servers_task: None, context_server_factory, }; - if maintain_server_loop { + if maintain_server_loop && !DisableAiSettings::get_global(cx).disable_ai { this.available_context_servers_changed(cx); } this diff --git a/crates/project/tests/integration/context_server_store.rs b/crates/project/tests/integration/context_server_store.rs index 56bdaed41cd77b665d316491e051582c7ccc078a..5b68e11bb95a8b9178a8febf91849ba3a65f76e6 100644 --- a/crates/project/tests/integration/context_server_store.rs +++ b/crates/project/tests/integration/context_server_store.rs @@ -8,10 +8,11 @@ use project::context_server_store::*; use project::project_settings::ContextServerSettings; use project::worktree_store::WorktreeStore; use project::{ - FakeFs, Project, context_server_store::registry::ContextServerDescriptor, + DisableAiSettings, FakeFs, Project, context_server_store::registry::ContextServerDescriptor, project_settings::ProjectSettings, }; use serde_json::json; +use settings::settings_content::SaturatingBool; use settings::{ContextServerCommand, Settings, SettingsStore}; use std::sync::Arc; use std::{cell::RefCell, path::PathBuf, rc::Rc}; @@ -553,6 +554,116 @@ async fn test_context_server_enabled_disabled(cx: &mut TestAppContext) { } } +#[gpui::test] +async fn test_context_server_respects_disable_ai(cx: &mut TestAppContext) { + const SERVER_1_ID: &str = "mcp-1"; + + let server_1_id = ContextServerId(SERVER_1_ID.into()); + + // Set up SettingsStore with disable_ai: true in user settings BEFORE creating project + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + DisableAiSettings::register(cx); + // Set disable_ai via user settings (not override_global) so it persists through recompute_values + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content.project.disable_ai = Some(SaturatingBool(true)); + }); + }); + }); + + // Now create the project (ContextServerStore will see disable_ai = true) + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({"code.rs": ""})).await; + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + + let executor = cx.executor(); + let store = project.read_with(cx, |project, _| project.context_server_store()); + store.update(cx, |store, _| { + store.set_context_server_factory(Box::new(move |id, _| { + Arc::new(ContextServer::new( + id.clone(), + Arc::new(create_fake_transport(id.0.to_string(), executor.clone())), + )) + })); + }); + + set_context_server_configuration( + vec![( + server_1_id.0.clone(), + settings::ContextServerSettingsContent::Stdio { + enabled: true, + remote: false, + command: ContextServerCommand { + path: "somebinary".into(), + args: vec!["arg".to_string()], + env: None, + timeout: None, + }, + }, + )], + cx, + ); + + cx.run_until_parked(); + + // Verify that no server started because AI is disabled + cx.update(|cx| { + assert_eq!( + store.read(cx).status_for_server(&server_1_id), + None, + "Server should not start when disable_ai is true" + ); + }); + + // Enable AI and verify server starts + { + let _server_events = assert_server_events( + &store, + vec![ + (server_1_id.clone(), ContextServerStatus::Starting), + (server_1_id.clone(), ContextServerStatus::Running), + ], + cx, + ); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content.project.disable_ai = Some(SaturatingBool(false)); + }); + }); + }); + cx.run_until_parked(); + } + + // Disable AI again and verify server stops + { + let _server_events = assert_server_events( + &store, + vec![(server_1_id.clone(), ContextServerStatus::Stopped)], + cx, + ); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content.project.disable_ai = Some(SaturatingBool(true)); + }); + }); + }); + cx.run_until_parked(); + } + + // Verify server is stopped + cx.update(|cx| { + assert_eq!( + store.read(cx).status_for_server(&server_1_id), + Some(ContextServerStatus::Stopped), + "Server should be stopped when disable_ai is true" + ); + }); +} + #[gpui::test] async fn test_server_ids_includes_disabled_servers(cx: &mut TestAppContext) { const ENABLED_SERVER_ID: &str = "enabled-server"; From a5525a811c915f646a3db539f7ff1d1d0958c06d Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Fri, 6 Mar 2026 10:23:06 -0600 Subject: [PATCH 033/219] ep: Refresh available experiments when opening ep button (#50949) Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/edit_prediction_ui/src/edit_prediction_button.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 5e9ec08b96442dc8c10e89aa43f891e7743f85ef..dac4c812f8ac1377423f7044c1c250b5a5333f64 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -1195,6 +1195,9 @@ impl EditPredictionButton { if cx.is_staff() { if let Some(store) = EditPredictionStore::try_global(cx) { + store.update(cx, |store, cx| { + store.refresh_available_experiments(cx); + }); let store = store.read(cx); let experiments = store.available_experiments().to_vec(); let preferred = store.preferred_experiment().map(|s| s.to_owned()); From 0e83147823f99c78306f85e643b9accbcbf726f0 Mon Sep 17 00:00:00 2001 From: Wesley Weisenberger Date: Fri, 6 Mar 2026 11:23:50 -0500 Subject: [PATCH 034/219] markdown_preview: Fix slow checkbox check/uncheck UI updates (#48633) When checking a box in the markdown preview, it's generally very slow. Preview updates when typing in the editor are debounced to be every 200ms, but when clicking an element in the preview, it feels sluggish to wait that long. In debugging, I found that the debounced task from the editor event subscriptions replaced the non-debounced event on [line 605](https://github.com/zed-industries/zed/blob/263d8e58d809b493044160cb26fb690169007e4e/crates/markdown_preview/src/markdown_preview_view.rs#L600C49-L602C51), and the UI took around 200 ms to update. Therefore, I've changed the markdown parsing function to not replace the task, unless another non-debounced task comes along. UI updates from the editor are still debounced. Before: [Screencast_20260206_145702.webm](https://github.com/user-attachments/assets/fed5f8fa-866e-4291-9ec3-f876bb6dc6ab) After: [Screencast_20260206_150124.webm](https://github.com/user-attachments/assets/e4e7dc2b-d899-42ff-bd28-ad1dc5a8d3d9) Release Notes: - Improved speed at which markdown lists update after checking or unchecking items --- crates/markdown_preview/src/markdown_preview_view.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 79bd7f33290e0510df8dff908b09541717b41696..d6e4a78fd8a5366bb05ad88dcd95cc822eb86629 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -312,6 +312,10 @@ impl MarkdownPreviewView { cx: &mut Context, ) { if let Some(state) = &self.active_editor { + // if there is already a task to update the ui and the current task is also debounced (not high priority), do nothing + if wait_for_debounce && self.parsing_markdown_task.is_some() { + return; + } self.parsing_markdown_task = Some(self.parse_markdown_in_background( wait_for_debounce, state.editor.clone(), @@ -355,6 +359,7 @@ impl MarkdownPreviewView { let scroll_top = view.list_state.logical_scroll_top(); view.list_state.reset(markdown_blocks_count); view.list_state.scroll_to(scroll_top); + view.parsing_markdown_task = None; cx.notify(); }) }) From 1418af673bf0c2e8e8ce9d0c9c70b9cb08e1e305 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:26:52 +0100 Subject: [PATCH 035/219] sidebar: Improve keyboard navigation (#50938) We know outline the focus sidebar entry similar to the project panel. This allows users to see what they have selected vs active Before you mark this PR as ready for review, make sure that you have: - [ ] 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: - N/A --- crates/sidebar/src/sidebar.rs | 19 +++++++++---- crates/ui/src/components/ai/thread_item.rs | 33 ++++++++++++++++++++++ crates/ui/src/components/list/list_item.rs | 14 +++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 3bb3ea9ea44efe2cf57a4d021b0a1755ac3b3681..eec55f16af8cf7deefdb8adeddeac5b6b4fb4ea9 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -677,9 +677,15 @@ impl Sidebar { label, workspace, highlight_positions, - } => { - self.render_project_header(ix, path_list, label, workspace, highlight_positions, cx) - } + } => self.render_project_header( + ix, + path_list, + label, + workspace, + highlight_positions, + is_selected, + cx, + ), ListEntry::Thread { session_info, icon, @@ -730,6 +736,7 @@ impl Sidebar { label: &SharedString, workspace: &Entity, highlight_positions: &[usize], + is_selected: bool, cx: &mut Context, ) -> AnyElement { let id = SharedString::from(format!("project-header-{}", ix)); @@ -770,6 +777,7 @@ impl Sidebar { // TODO: if is_selected, draw a blue border around the item. ListItem::new(id) + .selection_outlined(is_selected) .group_name(&group) .toggle_state(is_active_workspace) .child( @@ -1092,7 +1100,7 @@ impl Sidebar { status: AgentThreadStatus, workspace: &Entity, highlight_positions: &[usize], - _is_selected: bool, + is_selected: bool, cx: &mut Context, ) -> AnyElement { let has_notification = self.contents.is_thread_notified(&session_info.session_id); @@ -1114,6 +1122,7 @@ impl Sidebar { .status(status) .notified(has_notification) .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) + .outlined(is_selected) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; this.activate_thread(session_info.clone(), &workspace, window, cx); @@ -1159,7 +1168,7 @@ impl Sidebar { let count = format!("({})", remaining_count); ListItem::new(id) - .toggle_state(is_selected) + .selection_outlined(is_selected) .child( h_flex() .px_1() diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 52d91e09824077738bde6be75122b0bf7b9e3d52..c8f5c8a41cdf74dae16a411b4fe3170b2be04bf3 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -24,6 +24,7 @@ pub struct ThreadItem { notified: bool, status: AgentThreadStatus, selected: bool, + outlined: bool, hovered: bool, added: Option, removed: Option, @@ -47,6 +48,7 @@ impl ThreadItem { notified: false, status: AgentThreadStatus::default(), selected: false, + outlined: false, hovered: false, added: None, removed: None, @@ -90,6 +92,11 @@ impl ThreadItem { self } + pub fn outlined(mut self, outlined: bool) -> Self { + self.outlined = outlined; + self + } + pub fn added(mut self, added: usize) -> Self { self.added = Some(added); self @@ -221,6 +228,9 @@ impl RenderOnce for ThreadItem { } }) .when(self.selected, |s| s.bg(clr.element_active)) + .border_1() + .border_color(gpui::transparent_black()) + .when(self.outlined, |s| s.border_color(clr.panel_focused_border)) .hover(|s| s.bg(clr.element_hover)) .on_hover(self.on_hover) .child( @@ -409,6 +419,29 @@ impl Component for ThreadItem { ) .into_any_element(), ), + single_example( + "Outlined Item (Keyboard Selection)", + container() + .child( + ThreadItem::new("ti-7", "Implement keyboard navigation") + .icon(IconName::AiClaude) + .timestamp("4:00 PM") + .outlined(true), + ) + .into_any_element(), + ), + single_example( + "Selected + Outlined", + container() + .child( + ThreadItem::new("ti-8", "Active and keyboard-focused thread") + .icon(IconName::AiGemini) + .timestamp("5:00 PM") + .selected(true) + .outlined(true), + ) + .into_any_element(), + ), ]; Some( diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index d581fad9453d9812f17b7bc9e0297fb9927c8188..cc9c955fd35aa33355be84f9ee3f17f27995ffaf 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -42,6 +42,7 @@ pub struct ListItem { selectable: bool, always_show_disclosure_icon: bool, outlined: bool, + selection_outlined: Option, rounded: bool, overflow_x: bool, focused: Option, @@ -71,6 +72,7 @@ impl ListItem { selectable: true, always_show_disclosure_icon: false, outlined: false, + selection_outlined: None, rounded: false, overflow_x: false, focused: None, @@ -171,6 +173,11 @@ impl ListItem { self } + pub fn selection_outlined(mut self, outlined: bool) -> Self { + self.selection_outlined = Some(outlined); + self + } + pub fn rounded(mut self) -> Self { self.rounded = true; self @@ -241,6 +248,13 @@ impl RenderOnce for ListItem { }) }) .when(self.rounded, |this| this.rounded_sm()) + .when_some(self.selection_outlined, |this, outlined| { + this.border_1() + .border_color(gpui::transparent_black()) + .when(outlined, |this| { + this.border_color(cx.theme().colors().panel_focused_border) + }) + }) .when_some(self.on_hover, |this, on_hover| this.on_hover(on_hover)) .child( h_flex() From a3d9269567a08fb46111a6d59c26e6ce10dbb071 Mon Sep 17 00:00:00 2001 From: Antoine Mathie Date: Fri, 6 Mar 2026 17:49:55 +0100 Subject: [PATCH 036/219] ai: Add LMStudio API URL & API key support (#48309) Hello, This pull request aims to improve usage of lmstudio ai provider for remote lmstudio nodes and support api key authentication. This has been tested on my local network from a headless lms node. See attached demo vid Release Notes: - lmstudio: Added support for specifying an API key via the UI https://github.com/user-attachments/assets/7594cf49-3198-4171-b3e9-c3264cf35b6e --------- Co-authored-by: Bennet Bo Fenner --- .../language_models/src/provider/lmstudio.rs | 456 +++++++++++++----- crates/lmstudio/src/lmstudio.rs | 21 +- crates/settings_content/src/language_model.rs | 1 + 3 files changed, 357 insertions(+), 121 deletions(-) diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 9af8559c722d1fe726f7f871c9863cd85a3d2678..ee08f1689aeea9cfa18346108cd2d314b2259583 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -1,26 +1,30 @@ use anyhow::{Result, anyhow}; use collections::HashMap; +use fs::Fs; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task}; +use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Subscription, Task}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, - StopReason, TokenUsage, + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolUse, MessageContent, StopReason, TokenUsage, env_var, }; use language_model::{ - IconOrSvg, LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, RateLimiter, Role, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, RateLimiter, Role, }; -use lmstudio::{ModelType, get_models}; +use lmstudio::{LMSTUDIO_API_URL, ModelType, get_models}; + pub use settings::LmStudioAvailableModel as AvailableModel; -use settings::{Settings, SettingsStore}; +use settings::{Settings, SettingsStore, update_settings_file}; use std::pin::Pin; +use std::sync::LazyLock; use std::{collections::BTreeMap, sync::Arc}; -use ui::{ButtonLike, Indicator, List, ListBulletItem, prelude::*}; -use util::ResultExt; +use ui::{ + ButtonLike, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip, prelude::*, +}; +use ui_input::InputField; use crate::AllLanguageModelSettings; use crate::provider::util::parse_tool_arguments; @@ -32,6 +36,9 @@ const LMSTUDIO_SITE: &str = "https://lmstudio.ai/"; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("lmstudio"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("LM Studio"); +const API_KEY_ENV_VAR_NAME: &str = "LMSTUDIO_API_KEY"; +static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); + #[derive(Default, Debug, Clone, PartialEq)] pub struct LmStudioSettings { pub api_url: String, @@ -44,6 +51,7 @@ pub struct LmStudioLanguageModelProvider { } pub struct State { + api_key_state: ApiKeyState, http_client: Arc, available_models: Vec, fetch_model_task: Option>>, @@ -55,14 +63,25 @@ impl State { !self.available_models.is_empty() } + fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let api_url = LmStudioLanguageModelProvider::api_url(cx).into(); + let task = self + .api_key_state + .store(api_url, api_key, |this| &mut this.api_key_state, cx); + self.restart_fetch_models_task(cx); + task + } + fn fetch_models(&mut self, cx: &mut Context) -> Task> { let settings = &AllLanguageModelSettings::get_global(cx).lmstudio; let http_client = self.http_client.clone(); let api_url = settings.api_url.clone(); + let api_key = self.api_key_state.key(&api_url); // As a proxy for the server being "authenticated", we'll check if its up by fetching the models cx.spawn(async move |this, cx| { - let models = get_models(http_client.as_ref(), &api_url, None).await?; + let models = + get_models(http_client.as_ref(), &api_url, api_key.as_deref(), None).await?; let mut models: Vec = models .into_iter() @@ -95,6 +114,11 @@ impl State { } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let api_url = LmStudioLanguageModelProvider::api_url(cx).into(); + let _task = self + .api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx); + if self.is_authenticated() { return Task::ready(Ok(())); } @@ -145,6 +169,10 @@ impl LmStudioLanguageModelProvider { }); State { + api_key_state: ApiKeyState::new( + Self::api_url(cx).into(), + (*API_KEY_ENV_VAR).clone(), + ), http_client, available_models: Default::default(), fetch_model_task: None, @@ -156,6 +184,17 @@ impl LmStudioLanguageModelProvider { .update(cx, |state, cx| state.restart_fetch_models_task(cx)); this } + + fn api_url(cx: &App) -> String { + AllLanguageModelSettings::get_global(cx) + .lmstudio + .api_url + .clone() + } + + fn has_custom_url(cx: &App) -> bool { + Self::api_url(cx) != LMSTUDIO_API_URL + } } impl LanguageModelProviderState for LmStudioLanguageModelProvider { @@ -225,6 +264,7 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider { model, http_client: self.http_client.clone(), request_limiter: RateLimiter::new(4), + state: self.state.clone(), }) as Arc }) .collect() @@ -244,12 +284,13 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider { _window: &mut Window, cx: &mut App, ) -> AnyView { - let state = self.state.clone(); - cx.new(|cx| ConfigurationView::new(state, cx)).into() + cx.new(|cx| ConfigurationView::new(self.state.clone(), _window, cx)) + .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state.update(cx, |state, cx| state.fetch_models(cx)) + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) } } @@ -258,6 +299,7 @@ pub struct LmStudioLanguageModel { model: lmstudio::Model, http_client: Arc, request_limiter: RateLimiter, + state: Entity, } impl LmStudioLanguageModel { @@ -376,15 +418,20 @@ impl LmStudioLanguageModel { Result>>, > { let http_client = self.http_client.clone(); - let api_url = cx.update(|cx| { - let settings = &AllLanguageModelSettings::get_global(cx).lmstudio; - settings.api_url.clone() + let (api_key, api_url) = self.state.read_with(cx, |state, cx| { + let api_url = LmStudioLanguageModelProvider::api_url(cx); + (state.api_key_state.key(&api_url), api_url) }); let future = self.request_limiter.stream(async move { - let request = lmstudio::stream_chat_completion(http_client.as_ref(), &api_url, request); - let response = request.await?; - Ok(response) + let stream = lmstudio::stream_chat_completion( + http_client.as_ref(), + &api_url, + api_key.as_deref(), + request, + ) + .await?; + Ok(stream) }); async move { Ok(future.await?.boxed()) }.boxed() @@ -634,53 +681,212 @@ fn add_message_content_part( struct ConfigurationView { state: Entity, - loading_models_task: Option>, + api_key_editor: Entity, + api_url_editor: Entity, } impl ConfigurationView { - pub fn new(state: Entity, cx: &mut Context) -> Self { - let loading_models_task = Some(cx.spawn({ - let state = state.clone(); - async move |this, cx| { - state - .update(cx, |state, cx| state.authenticate(cx)) - .await - .log_err(); - - this.update(cx, |this, cx| { - this.loading_models_task = None; - cx.notify(); - }) - .log_err(); - } - })); + pub fn new(state: Entity, _window: &mut Window, cx: &mut Context) -> Self { + let api_key_editor = cx.new(|cx| InputField::new(_window, cx, "sk-...").label("API key")); + + let api_url_editor = cx.new(|cx| { + let input = InputField::new(_window, cx, LMSTUDIO_API_URL).label("API URL"); + input.set_text(&LmStudioLanguageModelProvider::api_url(cx), _window, cx); + input + }); + + cx.observe(&state, |_, _, cx| { + cx.notify(); + }) + .detach(); Self { state, - loading_models_task, + api_key_editor, + api_url_editor, } } - fn retry_connection(&self, cx: &mut App) { + fn retry_connection(&mut self, _window: &mut Window, cx: &mut Context) { + let has_api_url = LmStudioLanguageModelProvider::has_custom_url(cx); + let has_api_key = self + .state + .read_with(cx, |state, _| state.api_key_state.has_key()); + if !has_api_url { + self.save_api_url(cx); + } + if !has_api_key { + self.save_api_key(&Default::default(), _window, cx); + } + + self.state.update(cx, |state, cx| { + state.restart_fetch_models_task(cx); + }); + } + + fn save_api_key(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); + if api_key.is_empty() { + return; + } + + self.api_key_editor + .update(cx, |input, cx| input.set_text("", _window, cx)); + + let state = self.state.clone(); + cx.spawn_in(_window, async move |_, cx| { + state + .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) + .await + }) + .detach_and_log_err(cx); + } + + fn reset_api_key(&mut self, _window: &mut Window, cx: &mut Context) { + self.api_key_editor + .update(cx, |input, cx| input.set_text("", _window, cx)); + + let state = self.state.clone(); + cx.spawn_in(_window, async move |_, cx| { + state + .update(cx, |state, cx| state.set_api_key(None, cx)) + .await + }) + .detach_and_log_err(cx); + + cx.notify(); + } + + fn save_api_url(&self, cx: &mut Context) { + let api_url = self.api_url_editor.read(cx).text(cx).trim().to_string(); + let current_url = LmStudioLanguageModelProvider::api_url(cx); + if !api_url.is_empty() && &api_url != ¤t_url { + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) + .detach_and_log_err(cx); + + let fs = ::global(cx); + update_settings_file(fs, cx, move |settings, _| { + settings + .language_models + .get_or_insert_default() + .lmstudio + .get_or_insert_default() + .api_url = Some(api_url); + }); + } + } + + fn reset_api_url(&mut self, _window: &mut Window, cx: &mut Context) { + self.api_url_editor + .update(cx, |input, cx| input.set_text("", _window, cx)); + + // Clear API key when URL changes since keys are URL-specific self.state - .update(cx, |state, cx| state.fetch_models(cx)) + .update(cx, |state, cx| state.set_api_key(None, cx)) .detach_and_log_err(cx); - } -} -impl Render for ConfigurationView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let is_authenticated = self.state.read(cx).is_authenticated(); + let fs = ::global(cx); + update_settings_file(fs, cx, |settings, _cx| { + if let Some(settings) = settings + .language_models + .as_mut() + .and_then(|models| models.lmstudio.as_mut()) + { + settings.api_url = Some(LMSTUDIO_API_URL.into()); + } + }); + cx.notify(); + } - let lmstudio_intro = "Run local LLMs like Llama, Phi, and Qwen."; + fn render_api_url_editor(&self, cx: &Context) -> impl IntoElement { + let api_url = LmStudioLanguageModelProvider::api_url(cx); + let custom_api_url_set = api_url != LMSTUDIO_API_URL; - if self.loading_models_task.is_some() { - div().child(Label::new("Loading models...")).into_any() + if custom_api_url_set { + h_flex() + .p_3() + .justify_between() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().elevated_surface_background) + .child( + h_flex() + .gap_2() + .child(Icon::new(IconName::Check).color(Color::Success)) + .child(v_flex().gap_1().child(Label::new(api_url))), + ) + .child( + Button::new("reset-api-url", "Reset API URL") + .label_size(LabelSize::Small) + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .layer(ElevationIndex::ModalSurface) + .on_click( + cx.listener(|this, _, _window, cx| this.reset_api_url(_window, cx)), + ), + ) + .into_any_element() } else { v_flex() + .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| { + this.save_api_url(cx); + cx.notify(); + })) .gap_2() + .child(self.api_url_editor.clone()) + .into_any_element() + } + } + + fn render_api_key_editor(&self, cx: &Context) -> impl IntoElement { + let state = self.state.read(cx); + let env_var_set = state.api_key_state.is_from_env_var(); + let configured_card_label = if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable.") + } else { + "API key configured".to_string() + }; + + if !state.api_key_state.has_key() { + v_flex() + .on_action(cx.listener(Self::save_api_key)) + .child(self.api_key_editor.clone()) .child( - v_flex().gap_1().child(Label::new(lmstudio_intro)).child( + Label::new(format!( + "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element() + } else { + ConfiguredApiCard::new(configured_card_label) + .disabled(env_var_set) + .on_click(cx.listener(|this, _, _window, cx| this.reset_api_key(_window, cx))) + .when(env_var_set, |this| { + this.tooltip_label(format!( + "To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable." + )) + }) + .into_any_element() + } + } +} + +impl Render for ConfigurationView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_authenticated = self.state.read(cx).is_authenticated(); + + v_flex() + .gap_2() + .child( + v_flex() + .gap_1() + .child(Label::new("Run local LLMs like Llama, Phi, and Qwen.")) + .child( List::new() .child(ListBulletItem::new( "LM Studio needs to be running with at least one model downloaded.", @@ -690,86 +896,100 @@ impl Render for ConfigurationView { .child(Label::new("To get your first model, try running")) .child(Label::new("lms get qwen2.5-coder-7b").inline_code(cx)), ), - ), - ) - .child( - h_flex() - .w_full() - .justify_between() - .gap_2() - .child( - h_flex() - .w_full() - .gap_2() - .map(|this| { - if is_authenticated { - this.child( - Button::new("lmstudio-site", "LM Studio") - .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .on_click(move |_, _window, cx| { - cx.open_url(LMSTUDIO_SITE) - }) - .into_any_element(), - ) - } else { - this.child( - Button::new( - "download_lmstudio_button", - "Download LM Studio", - ) + ) + .child(Label::new( + "Alternatively, you can connect to an LM Studio server by specifying its \ + URL and API key (may not be required):", + )), + ) + .child(self.render_api_url_editor(cx)) + .child(self.render_api_key_editor(cx)) + .child( + h_flex() + .w_full() + .justify_between() + .gap_2() + .child( + h_flex() + .w_full() + .gap_2() + .map(|this| { + if is_authenticated { + this.child( + Button::new("lmstudio-site", "LM Studio") .style(ButtonStyle::Subtle) .icon(IconName::ArrowUpRight) .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(move |_, _window, cx| { - cx.open_url(LMSTUDIO_DOWNLOAD_URL) + cx.open_url(LMSTUDIO_SITE) }) .into_any_element(), + ) + } else { + this.child( + Button::new( + "download_lmstudio_button", + "Download LM Studio", ) - } - }) - .child( - Button::new("view-models", "Model Catalog") .style(ButtonStyle::Subtle) .icon(IconName::ArrowUpRight) .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(move |_, _window, cx| { - cx.open_url(LMSTUDIO_CATALOG_URL) - }), - ), - ) - .map(|this| { - if is_authenticated { - this.child( - ButtonLike::new("connected") - .disabled(true) - .cursor_style(gpui::CursorStyle::Arrow) - .child( - h_flex() - .gap_2() - .child(Indicator::dot().color(Color::Success)) - .child(Label::new("Connected")) - .into_any_element(), - ), - ) - } else { - this.child( - Button::new("retry_lmstudio_models", "Connect") - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .icon(IconName::PlayFilled) - .on_click(cx.listener(move |this, _, _window, cx| { - this.retry_connection(cx) - })), - ) - } - }), - ) - .into_any() - } + cx.open_url(LMSTUDIO_DOWNLOAD_URL) + }) + .into_any_element(), + ) + } + }) + .child( + Button::new("view-models", "Model Catalog") + .style(ButtonStyle::Subtle) + .icon(IconName::ArrowUpRight) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .on_click(move |_, _window, cx| { + cx.open_url(LMSTUDIO_CATALOG_URL) + }), + ), + ) + .map(|this| { + if is_authenticated { + this.child( + ButtonLike::new("connected") + .disabled(true) + .cursor_style(CursorStyle::Arrow) + .child( + h_flex() + .gap_2() + .child(Icon::new(IconName::Check).color(Color::Success)) + .child(Label::new("Connected")) + .into_any_element(), + ) + .child( + IconButton::new("refresh-models", IconName::RotateCcw) + .tooltip(Tooltip::text("Refresh Models")) + .on_click(cx.listener(|this, _, _window, cx| { + this.state.update(cx, |state, _| { + state.available_models.clear(); + }); + this.retry_connection(_window, cx); + })), + ), + ) + } else { + this.child( + Button::new("retry_lmstudio_models", "Connect") + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon(IconName::PlayFilled) + .on_click(cx.listener(move |this, _, _window, cx| { + this.retry_connection(_window, cx) + })), + ) + } + }), + ) } } diff --git a/crates/lmstudio/src/lmstudio.rs b/crates/lmstudio/src/lmstudio.rs index ef2f7b6208f62e079609049b8eff83a80034741e..8a44b7fdefe5262d955606b0413b2b2425014296 100644 --- a/crates/lmstudio/src/lmstudio.rs +++ b/crates/lmstudio/src/lmstudio.rs @@ -354,14 +354,19 @@ pub struct ResponseMessageDelta { pub async fn complete( client: &dyn HttpClient, api_url: &str, + api_key: Option<&str>, request: ChatCompletionRequest, ) -> Result { let uri = format!("{api_url}/chat/completions"); - let request_builder = HttpRequest::builder() + let mut request_builder = HttpRequest::builder() .method(Method::POST) .uri(uri) .header("Content-Type", "application/json"); + if let Some(api_key) = api_key { + request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key)); + } + let serialized_request = serde_json::to_string(&request)?; let request = request_builder.body(AsyncBody::from(serialized_request))?; @@ -386,14 +391,19 @@ pub async fn complete( pub async fn stream_chat_completion( client: &dyn HttpClient, api_url: &str, + api_key: Option<&str>, request: ChatCompletionRequest, ) -> Result>> { let uri = format!("{api_url}/chat/completions"); - let request_builder = http::Request::builder() + let mut request_builder = http::Request::builder() .method(Method::POST) .uri(uri) .header("Content-Type", "application/json"); + if let Some(api_key) = api_key { + request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key)); + } + let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?; let mut response = client.send(request).await?; if response.status().is_success() { @@ -434,14 +444,19 @@ pub async fn stream_chat_completion( pub async fn get_models( client: &dyn HttpClient, api_url: &str, + api_key: Option<&str>, _: Option, ) -> Result> { let uri = format!("{api_url}/models"); - let request_builder = HttpRequest::builder() + let mut request_builder = HttpRequest::builder() .method(Method::GET) .uri(uri) .header("Accept", "application/json"); + if let Some(api_key) = api_key { + request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key)); + } + let request = request_builder.body(AsyncBody::default())?; let mut response = client.send(request).await?; diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index 6af419119d819931f3ad826ff416f1b47c89824f..8ced6e0b487a673ff4dba34cae9c1e2c7ee45d13 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -148,6 +148,7 @@ impl Default for KeepAlive { #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] pub struct LmStudioSettingsContent { pub api_url: Option, + pub api_key: Option, pub available_models: Option>, } From 21e202ef0c534b8810603052e923b1a08aa63ce7 Mon Sep 17 00:00:00 2001 From: Kyle Kelley Date: Fri, 6 Mar 2026 09:04:23 -0800 Subject: [PATCH 037/219] repl: Switch to util::process::Child to rely on process groups (#48839) Follow up to https://github.com/zed-industries/zed/pull/48760 thanks to @miguelraz and @reflectronic. No new notes since #48760 did the same thing, only wasn't opting in to process groups we already had in place. Release Notes: - N/A --- crates/repl/src/kernels/native_kernel.rs | 27 ++++++++++++------------ 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/repl/src/kernels/native_kernel.rs b/crates/repl/src/kernels/native_kernel.rs index daefe99fef81b26f9bb9977a70075285fb4b4821..d7ee106cab6f1769b42e6958a69e39bffec44b3a 100644 --- a/crates/repl/src/kernels/native_kernel.rs +++ b/crates/repl/src/kernels/native_kernel.rs @@ -19,7 +19,7 @@ use std::{ path::PathBuf, sync::Arc, }; -use util::command::Command; + use uuid::Uuid; use super::{KernelSession, RunningKernel, start_kernel_tasks}; @@ -41,7 +41,7 @@ impl Eq for LocalKernelSpecification {} impl LocalKernelSpecification { #[must_use] - fn command(&self, connection_path: &PathBuf) -> Result { + fn command(&self, connection_path: &PathBuf) -> Result { let argv = &self.kernelspec.argv; anyhow::ensure!(!argv.is_empty(), "Empty argv in kernelspec {}", self.name); @@ -52,7 +52,7 @@ impl LocalKernelSpecification { self.name ); - let mut cmd = util::command::new_command(&argv[0]); + let mut cmd = util::command::new_std_command(&argv[0]); for arg in &argv[1..] { if arg == "{connection_file}" { @@ -91,7 +91,7 @@ async fn peek_ports(ip: IpAddr) -> Result<[u16; 5]> { } pub struct NativeRunningKernel { - pub process: util::command::Child, + pub process: util::process::Child, connection_path: PathBuf, _process_status_task: Option>, pub working_directory: PathBuf, @@ -104,7 +104,7 @@ pub struct NativeRunningKernel { impl Debug for NativeRunningKernel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RunningKernel") - .field("process", &self.process) + .field("process", &*self.process) .finish() } } @@ -146,15 +146,14 @@ impl NativeRunningKernel { fs.atomic_write(connection_path.clone(), content).await?; let mut cmd = kernel_specification.command(&connection_path)?; - - let mut process = cmd - .current_dir(&working_directory) - .stdout(util::command::Stdio::piped()) - .stderr(util::command::Stdio::piped()) - .stdin(util::command::Stdio::piped()) - .kill_on_drop(true) - .spawn() - .context("failed to start the kernel process")?; + cmd.current_dir(&working_directory); + + let mut process = util::process::Child::spawn( + cmd, + std::process::Stdio::piped(), + std::process::Stdio::piped(), + std::process::Stdio::piped(), + )?; let session_id = Uuid::new_v4().to_string(); From dc0e41f8342537732887d2e10b8dedad1e9d59bd Mon Sep 17 00:00:00 2001 From: Neel Date: Fri, 6 Mar 2026 19:15:21 +0000 Subject: [PATCH 038/219] Refresh LLM API token on organization change (#50931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit client-side organization changed events through `RefreshLlmTokenListener` so it produces the same `RefreshLlmTokenEvent` used for server-pushed `UserUpdated` messages. This keeps token refresh fan-out in one place. Closes CLO-383. Release Notes: - N/A --------- Co-authored-by: Tom Houlé --- crates/agent/src/edit_agent/evals.rs | 2 +- crates/agent/src/tests/mod.rs | 4 +-- crates/agent_servers/src/e2e_tests.rs | 4 ++- crates/agent_ui/src/inline_assistant.rs | 2 +- crates/client/src/user.rs | 18 +++++++++++-- crates/edit_prediction/src/capture_example.rs | 2 +- .../src/edit_prediction_tests.rs | 9 ++++--- crates/edit_prediction_cli/src/headless.rs | 2 +- crates/eval/src/eval.rs | 2 +- crates/eval_cli/src/headless.rs | 2 +- crates/language_model/src/language_model.rs | 7 +++--- .../language_model/src/model/cloud_model.rs | 25 ++++++++++++++----- crates/title_bar/src/title_bar.rs | 4 +-- crates/zed/src/main.rs | 2 +- crates/zed/src/visual_test_runner.rs | 2 +- crates/zed/src/zed.rs | 2 +- .../zed/src/zed/edit_prediction_registry.rs | 2 +- 17 files changed, 61 insertions(+), 30 deletions(-) diff --git a/crates/agent/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs index 2e8818b101995b374cf8172547c45b55c27c6f26..e7b67e37bf4a8b71664a78b99b757c6985794ec6 100644 --- a/crates/agent/src/edit_agent/evals.rs +++ b/crates/agent/src/edit_agent/evals.rs @@ -1423,7 +1423,7 @@ impl EditAgentTest { let client = Client::production(cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); settings::init(cx); - language_model::init(client.clone(), cx); + language_model::init(user_store.clone(), client.clone(), cx); language_models::init(user_store, client.clone(), cx); }); diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 79e8a5e24592d746675de670ca3288771e5eb5f4..d33c80a435e84359976d4d8a9edb2bdebd66e0ff 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -3167,7 +3167,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let clock = Arc::new(clock::FakeSystemClock::new()); let client = Client::new(clock, http_client, cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(client.clone(), cx); + language_model::init(user_store.clone(), client.clone(), cx); language_models::init(user_store, client.clone(), cx); LanguageModelRegistry::test(cx); }); @@ -3791,7 +3791,7 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { cx.set_http_client(Arc::new(http_client)); let client = Client::production(cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(client.clone(), cx); + language_model::init(user_store.clone(), client.clone(), cx); language_models::init(user_store, client.clone(), cx); } }; diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index c5754bcd7610dbf0c858058ea726a746bef37ab1..a0150d41726c94dc830be70e006f4370de919ead 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -2,6 +2,7 @@ use crate::{AgentServer, AgentServerDelegate}; use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; use agent_client_protocol as acp; use futures::{FutureExt, StreamExt, channel::mpsc, select}; +use gpui::AppContext; use gpui::{Entity, TestAppContext}; use indoc::indoc; use project::{FakeFs, Project}; @@ -408,7 +409,8 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { let http_client = reqwest_client::ReqwestClient::user_agent("agent tests").unwrap(); cx.set_http_client(Arc::new(http_client)); let client = client::Client::production(cx); - language_model::init(client, cx); + let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx)); + language_model::init(user_store, client, cx); #[cfg(test)] project::agent_server_store::AllAgentServersSettings::override_global( diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 9ac84addcc80c806739570ad9951209f16c31bb1..4e7eecfe07aac84269cb1d325cc5a95943578863 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -2120,7 +2120,7 @@ pub mod test { client::init(&client, cx); workspace::init(app_state.clone(), cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(client.clone(), cx); + language_model::init(user_store.clone(), client.clone(), cx); language_models::init(user_store, client.clone(), cx); cx.set_global(inline_assistant); diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index d27bf3387a7c8406885f078eef82be694dfa5dfa..5d38569cfd86c38e5b4780621db40d1f2a3b745c 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -140,6 +140,7 @@ pub enum Event { ParticipantIndicesChanged, PrivateUserInfoUpdated, PlanUpdated, + OrganizationChanged, } #[derive(Clone, Copy)] @@ -694,8 +695,21 @@ impl UserStore { self.current_organization.clone() } - pub fn set_current_organization(&mut self, organization: Arc) { - self.current_organization.replace(organization); + pub fn set_current_organization( + &mut self, + organization: Arc, + cx: &mut Context, + ) { + let is_same_organization = self + .current_organization + .as_ref() + .is_some_and(|current| current.id == organization.id); + + if !is_same_organization { + self.current_organization.replace(organization); + cx.emit(Event::OrganizationChanged); + cx.notify(); + } } pub fn organizations(&self) -> &Vec> { diff --git a/crates/edit_prediction/src/capture_example.rs b/crates/edit_prediction/src/capture_example.rs index 0fbece7478068d26c0c1a8accf7e93aba8c83b9c..e0df8cf957747256f86fe5d7f0d63d2ec873d9ca 100644 --- a/crates/edit_prediction/src/capture_example.rs +++ b/crates/edit_prediction/src/capture_example.rs @@ -533,8 +533,8 @@ mod tests { zlog::init_test(); let http_client = FakeHttpClient::with_404_response(); let client = Client::new(Arc::new(FakeSystemClock::new()), http_client, cx); - language_model::init(client.clone(), cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + language_model::init(user_store.clone(), client.clone(), cx); EditPredictionStore::global(&client, &user_store, cx); }) } diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index bbad3c104e6f84f30c7906ba310df132ee66191e..1ff77fd900db80894b973e79d8fe69e9d65a1e3b 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1850,9 +1850,8 @@ fn init_test_with_fake_client( let client = client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx); client.cloud_client().set_credentials(1, "test".into()); - language_model::init(client.clone(), cx); - let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + language_model::init(user_store.clone(), client.clone(), cx); let ep_store = EditPredictionStore::global(&client, &user_store, cx); ( @@ -2218,8 +2217,9 @@ async fn make_test_ep_store( }); let client = cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + let user_store = cx.update(|cx| cx.new(|cx| client::UserStore::new(client.clone(), cx))); cx.update(|cx| { - RefreshLlmTokenListener::register(client.clone(), cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); }); let _server = FakeServer::for_client(42, &client, cx).await; @@ -2301,8 +2301,9 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut let client = cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + let user_store = cx.update(|cx| cx.new(|cx| client::UserStore::new(client.clone(), cx))); cx.update(|cx| { - language_model::RefreshLlmTokenListener::register(client.clone(), cx); + language_model::RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); }); let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx)); diff --git a/crates/edit_prediction_cli/src/headless.rs b/crates/edit_prediction_cli/src/headless.rs index f78903b705a4718e31b59e56d3aa281004395d64..eb2895b06f2ea34bb96b1d16ef0bbd075b78aaf5 100644 --- a/crates/edit_prediction_cli/src/headless.rs +++ b/crates/edit_prediction_cli/src/headless.rs @@ -105,7 +105,7 @@ pub fn init(cx: &mut App) -> EpAppState { debug_adapter_extension::init(extension_host_proxy.clone(), cx); language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone()); - language_model::init(client.clone(), cx); + language_model::init(user_store.clone(), client.clone(), cx); language_models::init(user_store.clone(), client.clone(), cx); languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx); prompt_store::init(cx); diff --git a/crates/eval/src/eval.rs b/crates/eval/src/eval.rs index 4e9a0cb7915d8369c7989ca332a01ff12f86cefe..a621cb0dedb3f7cea512329829f7c99bc8803d41 100644 --- a/crates/eval/src/eval.rs +++ b/crates/eval/src/eval.rs @@ -429,7 +429,7 @@ pub fn init(cx: &mut App) -> Arc { let extension_host_proxy = ExtensionHostProxy::global(cx); debug_adapter_extension::init(extension_host_proxy.clone(), cx); language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone()); - language_model::init(client.clone(), cx); + language_model::init(user_store.clone(), client.clone(), cx); language_models::init(user_store.clone(), client.clone(), cx); languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx); prompt_store::init(cx); diff --git a/crates/eval_cli/src/headless.rs b/crates/eval_cli/src/headless.rs index 1448cbeb7a724b2b4dfdb1cbba430dcc3cdfd5b5..54f14ee1938d4b58bdc32acbd07eced8d8a86406 100644 --- a/crates/eval_cli/src/headless.rs +++ b/crates/eval_cli/src/headless.rs @@ -104,7 +104,7 @@ pub fn init(cx: &mut App) -> Arc { let extension_host_proxy = ExtensionHostProxy::global(cx); debug_adapter_extension::init(extension_host_proxy.clone(), cx); language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone()); - language_model::init(client.clone(), cx); + language_model::init(user_store.clone(), client.clone(), cx); language_models::init(user_store.clone(), client.clone(), cx); languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx); prompt_store::init(cx); diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index c403774499c9dcb384e93cf19367dc28e336aa60..0452c494a2ae0ce43d59de5ef26a75231249c642 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -13,10 +13,11 @@ pub mod fake_provider; use anthropic::{AnthropicError, parse_prompt_too_long}; use anyhow::{Result, anyhow}; use client::Client; +use client::UserStore; use cloud_llm_client::CompletionRequestStatus; use futures::FutureExt; use futures::{StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, SharedString, Task, Window}; +use gpui::{AnyView, App, AsyncApp, Entity, SharedString, Task, Window}; use http_client::{StatusCode, http}; use icons::IconName; use open_router::OpenRouterError; @@ -61,9 +62,9 @@ pub const ZED_CLOUD_PROVIDER_ID: LanguageModelProviderId = LanguageModelProvider pub const ZED_CLOUD_PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Zed"); -pub fn init(client: Arc, cx: &mut App) { +pub fn init(user_store: Entity, client: Arc, cx: &mut App) { init_settings(cx); - RefreshLlmTokenListener::register(client, cx); + RefreshLlmTokenListener::register(client, user_store, cx); } pub fn init_settings(cx: &mut App) { diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index b2af80a3c295cab1cf40a330eb8d84f94a137eb7..e64cc43edd8eef6cfaf0c6c966365c81d37b611c 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -3,11 +3,14 @@ use std::sync::Arc; use anyhow::{Context as _, Result}; use client::Client; +use client::UserStore; use cloud_api_client::ClientApiError; use cloud_api_types::OrganizationId; use cloud_api_types::websocket_protocol::MessageToClient; use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, OUTDATED_LLM_TOKEN_HEADER_NAME}; -use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _}; +use gpui::{ + App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _, Subscription, +}; use smol::lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard}; use thiserror::Error; @@ -101,13 +104,15 @@ impl Global for GlobalRefreshLlmTokenListener {} pub struct RefreshLlmTokenEvent; -pub struct RefreshLlmTokenListener; +pub struct RefreshLlmTokenListener { + _subscription: Subscription, +} impl EventEmitter for RefreshLlmTokenListener {} impl RefreshLlmTokenListener { - pub fn register(client: Arc, cx: &mut App) { - let listener = cx.new(|cx| RefreshLlmTokenListener::new(client, cx)); + pub fn register(client: Arc, user_store: Entity, cx: &mut App) { + let listener = cx.new(|cx| RefreshLlmTokenListener::new(client, user_store, cx)); cx.set_global(GlobalRefreshLlmTokenListener(listener)); } @@ -115,7 +120,7 @@ impl RefreshLlmTokenListener { GlobalRefreshLlmTokenListener::global(cx).0.clone() } - fn new(client: Arc, cx: &mut Context) -> Self { + fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { client.add_message_to_client_handler({ let this = cx.entity(); move |message, cx| { @@ -123,7 +128,15 @@ impl RefreshLlmTokenListener { } }); - Self + let subscription = cx.subscribe(&user_store, |_this, _user_store, event, cx| { + if matches!(event, client::user::Event::OrganizationChanged) { + cx.emit(RefreshLlmTokenEvent); + } + }); + + Self { + _subscription: subscription, + } } fn handle_refresh_llm_token(this: Entity, message: &MessageToClient, cx: &mut App) { diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 05ede406b91e1025729b23e229046192f94d73d0..3566d6210769c09a8a6de1706cb258ff2b119ce9 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -1014,9 +1014,9 @@ impl TitleBar { let user_store = user_store.clone(); let organization = organization.clone(); move |_window, cx| { - user_store.update(cx, |user_store, _cx| { + user_store.update(cx, |user_store, cx| { user_store - .set_current_organization(organization.clone()); + .set_current_organization(organization.clone(), cx); }); } }, diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 109b79ff06b6e6dff6334765050979f14b400d35..eccf6b51e01922590752a47589c1cdc1303df966 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -657,7 +657,7 @@ fn main() { ); copilot_ui::init(&app_state, cx); - language_model::init(app_state.client.clone(), cx); + language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); acp_tools::init(cx); zed::telemetry_log::init(cx); diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 57d2f4462b959ebe31abd3a3ecec298977e0a877..ead16b911e3ccf9ebd1b9f54113cb01dca849e9d 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -200,7 +200,7 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> }); prompt_store::init(cx); let prompt_builder = prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx); - language_model::init(app_state.client.clone(), cx); + language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); git_ui::init(cx); project::AgentRegistryStore::init_global( diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 0cb93bbc4c903b4f3290d1da2cc2e1c2f38829e8..562786fb3f01ff4c0781319e155bc47fda6a4822 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5024,7 +5024,7 @@ mod tests { cx, ); image_viewer::init(cx); - language_model::init(app_state.client.clone(), cx); + language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); web_search::init(cx); git_graph::init(cx); diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index 9f05c5795e6f16cab231df8a5586106ed25b03ee..952c840d4abe0cb99be170e27f66a2ba188c08ca 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -316,7 +316,7 @@ mod tests { let app_state = cx.update(|cx| { let app_state = AppState::test(cx); client::init(&app_state.client, cx); - language_model::init(app_state.client.clone(), cx); + language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx); editor::init(cx); app_state }); From e784c92e3c050dd76f3c6a82182139cc415e729a Mon Sep 17 00:00:00 2001 From: Josh Robson Chase Date: Fri, 6 Mar 2026 14:18:33 -0500 Subject: [PATCH 039/219] zed: Clear the FORCE_CLI_MODE environment variable after reading (#46475) This prevents child processes (e.g. shells) from inheriting it. This generally isn't an issue, since zed is the only thing that cares about it, but if you're trying to run zed *from* zed, you would first need to unset it manually. Release Notes: - N/A --- crates/cli/src/cli.rs | 3 +++ crates/zed/src/main.rs | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 8a2394372faf17281babf2cc9769648d64cd67be..1a3ce059b8116ac7438f3eb0330b47660cc863de 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -34,4 +34,7 @@ pub enum CliResponse { /// When Zed started not as an *.app but as a binary (e.g. local development), /// there's a possibility to tell it to behave "regularly". +/// +/// Note that in the main zed binary, this variable is unset after it's read for the first time, +/// therefore it should always be accessed through the `FORCE_CLI_MODE` static. pub const FORCE_CLI_MODE_ENV_VAR_NAME: &str = "ZED_FORCE_CLI_MODE"; diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index eccf6b51e01922590752a47589c1cdc1303df966..3bf3fd190f61ffead59d08d4da556468e2bb1fcf 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -48,7 +48,7 @@ use std::{ path::{Path, PathBuf}, process, rc::Rc, - sync::{Arc, OnceLock}, + sync::{Arc, LazyLock, OnceLock}, time::Instant, }; use theme::{ActiveTheme, GlobalTheme, ThemeRegistry}; @@ -1577,8 +1577,14 @@ fn init_paths() -> HashMap> { }) } +pub(crate) static FORCE_CLI_MODE: LazyLock = LazyLock::new(|| { + let env_var = std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_some(); + unsafe { std::env::remove_var(FORCE_CLI_MODE_ENV_VAR_NAME) }; + env_var +}); + fn stdout_is_a_pty() -> bool { - std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_none() && io::stdout().is_terminal() + !*FORCE_CLI_MODE && io::stdout().is_terminal() } #[derive(Parser, Debug)] From 4b3660bc337863445a6259e2eb7e08dc9f27673e Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Fri, 6 Mar 2026 22:07:54 +0200 Subject: [PATCH 040/219] ep: Unify code in `parse_output` and `zeta_prompt` (#50958) This also fixed `ep parse-output` for newer formats Release Notes: - N/A --- crates/edit_prediction/src/zeta.rs | 30 +++++++------- .../edit_prediction_cli/src/parse_output.rs | 37 +++++------------ crates/zeta_prompt/src/zeta_prompt.rs | 41 ++++++++++++------- 3 files changed, 53 insertions(+), 55 deletions(-) diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index f16239dff0ca28781f36abfcdaab9fcc3873651d..93fc6aa99a27f18436bc564fbaa39a15d3be0b44 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -19,7 +19,7 @@ use settings::EditPredictionPromptFormat; use text::{Anchor, Bias}; use ui::SharedString; use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; -use zeta_prompt::ZetaPromptInput; +use zeta_prompt::{ParsedOutput, ZetaPromptInput}; use std::{env, ops::Range, path::Path, sync::Arc, time::Instant}; use zeta_prompt::{ @@ -175,13 +175,12 @@ pub fn request_prediction_with_zeta( let request_id = EditPredictionId(request_id.into()); let output_text = zeta1::clean_zeta1_model_output(&response_text); + let parsed_output = output_text.map(|text| ParsedOutput { + new_editable_region: text, + range_in_excerpt: editable_range_in_excerpt, + }); - ( - request_id, - Some(editable_range_in_excerpt).zip(output_text), - None, - None, - ) + (request_id, parsed_output, None, None) } EditPredictionPromptFormat::Zeta2 => { let prompt = format_zeta_prompt(&prompt_input, zeta_version); @@ -271,20 +270,23 @@ pub fn request_prediction_with_zeta( let request_id = EditPredictionId(response.request_id.into()); let output_text = Some(response.output).filter(|s| !s.is_empty()); let model_version = response.model_version; + let parsed_output = ParsedOutput { + new_editable_region: output_text.unwrap_or_default(), + range_in_excerpt: response.editable_range, + }; - ( - request_id, - Some(response.editable_range).zip(output_text), - model_version, - usage, - ) + (request_id, Some(parsed_output), model_version, usage) }; let received_response_at = Instant::now(); log::trace!("Got edit prediction response"); - let Some((editable_range_in_excerpt, mut output_text)) = output else { + let Some(ParsedOutput { + new_editable_region: mut output_text, + range_in_excerpt: editable_range_in_excerpt, + }) = output + else { return Ok(((request_id, None), None)); }; diff --git a/crates/edit_prediction_cli/src/parse_output.rs b/crates/edit_prediction_cli/src/parse_output.rs index 041c57c36e958df45dd000f48c33e00b05c751f3..94058efd92ca4a166ba4976819963ef5d3286f5d 100644 --- a/crates/edit_prediction_cli/src/parse_output.rs +++ b/crates/edit_prediction_cli/src/parse_output.rs @@ -6,7 +6,7 @@ use crate::{ }; use anyhow::{Context as _, Result}; use edit_prediction::example_spec::encode_cursor_in_patch; -use zeta_prompt::{CURSOR_MARKER, ZetaFormat, output_end_marker_for_format, resolve_cursor_region}; +use zeta_prompt::{CURSOR_MARKER, ZetaFormat, parse_zeta2_model_output}; pub fn run_parse_output(example: &mut Example) -> Result<()> { example @@ -60,10 +60,13 @@ fn parse_zeta2_output( .as_ref() .context("prompt_inputs required")?; - let (context, editable_range, _, _) = resolve_cursor_region(prompt_inputs, format); - let old_text = context[editable_range].to_string(); + let parsed = parse_zeta2_model_output(actual_output, format, prompt_inputs)?; + let range_in_excerpt = parsed.range_in_excerpt; + + let excerpt = prompt_inputs.cursor_excerpt.as_ref(); + let old_text = excerpt[range_in_excerpt.clone()].to_string(); + let mut new_text = parsed.new_editable_region; - let mut new_text = actual_output.to_string(); let cursor_offset = if let Some(offset) = new_text.find(CURSOR_MARKER) { new_text.replace_range(offset..offset + CURSOR_MARKER.len(), ""); Some(offset) @@ -71,14 +74,8 @@ fn parse_zeta2_output( None }; - if let Some(marker) = output_end_marker_for_format(format) { - new_text = new_text - .strip_suffix(marker) - .unwrap_or(&new_text) - .to_string(); - } - - let mut old_text_normalized = old_text.clone(); + // Normalize trailing newlines for diff generation + let mut old_text_normalized = old_text; if !new_text.is_empty() && !new_text.ends_with('\n') { new_text.push('\n'); } @@ -86,22 +83,10 @@ fn parse_zeta2_output( old_text_normalized.push('\n'); } - let old_text_trimmed = old_text.trim_end_matches('\n'); - let excerpt = prompt_inputs.cursor_excerpt.as_ref(); - let (editable_region_offset, _) = excerpt - .match_indices(old_text_trimmed) - .min_by_key(|(index, _)| index.abs_diff(prompt_inputs.cursor_offset_in_excerpt)) - .with_context(|| { - format!( - "could not find editable region in content.\nLooking for:\n{}\n\nIn content:\n{}", - old_text_trimmed, excerpt - ) - })?; - + let editable_region_offset = range_in_excerpt.start; let editable_region_start_line = excerpt[..editable_region_offset].matches('\n').count(); - - // Use full context so cursor offset (relative to editable region start) aligns with diff content let editable_region_lines = old_text_normalized.lines().count() as u32; + let diff = language::unified_diff_with_context( &old_text_normalized, &new_text, diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 9469c056468ed91fe9c95aa5e5cd2edf3590b8bd..b7b67ed851419dcf0f125f46e5a17e7f9ac9aa92 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -470,12 +470,19 @@ pub fn encode_patch_as_output_for_format( } } +pub struct ParsedOutput { + /// Text that should replace the editable region + pub new_editable_region: String, + /// The byte range within `cursor_excerpt` that this replacement applies to + pub range_in_excerpt: Range, +} + /// Parse model output for the given zeta format pub fn parse_zeta2_model_output( output: &str, format: ZetaFormat, prompt_inputs: &ZetaPromptInput, -) -> Result<(Range, String)> { +) -> Result { let output = match output_end_marker_for_format(format) { Some(marker) => output.strip_suffix(marker).unwrap_or(output), None => output, @@ -509,7 +516,11 @@ pub fn parse_zeta2_model_output( let range_in_excerpt = range_in_context.start + context_start..range_in_context.end + context_start; - Ok((range_in_excerpt, output)) + + Ok(ParsedOutput { + new_editable_region: output, + range_in_excerpt, + }) } pub fn excerpt_range_for_format( @@ -4612,9 +4623,12 @@ mod tests { assert_eq!(cleaned, ""); } - fn apply_edit(excerpt: &str, range: &Range, new_text: &str) -> String { + fn apply_edit(excerpt: &str, parsed_output: &ParsedOutput) -> String { let mut result = excerpt.to_string(); - result.replace_range(range.clone(), new_text); + result.replace_range( + parsed_output.range_in_excerpt.clone(), + &parsed_output.new_editable_region, + ); result } @@ -4632,7 +4646,7 @@ mod tests { editable_start, ); - let (range, text) = parse_zeta2_model_output( + let output = parse_zeta2_model_output( "editable new\n>>>>>>> UPDATED\n", ZetaFormat::V0131GitMergeMarkersPrefix, &input, @@ -4640,7 +4654,7 @@ mod tests { .unwrap(); assert_eq!( - apply_edit(excerpt, &range, &text), + apply_edit(excerpt, &output), "before ctx\nctx start\neditable new\nctx end\nafter ctx\n" ); } @@ -4658,10 +4672,10 @@ mod tests { ); let format = ZetaFormat::V0131GitMergeMarkersPrefix; - let (range, text) = + let output = parse_zeta2_model_output("bbb\nccc\n>>>>>>> UPDATED\n", format, &input).unwrap(); - assert_eq!(apply_edit(excerpt, &range, &text), excerpt); + assert_eq!(apply_edit(excerpt, &output), excerpt); } #[test] @@ -4670,14 +4684,11 @@ mod tests { let input = make_input_with_context_range(excerpt, 0..excerpt.len(), 0..excerpt.len(), 0); let format = ZetaFormat::V0131GitMergeMarkersPrefix; - let (range1, text1) = + let output1 = parse_zeta2_model_output("new content\n>>>>>>> UPDATED\n", format, &input).unwrap(); - let (range2, text2) = parse_zeta2_model_output("new content\n", format, &input).unwrap(); + let output2 = parse_zeta2_model_output("new content\n", format, &input).unwrap(); - assert_eq!( - apply_edit(excerpt, &range1, &text1), - apply_edit(excerpt, &range2, &text2) - ); - assert_eq!(apply_edit(excerpt, &range1, &text1), "new content\n"); + assert_eq!(apply_edit(excerpt, &output1), apply_edit(excerpt, &output2)); + assert_eq!(apply_edit(excerpt, &output1), "new content\n"); } } From 5b2d39e3aec426f7abeb7ba7f2f2b5e34200c892 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Fri, 6 Mar 2026 14:09:36 -0600 Subject: [PATCH 041/219] ep: Add captured example fetching (#50960) Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/edit_prediction_cli/src/main.rs | 15 + .../edit_prediction_cli/src/pull_examples.rs | 263 +++++++++++++++++- 2 files changed, 276 insertions(+), 2 deletions(-) diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs index 8bb4b2a8e2f50d448fc314a70e2fc94cfa2c3d71..afe25c5badcfff03babd5e951ae66839ce0f790b 100644 --- a/crates/edit_prediction_cli/src/main.rs +++ b/crates/edit_prediction_cli/src/main.rs @@ -738,6 +738,21 @@ async fn load_examples( examples.append(&mut requested_examples); } + if !captured_after_timestamps.is_empty() { + captured_after_timestamps.sort(); + + let mut captured_examples = pull_examples::fetch_captured_examples_after( + http_client.clone(), + &captured_after_timestamps, + max_rows_per_timestamp, + remaining_offset, + background_executor.clone(), + Some(MIN_CAPTURE_VERSION), + ) + .await?; + examples.append(&mut captured_examples); + } + if !settled_after_timestamps.is_empty() { settled_after_timestamps.sort(); diff --git a/crates/edit_prediction_cli/src/pull_examples.rs b/crates/edit_prediction_cli/src/pull_examples.rs index cccd351dcdeda0dbf059d851a44b02bc1e558654..15591ae03ccd7b0d537b437c1da2c0898e7e9446 100644 --- a/crates/edit_prediction_cli/src/pull_examples.rs +++ b/crates/edit_prediction_cli/src/pull_examples.rs @@ -565,6 +565,101 @@ pub async fn fetch_requested_examples_after( Ok(all_examples) } +pub async fn fetch_captured_examples_after( + http_client: Arc, + after_timestamps: &[String], + max_rows_per_timestamp: usize, + offset: usize, + background_executor: BackgroundExecutor, + min_capture_version: Option, +) -> Result> { + if after_timestamps.is_empty() { + return Ok(Vec::new()); + } + + let progress = Progress::global(); + + let mut all_examples = Vec::new(); + + for after_date in after_timestamps.iter() { + let step_progress_name = format!("captured>{after_date}"); + let step_progress = progress.start(Step::PullExamples, &step_progress_name); + step_progress.set_substatus("querying"); + + let min_minor_str = min_capture_version.map(|version| version.minor.to_string()); + let min_patch_str = min_capture_version.map(|version| version.patch.to_string()); + let min_minor_str_ref = min_minor_str.as_deref(); + let min_patch_str_ref = min_patch_str.as_deref(); + + let statement = indoc! {r#" + SELECT + settled.event_properties:request_id::string AS request_id, + settled.device_id::string AS device_id, + settled.time::string AS time, + req.event_properties:input AS input, + settled.event_properties:settled_editable_region::string AS settled_editable_region, + settled.event_properties:example AS example, + req.event_properties:zed_version::string AS zed_version + FROM events settled + INNER JOIN events req + ON settled.event_properties:request_id::string = req.event_properties:request_id::string + WHERE settled.event_type = ? + AND req.event_type = ? + AND req.event_properties:version = 'V3' + AND req.event_properties:input:can_collect_data = true + AND settled.event_properties:example IS NOT NULL + AND TYPEOF(settled.event_properties:example) != 'NULL_VALUE' + AND settled.time > TRY_TO_TIMESTAMP_NTZ(?) + AND (? IS NULL OR ( + TRY_CAST(SPLIT_PART(req.event_properties:zed_version::string, '.', 2) AS INTEGER) > ? + OR ( + TRY_CAST(SPLIT_PART(req.event_properties:zed_version::string, '.', 2) AS INTEGER) = ? + AND TRY_CAST(SPLIT_PART(SPLIT_PART(req.event_properties:zed_version::string, '.', 3), '+', 1) AS INTEGER) >= ? + ) + )) + ORDER BY settled.time ASC + LIMIT ? + OFFSET ? + "#}; + + let bindings = json!({ + "1": { "type": "TEXT", "value": EDIT_PREDICTION_SETTLED_EVENT }, + "2": { "type": "TEXT", "value": PREDICTIVE_EDIT_REQUESTED_EVENT }, + "3": { "type": "TEXT", "value": after_date }, + "4": { "type": "FIXED", "value": min_minor_str_ref }, + "5": { "type": "FIXED", "value": min_minor_str_ref }, + "6": { "type": "FIXED", "value": min_minor_str_ref }, + "7": { "type": "FIXED", "value": min_patch_str_ref }, + "8": { "type": "FIXED", "value": max_rows_per_timestamp.to_string() }, + "9": { "type": "FIXED", "value": offset.to_string() } + }); + + let examples = fetch_examples_with_query( + http_client.clone(), + &step_progress, + background_executor.clone(), + statement, + bindings, + DEFAULT_STATEMENT_TIMEOUT_SECONDS, + &[ + "request_id", + "device_id", + "time", + "input", + "settled_editable_region", + "example", + "zed_version", + ], + captured_examples_from_response, + ) + .await?; + + all_examples.extend(examples); + } + + Ok(all_examples) +} + pub async fn fetch_settled_examples_after( http_client: Arc, after_timestamps: &[String], @@ -1018,7 +1113,7 @@ fn settled_examples_from_response<'a>( } }; - let parse_json_value = |_: &str, raw: Option<&JsonValue>| -> Option { + let parse_json_value = |raw: Option<&JsonValue>| -> Option { let value = raw?; match value { JsonValue::String(s) => serde_json::from_str::(s).ok(), @@ -1030,7 +1125,7 @@ fn settled_examples_from_response<'a>( let device_id = get_string("device_id"); let time = get_string("time"); let input_raw = get_value("input"); - let input_json = parse_json_value("input", input_raw.as_ref()); + let input_json = parse_json_value(input_raw.as_ref()); let input: Option = input_json .as_ref() .and_then(|parsed| serde_json::from_value(parsed.clone()).ok()); @@ -1104,6 +1199,133 @@ fn settled_examples_from_response<'a>( Ok(Box::new(iter)) } +fn captured_examples_from_response<'a>( + response: &'a SnowflakeStatementResponse, + column_indices: &'a std::collections::HashMap, +) -> Result + 'a>> { + if let Some(code) = &response.code { + if code != SNOWFLAKE_SUCCESS_CODE { + anyhow::bail!( + "snowflake sql api returned error code={code} message={}", + response.message.as_deref().unwrap_or("") + ); + } + } + + let iter = response + .data + .iter() + .enumerate() + .filter_map(move |(row_index, data_row)| { + let get_value = |name: &str| -> Option { + let index = column_indices.get(name).copied()?; + let value = data_row.get(index)?; + if value.is_null() { + None + } else { + Some(value.clone()) + } + }; + + let get_string = |name: &str| -> Option { + match get_value(name)? { + JsonValue::String(s) => Some(s), + other => Some(other.to_string()), + } + }; + + let parse_json_value = |raw: Option<&JsonValue>| -> Option { + let value = raw?; + match value { + JsonValue::String(s) => serde_json::from_str::(s).ok(), + other => Some(other.clone()), + } + }; + + let request_id = get_string("request_id"); + let device_id = get_string("device_id"); + let time = get_string("time"); + let input_raw = get_value("input"); + let input_json = parse_json_value(input_raw.as_ref()); + let input: Option = input_json + .as_ref() + .and_then(|parsed| serde_json::from_value(parsed.clone()).ok()); + let example_raw = get_value("example"); + let example_json = parse_json_value(example_raw.as_ref()); + let example_spec: Option = example_json.as_ref().and_then(|parsed| { + serde_json::from_value(parsed.clone()) + .or_else(|_| { + parsed + .as_str() + .and_then(|markdown| ExampleSpec::from_markdown(markdown).ok()) + .ok_or_else(|| { + serde_json::Error::io(std::io::Error::other("not markdown")) + }) + }) + .ok() + }); + let has_example_spec = example_spec.is_some(); + let settled_editable_region = get_string("settled_editable_region"); + let zed_version = get_string("zed_version"); + + match ( + request_id.clone(), + device_id.clone(), + time.clone(), + input.clone(), + example_spec, + settled_editable_region.clone(), + ) { + ( + Some(request_id), + Some(device_id), + Some(time), + Some(input), + Some(example_spec), + Some(settled_editable_region), + ) => Some(build_captured_example( + request_id, + device_id, + time, + input, + example_spec, + settled_editable_region, + zed_version, + )), + _ => { + let mut missing_fields = Vec::new(); + + if request_id.is_none() { + missing_fields.push("request_id"); + } + if device_id.is_none() { + missing_fields.push("device_id"); + } + if time.is_none() { + missing_fields.push("time"); + } + if input_raw.is_none() || input_json.is_none() || input.is_none() { + missing_fields.push("input"); + } + if example_raw.is_none() || !has_example_spec { + missing_fields.push("example"); + } + if settled_editable_region.is_none() { + missing_fields.push("settled_editable_region"); + } + + log::warn!( + "skipping captured row {row_index}: [{}]", + missing_fields.join(", "), + ); + None + } + } + }); + + Ok(Box::new(iter)) +} + fn build_settled_example( request_id: String, device_id: String, @@ -1160,6 +1382,43 @@ fn build_settled_example( example } +fn build_captured_example( + request_id: String, + device_id: String, + time: String, + input: ZetaPromptInput, + mut example_spec: ExampleSpec, + settled_editable_region: String, + zed_version: Option, +) -> Example { + let expected_patch = build_output_patch( + &input.cursor_path, + input.cursor_excerpt.as_ref(), + &input.excerpt_ranges.editable_350, + settled_editable_region.as_str(), + ); + + example_spec.expected_patches = vec![expected_patch]; + example_spec.telemetry = Some(TelemetrySource { + request_id, + device_id, + time, + rejection_reason: String::new(), + was_shown: false, + }); + + Example { + spec: example_spec, + zed_version, + prompt_inputs: Some(input), + prompt: None, + predictions: Vec::new(), + score: Vec::new(), + qa: Vec::new(), + state: None, + } +} + fn rejected_examples_from_response<'a>( response: &'a SnowflakeStatementResponse, column_indices: &'a std::collections::HashMap, From 1dd09c3e760cb854e97dd62a71e204683eaf0309 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Fri, 6 Mar 2026 14:14:53 -0800 Subject: [PATCH 042/219] Add Zed Feature Process document (#50747) Release Notes: - N/A --- CONTRIBUTING.md | 2 + docs/src/development/feature-process.md | 51 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 docs/src/development/feature-process.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 740b33dd55790bd3cabfc75146d71854eca6375d..e7e7629825b5f487a3b00af525d36458eb91956c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,8 @@ If you're looking for concrete ideas: - [Triaged bugs with confirmed steps to reproduce](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3Astate%3Areproducible). - [Area labels](https://github.com/zed-industries/zed/labels?q=area%3A*) to browse bugs in a specific part of the product you care about (after clicking on an area label, add type:Bug to the search). +If you're thinking about proposing or building a larger feature, read the [Zed Feature Process](./docs/src/development/feature-process.md) for how we think about feature design — what context to provide, what integration points to consider, and how to put together a strong proposal. + ## Sending changes The Zed culture values working code and synchronous conversations over long diff --git a/docs/src/development/feature-process.md b/docs/src/development/feature-process.md new file mode 100644 index 0000000000000000000000000000000000000000..811e1a4fd6130fdf0abc687f6943f58b24e81b08 --- /dev/null +++ b/docs/src/development/feature-process.md @@ -0,0 +1,51 @@ +# Zed's Feature Development Process + +This is for moderate-to-large features — new UI, behavior changes, or work that cuts across multiple parts of Zed. Small keybindings or settings tweaks don't need all of this. + +> **Before you start:** If you're an external contributor, make sure the feature is something the team wants before investing significant effort. That said, coming prepared with background research makes it much easier for the team to understand and approve the proposal. Read the [Contributing guide](../../../CONTRIBUTING.md#sending-changes) — if there isn't already a GitHub issue with staff confirmation, start with a GitHub Discussion or a Discord message rather than a PR. + +## 1. Why does this matter? + +Every feature starts as an idea. Before writing any code, ground it: + +- **What problem does this solve?** +- **What's the evidence?** GitHub issues, Discord requests, thumbs-up counts, blog posts. +- **Is there prior art?** If it's in VS Code, JetBrains, Neovim, or a wildly popular plugin, that's a strong signal. If the idea is more novel, name what it's based on — "This is X, adapted for Zed's multi-buffers" is far more useful than "I think this would be cool." + +## 2. What is it? + +Write a short, concrete feature statement, then back it up with the context gathered above. If you can't describe the feature in a few sentences, it might be too big or too vague. + +Here's an example format, though adapt it to whatever your feature needs: + +> **Feature:** Inline Git Blame +> **Purpose:** Show the last commit author and message for each line directly after the editor text, so developers can understand code history without opening the git blame. +> **Background:** +> This is standard across all major code editors +> \[screenshot of VSCode] +> \[screenshot of Intellij] +> \[screenshot of Neovim] +> and has 146 thumbs up on the [github issue](https://github.com). +> **Decisions:** +> We have to decide whether to use the git CLI or a git library. Zed uses a git library but its blame implementation is too slow for a code editor, so we should use the CLI's porcelain interface. + +## 3. What else does this affect? + +Walk through this list before you start building. Not everything will apply: + +- **Actions & keybindings.** What actions does your feature define? Do the default keybindings conflict with existing ones? +- **Settings.** Is any behavior configurable? Per-user vs. per-project vs. per-language? Don't forget to add new settings to the Settings UI. +- **Themes & styling.** Does this need a new semantic token? Does it look right in both light and dark mode? +- **Vim mode.** Vim users might have different expectations for this feature. +- **Remote development.** Does your feature work with remote projects? File paths, shell commands, and environment variables all might behave differently. +- **Persistence across restarts.** Should your feature's state persist across restarts? +- **Accessibility.** Is it keyboard-navigable? Are focus states clear? +- **Platform differences.** Does behavior differ on macOS, Linux, or Windows? +- **Performance.** How does it behave with large files or big projects? Are interactions instant? +- **Security.** How does this feature interact with Workspace Trust? Does it open new attack surfaces in Zed? + +If your feature touches the **editor** specifically: the editor has a lot of coexisting features — gutter elements, inline blocks, multiple cursors, folding, edit predictions, code intelligence popovers, the minimap. Test your changes with different combinations of them active. Features that work in a normal buffer might need to be disabled in a multi-buffer. + +## 4. Ship it + +Use this as the basis for your GitHub Discussion, issue, or PR description. Good product research gets everyone aligned on goals, the state of the art, and any tradeoffs we might need to consider. From 1dd80ac28f436cec7f481dbdfcb90d1d9c1d8cea Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:47:04 -0300 Subject: [PATCH 043/219] agent_ui: Add more refinements for v2 flag (#50968) - Fixes message editor in empty state not consuming the whole height of the panel - Remove duped focused method in the `ListItem` - Remove duped "new thread" buttons when group is empty - Add some UI adjustments like removing labels and fading out truncated items Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 2 +- .../src/connection_view/thread_view.rs | 26 ++ crates/agent_ui/src/message_editor.rs | 6 +- crates/sidebar/src/sidebar.rs | 227 ++++++++++-------- crates/ui/src/components/ai/thread_item.rs | 66 +++-- crates/ui/src/components/list/list_item.rs | 14 -- 6 files changed, 202 insertions(+), 139 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 917de98f7ebaab4c0a7f804b63ba54b2489258ee..4c05be77349aa7fecbe0855e3388e29ddbad2dcd 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -3685,7 +3685,7 @@ impl AgentPanel { h_flex() .gap_1() .child(agent_icon_element) - .child(Label::new(selected_agent_label).color(label_color)) + .child(Label::new(selected_agent_label).color(label_color).ml_0p5()) .child( Icon::new(chevron_icon) .color(icon_color) diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 0154ec920c82ccb829ad7486b3de97d5fb33e3ef..0519362ab1194a6e21ff9b3f213112f94f4cce55 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -2715,6 +2715,31 @@ 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() @@ -2731,6 +2756,7 @@ impl ThreadView { v_flex() .relative() .size_full() + .when(v2_empty_state, |this| this.flex_1()) .pt_1() .pr_2p5() .child(self.message_editor.clone()) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index cee6725cd15c15f4f39ad5e53be5578f5f5cc3d8..933e24e83c0450dcbdde27d49abebb7fda2fa119 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -1222,8 +1222,10 @@ impl MessageEditor { pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context) { self.editor.update(cx, |editor, cx| { - editor.set_mode(mode); - cx.notify() + if *editor.mode() != mode { + editor.set_mode(mode); + cx.notify() + } }); } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index eec55f16af8cf7deefdb8adeddeac5b6b4fb4ea9..40ba738ba98ff4d77932eabeca9bdf0a7d0b8861 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -73,6 +73,7 @@ enum ListEntry { label: SharedString, workspace: Entity, highlight_positions: Vec, + has_threads: bool, }, Thread { session_info: acp_thread::AgentSessionInfo, @@ -322,10 +323,15 @@ impl Sidebar { window, |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event { AgentPanelEvent::ActiveViewChanged => { - if let Some(thread) = agent_panel.read(cx).active_connection_view() - && let Some(session_id) = thread.read(cx).parent_id(cx) - { - this.focused_thread = Some(session_id); + match agent_panel.read(cx).active_connection_view() { + Some(thread) => { + if let Some(session_id) = thread.read(cx).parent_id(cx) { + this.focused_thread = Some(session_id); + } + } + None => { + this.focused_thread = None; + } } this.update_entries(cx); } @@ -334,7 +340,7 @@ impl Sidebar { .read(cx) .active_connection_view() .and_then(|thread| thread.read(cx).parent_id(cx)); - if new_focused != this.focused_thread { + if new_focused.is_some() && new_focused != this.focused_thread { this.focused_thread = new_focused; this.update_entries(cx); } @@ -522,6 +528,7 @@ impl Sidebar { } if !query.is_empty() { + let has_threads = !threads.is_empty(); let mut matched_threads = Vec::new(); for mut thread in threads { if let ListEntry::Thread { @@ -554,14 +561,17 @@ impl Sidebar { label, workspace: workspace.clone(), highlight_positions: workspace_highlight_positions, + has_threads, }); entries.extend(matched_threads); } else { + let has_threads = !threads.is_empty(); entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, workspace: workspace.clone(), highlight_positions: Vec::new(), + has_threads, }); if is_collapsed { @@ -677,12 +687,14 @@ impl Sidebar { label, workspace, highlight_positions, + has_threads, } => self.render_project_header( ix, path_list, label, workspace, highlight_positions, + *has_threads, is_selected, cx, ), @@ -736,12 +748,12 @@ impl Sidebar { label: &SharedString, workspace: &Entity, highlight_positions: &[usize], + has_threads: bool, is_selected: bool, cx: &mut Context, ) -> AnyElement { let id = SharedString::from(format!("project-header-{}", ix)); let ib_id = SharedString::from(format!("project-header-new-thread-{}", ix)); - let group = SharedString::from(format!("group-{}", ix)); let is_collapsed = self.collapsed_groups.contains(path_list); let disclosure_icon = if is_collapsed { @@ -774,20 +786,19 @@ impl Sidebar { .into_any_element() }; - // TODO: if is_selected, draw a blue border around the item. - ListItem::new(id) - .selection_outlined(is_selected) - .group_name(&group) .toggle_state(is_active_workspace) + .focused(is_selected) .child( - h_flex().px_1().py_1p5().gap_0p5().child(label).child( - div().visible_on_hover(group).child( + h_flex() + .p_1() + .gap_1p5() + .child( Icon::new(disclosure_icon) .size(IconSize::Small) - .color(Color::Muted), - ), - ), + .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))), + ) + .child(label), ) .end_hover_slot( h_flex() @@ -808,18 +819,21 @@ impl Sidebar { )), ) }) - .child( - IconButton::new(ib_id, IconName::NewThread) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("New Thread")) - .on_click(cx.listener(move |this, _, window, cx| { - this.selection = None; - this.create_new_thread(&workspace_for_new_thread, window, cx); - })), - ), + .when(has_threads, |this| { + this.child( + IconButton::new(ib_id, IconName::NewThread) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("New Thread")) + .on_click(cx.listener(move |this, _, window, cx| { + this.selection = None; + this.create_new_thread(&workspace_for_new_thread, window, cx); + })), + ) + }), ) .on_click(cx.listener(move |this, _, window, cx| { + this.selection = None; this.toggle_collapse(&path_list_for_toggle, window, cx); })) // TODO: Decide if we really want the header to be activating different workspaces @@ -887,12 +901,7 @@ impl Sidebar { self.update_entries(cx); } - fn focus_in(&mut self, _window: &mut Window, cx: &mut Context) { - if self.selection.is_none() && !self.contents.entries.is_empty() { - self.selection = Some(0); - cx.notify(); - } - } + fn focus_in(&mut self, _window: &mut Window, _cx: &mut Context) {} fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { if self.reset_filter_editor_text(window, cx) { @@ -1122,7 +1131,7 @@ impl Sidebar { .status(status) .notified(has_notification) .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) - .outlined(is_selected) + .focused(is_selected) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; this.activate_thread(session_info.clone(), &workspace, window, cx); @@ -1168,7 +1177,7 @@ impl Sidebar { let count = format!("({})", remaining_count); ListItem::new(id) - .selection_outlined(is_selected) + .focused(is_selected) .child( h_flex() .px_1() @@ -1319,52 +1328,45 @@ impl Render for Sidebar { .justify_between() .border_b_1() .border_color(cx.theme().colors().border) - .child( - h_flex() - .gap_1() - .child({ - let focus_handle_toggle = self.focus_handle.clone(); - let focus_handle_focus = self.focus_handle.clone(); - IconButton::new("close-sidebar", IconName::WorkspaceNavOpen) - .icon_size(IconSize::Small) - .tooltip(Tooltip::element(move |_, cx| { - v_flex() - .gap_1() - .child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new("Close Sidebar")) - .child(KeyBinding::for_action_in( - &ToggleWorkspaceSidebar, - &focus_handle_toggle, - cx, - )), - ) - .child( - h_flex() - .pt_1() - .gap_2() - .border_t_1() - .border_color( - cx.theme().colors().border_variant, - ) - .justify_between() - .child(Label::new(focus_tooltip_label)) - .child(KeyBinding::for_action_in( - &FocusWorkspaceSidebar, - &focus_handle_focus, - cx, - )), - ) - .into_any_element() - })) - .on_click(cx.listener(|_this, _, _window, cx| { - cx.emit(SidebarEvent::Close); - })) - }) - .child(Label::new("Threads").size(LabelSize::Small)), - ) + .child({ + let focus_handle_toggle = self.focus_handle.clone(); + let focus_handle_focus = self.focus_handle.clone(); + IconButton::new("close-sidebar", IconName::WorkspaceNavOpen) + .icon_size(IconSize::Small) + .tooltip(Tooltip::element(move |_, cx| { + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new("Close Sidebar")) + .child(KeyBinding::for_action_in( + &ToggleWorkspaceSidebar, + &focus_handle_toggle, + cx, + )), + ) + .child( + h_flex() + .pt_1() + .gap_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .child(Label::new(focus_tooltip_label)) + .child(KeyBinding::for_action_in( + &FocusWorkspaceSidebar, + &focus_handle_focus, + cx, + )), + ) + .into_any_element() + })) + .on_click(cx.listener(|_this, _, _window, cx| { + cx.emit(SidebarEvent::Close); + })) + }) .child( IconButton::new("open-project", IconName::OpenFolder) .icon_size(IconSize::Small) @@ -1852,6 +1854,7 @@ mod tests { label: "expanded-project".into(), workspace: workspace.clone(), highlight_positions: Vec::new(), + has_threads: true, }, // Thread with default (Completed) status, not active ListEntry::Thread { @@ -1954,6 +1957,7 @@ mod tests { label: "collapsed-project".into(), workspace: workspace.clone(), highlight_positions: Vec::new(), + has_threads: true, }, ]; // Select the Running thread (index 2) @@ -2014,11 +2018,16 @@ mod tests { cx.run_until_parked(); // Entries: [header, thread3, thread2, thread1] - // Focusing the sidebar triggers focus_in, which selects the first entry + // 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, &multi_workspace, 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 all entries + // Move down through remaining entries cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); @@ -2072,7 +2081,7 @@ mod tests { } #[gpui::test] - async fn test_keyboard_focus_in_selects_first(cx: &mut TestAppContext) { + 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)); @@ -2081,11 +2090,16 @@ mod tests { // 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 + // 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, &multi_workspace, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + 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); + }); - // Blur the sidebar, then refocus — existing selection should be preserved cx.update(|window, _cx| { window.blur(); }); @@ -2135,9 +2149,11 @@ mod tests { 1 ); - // Focus the sidebar — focus_in selects the header (index 0) + // Focus the sidebar and manually select the header (index 0) open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); // Press confirm on project header (workspace 0) to activate it. cx.dispatch_action(Confirm); @@ -2176,9 +2192,9 @@ mod tests { assert_eq!(entries.len(), 7); assert!(entries.iter().any(|e| e.contains("View More (3)"))); - // Focus sidebar (selects index 0), then navigate down to the "View More" entry (index 6) + // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - for _ in 0..6 { + for _ in 0..7 { cx.dispatch_action(SelectNext); } assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); @@ -2210,9 +2226,11 @@ mod tests { vec!["v [my-project]", " Thread 1"] ); - // Focus sidebar — focus_in selects the header (index 0). Press left to collapse. + // Focus sidebar and manually select the header (index 0). Press left to collapse. open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); cx.dispatch_action(CollapseSelectedEntry); cx.run_until_parked(); @@ -2248,9 +2266,10 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Focus sidebar (selects header at index 0), then navigate down to the thread (child) + // Focus sidebar (selection starts at None), then navigate down to the thread (child) open_and_focus_sidebar(&sidebar, &multi_workspace, cx); cx.dispatch_action(SelectNext); + cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); assert_eq!( @@ -2282,8 +2301,12 @@ mod tests { vec!["v [empty-project]", " [+ New Thread]"] ); - // Focus sidebar — focus_in selects the first entry (header at 0) + // Focus sidebar — focus_in does not set a selection open_and_focus_sidebar(&sidebar, &multi_workspace, 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 @@ -2311,9 +2334,10 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Focus sidebar (selects header at 0), navigate down to the thread (index 1) + // Focus sidebar (selection starts at None), navigate down to the thread (index 1) open_and_focus_sidebar(&sidebar, &multi_workspace, 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 @@ -2935,9 +2959,11 @@ mod tests { cx.run_until_parked(); // User focuses the sidebar and collapses the group using keyboard: - // select the header, then press CollapseSelectedEntry to collapse. + // manually select the header, then press CollapseSelectedEntry to collapse. open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); cx.dispatch_action(CollapseSelectedEntry); cx.run_until_parked(); @@ -3151,15 +3177,12 @@ mod tests { }); assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); - // When the user tabs back into the sidebar, focus_in restores - // selection to the first entry for keyboard navigation. + // 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), - Some(0) - ); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); } #[gpui::test] diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index c8f5c8a41cdf74dae16a411b4fe3170b2be04bf3..171a6968290b3239e21faf9cd669559b88f9a964 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -3,7 +3,7 @@ use crate::{ prelude::*, }; -use gpui::{AnyView, ClickEvent, Hsla, SharedString}; +use gpui::{AnyView, ClickEvent, Hsla, SharedString, linear_color_stop, linear_gradient}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AgentThreadStatus { @@ -24,7 +24,7 @@ pub struct ThreadItem { notified: bool, status: AgentThreadStatus, selected: bool, - outlined: bool, + focused: bool, hovered: bool, added: Option, removed: Option, @@ -48,7 +48,7 @@ impl ThreadItem { notified: false, status: AgentThreadStatus::default(), selected: false, - outlined: false, + focused: false, hovered: false, added: None, removed: None, @@ -92,8 +92,8 @@ impl ThreadItem { self } - pub fn outlined(mut self, outlined: bool) -> Self { - self.outlined = outlined; + pub fn focused(mut self, focused: bool) -> Self { + self.focused = focused; self } @@ -153,7 +153,7 @@ impl ThreadItem { impl RenderOnce for ThreadItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let clr = cx.theme().colors(); + let color = cx.theme().colors(); // let dot_separator = || { // Label::new("•") // .size(LabelSize::Small) @@ -161,7 +161,7 @@ impl RenderOnce for ThreadItem { // .alpha(0.5) // }; - let icon_container = || h_flex().size_4().justify_center(); + let icon_container = || h_flex().size_4().flex_none().justify_center(); let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg { Icon::from_external_svg(custom_svg) .color(Color::Muted) @@ -189,7 +189,7 @@ impl RenderOnce for ThreadItem { } else if self.status == AgentThreadStatus::Error { Some(decoration(IconDecorationKind::X, cx.theme().status().error)) } else if self.notified { - Some(decoration(IconDecorationKind::Dot, clr.text_accent)) + Some(decoration(IconDecorationKind::Dot, color.text_accent)) } else { None }; @@ -209,15 +209,41 @@ impl RenderOnce for ThreadItem { let title = self.title; let highlight_positions = self.highlight_positions; let title_label = if highlight_positions.is_empty() { - Label::new(title).truncate().into_any_element() + Label::new(title).into_any_element() } else { - HighlightedLabel::new(title, highlight_positions) - .truncate() - .into_any_element() + HighlightedLabel::new(title, highlight_positions).into_any_element() }; + let base_bg = if self.selected { + color.element_active + } else { + color.panel_background + }; + + let gradient_overlay = div() + .absolute() + .top_0() + .right(px(-10.0)) + .w_12() + .h_full() + .bg(linear_gradient( + 90., + linear_color_stop(base_bg, 0.6), + linear_color_stop(base_bg.opacity(0.0), 0.), + )) + .group_hover("thread-item", |s| { + s.bg(linear_gradient( + 90., + linear_color_stop(color.element_hover, 0.6), + linear_color_stop(color.element_hover.opacity(0.0), 0.), + )) + }); + v_flex() .id(self.id.clone()) + .group("thread-item") + .relative() + .overflow_hidden() .cursor_pointer() .w_full() .map(|this| { @@ -227,11 +253,11 @@ impl RenderOnce for ThreadItem { this.px_2().py_1() } }) - .when(self.selected, |s| s.bg(clr.element_active)) + .when(self.selected, |s| s.bg(color.element_active)) .border_1() .border_color(gpui::transparent_black()) - .when(self.outlined, |s| s.border_color(clr.panel_focused_border)) - .hover(|s| s.bg(clr.element_hover)) + .when(self.focused, |s| s.border_color(color.panel_focused_border)) + .hover(|s| s.bg(color.element_hover)) .on_hover(self.on_hover) .child( h_flex() @@ -249,6 +275,7 @@ impl RenderOnce for ThreadItem { .child(title_label) .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)), ) + .child(gradient_overlay) .when(running_or_action, |this| { this.child( h_flex() @@ -271,7 +298,6 @@ impl RenderOnce for ThreadItem { Label::new(worktree) .size(LabelSize::Small) .color(Color::Muted) - .truncate_start() .into_any_element() } else { HighlightedLabel::new(worktree, worktree_highlight_positions) @@ -420,25 +446,25 @@ impl Component for ThreadItem { .into_any_element(), ), single_example( - "Outlined Item (Keyboard Selection)", + "Focused Item (Keyboard Selection)", container() .child( ThreadItem::new("ti-7", "Implement keyboard navigation") .icon(IconName::AiClaude) .timestamp("4:00 PM") - .outlined(true), + .focused(true), ) .into_any_element(), ), single_example( - "Selected + Outlined", + "Selected + Focused", container() .child( ThreadItem::new("ti-8", "Active and keyboard-focused thread") .icon(IconName::AiGemini) .timestamp("5:00 PM") .selected(true) - .outlined(true), + .focused(true), ) .into_any_element(), ), diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index cc9c955fd35aa33355be84f9ee3f17f27995ffaf..d581fad9453d9812f17b7bc9e0297fb9927c8188 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -42,7 +42,6 @@ pub struct ListItem { selectable: bool, always_show_disclosure_icon: bool, outlined: bool, - selection_outlined: Option, rounded: bool, overflow_x: bool, focused: Option, @@ -72,7 +71,6 @@ impl ListItem { selectable: true, always_show_disclosure_icon: false, outlined: false, - selection_outlined: None, rounded: false, overflow_x: false, focused: None, @@ -173,11 +171,6 @@ impl ListItem { self } - pub fn selection_outlined(mut self, outlined: bool) -> Self { - self.selection_outlined = Some(outlined); - self - } - pub fn rounded(mut self) -> Self { self.rounded = true; self @@ -248,13 +241,6 @@ impl RenderOnce for ListItem { }) }) .when(self.rounded, |this| this.rounded_sm()) - .when_some(self.selection_outlined, |this, outlined| { - this.border_1() - .border_color(gpui::transparent_black()) - .when(outlined, |this| { - this.border_color(cx.theme().colors().panel_focused_border) - }) - }) .when_some(self.on_hover, |this, on_hover| this.on_hover(on_hover)) .child( h_flex() From c841b48e4f7d951e091ecd1d86fe120905ad9796 Mon Sep 17 00:00:00 2001 From: John Tur Date: Fri, 6 Mar 2026 22:22:49 -0500 Subject: [PATCH 044/219] Fix OpenGL initialization on Intel HD 4000 (#50983) Almost there! Release Notes: - N/A --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2b5cfff38787ce93619a904b04b38317cea2194b..3b5ad9a7b35b8e9acd37b5e40efd8a32e65bdc21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10803,7 +10803,7 @@ dependencies = [ [[package]] name = "naga" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" +source = "git+https://github.com/zed-industries/wgpu?rev=465557eccfe77c840a9b4936f1408da9503372c4#465557eccfe77c840a9b4936f1408da9503372c4" dependencies = [ "arrayvec", "bit-set", @@ -20081,7 +20081,7 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" +source = "git+https://github.com/zed-industries/wgpu?rev=465557eccfe77c840a9b4936f1408da9503372c4#465557eccfe77c840a9b4936f1408da9503372c4" dependencies = [ "arrayvec", "bitflags 2.10.0", @@ -20110,7 +20110,7 @@ dependencies = [ [[package]] name = "wgpu-core" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" +source = "git+https://github.com/zed-industries/wgpu?rev=465557eccfe77c840a9b4936f1408da9503372c4#465557eccfe77c840a9b4936f1408da9503372c4" dependencies = [ "arrayvec", "bit-set", @@ -20141,7 +20141,7 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" +source = "git+https://github.com/zed-industries/wgpu?rev=465557eccfe77c840a9b4936f1408da9503372c4#465557eccfe77c840a9b4936f1408da9503372c4" dependencies = [ "wgpu-hal", ] @@ -20149,7 +20149,7 @@ dependencies = [ [[package]] name = "wgpu-core-deps-emscripten" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" +source = "git+https://github.com/zed-industries/wgpu?rev=465557eccfe77c840a9b4936f1408da9503372c4#465557eccfe77c840a9b4936f1408da9503372c4" dependencies = [ "wgpu-hal", ] @@ -20157,7 +20157,7 @@ dependencies = [ [[package]] name = "wgpu-core-deps-windows-linux-android" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" +source = "git+https://github.com/zed-industries/wgpu?rev=465557eccfe77c840a9b4936f1408da9503372c4#465557eccfe77c840a9b4936f1408da9503372c4" dependencies = [ "wgpu-hal", ] @@ -20165,7 +20165,7 @@ dependencies = [ [[package]] name = "wgpu-hal" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" +source = "git+https://github.com/zed-industries/wgpu?rev=465557eccfe77c840a9b4936f1408da9503372c4#465557eccfe77c840a9b4936f1408da9503372c4" dependencies = [ "android_system_properties", "arrayvec", @@ -20212,7 +20212,7 @@ dependencies = [ [[package]] name = "wgpu-types" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=0343151f535c8386df3c1db014cd42f44470e4c0#0343151f535c8386df3c1db014cd42f44470e4c0" +source = "git+https://github.com/zed-industries/wgpu?rev=465557eccfe77c840a9b4936f1408da9503372c4#465557eccfe77c840a9b4936f1408da9503372c4" dependencies = [ "bitflags 2.10.0", "bytemuck", diff --git a/Cargo.toml b/Cargo.toml index 17dadab934b3066a352f28105814c8a7bc5988b1..9541d9e45b17f5ea92029082ab715a3c068067ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -783,7 +783,7 @@ wax = "0.7" which = "6.0.0" wasm-bindgen = "0.2.113" web-time = "1.1.0" -wgpu = { git = "https://github.com/zed-industries/wgpu", rev = "0343151f535c8386df3c1db014cd42f44470e4c0" } +wgpu = { git = "https://github.com/zed-industries/wgpu", rev = "465557eccfe77c840a9b4936f1408da9503372c4" } windows-core = "0.61" yawc = "0.2.5" zeroize = "1.8" From 6c9b813f38182a368de6dcecd59e60b162a6ae0c Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 6 Mar 2026 21:11:45 -0700 Subject: [PATCH 045/219] Remove Executor::close() (#50970) Co-Authored-By: Eric Holk In app drop we had been calling `.close()` on the executors. This caused problems with the BackgroundExecutor on Linux because it raced with concurrent work: If task A was running and about to poll task B, the poll to task B would panic with "Task polled after completion". This didn't really matter (because the app was shutting down anyway) but inflated our panic metrics on Linux. It turns out that the call to `.close()` is not needed. It was added to prevent foreground tasks being scheduled after the app was dropped; but on all platforms the App run method does not return until after the ForegroundExecutor is stopped (so no further tasks will run anyway). The background case is more interesting. In test code it didn't matter (the background executor is simulated on the main thread so tests can't leak tasks); in app code it also didn't really make a difference. When `fn main` returns (which it does immediately after the app is dropped) all the background threads will be cancelled anyway. Further confounding debugging, it turns out that the App does not get dropped on macOS and Windows due to a reference cycle; so this was only happening on Linux where the app quit callback is dropped instead of retained after being called. (Fix in #50985) Release Notes: - N/A --------- Co-authored-by: Eric Holk --- crates/gpui/Cargo.toml | 1 + crates/gpui/src/app.rs | 7 - crates/gpui/src/executor.rs | 154 +--------------------- crates/gpui/src/platform_scheduler.rs | 5 +- crates/gpui_linux/src/linux/dispatcher.rs | 10 -- crates/gpui_macos/src/dispatcher.rs | 9 +- crates/gpui_web/src/dispatcher.rs | 20 +-- crates/gpui_windows/src/dispatcher.rs | 8 -- crates/repl/src/repl.rs | 8 +- crates/scheduler/src/executor.rs | 45 +------ crates/scheduler/src/scheduler.rs | 16 +-- crates/scheduler/src/test_scheduler.rs | 4 - 12 files changed, 16 insertions(+), 271 deletions(-) diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index a07eb08576c31236df26787c9c9ade4186c466d6..28350e55702a88a0aef6686f16f45303c99a75d0 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -151,6 +151,7 @@ rand.workspace = true scheduler = { workspace = true, features = ["test-support"] } unicode-segmentation.workspace = true gpui_util = { workspace = true } +proptest = { workspace = true } [target.'cfg(not(target_family = "wasm"))'.dev-dependencies] http_client = { workspace = true, features = ["test-support"] } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index f1fe264f4ef4ccb09081a6672c7c4ddb1d24dc97..dbe221911a2619aad11dbd31f5bbf07ca8b9fb93 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -2613,13 +2613,6 @@ impl<'a, T> Drop for GpuiBorrow<'a, T> { } } -impl Drop for App { - fn drop(&mut self) { - self.foreground_executor.close(); - self.background_executor.close(); - } -} - #[cfg(test)] mod test { use std::{cell::RefCell, rc::Rc}; diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 31c1ed80b92efb5dfa9ead6dcaf9050fe68ea399..cb65f758d5a521f15f77e7be266b1b4ed0480d03 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -129,11 +129,6 @@ impl BackgroundExecutor { } } - /// Close this executor. Tasks will not run after this is called. - pub fn close(&self) { - self.inner.close(); - } - /// Enqueues the given future to be run to completion on a background thread. #[track_caller] pub fn spawn(&self, future: impl Future + Send + 'static) -> Task @@ -173,7 +168,6 @@ impl BackgroundExecutor { { use crate::RunnableMeta; use parking_lot::{Condvar, Mutex}; - use std::sync::{Arc, atomic::AtomicBool}; struct NotifyOnDrop<'a>(&'a (Condvar, Mutex)); @@ -197,14 +191,13 @@ impl BackgroundExecutor { let dispatcher = self.dispatcher.clone(); let location = core::panic::Location::caller(); - let closed = Arc::new(AtomicBool::new(false)); let pair = &(Condvar::new(), Mutex::new(false)); let _wait_guard = WaitOnDrop(pair); let (runnable, task) = unsafe { async_task::Builder::new() - .metadata(RunnableMeta { location, closed }) + .metadata(RunnableMeta { location }) .spawn_unchecked( move |_| async { let _notify_guard = NotifyOnDrop(pair); @@ -404,11 +397,6 @@ impl ForegroundExecutor { } } - /// Close this executor. Tasks will not run after this is called. - pub fn close(&self) { - self.inner.close(); - } - /// Enqueues the given Task to run on the main thread. #[track_caller] pub fn spawn(&self, future: impl Future + 'static) -> Task @@ -595,144 +583,4 @@ mod test { "Task should run normally when app is alive" ); } - - #[test] - fn test_task_cancelled_when_app_dropped() { - let (dispatcher, _background_executor, app) = create_test_app(); - let foreground_executor = app.borrow().foreground_executor.clone(); - let app_weak = Rc::downgrade(&app); - - let task_ran = Rc::new(RefCell::new(false)); - let task_ran_clone = Rc::clone(&task_ran); - - foreground_executor - .spawn(async move { - *task_ran_clone.borrow_mut() = true; - }) - .detach(); - - drop(app); - - assert!(app_weak.upgrade().is_none(), "App should have been dropped"); - - dispatcher.run_until_parked(); - - // The task should have been cancelled, not run - assert!( - !*task_ran.borrow(), - "Task should have been cancelled when app was dropped, but it ran!" - ); - } - - #[test] - fn test_nested_tasks_both_cancel() { - let (dispatcher, _background_executor, app) = create_test_app(); - let foreground_executor = app.borrow().foreground_executor.clone(); - let app_weak = Rc::downgrade(&app); - - let outer_completed = Rc::new(RefCell::new(false)); - let inner_completed = Rc::new(RefCell::new(false)); - let reached_await = Rc::new(RefCell::new(false)); - - let outer_flag = Rc::clone(&outer_completed); - let inner_flag = Rc::clone(&inner_completed); - let await_flag = Rc::clone(&reached_await); - - // Channel to block the inner task until we're ready - let (tx, rx) = futures::channel::oneshot::channel::<()>(); - - let inner_executor = foreground_executor.clone(); - - foreground_executor - .spawn(async move { - let inner_task = inner_executor.spawn({ - let inner_flag = Rc::clone(&inner_flag); - async move { - rx.await.ok(); - *inner_flag.borrow_mut() = true; - } - }); - - *await_flag.borrow_mut() = true; - - inner_task.await; - - *outer_flag.borrow_mut() = true; - }) - .detach(); - - // Run dispatcher until outer task reaches the await point - // The inner task will be blocked on the channel - dispatcher.run_until_parked(); - - // Verify we actually reached the await point before dropping the app - assert!( - *reached_await.borrow(), - "Outer task should have reached the await point" - ); - - // Neither task should have completed yet - assert!( - !*outer_completed.borrow(), - "Outer task should not have completed yet" - ); - assert!( - !*inner_completed.borrow(), - "Inner task should not have completed yet" - ); - - // Drop the channel sender and app while outer is awaiting inner - drop(tx); - drop(app); - assert!(app_weak.upgrade().is_none(), "App should have been dropped"); - - // Run dispatcher - both tasks should be cancelled - dispatcher.run_until_parked(); - - // Neither task should have completed (both were cancelled) - assert!( - !*outer_completed.borrow(), - "Outer task should have been cancelled, not completed" - ); - assert!( - !*inner_completed.borrow(), - "Inner task should have been cancelled, not completed" - ); - } - - #[test] - #[should_panic] - fn test_polling_cancelled_task_panics() { - let (dispatcher, _background_executor, app) = create_test_app(); - let foreground_executor = app.borrow().foreground_executor.clone(); - let app_weak = Rc::downgrade(&app); - - let task = foreground_executor.spawn(async move { 42 }); - - drop(app); - - assert!(app_weak.upgrade().is_none(), "App should have been dropped"); - - dispatcher.run_until_parked(); - - foreground_executor.block_on(task); - } - - #[test] - fn test_polling_cancelled_task_returns_none_with_fallible() { - let (dispatcher, _background_executor, app) = create_test_app(); - let foreground_executor = app.borrow().foreground_executor.clone(); - let app_weak = Rc::downgrade(&app); - - let task = foreground_executor.spawn(async move { 42 }).fallible(); - - drop(app); - - assert!(app_weak.upgrade().is_none(), "App should have been dropped"); - - dispatcher.run_until_parked(); - - let result = foreground_executor.block_on(task); - assert_eq!(result, None, "Cancelled task should return None"); - } } diff --git a/crates/gpui/src/platform_scheduler.rs b/crates/gpui/src/platform_scheduler.rs index 900cd6041d38380f4d9cb3ff9b87a3605b0ebd78..0087c588d8d6381fa1fe590a2366c2e35ffe0a7a 100644 --- a/crates/gpui/src/platform_scheduler.rs +++ b/crates/gpui/src/platform_scheduler.rs @@ -109,16 +109,13 @@ impl Scheduler for PlatformScheduler { #[track_caller] fn timer(&self, duration: Duration) -> Timer { - use std::sync::{Arc, atomic::AtomicBool}; - let (tx, rx) = oneshot::channel(); let dispatcher = self.dispatcher.clone(); // Create a runnable that will send the completion signal let location = std::panic::Location::caller(); - let closed = Arc::new(AtomicBool::new(false)); let (runnable, _task) = async_task::Builder::new() - .metadata(RunnableMeta { location, closed }) + .metadata(RunnableMeta { location }) .spawn( move |_| async move { let _ = tx.send(()); diff --git a/crates/gpui_linux/src/linux/dispatcher.rs b/crates/gpui_linux/src/linux/dispatcher.rs index ff17fd238ae2a4b40ebdf8e36133c05f3e41f9b3..a72276cc7658a399505fa62bd2d5fe7b41e43e14 100644 --- a/crates/gpui_linux/src/linux/dispatcher.rs +++ b/crates/gpui_linux/src/linux/dispatcher.rs @@ -44,11 +44,6 @@ impl LinuxDispatcher { .name(format!("Worker-{i}")) .spawn(move || { for runnable in receiver.iter() { - // Check if the executor that spawned this task was closed - if runnable.metadata().is_closed() { - continue; - } - let start = Instant::now(); let location = runnable.metadata().location; @@ -94,11 +89,6 @@ impl LinuxDispatcher { calloop::timer::Timer::from_duration(timer.duration), move |_, _, _| { if let Some(runnable) = runnable.take() { - // Check if the executor that spawned this task was closed - if runnable.metadata().is_closed() { - return TimeoutAction::Drop; - } - let start = Instant::now(); let location = runnable.metadata().location; let mut timing = TaskTiming { diff --git a/crates/gpui_macos/src/dispatcher.rs b/crates/gpui_macos/src/dispatcher.rs index 07638639e4bf5d3f002c1babfc213bc330e63dce..dd6f546f68b88efe6babc13e2d923d634eff5825 100644 --- a/crates/gpui_macos/src/dispatcher.rs +++ b/crates/gpui_macos/src/dispatcher.rs @@ -201,14 +201,7 @@ extern "C" fn trampoline(context: *mut c_void) { let runnable = unsafe { Runnable::::from_raw(NonNull::new_unchecked(context as *mut ())) }; - let metadata = runnable.metadata(); - - // Check if the executor that spawned this task was closed - if metadata.is_closed() { - return; - } - - let location = metadata.location; + let location = runnable.metadata().location; let start = Instant::now(); let timing = TaskTiming { diff --git a/crates/gpui_web/src/dispatcher.rs b/crates/gpui_web/src/dispatcher.rs index d9419fb35353cfadd809b0bbc1cb9e7dbf124cda..5a0911f7ef1a33d1959de6d03f9f9797978b7a9b 100644 --- a/crates/gpui_web/src/dispatcher.rs +++ b/crates/gpui_web/src/dispatcher.rs @@ -184,10 +184,6 @@ impl WebDispatcher { } }; - if runnable.metadata().is_closed() { - continue; - } - runnable.run(); } }) @@ -263,9 +259,7 @@ impl PlatformDispatcher for WebDispatcher { let millis = duration.as_millis().min(i32::MAX as u128) as i32; if self.on_main_thread() { let callback = Closure::once_into_js(move || { - if !runnable.metadata().is_closed() { - runnable.run(); - } + runnable.run(); }); self.browser_window .set_timeout_with_callback_and_timeout_and_arguments_0( @@ -300,15 +294,11 @@ impl PlatformDispatcher for WebDispatcher { fn execute_on_main_thread(window: &web_sys::Window, item: MainThreadItem) { match item { MainThreadItem::Runnable(runnable) => { - if !runnable.metadata().is_closed() { - runnable.run(); - } + runnable.run(); } MainThreadItem::Delayed { runnable, millis } => { let callback = Closure::once_into_js(move || { - if !runnable.metadata().is_closed() { - runnable.run(); - } + runnable.run(); }); window .set_timeout_with_callback_and_timeout_and_arguments_0( @@ -325,9 +315,7 @@ fn execute_on_main_thread(window: &web_sys::Window, item: MainThreadItem) { fn schedule_runnable(window: &web_sys::Window, runnable: RunnableVariant, priority: Priority) { let callback = Closure::once_into_js(move || { - if !runnable.metadata().is_closed() { - runnable.run(); - } + runnable.run(); }); let callback: &js_sys::Function = callback.unchecked_ref(); diff --git a/crates/gpui_windows/src/dispatcher.rs b/crates/gpui_windows/src/dispatcher.rs index 060cdb7ba626133b9c201980e54bd0479694faa6..a5cfd9dc10d9afcce9580565943c28cb83dc9dab 100644 --- a/crates/gpui_windows/src/dispatcher.rs +++ b/crates/gpui_windows/src/dispatcher.rs @@ -58,10 +58,6 @@ impl WindowsDispatcher { let mut task_wrapper = Some(runnable); WorkItemHandler::new(move |_| { let runnable = task_wrapper.take().unwrap(); - // Check if the executor that spawned this task was closed - if runnable.metadata().is_closed() { - return Ok(()); - } Self::execute_runnable(runnable); Ok(()) }) @@ -75,10 +71,6 @@ impl WindowsDispatcher { let mut task_wrapper = Some(runnable); TimerElapsedHandler::new(move |_| { let runnable = task_wrapper.take().unwrap(); - // Check if the executor that spawned this task was closed - if runnable.metadata().is_closed() { - return Ok(()); - } Self::execute_runnable(runnable); Ok(()) }) diff --git a/crates/repl/src/repl.rs b/crates/repl/src/repl.rs index f17cf8dfba5f5e0e950bd5f2967a6b20d2eebb51..8c3d15a2ad2dfdd18976d750c71e2b3cfb0393a4 100644 --- a/crates/repl/src/repl.rs +++ b/crates/repl/src/repl.rs @@ -46,11 +46,9 @@ fn zed_dispatcher(cx: &mut App) -> impl Dispatcher { impl Dispatcher for ZedDispatcher { #[track_caller] fn dispatch(&self, runnable: Runnable) { - use std::sync::{Arc, atomic::AtomicBool}; let location = core::panic::Location::caller(); - let closed = Arc::new(AtomicBool::new(false)); let (wrapper, task) = async_task::Builder::new() - .metadata(RunnableMeta { location, closed }) + .metadata(RunnableMeta { location }) .spawn(|_| async move { runnable.run() }, { let dispatcher = self.dispatcher.clone(); move |r| dispatcher.dispatch(r, Priority::default()) @@ -61,11 +59,9 @@ fn zed_dispatcher(cx: &mut App) -> impl Dispatcher { #[track_caller] fn dispatch_after(&self, duration: Duration, runnable: Runnable) { - use std::sync::{Arc, atomic::AtomicBool}; let location = core::panic::Location::caller(); - let closed = Arc::new(AtomicBool::new(false)); let (wrapper, task) = async_task::Builder::new() - .metadata(RunnableMeta { location, closed }) + .metadata(RunnableMeta { location }) .spawn(|_| async move { runnable.run() }, { let dispatcher = self.dispatcher.clone(); move |r| dispatcher.dispatch_after(duration, r) diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index 76df2e69f66398e3709e1db58a847b1cd0079fc4..602404142a1f4d19bbce841b3b06996cc2a7427b 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -6,10 +6,7 @@ use std::{ panic::Location, pin::Pin, rc::Rc, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, + sync::Arc, task::{Context, Poll}, thread::{self, ThreadId}, time::Duration, @@ -19,7 +16,6 @@ use std::{ pub struct ForegroundExecutor { session_id: SessionId, scheduler: Arc, - closed: Arc, not_send: PhantomData>, } @@ -28,7 +24,6 @@ impl ForegroundExecutor { Self { session_id, scheduler, - closed: Arc::new(AtomicBool::new(false)), not_send: PhantomData, } } @@ -41,16 +36,6 @@ impl ForegroundExecutor { &self.scheduler } - /// Returns the closed flag for this executor. - pub fn closed(&self) -> &Arc { - &self.closed - } - - /// Close this executor. Tasks will not run after this is called. - pub fn close(&self) { - self.closed.store(true, Ordering::SeqCst); - } - #[track_caller] pub fn spawn(&self, future: F) -> Task where @@ -60,13 +45,12 @@ impl ForegroundExecutor { let session_id = self.session_id; let scheduler = Arc::clone(&self.scheduler); let location = Location::caller(); - let closed = self.closed.clone(); let (runnable, task) = spawn_local_with_source_location( future, move |runnable| { scheduler.schedule_foreground(session_id, runnable); }, - RunnableMeta { location, closed }, + RunnableMeta { location }, ); runnable.schedule(); Task(TaskState::Spawned(task)) @@ -129,25 +113,11 @@ impl ForegroundExecutor { #[derive(Clone)] pub struct BackgroundExecutor { scheduler: Arc, - closed: Arc, } impl BackgroundExecutor { pub fn new(scheduler: Arc) -> Self { - Self { - scheduler, - closed: Arc::new(AtomicBool::new(false)), - } - } - - /// Returns the closed flag for this executor. - pub fn closed(&self) -> &Arc { - &self.closed - } - - /// Close this executor. Tasks will not run after this is called. - pub fn close(&self) { - self.closed.store(true, Ordering::SeqCst); + Self { scheduler } } #[track_caller] @@ -167,9 +137,8 @@ impl BackgroundExecutor { { let scheduler = Arc::clone(&self.scheduler); let location = Location::caller(); - let closed = self.closed.clone(); let (runnable, task) = async_task::Builder::new() - .metadata(RunnableMeta { location, closed }) + .metadata(RunnableMeta { location }) .spawn( move |_| future, move |runnable| { @@ -188,20 +157,16 @@ impl BackgroundExecutor { F::Output: Send + 'static, { let location = Location::caller(); - let closed = self.closed.clone(); let (tx, rx) = flume::bounded::>(1); self.scheduler.spawn_realtime(Box::new(move || { while let Ok(runnable) = rx.recv() { - if runnable.metadata().is_closed() { - continue; - } runnable.run(); } })); let (runnable, task) = async_task::Builder::new() - .metadata(RunnableMeta { location, closed }) + .metadata(RunnableMeta { location }) .spawn( move |_| future, move |runnable| { diff --git a/crates/scheduler/src/scheduler.rs b/crates/scheduler/src/scheduler.rs index 5b1fac258d088d3be7a2254bbf68431cdb507c70..05d285df8d9622ac901618f5543d2f219290ee0d 100644 --- a/crates/scheduler/src/scheduler.rs +++ b/crates/scheduler/src/scheduler.rs @@ -14,10 +14,7 @@ use std::{ future::Future, panic::Location, pin::Pin, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, + sync::Arc, task::{Context, Poll}, time::Duration, }; @@ -62,23 +59,12 @@ impl Priority { pub struct RunnableMeta { /// The source location where the task was spawned. pub location: &'static Location<'static>, - /// Shared flag indicating whether the scheduler has been closed. - /// When true, tasks should be dropped without running. - pub closed: Arc, -} - -impl RunnableMeta { - /// Returns true if the scheduler has been closed and this task should not run. - pub fn is_closed(&self) -> bool { - self.closed.load(Ordering::SeqCst) - } } impl std::fmt::Debug for RunnableMeta { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RunnableMeta") .field("location", &self.location) - .field("closed", &self.is_closed()) .finish() } } diff --git a/crates/scheduler/src/test_scheduler.rs b/crates/scheduler/src/test_scheduler.rs index e4c330dcd162ad6512da05c9e66449fd7da36083..5a14f9c335bfaaa16cbac2344a2d89dd585225a7 100644 --- a/crates/scheduler/src/test_scheduler.rs +++ b/crates/scheduler/src/test_scheduler.rs @@ -320,10 +320,6 @@ impl TestScheduler { }; if let Some(runnable) = runnable { - // Check if the executor that spawned this task was closed - if runnable.runnable.metadata().is_closed() { - return true; - } let is_foreground = runnable.session_id.is_some(); let was_main_thread = self.state.lock().is_main_thread; self.state.lock().is_main_thread = is_foreground; From 5c481c686a7eb542047ed231c1b95c15bdc95b8b Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 6 Mar 2026 21:15:23 -0700 Subject: [PATCH 046/219] Fix retain cycle in on_app_quit (#50985) Updates #50970 While investigating the executor shutdown code, Claude noticed that we never actually drop the app because of a reference cycle. Release Notes: - N/A --- crates/gpui/src/app.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index dbe221911a2619aad11dbd31f5bbf07ca8b9fb93..8af0a8923b38a6f711d701730996afca012fb48b 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -744,9 +744,11 @@ impl App { })); platform.on_quit(Box::new({ - let cx = app.clone(); + let cx = Rc::downgrade(&app); move || { - cx.borrow_mut().shutdown(); + if let Some(cx) = cx.upgrade() { + cx.borrow_mut().shutdown(); + } } })); From 9663d059bcf948689323753012982791e5094ba5 Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki <40514306+aviatesk@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:05:10 +0900 Subject: [PATCH 047/219] extension_api: Add language server schema methods (#48334) (This should be merged after #48332) This PR exposes the LSP settings schema functionality to extensions, allowing them to provide JSON schema for `initialization_options` and `settings` fields to enable autocomplete in settings files. New extension API methods (v0.8.0+): - `language_server_initialization_options_schema` - `language_server_settings_schema` Both methods return an optional JSON string conforming to JSON schema. Older extension versions gracefully return `None`. Release Notes: - Added support for settings schemas for the next version of the extension API so that settings autocompletion can be provided for language server settings. --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: MrSubidubi --- crates/extension/src/extension.rs | 12 ++++ crates/extension_api/src/extension_api.rs | 42 +++++++++++++ .../wit/since_v0.8.0/extension.wit | 10 ++++ crates/extension_host/src/wasm_host.rs | 42 +++++++++++++ crates/extension_host/src/wasm_host/wit.rs | 54 +++++++++++++++++ .../src/json_schema_store.rs | 60 +++++++++++++------ .../src/extension_lsp_adapter.rs | 38 ++++++++++++ .../src/lsp_store/json_language_server_ext.rs | 24 ++++---- 8 files changed, 252 insertions(+), 30 deletions(-) diff --git a/crates/extension/src/extension.rs b/crates/extension/src/extension.rs index 88f2bea0c0c68480a2ad67f536ecf9d465a6a9ae..02db6befb72b53f4610cdfddea80d7c030e5d29a 100644 --- a/crates/extension/src/extension.rs +++ b/crates/extension/src/extension.rs @@ -80,6 +80,18 @@ pub trait Extension: Send + Sync + 'static { worktree: Arc, ) -> Result>; + async fn language_server_initialization_options_schema( + &self, + language_server_id: LanguageServerName, + worktree: Arc, + ) -> Result>; + + async fn language_server_workspace_configuration_schema( + &self, + language_server_id: LanguageServerName, + worktree: Arc, + ) -> Result>; + async fn language_server_additional_initialization_options( &self, language_server_id: LanguageServerName, diff --git a/crates/extension_api/src/extension_api.rs b/crates/extension_api/src/extension_api.rs index acd1cba47b0150b85ddec8baafa8b5f341460a39..6607cdc9697d017ac51818bb277a1392a8d67d01 100644 --- a/crates/extension_api/src/extension_api.rs +++ b/crates/extension_api/src/extension_api.rs @@ -100,6 +100,28 @@ pub trait Extension: Send + Sync { Ok(None) } + /// Returns the JSON schema for the initialization options. + /// + /// The schema must conform to the JSON Schema speification. + fn language_server_initialization_options_schema( + &mut self, + _language_server_id: &LanguageServerId, + _worktree: &Worktree, + ) -> Option { + None + } + + /// Returns the JSON schema for the workspace configuration. + /// + /// The schema must conform to the JSON Schema specification. + fn language_server_workspace_configuration_schema( + &mut self, + _language_server_id: &LanguageServerId, + _worktree: &Worktree, + ) -> Option { + None + } + /// Returns the initialization options to pass to the other language server. fn language_server_additional_initialization_options( &mut self, @@ -370,6 +392,26 @@ impl wit::Guest for Component { .and_then(|value| serde_json::to_string(&value).ok())) } + fn language_server_initialization_options_schema( + language_server_id: String, + worktree: &Worktree, + ) -> Option { + let language_server_id = LanguageServerId(language_server_id); + extension() + .language_server_initialization_options_schema(&language_server_id, worktree) + .and_then(|value| serde_json::to_string(&value).ok()) + } + + fn language_server_workspace_configuration_schema( + language_server_id: String, + worktree: &Worktree, + ) -> Option { + let language_server_id = LanguageServerId(language_server_id); + extension() + .language_server_workspace_configuration_schema(&language_server_id, worktree) + .and_then(|value| serde_json::to_string(&value).ok()) + } + fn language_server_additional_initialization_options( language_server_id: String, target_language_server_id: String, diff --git a/crates/extension_api/wit/since_v0.8.0/extension.wit b/crates/extension_api/wit/since_v0.8.0/extension.wit index fc2735c72b463225feed0d371ae8274b56c78be1..052d670364b6958b51184def893c49f5b6abdc9e 100644 --- a/crates/extension_api/wit/since_v0.8.0/extension.wit +++ b/crates/extension_api/wit/since_v0.8.0/extension.wit @@ -101,6 +101,16 @@ world extension { /// Returns the workspace configuration options to pass to the language server. export language-server-workspace-configuration: func(language-server-id: string, worktree: borrow) -> result, string>; + /// Returns the JSON schema for the initialization options. + /// + /// The schema is represented as a JSON string conforming to the JSON Schema specification. + export language-server-initialization-options-schema: func(language-server-id: string, worktree: borrow) -> option; + + /// Returns the JSON schema for the workspace configuration. + /// + /// The schema is represented as a JSON string conforming to the JSON Schema specification. + export language-server-workspace-configuration-schema: func(language-server-id: string, worktree: borrow) -> option; + /// Returns the initialization options to pass to the other language server. export language-server-additional-initialization-options: func(language-server-id: string, target-language-server-id: string, worktree: borrow) -> result, string>; diff --git a/crates/extension_host/src/wasm_host.rs b/crates/extension_host/src/wasm_host.rs index fe3c11de3ae78115b8e5db08884b7e07be152324..286639cdd67d716b1137290baf269670ecddebe7 100644 --- a/crates/extension_host/src/wasm_host.rs +++ b/crates/extension_host/src/wasm_host.rs @@ -159,6 +159,48 @@ impl extension::Extension for WasmExtension { .await? } + async fn language_server_initialization_options_schema( + &self, + language_server_id: LanguageServerName, + worktree: Arc, + ) -> Result> { + self.call(|extension, store| { + async move { + let resource = store.data_mut().table().push(worktree)?; + extension + .call_language_server_initialization_options_schema( + store, + &language_server_id, + resource, + ) + .await + } + .boxed() + }) + .await? + } + + async fn language_server_workspace_configuration_schema( + &self, + language_server_id: LanguageServerName, + worktree: Arc, + ) -> Result> { + self.call(|extension, store| { + async move { + let resource = store.data_mut().table().push(worktree)?; + extension + .call_language_server_workspace_configuration_schema( + store, + &language_server_id, + resource, + ) + .await + } + .boxed() + }) + .await? + } + async fn language_server_additional_initialization_options( &self, language_server_id: LanguageServerName, diff --git a/crates/extension_host/src/wasm_host/wit.rs b/crates/extension_host/src/wasm_host/wit.rs index ddd3f604c991a43bc58f494410db1be22a93a772..9c4d3aa298c366ae91d0f8195ed090d74099c6d0 100644 --- a/crates/extension_host/src/wasm_host/wit.rs +++ b/crates/extension_host/src/wasm_host/wit.rs @@ -465,6 +465,60 @@ impl Extension { } } + pub async fn call_language_server_initialization_options_schema( + &self, + store: &mut Store, + language_server_id: &LanguageServerName, + resource: Resource>, + ) -> Result> { + match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_initialization_options_schema( + store, + &language_server_id.0, + resource, + ) + .await + } + Extension::V0_6_0(_) + | Extension::V0_5_0(_) + | Extension::V0_4_0(_) + | Extension::V0_3_0(_) + | Extension::V0_2_0(_) + | Extension::V0_1_0(_) + | Extension::V0_0_6(_) + | Extension::V0_0_4(_) + | Extension::V0_0_1(_) => Ok(None), + } + } + + pub async fn call_language_server_workspace_configuration_schema( + &self, + store: &mut Store, + language_server_id: &LanguageServerName, + resource: Resource>, + ) -> Result> { + match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_workspace_configuration_schema( + store, + &language_server_id.0, + resource, + ) + .await + } + Extension::V0_6_0(_) + | Extension::V0_5_0(_) + | Extension::V0_4_0(_) + | Extension::V0_3_0(_) + | Extension::V0_2_0(_) + | Extension::V0_1_0(_) + | Extension::V0_0_6(_) + | Extension::V0_0_4(_) + | Extension::V0_0_1(_) => Ok(None), + } + } + pub async fn call_language_server_additional_initialization_options( &self, store: &mut Store, diff --git a/crates/json_schema_store/src/json_schema_store.rs b/crates/json_schema_store/src/json_schema_store.rs index 756f64b2fb1bac13fc6d2868989504a3f8241281..c13f42f9bb7d92b7c136815f720abfe6ec6faac3 100644 --- a/crates/json_schema_store/src/json_schema_store.rs +++ b/crates/json_schema_store/src/json_schema_store.rs @@ -67,25 +67,22 @@ pub fn init(cx: &mut App) { .detach(); if let Some(extension_events) = extension::ExtensionEvents::try_global(cx) { - cx.subscribe(&extension_events, move |_, evt, cx| { - match evt { - extension::Event::ExtensionInstalled(_) - | extension::Event::ExtensionUninstalled(_) - | extension::Event::ConfigureExtensionRequested(_) => return, - extension::Event::ExtensionsInstalledChanged => {} + cx.subscribe(&extension_events, move |_, evt, cx| match evt { + extension::Event::ExtensionsInstalledChanged => { + cx.update_global::(|schema_store, cx| { + schema_store.notify_schema_changed(ChangedSchemas::Settings, cx); + }); } - cx.update_global::(|schema_store, cx| { - schema_store.notify_schema_changed(&format!("{SCHEMA_URI_PREFIX}settings"), cx); - schema_store - .notify_schema_changed(&format!("{SCHEMA_URI_PREFIX}project_settings"), cx); - }); + extension::Event::ExtensionUninstalled(_) + | extension::Event::ExtensionInstalled(_) + | extension::Event::ConfigureExtensionRequested(_) => {} }) .detach(); } cx.observe_global::(move |cx| { cx.update_global::(|schema_store, cx| { - schema_store.notify_schema_changed(&format!("{SCHEMA_URI_PREFIX}debug_tasks"), cx); + schema_store.notify_schema_changed(ChangedSchemas::DebugTasks, cx); }); }) .detach(); @@ -98,18 +95,42 @@ pub struct SchemaStore { impl gpui::Global for SchemaStore {} +enum ChangedSchemas { + Settings, + DebugTasks, +} + impl SchemaStore { - fn notify_schema_changed(&mut self, uri: &str, cx: &mut App) { - DYNAMIC_SCHEMA_CACHE.write().remove(uri); + fn notify_schema_changed(&mut self, changed_schemas: ChangedSchemas, cx: &mut App) { + let uris_to_invalidate = match changed_schemas { + ChangedSchemas::Settings => { + let settings_uri_prefix = &format!("{SCHEMA_URI_PREFIX}settings"); + let project_settings_uri = &format!("{SCHEMA_URI_PREFIX}project_settings"); + DYNAMIC_SCHEMA_CACHE + .write() + .extract_if(|uri, _| { + uri == project_settings_uri || uri.starts_with(settings_uri_prefix) + }) + .map(|(url, _)| url) + .collect() + } + ChangedSchemas::DebugTasks => DYNAMIC_SCHEMA_CACHE + .write() + .remove_entry(&format!("{SCHEMA_URI_PREFIX}debug_tasks")) + .map_or_else(Vec::new, |(uri, _)| vec![uri]), + }; + + if uris_to_invalidate.is_empty() { + return; + } - let uri = uri.to_string(); self.lsp_stores.retain(|lsp_store| { let Some(lsp_store) = lsp_store.upgrade() else { return false; }; - project::lsp_store::json_language_server_ext::notify_schema_changed( + project::lsp_store::json_language_server_ext::notify_schemas_changed( lsp_store, - uri.clone(), + &uris_to_invalidate, cx, ); true @@ -238,7 +259,8 @@ async fn resolve_dynamic_schema( (adapter_name, LspSchemaKind::Settings) } else { anyhow::bail!( - "Invalid LSP schema path: expected '{{adapter}}/initialization_options' or '{{adapter}}/settings', got '{}'", + "Invalid LSP schema path: \ + Expected '{{adapter}}/initialization_options' or '{{adapter}}/settings', got '{}'", lsp_path ); }; @@ -484,7 +506,7 @@ pub fn all_schema_file_associations( let file_name = normalized_action_name_to_file_name(normalized_name.clone()); serde_json::json!({ "fileMatch": [file_name], - "url": format!("{}action/{normalized_name}", SCHEMA_URI_PREFIX) + "url": format!("{SCHEMA_URI_PREFIX}action/{normalized_name}") }) })); diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 6f5300991fd8afbfaba710ed2bde068dd4d3a969..88401906fc28bb297fc2798346e110c9651b1387 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -350,6 +350,44 @@ impl LspAdapter for ExtensionLspAdapter { }) } + async fn initialization_options_schema( + self: Arc, + delegate: &Arc, + _cached_binary: OwnedMutexGuard>, + _cx: &mut AsyncApp, + ) -> Option { + let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; + let json_schema: Option = self + .extension + .language_server_initialization_options_schema( + self.language_server_id.clone(), + delegate, + ) + .await + .ok() + .flatten(); + json_schema.and_then(|s| serde_json::from_str(&s).ok()) + } + + async fn settings_schema( + self: Arc, + delegate: &Arc, + _cached_binary: OwnedMutexGuard>, + _cx: &mut AsyncApp, + ) -> Option { + let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; + let json_schema: Option = self + .extension + .language_server_workspace_configuration_schema( + self.language_server_id.clone(), + delegate, + ) + .await + .ok() + .flatten(); + json_schema.and_then(|s| serde_json::from_str(&s).ok()) + } + async fn additional_initialization_options( self: Arc, target_language_server_id: LanguageServerName, diff --git a/crates/project/src/lsp_store/json_language_server_ext.rs b/crates/project/src/lsp_store/json_language_server_ext.rs index 13c3aeb2b1ab2f4ab5f22a3cd065d4d0ff4bcb38..1f2fa0330b75deeb41342ae2401ddc8dbe05159c 100644 --- a/crates/project/src/lsp_store/json_language_server_ext.rs +++ b/crates/project/src/lsp_store/json_language_server_ext.rs @@ -42,8 +42,8 @@ impl lsp::notification::Notification for SchemaContentsChanged { type Params = String; } -pub fn notify_schema_changed(lsp_store: Entity, uri: String, cx: &App) { - zlog::trace!(LOGGER => "Notifying schema changed for URI: {:?}", uri); +pub fn notify_schemas_changed(lsp_store: Entity, uris: &[String], cx: &App) { + zlog::trace!(LOGGER => "Notifying schema changes for URIs: {:?}", uris); let servers = lsp_store.read_with(cx, |lsp_store, _| { let mut servers = Vec::new(); let Some(local) = lsp_store.as_local() else { @@ -63,16 +63,18 @@ pub fn notify_schema_changed(lsp_store: Entity, uri: String, cx: &App) servers }); for server in servers { - zlog::trace!(LOGGER => "Notifying server {NAME} (id {ID:?}) of schema change for URI: {uri:?}", - NAME = server.name(), - ID = server.server_id() - ); - if let Err(error) = server.notify::(uri.clone()) { - zlog::error!( - LOGGER => "Failed to notify server {NAME} (id {ID:?}) of schema change for URI {uri:?}: {error:#}", - NAME = server.name(), - ID = server.server_id(), + for uri in uris { + zlog::trace!(LOGGER => "Notifying server {NAME} (id {ID:?}) of schema change for URI: {uri:?}", + NAME = server.name(), + ID = server.server_id() ); + if let Err(error) = server.notify::(uri.clone()) { + zlog::error!( + LOGGER => "Failed to notify server {NAME} (id {ID:?}) of schema change for URI {uri:?}: {error:#}", + NAME = server.name(), + ID = server.server_id(), + ); + } } } } From ba8f4d839af31b5d1cadc43f9a802915c4e4126d Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Sat, 7 Mar 2026 23:38:28 +0530 Subject: [PATCH 048/219] git_ui: Fix mouse cursor hiding when clicking git entry in project diff (#51016) Release Notes: - Fixed mouse cursor disappearing when clicking a changed file in the Git Changes panel. --- crates/git_ui/src/project_diff.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index f62b08e4c0d99db7d2e60e6aac730a69b139cca3..ad7d6b86befd0b0f4a1ecf6386c030d4294cdf5e 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -517,7 +517,11 @@ impl ProjectDiff { fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.rhs_editor().update(cx, |editor, cx| { - editor.move_to_beginning(&Default::default(), window, cx); + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(vec![ + multi_buffer::Anchor::min()..multi_buffer::Anchor::min(), + ]); + }); }); }); } From 8d083a639cf42c760fe337ab4486d9f6eb55ddd1 Mon Sep 17 00:00:00 2001 From: John Tur Date: Sat, 7 Mar 2026 20:51:22 -0500 Subject: [PATCH 049/219] Lazily initialize kernelspecs (#51026) On Windows, the WSL VM always boots up when Zed is opened, because we eagerly discover which jupyter kernels are installed inside WSL on startup. This is not desirable if the REPL feature is not being used. Defer this work to the point where we actually need to know what kernels are installed. Release Notes: - N/A --- crates/repl/src/components/kernel_options.rs | 4 +++- crates/repl/src/repl_editor.rs | 1 + crates/repl/src/repl_sessions_ui.rs | 3 ++- crates/repl/src/repl_store.rs | 17 ++++++++++------- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index 45e55f0d5f8a17d66a76d206216c07ba7cc36e8a..b6d4f39c0ccb75619a7e4efd6a532202893c8722 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -448,7 +448,9 @@ where TT: Fn(&mut Window, &mut App) -> AnyView + 'static, { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let store = ReplStore::global(cx).read(cx); + let store = ReplStore::global(cx); + store.update(cx, |store, cx| store.ensure_kernelspecs(cx)); + let store = store.read(cx); let all_entries = build_grouped_entries(store, self.worktree_id); let selected_kernelspec = store.active_kernelspec(self.worktree_id, None, cx); diff --git a/crates/repl/src/repl_editor.rs b/crates/repl/src/repl_editor.rs index 56b79e20ffca74ab3f9f9c7948a7caeffc4ad4ce..cf1493000edb5881bff412224f7e44dbfbf88b25 100644 --- a/crates/repl/src/repl_editor.rs +++ b/crates/repl/src/repl_editor.rs @@ -191,6 +191,7 @@ pub fn run( if !store.read(cx).is_enabled() { return Ok(()); } + store.update(cx, |store, cx| store.ensure_kernelspecs(cx)); let editor = editor.upgrade().context("editor was dropped")?; let selected_range = editor diff --git a/crates/repl/src/repl_sessions_ui.rs b/crates/repl/src/repl_sessions_ui.rs index 1dc2107adde84d4625ffee489805570cd7e5f791..9781382fc85d5da549a65dce2ca06fef4a3bff15 100644 --- a/crates/repl/src/repl_sessions_ui.rs +++ b/crates/repl/src/repl_sessions_ui.rs @@ -204,7 +204,8 @@ impl Render for ReplSessionsPage { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let store = ReplStore::global(cx); - let (kernel_specifications, sessions) = store.update(cx, |store, _cx| { + let (kernel_specifications, sessions) = store.update(cx, |store, cx| { + store.ensure_kernelspecs(cx); ( store .pure_jupyter_kernel_specifications() diff --git a/crates/repl/src/repl_store.rs b/crates/repl/src/repl_store.rs index ff0a2793617982e75d6c81d6c3a180d2f9b3c8ee..800bab030143de70f08ce2c020bd3095b6767e16 100644 --- a/crates/repl/src/repl_store.rs +++ b/crates/repl/src/repl_store.rs @@ -27,6 +27,7 @@ pub struct ReplStore { enabled: bool, sessions: HashMap>, kernel_specifications: Vec, + kernelspecs_initialized: bool, selected_kernel_for_worktree: HashMap, kernel_specifications_for_worktree: HashMap>, active_python_toolchain_for_worktree: HashMap, @@ -39,12 +40,6 @@ impl ReplStore { pub(crate) fn init(fs: Arc, cx: &mut App) { let store = cx.new(move |cx| Self::new(fs, cx)); - - #[cfg(not(feature = "test-support"))] - store - .update(cx, |store, cx| store.refresh_kernelspecs(cx)) - .detach_and_log_err(cx); - cx.set_global(GlobalReplStore(store)) } @@ -65,6 +60,7 @@ impl ReplStore { enabled: JupyterSettings::enabled(cx), sessions: HashMap::default(), kernel_specifications: Vec::new(), + kernelspecs_initialized: false, _subscriptions: subscriptions, kernel_specifications_for_worktree: HashMap::default(), selected_kernel_for_worktree: HashMap::default(), @@ -216,10 +212,17 @@ impl ReplStore { } } + pub fn ensure_kernelspecs(&mut self, cx: &mut Context) { + if self.kernelspecs_initialized { + return; + } + self.kernelspecs_initialized = true; + self.refresh_kernelspecs(cx).detach_and_log_err(cx); + } + pub fn refresh_kernelspecs(&mut self, cx: &mut Context) -> Task> { let local_kernel_specifications = local_kernel_specifications(self.fs.clone()); let wsl_kernel_specifications = wsl_kernel_specifications(cx.background_executor().clone()); - let remote_kernel_specifications = self.get_remote_kernel_specifications(cx); let all_specs = cx.background_spawn(async move { From 4031db17df9bc09d263e1b7d116c2cc2f27a45e3 Mon Sep 17 00:00:00 2001 From: John Tur Date: Sat, 7 Mar 2026 23:54:05 -0500 Subject: [PATCH 050/219] Disable the IME on Windows when text input is unexpected (#51041) Fixes #42444 - Changed `accepts_text_input` on the editor to be more precise. Previously, it returned `true` only in insert mode. Now it also returns `true` when an operator is pending. - On Windows, we disable the IME whenever there is no input handler which `accepts_text_input`. - How this improves Vim mode: in insert mode, the IME is enabled; in normal mode, it is disabled (command keys are not intercepted); when an operator is pending, the IME is re-enabled. Release Notes: - On Windows, the IME is disabled in Vim normal and visual modes. --- crates/editor/src/editor.rs | 8 ++- crates/gpui/src/platform.rs | 7 ++ crates/gpui_windows/src/events.rs | 103 ++++++++++++++++++++++++------ crates/gpui_windows/src/window.rs | 2 + crates/vim/src/vim.rs | 13 ++++ 5 files changed, 111 insertions(+), 22 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 94c7bb06eb98f56e05ff96bd3b64d96d2397730b..ead4f97ee351246f4d00f4275c4a736c7ffa4926 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1233,6 +1233,7 @@ pub struct Editor { autoindent_mode: Option, workspace: Option<(WeakEntity, Option)>, input_enabled: bool, + expects_character_input: bool, use_modal_editing: bool, read_only: bool, leader_id: Option, @@ -2469,6 +2470,7 @@ impl Editor { collapse_matches: false, workspace: None, input_enabled: !is_minimap, + expects_character_input: !is_minimap, use_modal_editing: full_mode, read_only: is_minimap, use_autoclose: true, @@ -3365,6 +3367,10 @@ impl Editor { self.input_enabled = input_enabled; } + pub fn set_expects_character_input(&mut self, expects_character_input: bool) { + self.expects_character_input = expects_character_input; + } + pub fn set_edit_predictions_hidden_for_vim_mode( &mut self, hidden: bool, @@ -28409,7 +28415,7 @@ impl EntityInputHandler for Editor { } fn accepts_text_input(&self, _window: &mut Window, _cx: &mut Context) -> bool { - self.input_enabled + self.expects_character_input } } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index a6714ff250f2f854c51d30bfea5e2e5911ce60ee..061a055e7ef23bc4a76b44eaadb90bc1660fdb42 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1062,6 +1062,13 @@ impl PlatformInputHandler { pub fn accepts_text_input(&mut self, window: &mut Window, cx: &mut App) -> bool { self.handler.accepts_text_input(window, cx) } + + #[allow(dead_code)] + pub fn query_accepts_text_input(&mut self) -> bool { + self.cx + .update(|window, cx| self.handler.accepts_text_input(window, cx)) + .unwrap_or(true) + } } /// A struct representing a selection in a text buffer, in UTF16 characters. diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 6bc7b73cc756b44b08ddf7abc5f668681c03dcb9..3506ae2a2cc22d57c4cefba1a4c5a1850c411453 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -593,33 +593,63 @@ impl WindowsWindowInner { } pub(crate) fn update_ime_position(&self, handle: HWND, caret_position: POINT) { + let Some(ctx) = ImeContext::get(handle) else { + return; + }; unsafe { - let ctx = ImmGetContext(handle); - if ctx.is_invalid() { - return; - } + ImmSetCompositionWindow( + *ctx, + &COMPOSITIONFORM { + dwStyle: CFS_POINT, + ptCurrentPos: caret_position, + ..Default::default() + }, + ) + .ok() + .log_err(); - let config = COMPOSITIONFORM { - dwStyle: CFS_POINT, - ptCurrentPos: caret_position, - ..Default::default() - }; - ImmSetCompositionWindow(ctx, &config).ok().log_err(); - let config = CANDIDATEFORM { - dwStyle: CFS_CANDIDATEPOS, - ptCurrentPos: caret_position, - ..Default::default() - }; - ImmSetCandidateWindow(ctx, &config).ok().log_err(); - ImmReleaseContext(handle, ctx).ok().log_err(); + ImmSetCandidateWindow( + *ctx, + &CANDIDATEFORM { + dwStyle: CFS_CANDIDATEPOS, + ptCurrentPos: caret_position, + ..Default::default() + }, + ) + .ok() + .log_err(); + } + } + + fn update_ime_enabled(&self, handle: HWND) { + let ime_enabled = self + .with_input_handler(|input_handler| input_handler.query_accepts_text_input()) + .unwrap_or(false); + if ime_enabled == self.state.ime_enabled.get() { + return; + } + self.state.ime_enabled.set(ime_enabled); + unsafe { + if ime_enabled { + ImmAssociateContextEx(handle, HIMC::default(), IACE_DEFAULT) + .ok() + .log_err(); + } else { + if let Some(ctx) = ImeContext::get(handle) { + ImmNotifyIME(*ctx, NI_COMPOSITIONSTR, CPS_COMPLETE, 0) + .ok() + .log_err(); + } + ImmAssociateContextEx(handle, HIMC::default(), 0) + .ok() + .log_err(); + } } } fn handle_ime_composition(&self, handle: HWND, lparam: LPARAM) -> Option { - let ctx = unsafe { ImmGetContext(handle) }; - let result = self.handle_ime_composition_inner(ctx, lparam); - unsafe { ImmReleaseContext(handle, ctx).ok().log_err() }; - result + let ctx = ImeContext::get(handle)?; + self.handle_ime_composition_inner(*ctx, lparam) } fn handle_ime_composition_inner(&self, ctx: HIMC, lparam: LPARAM) -> Option { @@ -1123,6 +1153,7 @@ impl WindowsWindowInner { }); self.state.callbacks.request_frame.set(Some(request_frame)); + self.update_ime_enabled(handle); unsafe { ValidateRect(Some(handle), None).ok().log_err() }; Some(0) @@ -1205,6 +1236,36 @@ impl WindowsWindowInner { } } +struct ImeContext { + hwnd: HWND, + himc: HIMC, +} + +impl ImeContext { + fn get(hwnd: HWND) -> Option { + let himc = unsafe { ImmGetContext(hwnd) }; + if himc.is_invalid() { + return None; + } + Some(Self { hwnd, himc }) + } +} + +impl std::ops::Deref for ImeContext { + type Target = HIMC; + fn deref(&self) -> &HIMC { + &self.himc + } +} + +impl Drop for ImeContext { + fn drop(&mut self) { + unsafe { + ImmReleaseContext(self.hwnd, self.himc).ok().log_err(); + } + } +} + fn handle_key_event( wparam: WPARAM, lparam: LPARAM, diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 02653d7e53a4356979b81897b39ab0393bbf54a9..62e88c47dfc10fedf6d636e2c6d6cbdcdc2e37c5 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -52,6 +52,7 @@ pub struct WindowsWindowState { pub callbacks: Callbacks, pub input_handler: Cell>, + pub ime_enabled: Cell, pub pending_surrogate: Cell>, pub last_reported_modifiers: Cell>, pub last_reported_capslock: Cell>, @@ -142,6 +143,7 @@ impl WindowsWindowState { min_size, callbacks, input_handler: Cell::new(input_handler), + ime_enabled: Cell::new(true), pending_surrogate: Cell::new(pending_surrogate), last_reported_modifiers: Cell::new(last_reported_modifiers), last_reported_capslock: Cell::new(last_reported_capslock), diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index edbbca1c30fb1bda0bedc35d0de6666228b9ef5d..8c551bcd2768043ae416157c80d4d2f9faa19092 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -978,6 +978,7 @@ impl Vim { editor.set_clip_at_line_ends(false, cx); editor.set_collapse_matches(false); editor.set_input_enabled(true); + editor.set_expects_character_input(true); editor.set_autoindent(true); editor.selections.set_line_mode(false); editor.unregister_addon::(); @@ -1346,6 +1347,15 @@ impl Vim { } } + fn expects_character_input(&self) -> bool { + if let Some(operator) = self.operator_stack.last() { + if operator.is_waiting(self.mode) { + return true; + } + } + self.editor_input_enabled() + } + pub fn editor_input_enabled(&self) -> bool { match self.mode { Mode::Insert => { @@ -2058,6 +2068,7 @@ impl Vim { clip_at_line_ends: self.clip_at_line_ends(), collapse_matches: !HelixModeSetting::get_global(cx).0, input_enabled: self.editor_input_enabled(), + expects_character_input: self.expects_character_input(), autoindent: self.should_autoindent(), cursor_offset_on_selection: self.mode.is_visual(), line_mode: matches!(self.mode, Mode::VisualLine), @@ -2075,6 +2086,7 @@ impl Vim { editor.set_clip_at_line_ends(state.clip_at_line_ends, cx); editor.set_collapse_matches(state.collapse_matches); editor.set_input_enabled(state.input_enabled); + editor.set_expects_character_input(state.expects_character_input); editor.set_autoindent(state.autoindent); editor.set_cursor_offset_on_selection(state.cursor_offset_on_selection); editor.selections.set_line_mode(state.line_mode); @@ -2087,6 +2099,7 @@ struct VimEditorSettingsState { clip_at_line_ends: bool, collapse_matches: bool, input_enabled: bool, + expects_character_input: bool, autoindent: bool, cursor_offset_on_selection: bool, line_mode: bool, From 50ca710f515ada2d00803ccc9a8900981ed5eb50 Mon Sep 17 00:00:00 2001 From: Leonard Seibold Date: Sun, 8 Mar 2026 06:39:04 +0100 Subject: [PATCH 051/219] gpui(linux): Pass display_id to layer shell get_layer_surface (#50520) Previously, `get_layer_surface` always passed `None` as the output, causing all layer shell windows to appear on the compositor's default output regardless of the requested display. Store `wl_output` proxies in client state and resolve them from `DisplayId` so the correct output is passed to `get_layer_surface`. Release Notes: - N/A --- crates/gpui_linux/src/linux/wayland/client.rs | 16 ++++++++++++++++ crates/gpui_linux/src/linux/wayland/window.rs | 12 +++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 02da0190b3b198f8ae04761f1d159872627309d5..8dd48b878cc1ffcb87201e9b1b252966bfce5efb 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -221,6 +221,7 @@ pub(crate) struct WaylandClientState { // Output to scale mapping outputs: HashMap, in_progress_outputs: HashMap, + wl_outputs: HashMap, keyboard_layout: LinuxKeyboardLayout, keymap_state: Option, compose_state: Option, @@ -463,6 +464,8 @@ impl WaylandClient { let mut seat: Option = None; #[allow(clippy::mutable_key_type)] let mut in_progress_outputs = HashMap::default(); + #[allow(clippy::mutable_key_type)] + let mut wl_outputs: HashMap = HashMap::default(); globals.contents().with_list(|list| { for global in list { match &global.interface[..] { @@ -482,6 +485,7 @@ impl WaylandClient { (), ); in_progress_outputs.insert(output.id(), InProgressOutput::default()); + wl_outputs.insert(output.id(), output); } _ => {} } @@ -589,6 +593,7 @@ impl WaylandClient { composing: false, outputs: HashMap::default(), in_progress_outputs, + wl_outputs, windows: HashMap::default(), common, keyboard_layout: LinuxKeyboardLayout::new(UNKNOWN_KEYBOARD_LAYOUT_NAME), @@ -720,6 +725,15 @@ impl LinuxClient for WaylandClient { let parent = state.keyboard_focused_window.clone(); + let target_output = params.display_id.and_then(|display_id| { + let target_protocol_id: u32 = display_id.into(); + state + .wl_outputs + .iter() + .find(|(id, _)| id.protocol_id() == target_protocol_id) + .map(|(_, output)| output.clone()) + }); + let appearance = state.common.appearance; let compositor_gpu = state.compositor_gpu.take(); let (window, surface_id) = WaylandWindow::new( @@ -731,6 +745,7 @@ impl LinuxClient for WaylandClient { params, appearance, parent, + target_output, )?; state.windows.insert(surface_id, window.0.clone()); @@ -1020,6 +1035,7 @@ impl Dispatch for WaylandClientStat state .in_progress_outputs .insert(output.id(), InProgressOutput::default()); + state.wl_outputs.insert(output.id(), output); } _ => {} }, diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 201ce7d2dc07f33faabf30e32094d9f3a135711c..71a4ee2ab5033a69c5872fab631fd13af6c82b0e 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -12,7 +12,10 @@ use futures::channel::oneshot::Receiver; use raw_window_handle as rwh; use wayland_backend::client::ObjectId; use wayland_client::WEnum; -use wayland_client::{Proxy, protocol::wl_surface}; +use wayland_client::{ + Proxy, + protocol::{wl_output, wl_surface}, +}; use wayland_protocols::wp::viewporter::client::wp_viewport; use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1; use wayland_protocols::xdg::shell::client::xdg_surface; @@ -129,6 +132,7 @@ impl WaylandSurfaceState { globals: &Globals, params: &WindowParams, parent: Option, + target_output: Option, ) -> anyhow::Result { // For layer_shell windows, create a layer surface instead of an xdg surface if let WindowKind::LayerShell(options) = ¶ms.kind { @@ -138,7 +142,7 @@ impl WaylandSurfaceState { let layer_surface = layer_shell.get_layer_surface( &surface, - None, + target_output.as_ref(), super::layer_shell::wayland_layer(options.layer), options.namespace.clone(), &globals.qh, @@ -494,9 +498,11 @@ impl WaylandWindow { params: WindowParams, appearance: WindowAppearance, parent: Option, + target_output: Option, ) -> anyhow::Result<(Self, ObjectId)> { let surface = globals.compositor.create_surface(&globals.qh, ()); - let surface_state = WaylandSurfaceState::new(&surface, &globals, ¶ms, parent.clone())?; + let surface_state = + WaylandSurfaceState::new(&surface, &globals, ¶ms, parent.clone(), target_output)?; if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() { fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id()); From af2b35a83ddbdcb3132c044b22696a386d3990cd Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Sun, 8 Mar 2026 11:27:10 +0200 Subject: [PATCH 052/219] ep: Make repair parser more robust (#51044) Release Notes: - N/A --- crates/edit_prediction_cli/src/repair.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/edit_prediction_cli/src/repair.rs b/crates/edit_prediction_cli/src/repair.rs index b6ad41d553dabf1e49f261f4cc745395fdb1d1f6..9d891314bc62a44e730b584cea3423df665dc381 100644 --- a/crates/edit_prediction_cli/src/repair.rs +++ b/crates/edit_prediction_cli/src/repair.rs @@ -227,16 +227,17 @@ pub fn needs_repair(example: &Example, confidence_threshold: u8) -> bool { /// Handles the `KEEP_PREVIOUS` sentinel by copying the teacher's prediction, /// and delegates normal output to `TeacherPrompt::parse`. pub fn parse(example: &Example, actual_output: &str) -> Result<(String, Option)> { - if let Some(last_codeblock) = extract_last_codeblock(actual_output) { - if last_codeblock.trim() == KEEP_PREVIOUS { - let original = example - .predictions - .first() - .context("no original prediction to keep")?; - let patch = original.actual_patch.clone().unwrap_or_default(); - let cursor = original.actual_cursor.clone(); - return Ok((patch, cursor)); - } + let last_codeblock = + extract_last_codeblock(actual_output).unwrap_or_else(|| actual_output.to_string()); + + if last_codeblock.contains(KEEP_PREVIOUS) { + let original = example + .predictions + .first() + .context("no original prediction to keep")?; + let patch = original.actual_patch.clone().unwrap_or_default(); + let cursor = original.actual_cursor.clone(); + return Ok((patch, cursor)); } TeacherPrompt::parse(example, actual_output) From dde76cd5f8a1cad88f7231681cf146f2bb697ea5 Mon Sep 17 00:00:00 2001 From: John Tur Date: Sun, 8 Mar 2026 05:34:46 -0400 Subject: [PATCH 053/219] Enable extended reasoning for Anthropic models in Copilot (#46540) Fixes https://github.com/zed-industries/zed/issues/45668 https://github.com/microsoft/vscode-copilot-chat used as a reference for headers and properties we need to set | Before | After | | --- | --- | | | | Release Notes: - Enabled thinking mode when using Anthropic models with Copilot --- Cargo.lock | 1 + crates/anthropic/src/anthropic.rs | 2 +- crates/copilot_chat/Cargo.toml | 1 + crates/copilot_chat/src/copilot_chat.rs | 185 ++++++++++++++++- crates/copilot_chat/src/responses.rs | 18 +- .../src/provider/copilot_chat.rs | 194 ++++++++++++++++-- 6 files changed, 370 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b5ad9a7b35b8e9acd37b5e40efd8a32e65bdc21..2436baad07e78670837490cf8e9bc897ba0b6716 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3696,6 +3696,7 @@ dependencies = [ name = "copilot_chat" version = "0.1.0" dependencies = [ + "anthropic", "anyhow", "collections", "dirs 4.0.0", diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 6bff2be4c15841de597309b626e768bbf79e880a..a6509c81fa1ecabac32ff9e8bb0fafdddd9e7414 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -995,7 +995,7 @@ pub enum Speed { } #[derive(Debug, Serialize, Deserialize)] -struct StreamingRequest { +pub struct StreamingRequest { #[serde(flatten)] pub base: Request, pub stream: bool, diff --git a/crates/copilot_chat/Cargo.toml b/crates/copilot_chat/Cargo.toml index 991a58ac85227ebc84fad5a6d631fe17811fabd4..79159d59cc05aecd5d4298831a33698762d9a743 100644 --- a/crates/copilot_chat/Cargo.toml +++ b/crates/copilot_chat/Cargo.toml @@ -21,6 +21,7 @@ test-support = [ ] [dependencies] +anthropic.workspace = true anyhow.workspace = true collections.workspace = true dirs.workspace = true diff --git a/crates/copilot_chat/src/copilot_chat.rs b/crates/copilot_chat/src/copilot_chat.rs index 6ac7167c94f0b85e6470b2a20bbf3a17fe190b43..d1f339f89a01d1ed0d17e03b8712b42232177db8 100644 --- a/crates/copilot_chat/src/copilot_chat.rs +++ b/crates/copilot_chat/src/copilot_chat.rs @@ -52,6 +52,10 @@ impl CopilotChatConfiguration { format!("{}/responses", api_endpoint) } + pub fn messages_url(&self, api_endpoint: &str) -> String { + format!("{}/v1/messages", api_endpoint) + } + pub fn models_url(&self, api_endpoint: &str) -> String { format!("{}/models", api_endpoint) } @@ -77,6 +81,30 @@ pub enum Role { System, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ChatLocation { + #[default] + Panel, + Editor, + EditingSession, + Terminal, + Agent, + Other, +} + +impl ChatLocation { + pub fn to_intent_string(self) -> &'static str { + match self { + ChatLocation::Panel => "conversation-panel", + ChatLocation::Editor => "conversation-inline", + ChatLocation::EditingSession => "conversation-edits", + ChatLocation::Terminal => "conversation-terminal", + ChatLocation::Agent => "conversation-agent", + ChatLocation::Other => "conversation-other", + } + } +} + #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub enum ModelSupportedEndpoint { #[serde(rename = "/chat/completions")] @@ -179,6 +207,16 @@ struct ModelSupportedFeatures { parallel_tool_calls: bool, #[serde(default)] vision: bool, + #[serde(default)] + thinking: bool, + #[serde(default)] + adaptive_thinking: bool, + #[serde(default)] + max_thinking_budget: Option, + #[serde(default)] + min_thinking_budget: Option, + #[serde(default)] + reasoning_effort: Vec, } #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] @@ -226,6 +264,10 @@ impl Model { self.capabilities.limits.max_context_window_tokens as u64 } + pub fn max_output_tokens(&self) -> usize { + self.capabilities.limits.max_output_tokens + } + pub fn supports_tools(&self) -> bool { self.capabilities.supports.tool_calls } @@ -256,6 +298,41 @@ impl Model { .contains(&ModelSupportedEndpoint::Responses) } + pub fn supports_messages(&self) -> bool { + self.supported_endpoints + .contains(&ModelSupportedEndpoint::Messages) + } + + pub fn supports_thinking(&self) -> bool { + self.capabilities.supports.thinking + } + + pub fn supports_adaptive_thinking(&self) -> bool { + self.capabilities.supports.adaptive_thinking + } + + pub fn can_think(&self) -> bool { + self.supports_thinking() + || self.supports_adaptive_thinking() + || self.max_thinking_budget().is_some() + } + + pub fn max_thinking_budget(&self) -> Option { + self.capabilities.supports.max_thinking_budget + } + + pub fn min_thinking_budget(&self) -> Option { + self.capabilities.supports.min_thinking_budget + } + + pub fn reasoning_effort_levels(&self) -> &[String] { + &self.capabilities.supports.reasoning_effort + } + + pub fn family(&self) -> &str { + &self.capabilities.family + } + pub fn multiplier(&self) -> f64 { self.billing.multiplier } @@ -263,7 +340,6 @@ impl Model { #[derive(Serialize, Deserialize)] pub struct Request { - pub intent: bool, pub n: usize, pub stream: bool, pub temperature: f32, @@ -273,6 +349,8 @@ pub struct Request { pub tools: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking_budget: Option, } #[derive(Serialize, Deserialize)] @@ -550,6 +628,7 @@ impl CopilotChat { pub async fn stream_completion( request: Request, + location: ChatLocation, is_user_initiated: bool, mut cx: AsyncApp, ) -> Result>> { @@ -563,12 +642,14 @@ impl CopilotChat { api_url.into(), request, is_user_initiated, + location, ) .await } pub async fn stream_response( request: responses::Request, + location: ChatLocation, is_user_initiated: bool, mut cx: AsyncApp, ) -> Result>> { @@ -582,6 +663,30 @@ impl CopilotChat { api_url, request, is_user_initiated, + location, + ) + .await + } + + pub async fn stream_messages( + body: String, + location: ChatLocation, + is_user_initiated: bool, + anthropic_beta: Option, + mut cx: AsyncApp, + ) -> Result>> { + let (client, oauth_token, api_endpoint, configuration) = + Self::get_auth_details(&mut cx).await?; + + let api_url = configuration.messages_url(&api_endpoint); + stream_messages( + client.clone(), + oauth_token, + api_url, + body, + is_user_initiated, + location, + anthropic_beta, ) .await } @@ -755,6 +860,7 @@ pub(crate) fn copilot_request_headers( builder: http_client::Builder, oauth_token: &str, is_user_initiated: Option, + location: Option, ) -> http_client::Builder { builder .header("Authorization", format!("Bearer {}", oauth_token)) @@ -766,12 +872,19 @@ pub(crate) fn copilot_request_headers( option_env!("CARGO_PKG_VERSION").unwrap_or("unknown") ), ) + .header("X-GitHub-Api-Version", "2025-10-01") .when_some(is_user_initiated, |builder, is_user_initiated| { builder.header( "X-Initiator", if is_user_initiated { "user" } else { "agent" }, ) }) + .when_some(location, |builder, loc| { + let interaction_type = loc.to_intent_string(); + builder + .header("X-Interaction-Type", interaction_type) + .header("OpenAI-Intent", interaction_type) + }) } async fn request_models( @@ -785,8 +898,8 @@ async fn request_models( .uri(models_url.as_ref()), &oauth_token, None, - ) - .header("x-github-api-version", "2025-05-01"); + None, + ); let request = request_builder.body(AsyncBody::empty())?; @@ -830,6 +943,7 @@ async fn stream_completion( completion_url: Arc, request: Request, is_user_initiated: bool, + location: ChatLocation, ) -> Result>> { let is_vision_request = request.messages.iter().any(|message| match message { ChatMessage::User { content } @@ -846,6 +960,7 @@ async fn stream_completion( .uri(completion_url.as_ref()), &oauth_token, Some(is_user_initiated), + Some(location), ) .when(is_vision_request, |builder| { builder.header("Copilot-Vision-Request", is_vision_request.to_string()) @@ -905,6 +1020,65 @@ async fn stream_completion( } } +async fn stream_messages( + client: Arc, + oauth_token: String, + api_url: String, + body: String, + is_user_initiated: bool, + location: ChatLocation, + anthropic_beta: Option, +) -> Result>> { + let mut request_builder = copilot_request_headers( + HttpRequest::builder().method(Method::POST).uri(&api_url), + &oauth_token, + Some(is_user_initiated), + Some(location), + ); + + if let Some(beta) = &anthropic_beta { + request_builder = request_builder.header("anthropic-beta", beta.as_str()); + } + + let request = request_builder.body(AsyncBody::from(body))?; + let mut response = client.send(request).await?; + + if !response.status().is_success() { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + anyhow::bail!("Failed to connect to API: {} {}", response.status(), body); + } + + let reader = BufReader::new(response.into_body()); + Ok(reader + .lines() + .filter_map(|line| async move { + match line { + Ok(line) => { + let line = line + .strip_prefix("data: ") + .or_else(|| line.strip_prefix("data:"))?; + if line.starts_with("[DONE]") || line.is_empty() { + return None; + } + match serde_json::from_str(line) { + Ok(event) => Some(Ok(event)), + Err(error) => { + log::error!( + "Failed to parse Copilot messages stream event: `{}`\nResponse: `{}`", + error, + line, + ); + Some(Err(anthropic::AnthropicError::DeserializeResponse(error))) + } + } + } + Err(error) => Some(Err(anthropic::AnthropicError::ReadResponse(error))), + } + }) + .boxed()) +} + #[cfg(test)] mod tests { use super::*; @@ -1513,6 +1687,11 @@ mod tests { tool_calls: true, parallel_tool_calls: false, vision: false, + thinking: false, + adaptive_thinking: false, + max_thinking_budget: None, + min_thinking_budget: None, + reasoning_effort: vec![], }, model_type: "chat".to_string(), tokenizer: None, diff --git a/crates/copilot_chat/src/responses.rs b/crates/copilot_chat/src/responses.rs index 473e583027bf77f3f7dc43d7914f6d2afff743a0..4f30ba1eb083c8a70c9a91853c7df37e65783ce3 100644 --- a/crates/copilot_chat/src/responses.rs +++ b/crates/copilot_chat/src/responses.rs @@ -1,9 +1,9 @@ use std::sync::Arc; -use super::copilot_request_headers; +use super::{ChatLocation, copilot_request_headers}; use anyhow::{Result, anyhow}; use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; -use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; +use http_client::{AsyncBody, HttpClient, HttpRequestExt, Method, Request as HttpRequest}; use serde::{Deserialize, Serialize}; use serde_json::Value; pub use settings::OpenAiReasoningEffort as ReasoningEffort; @@ -24,6 +24,7 @@ pub struct Request { pub reasoning: Option, #[serde(skip_serializing_if = "Option::is_none")] pub include: Option>, + pub store: bool, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -280,6 +281,7 @@ pub async fn stream_response( api_url: String, request: Request, is_user_initiated: bool, + location: ChatLocation, ) -> Result>> { let is_vision_request = request.input.iter().any(|item| match item { ResponseInputItem::Message { @@ -295,13 +297,11 @@ pub async fn stream_response( HttpRequest::builder().method(Method::POST).uri(&api_url), &oauth_token, Some(is_user_initiated), - ); - - let request_builder = if is_vision_request { - request_builder.header("Copilot-Vision-Request", "true") - } else { - request_builder - }; + Some(location), + ) + .when(is_vision_request, |builder| { + builder.header("Copilot-Vision-Request", "true") + }); let is_streaming = request.stream; let json = serde_json::to_string(&request)?; diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 599dd8ac51fd6591987d4ee564b854fcf018d88f..47d1b316a581c8013843940ecb3e55ed29bc4500 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -2,15 +2,17 @@ use std::pin::Pin; use std::str::FromStr as _; use std::sync::Arc; +use anthropic::AnthropicModelMode; use anyhow::{Result, anyhow}; use cloud_llm_client::CompletionIntent; use collections::HashMap; use copilot::{GlobalCopilotAuth, Status}; use copilot_chat::responses as copilot_responses; use copilot_chat::{ - ChatMessage, ChatMessageContent, ChatMessagePart, CopilotChat, CopilotChatConfiguration, - Function, FunctionContent, ImageUrl, Model as CopilotChatModel, ModelVendor, - Request as CopilotChatRequest, ResponseEvent, Tool, ToolCall, ToolCallContent, ToolChoice, + ChatLocation, ChatMessage, ChatMessageContent, ChatMessagePart, CopilotChat, + CopilotChatConfiguration, Function, FunctionContent, ImageUrl, Model as CopilotChatModel, + ModelVendor, Request as CopilotChatRequest, ResponseEvent, Tool, ToolCall, ToolCallContent, + ToolChoice, }; use futures::future::BoxFuture; use futures::stream::BoxStream; @@ -20,8 +22,8 @@ use http_client::StatusCode; use language::language_settings::all_language_settings; use language_model::{ AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelCostInfo, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelCompletionEvent, LanguageModelCostInfo, LanguageModelEffortLevel, LanguageModelId, + LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, @@ -30,6 +32,7 @@ use settings::SettingsStore; use ui::prelude::*; use util::debug_panic; +use crate::provider::anthropic::{AnthropicEventMapper, into_anthropic}; use crate::provider::util::parse_tool_arguments; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat"); @@ -254,6 +257,33 @@ impl LanguageModel for CopilotChatLanguageModel { self.model.supports_vision() } + fn supports_thinking(&self) -> bool { + self.model.can_think() + } + + fn supported_effort_levels(&self) -> Vec { + let levels = self.model.reasoning_effort_levels(); + if levels.is_empty() { + return vec![]; + } + levels + .iter() + .map(|level| { + let name: SharedString = match level.as_str() { + "low" => "Low".into(), + "medium" => "Medium".into(), + "high" => "High".into(), + _ => SharedString::from(level.clone()), + }; + LanguageModelEffortLevel { + name, + value: SharedString::from(level.clone()), + is_default: level == "high", + } + }) + .collect() + } + fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { match self.model.vendor() { ModelVendor::OpenAI | ModelVendor::Anthropic => { @@ -333,12 +363,94 @@ impl LanguageModel for CopilotChatLanguageModel { | CompletionIntent::EditFile => false, }); + if self.model.supports_messages() { + let location = intent_to_chat_location(request.intent); + let model = self.model.clone(); + let request_limiter = self.request_limiter.clone(); + let future = cx.spawn(async move |cx| { + let effort = request + .thinking_effort + .as_ref() + .and_then(|e| anthropic::Effort::from_str(e).ok()); + + let mut anthropic_request = into_anthropic( + request, + model.id().to_string(), + 0.0, + model.max_output_tokens() as u64, + if model.supports_adaptive_thinking() { + AnthropicModelMode::Thinking { + budget_tokens: None, + } + } else if model.can_think() { + AnthropicModelMode::Thinking { + budget_tokens: compute_thinking_budget( + model.min_thinking_budget(), + model.max_thinking_budget(), + model.max_output_tokens() as u32, + ), + } + } else { + AnthropicModelMode::Default + }, + ); + + anthropic_request.temperature = None; + + // The Copilot proxy doesn't support eager_input_streaming on tools. + for tool in &mut anthropic_request.tools { + tool.eager_input_streaming = false; + } + + if model.supports_adaptive_thinking() { + if anthropic_request.thinking.is_some() { + anthropic_request.thinking = Some(anthropic::Thinking::Adaptive); + anthropic_request.output_config = Some(anthropic::OutputConfig { effort }); + } + } + + let anthropic_beta = if !model.supports_adaptive_thinking() && model.can_think() { + Some("interleaved-thinking-2025-05-14".to_string()) + } else { + None + }; + + let body = serde_json::to_string(&anthropic::StreamingRequest { + base: anthropic_request, + stream: true, + }) + .map_err(|e| anyhow::anyhow!(e))?; + + let stream = CopilotChat::stream_messages( + body, + location, + is_user_initiated, + anthropic_beta, + cx.clone(), + ); + + request_limiter + .stream(async move { + let events = stream.await?; + let mapper = AnthropicEventMapper::new(); + Ok(mapper.map_stream(events).boxed()) + }) + .await + }); + return async move { Ok(future.await?.boxed()) }.boxed(); + } + if self.model.supports_response() { + let location = intent_to_chat_location(request.intent); let responses_request = into_copilot_responses(&self.model, request); let request_limiter = self.request_limiter.clone(); let future = cx.spawn(async move |cx| { - let request = - CopilotChat::stream_response(responses_request, is_user_initiated, cx.clone()); + let request = CopilotChat::stream_response( + responses_request, + location, + is_user_initiated, + cx.clone(), + ); request_limiter .stream(async move { let stream = request.await?; @@ -350,6 +462,7 @@ impl LanguageModel for CopilotChatLanguageModel { return async move { Ok(future.await?.boxed()) }.boxed(); } + let location = intent_to_chat_location(request.intent); let copilot_request = match into_copilot_chat(&self.model, request) { Ok(request) => request, Err(err) => return futures::future::ready(Err(err.into())).boxed(), @@ -358,8 +471,12 @@ impl LanguageModel for CopilotChatLanguageModel { let request_limiter = self.request_limiter.clone(); let future = cx.spawn(async move |cx| { - let request = - CopilotChat::stream_completion(copilot_request, is_user_initiated, cx.clone()); + let request = CopilotChat::stream_completion( + copilot_request, + location, + is_user_initiated, + cx.clone(), + ); request_limiter .stream(async move { let response = request.await?; @@ -761,6 +878,9 @@ fn into_copilot_chat( model: &CopilotChatModel, request: LanguageModelRequest, ) -> Result { + let temperature = request.temperature; + let tool_choice = request.tool_choice; + let mut request_messages: Vec = Vec::new(); for message in request.messages { if let Some(last_message) = request_messages.last_mut() { @@ -859,10 +979,9 @@ fn into_copilot_chat( let text_content = { let mut buffer = String::new(); for string in message.content.iter().filter_map(|content| match content { - MessageContent::Text(text) | MessageContent::Thinking { text, .. } => { - Some(text.as_str()) - } - MessageContent::ToolUse(_) + MessageContent::Text(text) => Some(text.as_str()), + MessageContent::Thinking { .. } + | MessageContent::ToolUse(_) | MessageContent::RedactedThinking(_) | MessageContent::ToolResult(_) | MessageContent::Image(_) => None, @@ -919,21 +1038,52 @@ fn into_copilot_chat( .collect::>(); Ok(CopilotChatRequest { - intent: true, n: 1, stream: model.uses_streaming(), - temperature: 0.1, + temperature: temperature.unwrap_or(0.1), model: model.id().to_string(), messages, tools, - tool_choice: request.tool_choice.map(|choice| match choice { + tool_choice: tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => ToolChoice::Auto, LanguageModelToolChoice::Any => ToolChoice::Any, LanguageModelToolChoice::None => ToolChoice::None, }), + thinking_budget: None, }) } +fn compute_thinking_budget( + min_budget: Option, + max_budget: Option, + max_output_tokens: u32, +) -> Option { + let configured_budget: u32 = 16000; + let min_budget = min_budget.unwrap_or(1024); + let max_budget = max_budget.unwrap_or(max_output_tokens.saturating_sub(1)); + let normalized = configured_budget.max(min_budget); + Some( + normalized + .min(max_budget) + .min(max_output_tokens.saturating_sub(1)), + ) +} + +fn intent_to_chat_location(intent: Option) -> ChatLocation { + match intent { + Some(CompletionIntent::UserPrompt) => ChatLocation::Agent, + Some(CompletionIntent::ToolResults) => ChatLocation::Agent, + Some(CompletionIntent::ThreadSummarization) => ChatLocation::Panel, + Some(CompletionIntent::ThreadContextSummarization) => ChatLocation::Panel, + Some(CompletionIntent::CreateFile) => ChatLocation::Agent, + Some(CompletionIntent::EditFile) => ChatLocation::Agent, + Some(CompletionIntent::InlineAssist) => ChatLocation::Editor, + Some(CompletionIntent::TerminalInlineAssist) => ChatLocation::Terminal, + Some(CompletionIntent::GenerateGitCommitMessage) => ChatLocation::Other, + None => ChatLocation::Panel, + } +} + fn into_copilot_responses( model: &CopilotChatModel, request: LanguageModelRequest, @@ -949,7 +1099,7 @@ fn into_copilot_responses( tool_choice, stop: _, temperature, - thinking_allowed: _, + thinking_allowed, thinking_effort: _, speed: _, } = request; @@ -1128,10 +1278,18 @@ fn into_copilot_responses( temperature, tools: converted_tools, tool_choice: mapped_tool_choice, - reasoning: None, // We would need to add support for setting from user settings. + reasoning: if thinking_allowed { + Some(copilot_responses::ReasoningConfig { + effort: copilot_responses::ReasoningEffort::Medium, + summary: Some(copilot_responses::ReasoningSummary::Detailed), + }) + } else { + None + }, include: Some(vec![ copilot_responses::ResponseIncludable::ReasoningEncryptedContent, ]), + store: false, } } From 598f8ac486daa2c8d8e1a6472269969727d91a8a Mon Sep 17 00:00:00 2001 From: ishaksebsib <121272992+ishaksebsib@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:53:46 +0300 Subject: [PATCH 054/219] docs: Fix YAML syntax error in frontmatter (#51004) The title in the YAML front matter contained a colon, which YAML interprets as a key-value separator, causing a parse error. Quoted the title value to fix it. image Release Notes: - N/A --------- Co-authored-by: Kunall Banerjee --- docs/src/ai/agent-settings.md | 2 +- docs/src/ai/privacy-and-security.md | 2 +- docs/src/development/glossary.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/ai/agent-settings.md b/docs/src/ai/agent-settings.md index 0547f19c9ca0e58cb5d63d7ae1c5231d091a6503..3e152fc5671225abef4a6477b3f73be5d054a365 100644 --- a/docs/src/ai/agent-settings.md +++ b/docs/src/ai/agent-settings.md @@ -1,6 +1,6 @@ --- title: AI Agent Settings - Zed -description: Customize Zed's AI agent: default models, temperature, tool approval, auto-run commands, notifications, and panel options. +description: "Customize Zed's AI agent: default models, temperature, tool approval, auto-run commands, notifications, and panel options." --- # Agent Settings diff --git a/docs/src/ai/privacy-and-security.md b/docs/src/ai/privacy-and-security.md index 4aada3dff47ba8d0eca8f1056e326d6060451306..828953cca74868b097490dfafcb318b8245a2ef8 100644 --- a/docs/src/ai/privacy-and-security.md +++ b/docs/src/ai/privacy-and-security.md @@ -1,6 +1,6 @@ --- title: AI Privacy and Security - Zed -description: Zed's approach to AI privacy: opt-in data sharing by default, zero-data retention with providers, and full open-source transparency. +description: "Zed's approach to AI privacy: opt-in data sharing by default, zero-data retention with providers, and full open-source transparency." --- # Privacy and Security diff --git a/docs/src/development/glossary.md b/docs/src/development/glossary.md index 720c20c3bd42074b3e2b4863b879a54001d27e73..ed3b9fdde00a605ec04e3efc25271b57691a45af 100644 --- a/docs/src/development/glossary.md +++ b/docs/src/development/glossary.md @@ -1,5 +1,5 @@ --- -title: Zed Development: Glossary +title: "Zed Development: Glossary" description: "Guide to zed development: glossary for Zed development." --- From 8762b7f503fda985f481ccd301960cf01adbecb8 Mon Sep 17 00:00:00 2001 From: ishaksebsib <121272992+ishaksebsib@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:48:08 +0300 Subject: [PATCH 055/219] Reuse existing bundled file editors (#51053) Closes #46837 Reuse the existing read-only bundled editor when opening default settings or default key bindings again, instead of creating a duplicate tab each time. Also adds a regression test covering repeated `OpenDefaultSettings` dispatches. ### Before https://github.com/user-attachments/assets/ac2477b0-dc57-451c-a400-667c3613da2c ### After https://github.com/user-attachments/assets/309fbd32-6dad-40a0-a864-b638a583ef52 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 default settings and default key bindings reopening duplicate tabs instead of reusing the existing tab. --- crates/zed/src/zed.rs | 66 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 562786fb3f01ff4c0781319e155bc47fda6a4822..079a78225c248e341121f1980a368b37f85eea84 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -2008,13 +2008,29 @@ fn open_local_file( } fn open_bundled_file( - workspace: &Workspace, + workspace: &mut Workspace, text: Cow<'static, str>, title: &'static str, language: &'static str, window: &mut Window, cx: &mut Context, ) { + let existing = workspace.items_of_type::(cx).find(|editor| { + editor.read_with(cx, |editor, cx| { + editor.read_only(cx) + && editor.title(cx).as_ref() == title + && editor + .buffer() + .read(cx) + .as_singleton() + .is_some_and(|buffer| buffer.read(cx).file().is_none()) + }) + }); + if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + return; + } + let language = workspace.app_state().languages.language_for_name(language); cx.spawn_in(window, async move |workspace, cx| { let language = language.await.log_err(); @@ -4965,6 +4981,54 @@ mod tests { ); } + #[gpui::test] + async fn test_bundled_files_reuse_existing_editor(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(init); + + let project = Project::test(app_state.fs.clone(), [], cx).await; + let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + cx.update(|cx| { + cx.dispatch_action(&OpenDefaultSettings); + }); + cx.run_until_parked(); + + let multi_workspace = cx.windows()[0].downcast::().unwrap(); + let first_item_id = multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace + .active_item(cx) + .expect("default settings should be open") + .item_id() + }) + }) + .unwrap(); + + cx.update(|cx| { + cx.dispatch_action(&OpenDefaultSettings); + }); + cx.run_until_parked(); + + let (second_item_id, item_count) = multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let pane = workspace.active_pane().read(cx); + ( + pane.active_item() + .expect("default settings should still be open") + .item_id(), + pane.items_len(), + ) + }) + }) + .unwrap(); + + assert_eq!(first_item_id, second_item_id); + assert_eq!(item_count, 1); + } + #[gpui::test] async fn test_bundled_languages(cx: &mut TestAppContext) { let fs = fs::FakeFs::new(cx.background_executor.clone()); From 3f2ddcbca327a726823063370eee3584f39fbd5d Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 9 Mar 2026 09:35:19 +0100 Subject: [PATCH 056/219] editor: Prevent panic in `lsp_symbols_at_cursor` with diff hunks handling (#51077) Fixes ZED-5M9 No test as I couldn't quite reproduce this, as the cause is mostly a guess Release Notes: - Fixed a panic in `lsp_symbols_at_cursor` when dealing with diff hunks --- crates/auto_update/src/auto_update.rs | 16 ++++------------ crates/editor/src/document_symbols.rs | 3 +++ crates/rope/src/rope.rs | 18 ++++++++++++++---- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 53fac7beac2475d06f4a0f886536942308f9976c..33cc1006792dbcfdb1be7b08423870e8827ef1e5 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -212,18 +212,10 @@ pub fn init(client: Arc, cx: &mut App) { } pub fn check(_: &Check, window: &mut Window, cx: &mut App) { - if let Some(message) = option_env!("ZED_UPDATE_EXPLANATION") { - drop(window.prompt( - gpui::PromptLevel::Info, - "Zed was installed via a package manager.", - Some(message), - &["Ok"], - cx, - )); - return; - } - - if let Ok(message) = env::var("ZED_UPDATE_EXPLANATION") { + if let Some(message) = option_env!("ZED_UPDATE_EXPLANATION") + .map(ToOwned::to_owned) + .or_else(|| env::var("ZED_UPDATE_EXPLANATION").ok()) + { drop(window.prompt( gpui::PromptLevel::Info, "Zed was installed via a package manager.", diff --git a/crates/editor/src/document_symbols.rs b/crates/editor/src/document_symbols.rs index 927ef34690477ba436bf70a66b3f9f45b8864587..94d53eb19621cbe4d84734e2e77286180a59adf7 100644 --- a/crates/editor/src/document_symbols.rs +++ b/crates/editor/src/document_symbols.rs @@ -77,6 +77,9 @@ impl Editor { let excerpt = multi_buffer_snapshot.excerpt_containing(cursor..cursor)?; let excerpt_id = excerpt.id(); let buffer_id = excerpt.buffer_id(); + if Some(buffer_id) != cursor.text_anchor.buffer_id { + return None; + } let buffer = self.buffer.read(cx).buffer(buffer_id)?; let buffer_snapshot = buffer.read(cx).snapshot(); let cursor_text_anchor = cursor.text_anchor; diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index 5b599bad51c2f571cca11625be0b290e7e748504..04a38168dfa32bcbf96a3ee5062fe6ab4c62521b 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -693,16 +693,21 @@ impl<'a> Cursor<'a> { } pub fn seek_forward(&mut self, end_offset: usize) { - debug_assert!(end_offset >= self.offset); + assert!( + end_offset >= self.offset, + "cannot seek backward from {} to {}", + self.offset, + end_offset + ); self.chunks.seek_forward(&end_offset, Bias::Right); self.offset = end_offset; } pub fn slice(&mut self, end_offset: usize) -> Rope { - debug_assert!( + assert!( end_offset >= self.offset, - "cannot slice backwards from {} to {}", + "cannot slice backward from {} to {}", self.offset, end_offset ); @@ -730,7 +735,12 @@ impl<'a> Cursor<'a> { } pub fn summary(&mut self, end_offset: usize) -> D { - debug_assert!(end_offset >= self.offset); + assert!( + end_offset >= self.offset, + "cannot summarize backward from {} to {}", + self.offset, + end_offset + ); let mut summary = D::zero(()); if let Some(start_chunk) = self.chunks.item() { From 8d5689a7faa7c4d52bcbb46e8133081d0c950f69 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 9 Mar 2026 09:45:46 +0100 Subject: [PATCH 057/219] editor: Fix underflow panic in block map sync when blocks overlap (#51078) In `BlockMap::sync`, blocks within an edited region are sorted and processed sequentially. Each block placement computes `rows_before_block` by subtracting `new_transforms.summary().input_rows` from the block's target position. The `Near`/`Below` cases have a guard that skips the block if the target is already behind the current progress, but `Above` and `Replace` were missing this guard. When a `Replace` block (tie_break 0) is processed before an `Above` block (tie_break 1) at the same or overlapping position, the `Replace` block consumes multiple input rows, advancing `input_rows` past the `Above` block's position. The subsequent `position - input_rows` subtraction underflows on `u32`, producing a huge `RowDelta` that wraps `wrap_row_end` past `wrap_row_start`, creating an inverted range that propagates through the display map layers and panics as `begin <= end (47 <= 0)` in a rope chunk slice. Add underflow guards to `Above` and `Replace`, matching the existing pattern in `Near`/`Below`. Release Notes: - Fixed a source of underflowing subtractions causing spurious panics --- crates/editor/src/display_map/block_map.rs | 26 +++++++++++++-------- crates/editor/src/display_map/dimensions.rs | 4 ++++ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 2673baae84ab74b2852004320cf1d94c5ed1ed42..d45165660d92170ecc176ebd8e038b890933bd57 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -1091,23 +1091,29 @@ impl BlockMap { }; let rows_before_block; - match block_placement { - BlockPlacement::Above(position) => { - rows_before_block = position - new_transforms.summary().input_rows; + let input_rows = new_transforms.summary().input_rows; + match &block_placement { + &BlockPlacement::Above(position) => { + let Some(delta) = position.checked_sub(input_rows) else { + continue; + }; + rows_before_block = delta; just_processed_folded_buffer = false; } - BlockPlacement::Near(position) | BlockPlacement::Below(position) => { + &BlockPlacement::Near(position) | &BlockPlacement::Below(position) => { if just_processed_folded_buffer { continue; } - if position + RowDelta(1) < new_transforms.summary().input_rows { + let Some(delta) = (position + RowDelta(1)).checked_sub(input_rows) else { continue; - } - rows_before_block = - (position + RowDelta(1)) - new_transforms.summary().input_rows; + }; + rows_before_block = delta; } - BlockPlacement::Replace(ref range) => { - rows_before_block = *range.start() - new_transforms.summary().input_rows; + BlockPlacement::Replace(range) => { + let Some(delta) = range.start().checked_sub(input_rows) else { + continue; + }; + rows_before_block = delta; summary.input_rows = WrapRow(1) + (*range.end() - *range.start()); just_processed_folded_buffer = matches!(block, Block::FoldedBuffer { .. }); } diff --git a/crates/editor/src/display_map/dimensions.rs b/crates/editor/src/display_map/dimensions.rs index fd8efa6ca539d7eee8d59962ad7541d2bbc4fc4b..0bee934f8f87f1ad490cc74e60bb40bf86d8cdc8 100644 --- a/crates/editor/src/display_map/dimensions.rs +++ b/crates/editor/src/display_map/dimensions.rs @@ -41,6 +41,10 @@ macro_rules! impl_for_row_types { pub fn saturating_sub(self, other: $row_delta) -> $row { $row(self.0.saturating_sub(other.0)) } + + pub fn checked_sub(self, other: $row) -> Option<$row_delta> { + self.0.checked_sub(other.0).map($row_delta) + } } impl ::std::ops::Add for $row { From 1fa4fed96776e9ea533df1944168a07eb1468e97 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 9 Mar 2026 11:10:42 +0100 Subject: [PATCH 058/219] auto_update: Always display update progress when requesting manual update (#51087) Before if a user requested a manual update check while an automatic one was going we were not showing the update status as automatic ones force hide them. Now requesting a manual check while an automatic one is already going will instead make it visible. Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/auto_update/src/auto_update.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 33cc1006792dbcfdb1be7b08423870e8827ef1e5..9b9ccee3b695bebdb08706815bcb407c901e4b5f 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -380,6 +380,10 @@ impl AutoUpdater { pub fn poll(&mut self, check_type: UpdateCheckType, cx: &mut Context) { if self.pending_poll.is_some() { + if self.update_check_type == UpdateCheckType::Automatic { + self.update_check_type = check_type; + cx.notify(); + } return; } self.update_check_type = check_type; @@ -549,7 +553,7 @@ impl AutoUpdater { asset, metrics_id: metrics_id.as_deref(), system_id: system_id.as_deref(), - is_staff: is_staff, + is_staff, }, )?; From f5ff9eea65d9765d6fd38fbf98039181ef2464ca Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 9 Mar 2026 11:32:53 +0100 Subject: [PATCH 059/219] docs: Add CC BY 4.0 and Unlicense as accepted extension licenses (#51089) Release Notes: - N/A --- docs/src/extensions/developing-extensions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index 84e57df49fca95adb6c5c4fb5d9aad3b8c771383..c5b4b1079066ba3f7b5e4149778c8e369d03d9cd 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -126,9 +126,11 @@ The following licenses are accepted: - [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0) - [BSD 2-Clause](https://opensource.org/license/bsd-2-clause) - [BSD 3-Clause](https://opensource.org/license/bsd-3-clause) +- [CC BY 4.0](https://creativecommons.org/licenses/by/4.0) - [GNU GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html) - [GNU LGPLv3](https://www.gnu.org/licenses/lgpl-3.0.en.html) - [MIT](https://opensource.org/license/mit) +- [Unlicense](https://unlicense.org) - [zlib](https://opensource.org/license/zlib) This allows us to distribute the resulting binary produced from your extension code to our users. From 8475280eb11fa79b3388073967ec8c3beb001a52 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 9 Mar 2026 11:47:12 +0100 Subject: [PATCH 060/219] extension_cli: Add tests for semantic token rules and language tasks (#50750) This adds checks to the extension CLI to ensure that tasks and semantic token rules are actually valid for the compiled extensions. Release Notes: - N/A --- Cargo.lock | 2 + crates/extension/src/extension_builder.rs | 3 +- crates/extension_cli/Cargo.toml | 2 + crates/extension_cli/src/main.rs | 61 ++++++++++++++++----- crates/extension_host/src/extension_host.rs | 29 ++++------ crates/extension_host/src/headless_host.rs | 4 +- crates/language/Cargo.toml | 1 + crates/language/src/language.rs | 9 +++ crates/settings_content/src/project.rs | 26 ++++++++- crates/task/src/task_template.rs | 1 + 10 files changed, 102 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2436baad07e78670837490cf8e9bc897ba0b6716..6cfbab0d585fe93d7b984f674475dfbc411ca14b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6082,7 +6082,9 @@ dependencies = [ "serde", "serde_json", "serde_json_lenient", + "settings_content", "snippet_provider", + "task", "theme", "tokio", "toml 0.8.23", diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs index eae51846f164d4aa6baf2fac897d25a8961b4d6c..1c204398c34728cab6b05687050243b4a988902c 100644 --- a/crates/extension/src/extension_builder.rs +++ b/crates/extension/src/extension_builder.rs @@ -7,6 +7,7 @@ use anyhow::{Context as _, Result, bail}; use futures::{StreamExt, io}; use heck::ToSnakeCase; use http_client::{self, AsyncBody, HttpClient}; +use language::LanguageConfig; use serde::Deserialize; use std::{ env, fs, mem, @@ -583,7 +584,7 @@ async fn populate_defaults( while let Some(language_dir) = language_dir_entries.next().await { let language_dir = language_dir?; - let config_path = language_dir.join("config.toml"); + let config_path = language_dir.join(LanguageConfig::FILE_NAME); if fs.is_file(config_path.as_path()).await { let relative_language_dir = language_dir.strip_prefix(extension_path)?.to_path_buf(); diff --git a/crates/extension_cli/Cargo.toml b/crates/extension_cli/Cargo.toml index 9795c13e75864184299fba026f499bbcbefee117..24ea9cfafadc61b2753f7b739fd4b7cbbd24dbfe 100644 --- a/crates/extension_cli/Cargo.toml +++ b/crates/extension_cli/Cargo.toml @@ -26,7 +26,9 @@ reqwest_client.workspace = true serde.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true +settings_content.workspace = true snippet_provider.workspace = true +task.workspace = true theme.workspace = true tokio = { workspace = true, features = ["full"] } toml.workspace = true diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index baefb72fe4bd986edbfaa866e50663b159eff3c9..d0a533bfeb331c196d802df9894e726201794ce7 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -11,8 +11,10 @@ use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder}; use extension::{ExtensionManifest, ExtensionSnippets}; use language::LanguageConfig; use reqwest_client::ReqwestClient; +use settings_content::SemanticTokenRules; use snippet_provider::file_to_snippets; use snippet_provider::format::VsSnippetsFile; +use task::TaskTemplates; use tokio::process::Command; use tree_sitter::{Language, Query, WasmStore}; @@ -323,9 +325,8 @@ fn test_languages( ) -> Result<()> { for relative_language_dir in &manifest.languages { let language_dir = extension_path.join(relative_language_dir); - let config_path = language_dir.join("config.toml"); - let config_content = fs::read_to_string(&config_path)?; - let config: LanguageConfig = toml::from_str(&config_content)?; + let config_path = language_dir.join(LanguageConfig::FILE_NAME); + let config = LanguageConfig::load(&config_path)?; let grammar = if let Some(name) = &config.grammar { Some( grammars @@ -339,18 +340,48 @@ fn test_languages( let query_entries = fs::read_dir(&language_dir)?; for entry in query_entries { let entry = entry?; - let query_path = entry.path(); - if query_path.extension() == Some("scm".as_ref()) { - let grammar = grammar.with_context(|| { - format! { - "language {} provides query {} but no grammar", - config.name, - query_path.display() - } - })?; - - let query_source = fs::read_to_string(&query_path)?; - let _query = Query::new(grammar, &query_source)?; + let file_path = entry.path(); + + let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + match file_name { + LanguageConfig::FILE_NAME => { + // Loaded above + } + SemanticTokenRules::FILE_NAME => { + let _token_rules = SemanticTokenRules::load(&file_path)?; + } + TaskTemplates::FILE_NAME => { + let task_file_content = std::fs::read(&file_path).with_context(|| { + anyhow!( + "Failed to read tasks file at {path}", + path = file_path.display() + ) + })?; + let _task_templates = + serde_json_lenient::from_slice::(&task_file_content) + .with_context(|| { + anyhow!( + "Failed to parse tasks file at {path}", + path = file_path.display() + ) + })?; + } + _ if file_name.ends_with(".scm") => { + let grammar = grammar.with_context(|| { + format! { + "language {} provides query {} but no grammar", + config.name, + file_path.display() + } + })?; + + let query_source = fs::read_to_string(&file_path)?; + let _query = Query::new(grammar, &query_source)?; + } + _ => {} } } diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index c691296d61183c9bb0fcd41ff6c74eed6cb61149..5418f630537c1acd98edc8c6af753d9358b23e8f 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -55,6 +55,7 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; +use task::TaskTemplates; use url::Url; use util::{ResultExt, paths::RemotePathBuf}; use wasm_host::{ @@ -1285,19 +1286,11 @@ impl ExtensionStore { ]); // Load semantic token rules if present in the language directory. - let rules_path = language_path.join("semantic_token_rules.json"); - if let Ok(rules_json) = std::fs::read_to_string(&rules_path) { - match serde_json_lenient::from_str::(&rules_json) { - Ok(rules) => { - semantic_token_rules_to_add.push((language_name.clone(), rules)); - } - Err(err) => { - log::error!( - "Failed to parse semantic token rules from {}: {err:#}", - rules_path.display() - ); - } - } + let rules_path = language_path.join(SemanticTokenRules::FILE_NAME); + if std::fs::exists(&rules_path).is_ok_and(|exists| exists) + && let Some(rules) = SemanticTokenRules::load(&rules_path).log_err() + { + semantic_token_rules_to_add.push((language_name.clone(), rules)); } self.proxy.register_language( @@ -1306,11 +1299,11 @@ impl ExtensionStore { language.matcher.clone(), language.hidden, Arc::new(move || { - let config = std::fs::read_to_string(language_path.join("config.toml"))?; - let config: LanguageConfig = ::toml::from_str(&config)?; + let config = + LanguageConfig::load(language_path.join(LanguageConfig::FILE_NAME))?; let queries = load_plugin_queries(&language_path); let context_provider = - std::fs::read_to_string(language_path.join("tasks.json")) + std::fs::read_to_string(language_path.join(TaskTemplates::FILE_NAME)) .ok() .and_then(|contents| { let definitions = @@ -1580,7 +1573,7 @@ impl ExtensionStore { if !fs_metadata.is_dir { continue; } - let language_config_path = language_path.join("config.toml"); + let language_config_path = language_path.join(LanguageConfig::FILE_NAME); let config = fs.load(&language_config_path).await.with_context(|| { format!("loading language config from {language_config_path:?}") })?; @@ -1703,7 +1696,7 @@ impl ExtensionStore { cx.background_spawn(async move { const EXTENSION_TOML: &str = "extension.toml"; const EXTENSION_WASM: &str = "extension.wasm"; - const CONFIG_TOML: &str = "config.toml"; + const CONFIG_TOML: &str = LanguageConfig::FILE_NAME; if is_dev { let manifest_toml = toml::to_string(&loaded_extension.manifest)?; diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index 290dbb6fd40fc3c15dcb210c767b9102b7117544..0aff06fdddcf5c075bd669528b5c52137f745863 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -138,7 +138,9 @@ impl HeadlessExtensionStore { for language_path in &manifest.languages { let language_path = extension_dir.join(language_path); - let config = fs.load(&language_path.join("config.toml")).await?; + let config = fs + .load(&language_path.join(LanguageConfig::FILE_NAME)) + .await?; let mut config = ::toml::from_str::(&config)?; this.update(cx, |this, _cx| { diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 58db79afe59f0e6d27e23eceb9861ea493d853fd..37c19172f7c48743e1436ba41e30d0c7ebf99d1d 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -62,6 +62,7 @@ sum_tree.workspace = true task.workspace = true text.workspace = true theme.workspace = true +toml.workspace = true tracing.workspace = true tree-sitter-md = { workspace = true, optional = true } tree-sitter-python = { workspace = true, optional = true } diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 29b569ba1aa68fe83f3456a2eaf9911b4c83677d..4e994a7e60f58b6e4ccd50c2cb0584f91bd351f2 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -961,6 +961,15 @@ pub struct LanguageConfig { pub import_path_strip_regex: Option, } +impl LanguageConfig { + pub const FILE_NAME: &str = "config.toml"; + + pub fn load(config_path: impl AsRef) -> Result { + let config = std::fs::read_to_string(config_path.as_ref())?; + toml::from_str(&config).map_err(Into::into) + } +} + #[derive(Clone, Debug, Deserialize, Default, JsonSchema)] pub struct DecreaseIndentConfig { #[serde(default, deserialize_with = "deserialize_regex")] diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index 70544646b1878c163bf5c17d2364eeebd98f6908..85a39f389efc621e902154431278c2050c81a210 100644 --- a/crates/settings_content/src/project.rs +++ b/crates/settings_content/src/project.rs @@ -1,5 +1,9 @@ -use std::{path::PathBuf, sync::Arc}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use anyhow::Context; use collections::{BTreeMap, HashMap}; use gpui::Rgba; use schemars::JsonSchema; @@ -233,6 +237,26 @@ pub struct SemanticTokenRules { pub rules: Vec, } +impl SemanticTokenRules { + pub const FILE_NAME: &'static str = "semantic_token_rules.json"; + + pub fn load(file_path: &Path) -> anyhow::Result { + let rules_content = std::fs::read(file_path).with_context(|| { + anyhow::anyhow!( + "Could not read semantic token rules from {}", + file_path.display() + ) + })?; + + serde_json_lenient::from_slice::(&rules_content).with_context(|| { + anyhow::anyhow!( + "Failed to parse semantic token rules from {}", + file_path.display() + ) + }) + } +} + impl crate::merge_from::MergeFrom for SemanticTokenRules { fn merge_from(&mut self, other: &Self) { self.rules.splice(0..0, other.rules.iter().cloned()); diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index 539b2779cc85b5830af90aeb4ffd28596c2c29c3..a85c3565e2869e10f093a47f71024384e496fbd2 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -114,6 +114,7 @@ pub enum HideStrategy { pub struct TaskTemplates(pub Vec); impl TaskTemplates { + pub const FILE_NAME: &str = "tasks.json"; /// Generates JSON schema of Tasks JSON template format. pub fn generate_json_schema() -> serde_json::Value { let schema = schemars::generate::SchemaSettings::draft2019_09() From 0a436bec175806d9d1785f79c4ab793e2a5e772e Mon Sep 17 00:00:00 2001 From: Dino Date: Mon, 9 Mar 2026 10:50:43 +0000 Subject: [PATCH 061/219] git: Introduce restore and next action (#50324) Add a `git::RestoreAndNext` action that restores the diff hunk at the cursor and advances to the next hunk. In the git diff view, the default restore keybinding (`cmd-alt-z` on macOS, `ctrl-k ctrl-r` on Linux/Windows) is remapped to this action so users can quickly restore hunks in sequence. Also refactor `go_to_hunk_before_or_after_position` to accept a `wrap_around` parameter, eliminating duplicated hunk-navigation logic in `do_stage_or_unstage_and_next` and `restore_and_next`. Release Notes: - Added a `git: restore and next` action that restores the diff hunk at the cursor and moves to the next one. In the git diff view, the default restore keybinding (`cmd-alt-z` on macOS, `ctrl-k ctrl-r` on Linux/Windows) now triggers this action instead of `git: restore`. --------- Co-authored-by: Afonso <4775087+afonsograca@users.noreply.github.com> --- assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 1 + assets/keymaps/default-windows.json | 1 + crates/agent_ui/src/agent_diff.rs | 2 + crates/editor/src/editor.rs | 107 +++++++++++++++++++--------- crates/editor/src/editor_tests.rs | 63 ++++++++++++++++ crates/editor/src/element.rs | 1 + crates/git/src/git.rs | 3 + crates/git_ui/src/git_panel.rs | 1 + 9 files changed, 145 insertions(+), 35 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 0b354ef1c039c2fe7dde2f20bb30ef71f067e84d..21ab61065896953fdc950943ee89e778ee3ef726 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -982,6 +982,7 @@ "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll", + "ctrl-k ctrl-r": "git::RestoreAndNext", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 052475ddb981c4db5495914096ffd72dee54d80f..ae2e80bcccc6c86a17d6640cde07ff9211d4cbbf 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1033,6 +1033,7 @@ "cmd-shift-enter": "git::Amend", "cmd-ctrl-y": "git::StageAll", "cmd-ctrl-shift-y": "git::UnstageAll", + "cmd-alt-z": "git::RestoreAndNext", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index ef2b339951382a44433372b34e7e62b082428362..a81e34cc16bb1a8e55c7106b22c55c9aa5796136 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -983,6 +983,7 @@ "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll", + "ctrl-k ctrl-r": "git::RestoreAndNext", }, }, { diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 8fa68b0c510c086d7c6e224b24675e6f19344b82..13e62eb502de1d4bf454b47b216374a0abf2bc79 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -831,6 +831,7 @@ fn render_diff_hunk_controls( &snapshot, position, Direction::Next, + true, window, cx, ); @@ -866,6 +867,7 @@ fn render_diff_hunk_controls( &snapshot, point, Direction::Prev, + true, window, cx, ); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ead4f97ee351246f4d00f4275c4a736c7ffa4926..cb63e5f85d766637f5775bb864d79998ada9c254 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11683,6 +11683,43 @@ impl Editor { self.restore_hunks_in_ranges(selections, window, cx); } + /// Restores the diff hunks in the editor's selections and moves the cursor + /// to the next diff hunk. Wraps around to the beginning of the buffer if + /// not all diff hunks are expanded. + pub fn restore_and_next( + &mut self, + _: &::git::RestoreAndNext, + window: &mut Window, + cx: &mut Context, + ) { + let selections = self + .selections + .all(&self.display_snapshot(cx)) + .into_iter() + .map(|selection| selection.range()) + .collect(); + + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + self.restore_hunks_in_ranges(selections, window, cx); + + let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded(); + let wrap_around = !all_diff_hunks_expanded; + let snapshot = self.snapshot(window, cx); + let position = self + .selections + .newest::(&snapshot.display_snapshot) + .head(); + + self.go_to_hunk_before_or_after_position( + &snapshot, + position, + Direction::Next, + wrap_around, + window, + cx, + ); + } + pub fn restore_hunks_in_ranges( &mut self, ranges: Vec>, @@ -17735,6 +17772,7 @@ impl Editor { &snapshot, selection.head(), Direction::Next, + true, window, cx, ); @@ -17745,14 +17783,15 @@ impl Editor { snapshot: &EditorSnapshot, position: Point, direction: Direction, + wrap_around: bool, window: &mut Window, cx: &mut Context, ) { let row = if direction == Direction::Next { - self.hunk_after_position(snapshot, position) + self.hunk_after_position(snapshot, position, wrap_around) .map(|hunk| hunk.row_range.start) } else { - self.hunk_before_position(snapshot, position) + self.hunk_before_position(snapshot, position, wrap_around) }; if let Some(row) = row { @@ -17770,17 +17809,23 @@ impl Editor { &mut self, snapshot: &EditorSnapshot, position: Point, + wrap_around: bool, ) -> Option { - snapshot + let result = snapshot .buffer_snapshot() .diff_hunks_in_range(position..snapshot.buffer_snapshot().max_point()) - .find(|hunk| hunk.row_range.start.0 > position.row) - .or_else(|| { + .find(|hunk| hunk.row_range.start.0 > position.row); + + if wrap_around { + result.or_else(|| { snapshot .buffer_snapshot() .diff_hunks_in_range(Point::zero()..position) .find(|hunk| hunk.row_range.end.0 < position.row) }) + } else { + result + } } fn go_to_prev_hunk( @@ -17796,6 +17841,7 @@ impl Editor { &snapshot, selection.head(), Direction::Prev, + true, window, cx, ); @@ -17805,11 +17851,15 @@ impl Editor { &mut self, snapshot: &EditorSnapshot, position: Point, + wrap_around: bool, ) -> Option { - snapshot - .buffer_snapshot() - .diff_hunk_before(position) - .or_else(|| snapshot.buffer_snapshot().diff_hunk_before(Point::MAX)) + let result = snapshot.buffer_snapshot().diff_hunk_before(position); + + if wrap_around { + result.or_else(|| snapshot.buffer_snapshot().diff_hunk_before(Point::MAX)) + } else { + result + } } fn go_to_next_change( @@ -20793,38 +20843,23 @@ impl Editor { } self.stage_or_unstage_diff_hunks(stage, ranges, cx); + + let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded(); + let wrap_around = !all_diff_hunks_expanded; let snapshot = self.snapshot(window, cx); let position = self .selections .newest::(&snapshot.display_snapshot) .head(); - let mut row = snapshot - .buffer_snapshot() - .diff_hunks_in_range(position..snapshot.buffer_snapshot().max_point()) - .find(|hunk| hunk.row_range.start.0 > position.row) - .map(|hunk| hunk.row_range.start); - - let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded(); - // Outside of the project diff editor, wrap around to the beginning. - if !all_diff_hunks_expanded { - row = row.or_else(|| { - snapshot - .buffer_snapshot() - .diff_hunks_in_range(Point::zero()..position) - .find(|hunk| hunk.row_range.end.0 < position.row) - .map(|hunk| hunk.row_range.start) - }); - } - if let Some(row) = row { - let destination = Point::new(row.0, 0); - let autoscroll = Autoscroll::center(); - - self.unfold_ranges(&[destination..destination], false, false, cx); - self.change_selections(SelectionEffects::scroll(autoscroll), window, cx, |s| { - s.select_ranges([destination..destination]); - }); - } + self.go_to_hunk_before_or_after_position( + &snapshot, + position, + Direction::Next, + wrap_around, + window, + cx, + ); } pub(crate) fn do_stage_or_unstage( @@ -29249,6 +29284,7 @@ fn render_diff_hunk_controls( &snapshot, position, Direction::Next, + true, window, cx, ); @@ -29284,6 +29320,7 @@ fn render_diff_hunk_controls( &snapshot, point, Direction::Prev, + true, window, cx, ); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 3cb2ac6ceec6e54b93266e2052403722651f89e3..d3da58733dd0a24622a6dcde87f638069e206cf4 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -33557,3 +33557,66 @@ comment */ˇ»;"#}, assert_text_with_selections(editor, indoc! {r#"let arr = [«1, 2, 3]ˇ»;"#}, cx); }); } + +#[gpui::test] +async fn test_restore_and_next(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + one + two + three + four + five + "# + .unindent(); + + cx.set_state( + &r#" + ONE + two + ˇTHREE + four + FIVE + "# + .unindent(), + ); + cx.set_head_text(&diff_base); + + cx.update_editor(|editor, window, cx| { + editor.set_expand_all_diff_hunks(cx); + editor.restore_and_next(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + cx.assert_state_with_diff( + r#" + - one + + ONE + two + three + four + - ˇfive + + FIVE + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.restore_and_next(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + cx.assert_state_with_diff( + r#" + - one + + ONE + two + three + four + ˇfive + "# + .unindent(), + ); +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 159aee456a6894824ff8e3e212281074498df3c6..b7207fce71bc71c5bdd5962ca3328030935238ca 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -637,6 +637,7 @@ impl EditorElement { register_action(editor, window, Editor::accept_edit_prediction); register_action(editor, window, Editor::restore_file); register_action(editor, window, Editor::git_restore); + register_action(editor, window, Editor::restore_and_next); register_action(editor, window, Editor::apply_all_diff_hunks); register_action(editor, window, Editor::apply_selected_diff_hunks); register_action(editor, window, Editor::open_active_item_in_terminal); diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 805d8d181ab7a434b565d38bdb2f802a8a3cda1a..13745c1fdfc0523d850b95e45a81cae286a77a00 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -40,6 +40,9 @@ actions!( /// Restores the selected hunks to their original state. #[action(deprecated_aliases = ["editor::RevertSelectedHunks"])] Restore, + /// Restores the selected hunks to their original state and moves to the + /// next one. + RestoreAndNext, // per-file /// Shows git blame information for the current file. #[action(deprecated_aliases = ["editor::ToggleGitBlame"])] diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 61d94b68a118525bd9b67217a929ce7462696dc7..8205f5ee7b6a9966a37a8406331d171d8ca57f1d 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1343,6 +1343,7 @@ impl GitPanel { &snapshot, language::Point::new(0, 0), Direction::Next, + true, window, cx, ); From 4abeeda0b2468aeccba4f3788bfcd7b79de9496c Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:07:33 +0100 Subject: [PATCH 062/219] recent_projects: Don't panic when attempting to delete SSH server out of bounds (#51091) Fixes ZED-517 Can be reproed by: Going into server options of the last server on your list. selecting "Remove server". Clicking on the button AND issuing menu::Confirm action at the same time (well, roughly the same time). The result: OS pop-up is issued twice; if the user does confirm twice, that's when that panic is hit. Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed a potential crash when deleting SSH servers too eagerly. --- crates/recent_projects/src/remote_servers.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index a94f7b1d57eaef8657fb0d448480f84c97ce7e70..b094ff6c5bc5499e7ed1f3e6c9e0b9331b6bb7c2 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1656,7 +1656,9 @@ impl RemoteServerProjects { fn delete_ssh_server(&mut self, server: SshServerIndex, cx: &mut Context) { self.update_settings_file(cx, move |setting, _| { - if let Some(connections) = setting.ssh_connections.as_mut() { + if let Some(connections) = setting.ssh_connections.as_mut() + && connections.get(server.0).is_some() + { connections.remove(server.0); } }); From 6b64b4c6c1cbc1bebe5a7e43ff063e18b84366c3 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 9 Mar 2026 08:55:16 -0300 Subject: [PATCH 063/219] agent_ui: Add keybinding and action for worktree toggle (#51092) This PR adds an action and keybinding to trigger the worktree dropdown in the agent panel. This is still under a feature flag, so no release notes yet. Release Notes: - N/A --- assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 1 + assets/keymaps/default-windows.json | 1 + crates/agent_ui/src/agent_panel.rs | 47 +++++++++++++++++++++++++++-- crates/agent_ui/src/agent_ui.rs | 2 ++ 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 21ab61065896953fdc950943ee89e778ee3ef726..55903cdd1532a4b8a1f5a28b97b650367cd44603 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -258,6 +258,7 @@ "ctrl-shift-j": "agent::ToggleNavigationMenu", "ctrl-alt-i": "agent::ToggleOptionsMenu", "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu", + "ctrl-alt-shift-t": "agent::ToggleStartThreadInSelector", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl->": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ae2e80bcccc6c86a17d6640cde07ff9211d4cbbf..f023c0dee408d58e50853e5d1ad27637c870bbb4 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -297,6 +297,7 @@ "cmd-shift-j": "agent::ToggleNavigationMenu", "cmd-alt-m": "agent::ToggleOptionsMenu", "cmd-alt-shift-n": "agent::ToggleNewThreadMenu", + "cmd-alt-shift-t": "agent::ToggleStartThreadInSelector", "shift-alt-escape": "agent::ExpandMessageEditor", "cmd->": "agent::AddSelectionToThread", "cmd-shift-e": "project_panel::ToggleFocus", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index a81e34cc16bb1a8e55c7106b22c55c9aa5796136..83fda88f398aba1d72d2c93bbe77239dbbad360b 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -259,6 +259,7 @@ "shift-alt-j": "agent::ToggleNavigationMenu", "shift-alt-i": "agent::ToggleOptionsMenu", "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", + "ctrl-shift-alt-t": "agent::ToggleStartThreadInSelector", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl-shift-.": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 4c05be77349aa7fecbe0855e3388e29ddbad2dcd..be12610a82f571edf140f8a30e8775fa377aac60 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -31,7 +31,7 @@ use crate::{ AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow, InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn, - ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, + ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, ToggleStartThreadInSelector, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, connection_view::{AcpThreadViewEvent, ThreadView}, slash_command::SlashCommandCompletionProvider, @@ -255,6 +255,18 @@ pub fn init(cx: &mut App) { }); } }) + .register_action(|workspace, _: &ToggleStartThreadInSelector, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + workspace.focus_panel::(window, cx); + panel.update(cx, |panel, cx| { + panel.toggle_start_thread_in_selector( + &ToggleStartThreadInSelector, + window, + cx, + ); + }); + } + }) .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| { AcpOnboardingModal::toggle(workspace, window, cx) }) @@ -1347,6 +1359,15 @@ impl AgentPanel { self.new_thread_menu_handle.toggle(window, cx); } + pub fn toggle_start_thread_in_selector( + &mut self, + _: &ToggleStartThreadInSelector, + window: &mut Window, + cx: &mut Context, + ) { + self.start_thread_in_menu_handle.toggle(window, cx); + } + pub fn increase_font_size( &mut self, action: &IncreaseBufferFontSize, @@ -3179,6 +3200,7 @@ impl AgentPanel { } fn render_start_thread_in_selector(&self, cx: &mut Context) -> impl IntoElement { + let focus_handle = self.focus_handle(cx); let has_git_repo = self.project_has_git_repository(cx); let is_via_collab = self.project.read(cx).is_via_collab(); @@ -3213,7 +3235,16 @@ impl AgentPanel { }; PopoverMenu::new("thread-target-selector") - .trigger(trigger_button) + .trigger_with_tooltip(trigger_button, { + move |_window, cx| { + Tooltip::for_action_in( + "Start Thread In…", + &ToggleStartThreadInSelector, + &focus_handle, + cx, + ) + } + }) .menu(move |window, cx| { let is_local_selected = current_target == StartThreadIn::LocalProject; let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree; @@ -3694,7 +3725,16 @@ impl AgentPanel { ); let agent_selector_menu = PopoverMenu::new("new_thread_menu") - .trigger(agent_selector_button) + .trigger_with_tooltip(agent_selector_button, { + move |_window, cx| { + Tooltip::for_action_in( + "New Thread\u{2026}", + &ToggleNewThreadMenu, + &focus_handle, + cx, + ) + } + }) .menu({ let builder = new_thread_menu_builder.clone(); move |window, cx| builder(window, cx) @@ -4269,6 +4309,7 @@ impl Render for AgentPanel { .on_action(cx.listener(Self::go_back)) .on_action(cx.listener(Self::toggle_navigation_menu)) .on_action(cx.listener(Self::toggle_options_menu)) + .on_action(cx.listener(Self::toggle_start_thread_in_selector)) .on_action(cx.listener(Self::increase_font_size)) .on_action(cx.listener(Self::decrease_font_size)) .on_action(cx.listener(Self::reset_font_size)) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index e8a80597f330cb5f10f25a44fa41cb4e38d69818..8cf18a872e8c3f2332c1633d34833d7a09ad5c95 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -82,6 +82,8 @@ actions!( NewTextThread, /// Toggles the menu to create new agent threads. ToggleNewThreadMenu, + /// Toggles the selector for choosing where new threads start (current project or new worktree). + ToggleStartThreadInSelector, /// Toggles the navigation menu for switching between threads and views. ToggleNavigationMenu, /// Toggles the options menu for agent settings and preferences. From 97421c670e18095acef5db02496b9c33fe975faa Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:22:12 +0100 Subject: [PATCH 064/219] Remove unreferenced dev dependencies (#51093) This will help with test times (in some cases), as nextest cannot figure out whether a given rdep is actually an alive edge of the build graph Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --- Cargo.lock | 123 -------------------- crates/acp_thread/Cargo.toml | 2 - crates/action_log/Cargo.toml | 2 +- crates/activity_indicator/Cargo.toml | 2 +- crates/agent/Cargo.toml | 6 +- crates/agent_servers/Cargo.toml | 2 +- crates/agent_settings/Cargo.toml | 2 +- crates/agent_ui/Cargo.toml | 8 +- crates/anthropic/Cargo.toml | 6 +- crates/assistant_text_thread/Cargo.toml | 2 +- crates/buffer_diff/Cargo.toml | 2 +- crates/call/Cargo.toml | 2 +- crates/cloud_llm_client/Cargo.toml | 4 +- crates/collab/Cargo.toml | 16 +-- crates/collab_ui/Cargo.toml | 8 +- crates/command_palette/Cargo.toml | 6 +- crates/copilot/Cargo.toml | 4 - crates/dap/Cargo.toml | 3 +- crates/dev_container/Cargo.toml | 2 +- crates/diagnostics/Cargo.toml | 2 +- crates/edit_prediction/Cargo.toml | 2 +- crates/edit_prediction_context/Cargo.toml | 2 +- crates/edit_prediction_ui/Cargo.toml | 10 +- crates/editor/Cargo.toml | 4 +- crates/extension_host/Cargo.toml | 2 +- crates/feedback/Cargo.toml | 2 - crates/file_finder/Cargo.toml | 2 +- crates/git/Cargo.toml | 1 - crates/git_graph/Cargo.toml | 1 - crates/git_ui/Cargo.toml | 1 - crates/go_to_line/Cargo.toml | 2 - crates/gpui/Cargo.toml | 1 - crates/language_models/Cargo.toml | 4 +- crates/languages/Cargo.toml | 2 - crates/livekit_client/Cargo.toml | 1 - crates/multi_buffer/Cargo.toml | 1 - crates/notifications/Cargo.toml | 4 +- crates/outline/Cargo.toml | 2 - crates/project/Cargo.toml | 3 - crates/project_panel/Cargo.toml | 1 - crates/proto/Cargo.toml | 4 +- crates/recent_projects/Cargo.toml | 1 - crates/remote_server/Cargo.toml | 3 - crates/repl/Cargo.toml | 1 - crates/reqwest_client/Cargo.toml | 1 - crates/search/Cargo.toml | 3 +- crates/settings_profile_selector/Cargo.toml | 2 - crates/settings_ui/Cargo.toml | 7 -- crates/tab_switcher/Cargo.toml | 2 - crates/terminal/Cargo.toml | 1 - crates/terminal_view/Cargo.toml | 2 - crates/text/Cargo.toml | 2 - crates/title_bar/Cargo.toml | 8 +- crates/util/Cargo.toml | 1 - crates/vim/Cargo.toml | 2 - crates/watch/Cargo.toml | 1 - crates/workspace/Cargo.toml | 1 - crates/worktree/Cargo.toml | 4 +- crates/zed/Cargo.toml | 3 - 59 files changed, 49 insertions(+), 252 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6cfbab0d585fe93d7b984f674475dfbc411ca14b..ed028d2de80dcd05487f2621102d8b3e8de8512d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,7 +36,6 @@ dependencies = [ "smol", "task", "telemetry", - "tempfile", "terminal", "text", "ui", @@ -45,7 +44,6 @@ dependencies = [ "util", "uuid", "watch", - "zlog", ] [[package]] @@ -79,7 +77,6 @@ dependencies = [ "fs", "futures 0.3.31", "gpui", - "indoc", "language", "log", "pretty_assertions", @@ -108,7 +105,6 @@ dependencies = [ "language", "project", "proto", - "release_channel", "smallvec", "ui", "util", @@ -214,11 +210,9 @@ dependencies = [ "task", "telemetry", "tempfile", - "terminal", "text", "theme", "thiserror 2.0.17", - "tree-sitter-rust", "ui", "unindent", "url", @@ -226,7 +220,6 @@ dependencies = [ "uuid", "watch", "web_search", - "worktree", "zed_env_vars", "zlog", "zstd", @@ -285,7 +278,6 @@ dependencies = [ "gpui_tokio", "http_client", "indoc", - "language", "language_model", "libc", "log", @@ -319,7 +311,6 @@ dependencies = [ "gpui", "language_model", "log", - "paths", "project", "regex", "schemars", @@ -352,7 +343,6 @@ dependencies = [ "buffer_diff", "chrono", "client", - "clock", "cloud_api_types", "cloud_llm_client", "collections", @@ -398,9 +388,7 @@ dependencies = [ "prompt_store", "proto", "rand 0.9.2", - "recent_projects", "release_channel", - "remote_connection", "reqwest_client", "rope", "rules_library", @@ -415,14 +403,12 @@ dependencies = [ "streaming_diff", "task", "telemetry", - "tempfile", "terminal", "terminal_view", "text", "theme", "time", "time_format", - "title_bar", "tree-sitter-md", "ui", "ui_input", @@ -671,17 +657,13 @@ dependencies = [ "anyhow", "chrono", "futures 0.3.31", - "gpui", - "gpui_tokio", "http_client", - "reqwest_client", "schemars", "serde", "serde_json", "settings", "strum 0.27.2", "thiserror 2.0.17", - "tokio", ] [[package]] @@ -893,7 +875,6 @@ dependencies = [ "futures 0.3.31", "fuzzy", "gpui", - "indoc", "itertools 0.14.0", "language", "language_model", @@ -2320,7 +2301,6 @@ dependencies = [ "pretty_assertions", "rand 0.9.2", "rope", - "serde_json", "settings", "sum_tree", "text", @@ -2504,7 +2484,6 @@ dependencies = [ "futures 0.3.31", "gpui", "gpui_tokio", - "http_client", "language", "livekit_client", "log", @@ -3099,8 +3078,6 @@ name = "cloud_llm_client" version = "0.1.0" dependencies = [ "anyhow", - "indoc", - "pretty_assertions", "serde", "serde_json", "strum 0.27.2", @@ -3232,15 +3209,11 @@ name = "collab" version = "0.44.0" dependencies = [ "agent", - "agent-client-protocol", - "agent_settings", - "agent_ui", "anyhow", "assistant_slash_command", "assistant_text_thread", "async-trait", "async-tungstenite", - "audio", "aws-config", "aws-sdk-kinesis", "aws-sdk-s3", @@ -3256,10 +3229,8 @@ dependencies = [ "collab_ui", "collections", "command_palette_hooks", - "context_server", "ctor", "dap", - "dap-types", "dap_adapters", "dashmap", "debugger_ui", @@ -3276,7 +3247,6 @@ dependencies = [ "gpui_tokio", "hex", "http_client", - "hyper 0.14.32", "indoc", "language", "language_model", @@ -3318,7 +3288,6 @@ dependencies = [ "text", "theme", "time", - "title_bar", "tokio", "toml 0.8.23", "tower 0.4.13", @@ -3349,12 +3318,10 @@ dependencies = [ "futures 0.3.31", "fuzzy", "gpui", - "http_client", "log", "menu", "notifications", "picker", - "pretty_assertions", "project", "release_channel", "rpc", @@ -3367,7 +3334,6 @@ dependencies = [ "time", "time_format", "title_bar", - "tree-sitter-md", "ui", "util", "workspace", @@ -3421,10 +3387,8 @@ dependencies = [ "client", "collections", "command_palette_hooks", - "ctor", "db", "editor", - "env_logger 0.11.8", "fuzzy", "go_to_line", "gpui", @@ -3435,7 +3399,6 @@ dependencies = [ "postage", "project", "serde", - "serde_json", "settings", "telemetry", "theme", @@ -3658,18 +3621,14 @@ version = "0.1.0" dependencies = [ "anyhow", "async-std", - "client", - "clock", "collections", "command_palette_hooks", "copilot_chat", - "ctor", "edit_prediction_types", "editor", "fs", "futures 0.3.31", "gpui", - "http_client", "icons", "indoc", "language", @@ -4507,8 +4466,6 @@ dependencies = [ "smol", "task", "telemetry", - "tree-sitter", - "tree-sitter-go", "util", "zlog", ] @@ -4879,7 +4836,6 @@ dependencies = [ "serde_json", "settings", "smol", - "theme", "ui", "util", "workspace", @@ -4891,7 +4847,6 @@ name = "diagnostics" version = "0.1.0" dependencies = [ "anyhow", - "client", "collections", "component", "ctor", @@ -5284,7 +5239,6 @@ dependencies = [ "thiserror 2.0.17", "time", "toml 0.8.23", - "tree-sitter-rust", "ui", "util", "uuid", @@ -5382,7 +5336,6 @@ dependencies = [ "tree-sitter", "util", "zeta_prompt", - "zlog", ] [[package]] @@ -5403,7 +5356,6 @@ dependencies = [ "anyhow", "buffer_diff", "client", - "clock", "cloud_llm_client", "codestral", "collections", @@ -5420,18 +5372,12 @@ dependencies = [ "gpui", "indoc", "language", - "language_model", - "lsp", "markdown", "menu", "multi_buffer", "paths", - "pretty_assertions", "project", "regex", - "release_channel", - "semver", - "serde_json", "settings", "telemetry", "text", @@ -5442,7 +5388,6 @@ dependencies = [ "workspace", "zed_actions", "zeta_prompt", - "zlog", ] [[package]] @@ -5471,7 +5416,6 @@ dependencies = [ "fuzzy", "git", "gpui", - "http_client", "indoc", "itertools 0.14.0", "language", @@ -5504,7 +5448,6 @@ dependencies = [ "sum_tree", "task", "telemetry", - "tempfile", "text", "theme", "time", @@ -6121,7 +6064,6 @@ dependencies = [ "parking_lot", "paths", "project", - "rand 0.9.2", "release_channel", "remote", "reqwest_client", @@ -6277,7 +6219,6 @@ dependencies = [ name = "feedback" version = "0.1.0" dependencies = [ - "editor", "gpui", "system_specs", "urlencoding", @@ -6308,7 +6249,6 @@ dependencies = [ "futures 0.3.31", "fuzzy", "gpui", - "language", "menu", "open_path_prompt", "picker", @@ -7294,7 +7234,6 @@ dependencies = [ "text", "thiserror 2.0.17", "time", - "unindent", "url", "urlencoding", "util", @@ -7331,7 +7270,6 @@ dependencies = [ "menu", "project", "rand 0.9.2", - "recent_projects", "serde_json", "settings", "smallvec", @@ -7382,7 +7320,6 @@ dependencies = [ "futures 0.3.31", "fuzzy", "git", - "git_hosting_providers", "gpui", "indoc", "itertools 0.14.0", @@ -7551,8 +7488,6 @@ dependencies = [ "settings", "text", "theme", - "tree-sitter-rust", - "tree-sitter-typescript", "ui", "util", "workspace", @@ -7683,7 +7618,6 @@ dependencies = [ "pin-project", "pollster 0.4.0", "postage", - "pretty_assertions", "profiling", "proptest", "rand 0.9.2", @@ -9484,7 +9418,6 @@ dependencies = [ "copilot_ui", "credentials_provider", "deepseek", - "editor", "extension", "extension_host", "fs", @@ -9504,7 +9437,6 @@ dependencies = [ "open_router", "partial-json-fixer", "pretty_assertions", - "project", "release_channel", "schemars", "semver", @@ -9632,7 +9564,6 @@ dependencies = [ "snippet", "task", "terminal", - "text", "theme", "toml 0.8.23", "tree-sitter", @@ -9656,7 +9587,6 @@ dependencies = [ "unindent", "url", "util", - "workspace", ] [[package]] @@ -10010,7 +9940,6 @@ dependencies = [ "serde_json", "serde_urlencoded", "settings", - "sha2", "simplelog", "smallvec", "ui", @@ -10755,7 +10684,6 @@ dependencies = [ "log", "parking_lot", "pretty_assertions", - "project", "rand 0.9.2", "rope", "serde", @@ -11033,12 +10961,10 @@ dependencies = [ "anyhow", "channel", "client", - "collections", "component", "db", "gpui", "rpc", - "settings", "sum_tree", "time", "ui", @@ -11789,8 +11715,6 @@ dependencies = [ "settings", "smol", "theme", - "tree-sitter-rust", - "tree-sitter-typescript", "ui", "util", "workspace", @@ -13153,8 +13077,6 @@ dependencies = [ "collections", "context_server", "dap", - "dap_adapters", - "db", "encoding_rs", "extension", "fancy-regex", @@ -13261,7 +13183,6 @@ dependencies = [ "pretty_assertions", "project", "rayon", - "remote_connection", "schemars", "search", "serde", @@ -13495,11 +13416,9 @@ name = "proto" version = "0.1.0" dependencies = [ "anyhow", - "collections", "prost 0.9.0", "prost-build 0.9.0", "serde", - "typed-path", ] [[package]] @@ -14052,7 +13971,6 @@ dependencies = [ "anyhow", "askpass", "chrono", - "dap", "db", "dev_container", "editor", @@ -14301,7 +14219,6 @@ dependencies = [ "collections", "crash-handler", "crashes", - "dap", "dap_adapters", "debug_adapter_extension", "editor", @@ -14333,7 +14250,6 @@ dependencies = [ "paths", "pretty_assertions", "project", - "prompt_store", "proto", "rayon", "release_channel", @@ -14357,7 +14273,6 @@ dependencies = [ "uuid", "watch", "windows 0.61.3", - "workspace", "worktree", "zlog", ] @@ -14391,7 +14306,6 @@ dependencies = [ "collections", "command_palette_hooks", "editor", - "env_logger 0.11.8", "feature_flags", "file_icons", "futures 0.3.31", @@ -14519,7 +14433,6 @@ dependencies = [ "anyhow", "bytes 1.11.1", "futures 0.3.31", - "gpui", "gpui_util", "http_client", "http_client_tls", @@ -15393,7 +15306,6 @@ dependencies = [ "any_vec", "anyhow", "bitflags 2.10.0", - "client", "collections", "editor", "fs", @@ -15745,11 +15657,9 @@ dependencies = [ name = "settings_profile_selector" version = "0.1.0" dependencies = [ - "client", "editor", "fuzzy", "gpui", - "language", "menu", "picker", "project", @@ -15768,9 +15678,7 @@ dependencies = [ "agent", "agent_settings", "anyhow", - "assets", "audio", - "client", "codestral", "component", "copilot", @@ -15788,13 +15696,11 @@ dependencies = [ "language", "log", "menu", - "node_runtime", "paths", "picker", "platform_title_bar", "pretty_assertions", "project", - "recent_projects", "regex", "release_channel", "rodio", @@ -15802,7 +15708,6 @@ dependencies = [ "search", "serde", "serde_json", - "session", "settings", "shell_command_parser", "strum 0.27.2", @@ -15813,7 +15718,6 @@ dependencies = [ "util", "workspace", "zed_actions", - "zlog", ] [[package]] @@ -17206,13 +17110,11 @@ dependencies = [ name = "tab_switcher" version = "0.1.0" dependencies = [ - "anyhow", "collections", "ctor", "editor", "fuzzy", "gpui", - "language", "menu", "picker", "project", @@ -17401,7 +17303,6 @@ dependencies = [ "release_channel", "schemars", "serde", - "serde_json", "settings", "smol", "sysinfo 0.37.2", @@ -17433,7 +17334,6 @@ dependencies = [ "assistant_slash_command", "async-recursion", "breadcrumbs", - "client", "collections", "db", "dirs 4.0.0", @@ -17446,7 +17346,6 @@ dependencies = [ "menu", "pretty_assertions", "project", - "rand 0.9.2", "regex", "schemars", "serde", @@ -17471,11 +17370,9 @@ dependencies = [ "collections", "ctor", "gpui", - "http_client", "log", "parking_lot", "postage", - "proptest", "rand 0.9.2", "regex", "rope", @@ -17775,15 +17672,12 @@ dependencies = [ "chrono", "client", "cloud_api_types", - "collections", "db", "feature_flags", "git_ui", "gpui", - "http_client", "notifications", "platform_title_bar", - "pretty_assertions", "project", "recent_projects", "release_channel", @@ -17797,7 +17691,6 @@ dependencies = [ "story", "telemetry", "theme", - "tree-sitter-md", "ui", "util", "windows 0.61.3", @@ -18627,12 +18520,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "typed-path" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c462d18470a2857aa657d338af5fa67170bb48bcc80a296710ce3b0802a32566" - [[package]] name = "typeid" version = "1.0.3" @@ -18959,7 +18846,6 @@ dependencies = [ "git2", "globset", "gpui_util", - "indoc", "itertools 0.14.0", "libc", "log", @@ -19104,7 +18990,6 @@ name = "vim" version = "0.1.0" dependencies = [ "anyhow", - "assets", "async-compat", "async-trait", "collections", @@ -19144,7 +19029,6 @@ dependencies = [ "task", "text", "theme", - "title_bar", "tokio", "ui", "util", @@ -19852,7 +19736,6 @@ dependencies = [ "futures 0.3.31", "gpui", "parking_lot", - "rand 0.9.2", "zlog", ] @@ -21444,7 +21327,6 @@ dependencies = [ "clock", "collections", "component", - "dap", "db", "feature_flags", "fs", @@ -21497,9 +21379,7 @@ dependencies = [ "futures 0.3.31", "fuzzy", "git", - "git2", "gpui", - "http_client", "ignore", "language", "log", @@ -21933,7 +21813,6 @@ dependencies = [ "copilot_ui", "crashes", "csv_preview", - "dap", "dap_adapters", "db", "debug_adapter_extension", @@ -22043,8 +21922,6 @@ dependencies = [ "title_bar", "toolchain_selector", "tracing", - "tree-sitter-md", - "tree-sitter-rust", "ui", "ui_prompt", "url", diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 83cf86bfafc33e4d1b520ca5af04da626831aed7..7ef53bc522708680e64cfcc9ce2860990bfd7d13 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -59,7 +59,5 @@ indoc.workspace = true parking_lot.workspace = true project = { workspace = true, "features" = ["test-support"] } rand.workspace = true -tempfile.workspace = true util.workspace = true settings.workspace = true -zlog.workspace = true diff --git a/crates/action_log/Cargo.toml b/crates/action_log/Cargo.toml index b1a1bf824fb770b8378e596fd0c799a7cf98b13d..5227a61651012279e83a3b6e3e68b1484acb0f66 100644 --- a/crates/action_log/Cargo.toml +++ b/crates/action_log/Cargo.toml @@ -37,7 +37,7 @@ collections = { workspace = true, features = ["test-support"] } clock = { workspace = true, features = ["test-support"] } ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } -indoc.workspace = true + language = { workspace = true, features = ["test-support"] } log.workspace = true pretty_assertions.workspace = true diff --git a/crates/activity_indicator/Cargo.toml b/crates/activity_indicator/Cargo.toml index 99ae5b5b077a14c0909737d64935220698a007c7..ce53f23365d57666e25cac434935514fc4bd7e3f 100644 --- a/crates/activity_indicator/Cargo.toml +++ b/crates/activity_indicator/Cargo.toml @@ -30,4 +30,4 @@ workspace.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } -release_channel.workspace = true + diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 9f563cf0b1b009a496d36a6f090b0f4b476433a7..fe2089d94dc2e3fc812f6cbe39c16c5cadc1a1f5 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -100,9 +100,9 @@ rand.workspace = true reqwest_client.workspace = true settings = { workspace = true, "features" = ["test-support"] } tempfile.workspace = true -terminal = { workspace = true, "features" = ["test-support"] } + theme = { workspace = true, "features" = ["test-support"] } -tree-sitter-rust.workspace = true + unindent = { workspace = true } -worktree = { workspace = true, "features" = ["test-support"] } + zlog.workspace = true diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 4d34632a248c5db35666e93cb068c7ec6727fc48..4fb4109129ee5b8896f7a62afe49e0bcaef701ed 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -61,7 +61,7 @@ nix.workspace = true client = { workspace = true, features = ["test-support"] } env_logger.workspace = true fs.workspace = true -language.workspace = true + indoc.workspace = true acp_thread = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index 01f74de2f2ca5be863dbe27174e5131b9b8a657c..15f35a931dedad303c46895c487655b9ddbc7496 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -30,7 +30,7 @@ util.workspace = true [dev-dependencies] fs.workspace = true gpui = { workspace = true, features = ["test-support"] } -paths.workspace = true + serde_json_lenient.workspace = true serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 3e46e14b53c46a2aec3ac9552246a10ffc2aeee9..8b06417d2f5812ef2e0fb265e6afa4cfeb26eb3f 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -121,7 +121,7 @@ acp_thread = { workspace = true, features = ["test-support"] } agent = { workspace = true, features = ["test-support"] } assistant_text_thread = { workspace = true, features = ["test-support"] } buffer_diff = { workspace = true, features = ["test-support"] } -clock.workspace = true + db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } eval_utils.workspace = true @@ -132,11 +132,9 @@ languages = { workspace = true, features = ["test-support"] } language_model = { workspace = true, "features" = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } -recent_projects = { workspace = true, features = ["test-support"] } -remote_connection = { workspace = true, features = ["test-support"] } -title_bar = { workspace = true, features = ["test-support"] } + semver.workspace = true reqwest_client.workspace = true -tempfile.workspace = true + tree-sitter-md.workspace = true unindent.workspace = true diff --git a/crates/anthropic/Cargo.toml b/crates/anthropic/Cargo.toml index f344470475a7603782d3eba9a8c461a92d7b4855..065879bc94b68abe193a1a4fc530142d7695ff49 100644 --- a/crates/anthropic/Cargo.toml +++ b/crates/anthropic/Cargo.toml @@ -27,8 +27,4 @@ settings.workspace = true strum.workspace = true thiserror.workspace = true -[dev-dependencies] -reqwest_client.workspace = true -gpui_tokio.workspace = true -gpui.workspace = true -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + diff --git a/crates/assistant_text_thread/Cargo.toml b/crates/assistant_text_thread/Cargo.toml index 4c3563a7d26dca06282d5f3d15ec2a64c411dfba..bbb5cf4778efd5d74b880b7350a71e72562f4d70 100644 --- a/crates/assistant_text_thread/Cargo.toml +++ b/crates/assistant_text_thread/Cargo.toml @@ -55,7 +55,7 @@ zed_env_vars.workspace = true [dev-dependencies] assistant_slash_commands.workspace = true -indoc.workspace = true + language_model = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true rand.workspace = true diff --git a/crates/buffer_diff/Cargo.toml b/crates/buffer_diff/Cargo.toml index 06cb6cfa76c66c2d5a7b3b4197566cdef3e0c18c..da18728ed4da5cafc972eb80d4dd93117bcff6ed 100644 --- a/crates/buffer_diff/Cargo.toml +++ b/crates/buffer_diff/Cargo.toml @@ -34,7 +34,7 @@ ztracing.workspace = true ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } rand.workspace = true -serde_json.workspace = true + settings.workspace = true text = { workspace = true, features = ["test-support"] } unindent.workspace = true diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml index 2e46b58b74b826e8892d1e9da28c3cf06c99aa9b..64f741bd588d2227198fda13c0a8fbf5fdb4337c 100644 --- a/crates/call/Cargo.toml +++ b/crates/call/Cargo.toml @@ -51,5 +51,5 @@ gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } + livekit_client = { workspace = true, features = ["test-support"] } diff --git a/crates/cloud_llm_client/Cargo.toml b/crates/cloud_llm_client/Cargo.toml index 0f0f2e77360dab0793f5740a24965711f4d80fda..a7b4f925a9302296e8fe25a14177a583e5f44b33 100644 --- a/crates/cloud_llm_client/Cargo.toml +++ b/crates/cloud_llm_client/Cargo.toml @@ -22,6 +22,4 @@ strum = { workspace = true, features = ["derive"] } uuid = { workspace = true, features = ["serde"] } zeta_prompt.workspace = true -[dev-dependencies] -pretty_assertions.workspace = true -indoc.workspace = true + diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 5db06ef8e73d3cf276f73fbd8aa53e932e6c75b8..447c2da08e054c9964f3813ac569964173ded5c3 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -75,13 +75,13 @@ uuid.workspace = true [dev-dependencies] agent = { workspace = true, features = ["test-support"] } -agent-client-protocol.workspace = true -agent_settings.workspace = true -agent_ui = { workspace = true, features = ["test-support"] } + + + assistant_text_thread.workspace = true assistant_slash_command.workspace = true async-trait.workspace = true -audio.workspace = true + buffer_diff.workspace = true call = { workspace = true, features = ["test-support"] } channel.workspace = true @@ -90,11 +90,11 @@ collab = { workspace = true, features = ["test-support"] } collab_ui = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } command_palette_hooks.workspace = true -context_server.workspace = true + ctor.workspace = true dap = { workspace = true, features = ["test-support"] } dap_adapters = { workspace = true, features = ["test-support"] } -dap-types.workspace = true + debugger_ui = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } extension.workspace = true @@ -105,7 +105,7 @@ git_hosting_providers.workspace = true git_ui = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } gpui_tokio.workspace = true -hyper.workspace = true + indoc.workspace = true language = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } @@ -131,7 +131,7 @@ smol.workspace = true sqlx = { version = "0.8", features = ["sqlite"] } task.workspace = true theme.workspace = true -title_bar = { workspace = true, features = ["test-support"] } + unindent.workspace = true util.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index c996e3821fee17dbea99f660304e0b76b6e9bc28..0ac413d1863dbbcdbcd81ad2bb3907f7a370c866 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -24,7 +24,7 @@ test-support = [ "settings/test-support", "util/test-support", "workspace/test-support", - "http_client/test-support", + "title_bar/test-support", ] @@ -67,11 +67,11 @@ collections = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } notifications = { workspace = true, features = ["test-support"] } -pretty_assertions.workspace = true + project = { workspace = true, features = ["test-support"] } rpc = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } -tree-sitter-md.workspace = true + util = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } + workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml index bd86c10a8071896f0b24ea531d354c0e46114d48..96be6cb9ee2b767bc14503cbae7e2de6838e6724 100644 --- a/crates/command_palette/Cargo.toml +++ b/crates/command_palette/Cargo.toml @@ -38,14 +38,14 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] -ctor.workspace = true + db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } -env_logger.workspace = true + go_to_line.workspace = true gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } menu.workspace = true project = { workspace = true, features = ["test-support"] } -serde_json.workspace = true + workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index 236216a8d9a64f736c76399867f0b8766c93c16b..d625c998b034a249cb3f498ae1fdd4e0e179a4cc 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -52,14 +52,10 @@ workspace.workspace = true async-std = { version = "1.12.0", features = ["unstable"] } [dev-dependencies] -client = { workspace = true, features = ["test-support"] } -clock = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } -ctor.workspace = true editor = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } indoc.workspace = true language = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } diff --git a/crates/dap/Cargo.toml b/crates/dap/Cargo.toml index d856ae0164ff35236f7a133361cdf28908f8b044..a1b107eb42ac44e95b84f4b5bfd1f0871cfcfc93 100644 --- a/crates/dap/Cargo.toml +++ b/crates/dap/Cargo.toml @@ -58,7 +58,6 @@ async-pipe.workspace = true gpui = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } task = { workspace = true, features = ["test-support"] } -tree-sitter.workspace = true -tree-sitter-go.workspace = true + util = { workspace = true, features = ["test-support"] } zlog.workspace = true diff --git a/crates/dev_container/Cargo.toml b/crates/dev_container/Cargo.toml index 7b1574da69729a8ff5ddeb5523a8c249779a721b..e3a67601c3837bd9579a477576e9c837f73c1e75 100644 --- a/crates/dev_container/Cargo.toml +++ b/crates/dev_container/Cargo.toml @@ -29,7 +29,7 @@ gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } -theme.workspace = true + workspace = { workspace = true, features = ["test-support"] } worktree = { workspace = true, features = ["test-support"] } diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml index a5328a1a6dd2e492dc4fb38a963b68a84d98cc03..09ee023d57fbb9b9f2c7d828f9b2ea25f73d23d9 100644 --- a/crates/diagnostics/Cargo.toml +++ b/crates/diagnostics/Cargo.toml @@ -38,7 +38,7 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] -client = { workspace = true, features = ["test-support"] } + editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index 9f867584b57c8aed86f7003cca3a2b034c184476..d2a23b8b4ec3425072ffbe9d042ff89d26a56778 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -82,5 +82,5 @@ parking_lot.workspace = true project = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } -tree-sitter-rust.workspace = true + zlog.workspace = true diff --git a/crates/edit_prediction_context/Cargo.toml b/crates/edit_prediction_context/Cargo.toml index e1c1aed4e35f518258edcec8acd59dd9fcac7338..3a63f16610a6b60d2e5a3d415d87698070e7b3f4 100644 --- a/crates/edit_prediction_context/Cargo.toml +++ b/crates/edit_prediction_context/Cargo.toml @@ -42,4 +42,4 @@ serde_json.workspace = true settings = {workspace= true, features = ["test-support"]} text = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } -zlog.workspace = true + diff --git a/crates/edit_prediction_ui/Cargo.toml b/crates/edit_prediction_ui/Cargo.toml index 05afbabd2045e9bca591b6c2edba846e95953a4f..b6b6473bafa0222a670e1c541e03d255ee0d2d5a 100644 --- a/crates/edit_prediction_ui/Cargo.toml +++ b/crates/edit_prediction_ui/Cargo.toml @@ -50,18 +50,12 @@ zed_actions.workspace = true zeta_prompt.workspace = true [dev-dependencies] -clock.workspace = true copilot = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } futures.workspace = true indoc.workspace = true -language_model.workspace = true -lsp = { workspace = true, features = ["test-support"] } -pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } -release_channel.workspace = true -semver.workspace = true -serde_json.workspace = true theme = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } -zlog.workspace = true + + diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 2a8709dea29cf1398a862216e407b973eae41004..22a9b8effbe52caa67812619d254076493210e68 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -119,7 +119,7 @@ release_channel.workspace = true rand.workspace = true semver.workspace = true settings = { workspace = true, features = ["test-support"] } -tempfile.workspace = true + text = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } tree-sitter-c.workspace = true @@ -133,7 +133,7 @@ unicode-width.workspace = true unindent.workspace = true util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } + zlog.workspace = true diff --git a/crates/extension_host/Cargo.toml b/crates/extension_host/Cargo.toml index c4d1f6d98c82ee348f4a7453a3bb6e3255924b77..c6f4db47c97d69173242953926c6965c039a6397 100644 --- a/crates/extension_host/Cargo.toml +++ b/crates/extension_host/Cargo.toml @@ -65,7 +65,7 @@ language = { workspace = true, features = ["test-support"] } language_extension.workspace = true parking_lot.workspace = true project = { workspace = true, features = ["test-support"] } -rand.workspace = true + reqwest_client.workspace = true theme = { workspace = true, features = ["test-support"] } theme_extension.workspace = true diff --git a/crates/feedback/Cargo.toml b/crates/feedback/Cargo.toml index 0a53a1b6f38d1af0a6b913d61969d4df105a6a10..c2279d778865cb819a5b0e2e494ad9d1e4470067 100644 --- a/crates/feedback/Cargo.toml +++ b/crates/feedback/Cargo.toml @@ -22,5 +22,3 @@ util.workspace = true workspace.workspace = true zed_actions.workspace = true -[dev-dependencies] -editor = { workspace = true, features = ["test-support"] } diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 8800c7cdcb86735e3b884bd7bd1fbbf5a0522174..113bf68d34f778f8fba9fdc62b586c31e689a380 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -38,7 +38,7 @@ project_panel.workspace = true ctor.workspace = true editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } -language = { workspace = true, features = ["test-support"] } + picker = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true serde_json.workspace = true diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 4d96312e274b3934e0d1ae8aa1f16f235d30a59f..23a937bf1fa17481eb5e130b3e083274dd3f1d16 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -48,7 +48,6 @@ ztracing.workspace = true pretty_assertions.workspace = true serde_json.workspace = true text = { workspace = true, features = ["test-support"] } -unindent.workspace = true gpui = { workspace = true, features = ["test-support"] } tempfile.workspace = true rand.workspace = true diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml index 386d82389ca3370f071f8733b039f91fc3f21feb..4756c55ac9232631a46056e252021a704d4a25b6 100644 --- a/crates/git_graph/Cargo.toml +++ b/crates/git_graph/Cargo.toml @@ -43,7 +43,6 @@ git = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } rand.workspace = true -recent_projects = { workspace = true, features = ["test-support"] } serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index a25911d65eb87d176a0a987d996e159e2c43628c..4493cb58471aed9dcf4a259f5a82117992b1dedb 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -73,7 +73,6 @@ windows.workspace = true [dev-dependencies] ctor.workspace = true editor = { workspace = true, features = ["test-support"] } -git_hosting_providers.workspace = true gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true pretty_assertions.workspace = true diff --git a/crates/go_to_line/Cargo.toml b/crates/go_to_line/Cargo.toml index 0260cd2d122f83f2c11505be9e6e8a84f69f8569..58c58dc389e37210063efb55337fc385cc0ad435 100644 --- a/crates/go_to_line/Cargo.toml +++ b/crates/go_to_line/Cargo.toml @@ -34,6 +34,4 @@ menu.workspace = true project = { workspace = true, features = ["test-support"] } rope.workspace = true serde_json.workspace = true -tree-sitter-rust.workspace = true -tree-sitter-typescript.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 28350e55702a88a0aef6686f16f45303c99a75d0..61782fbe50e26a089eefe3c11e70a0016909f6b3 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -146,7 +146,6 @@ collections = { workspace = true, features = ["test-support"] } env_logger.workspace = true gpui_platform.workspace = true lyon = { version = "1.0", features = ["extra"] } -pretty_assertions.workspace = true rand.workspace = true scheduler = { workspace = true, features = ["test-support"] } unicode-segmentation.workspace = true diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index ece0d68152a20cbf77d0c082746959684816f115..b37f783eb9213a3d1d4bb4cc1bb0011c24879b05 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -68,7 +68,7 @@ vercel = { workspace = true, features = ["schemars"] } x_ai = { workspace = true, features = ["schemars"] } [dev-dependencies] -editor = { workspace = true, features = ["test-support"] } + language_model = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true -project = { workspace = true, features = ["test-support"] } + diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index 8529bdb82ace33d6f3c747ed707b9aac9d319627..b66f661b5e8782a7a072332141e4e2246ab1a2b9 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -98,7 +98,6 @@ util.workspace = true [dev-dependencies] pretty_assertions.workspace = true -text.workspace = true theme = { workspace = true, features = ["test-support"] } tree-sitter-bash.workspace = true tree-sitter-c.workspace = true @@ -109,4 +108,3 @@ tree-sitter-python.workspace = true tree-sitter-typescript.workspace = true tree-sitter.workspace = true unindent.workspace = true -workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/livekit_client/Cargo.toml b/crates/livekit_client/Cargo.toml index 66511da9daa943628e71000a2009b2026eeace6c..df1024aa99e15e322c7dff5ee7933db2a9df80b4 100644 --- a/crates/livekit_client/Cargo.toml +++ b/crates/livekit_client/Cargo.toml @@ -61,7 +61,6 @@ objc.workspace = true collections = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } gpui_platform.workspace = true -sha2.workspace = true simplelog.workspace = true [build-dependencies] diff --git a/crates/multi_buffer/Cargo.toml b/crates/multi_buffer/Cargo.toml index 524c916682f4d17b4e4b598a9af158e259b40ffc..66c23101ab26ac6be58d482c752f366522bb9305 100644 --- a/crates/multi_buffer/Cargo.toml +++ b/crates/multi_buffer/Cargo.toml @@ -52,7 +52,6 @@ gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true language = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true -project = { workspace = true, features = ["test-support"] } rand.workspace = true settings = { workspace = true, features = ["test-support"] } text = { workspace = true, features = ["test-support"] } diff --git a/crates/notifications/Cargo.toml b/crates/notifications/Cargo.toml index 8304c788fdd1ca840d68dbb4eb24bf5e3e79abdc..e0640c67cc55b3c2ba742e762d0e7a1e9d414c40 100644 --- a/crates/notifications/Cargo.toml +++ b/crates/notifications/Cargo.toml @@ -15,7 +15,7 @@ doctest = false [features] test-support = [ "channel/test-support", - "collections/test-support", + "gpui/test-support", "rpc/test-support", ] @@ -37,8 +37,6 @@ zed_actions.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } -collections = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } rpc = { workspace = true, features = ["test-support"] } -settings = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } diff --git a/crates/outline/Cargo.toml b/crates/outline/Cargo.toml index 905f323624437d988ff9a9eb3bde4f9a7becaa91..79559e03e8b2339fd8b4473d9e06ca6ff47b2b8c 100644 --- a/crates/outline/Cargo.toml +++ b/crates/outline/Cargo.toml @@ -38,6 +38,4 @@ project = { workspace = true, features = ["test-support"] } rope.workspace = true serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } -tree-sitter-rust.workspace = true -tree-sitter-typescript.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index cbcd5481ee3c48655fc78e17d5cf65d2ec978a09..dfcc8faf64a7e66cce7b9f07f2daa12eae984fa5 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -31,7 +31,6 @@ test-support = [ "worktree/test-support", "gpui/test-support", "dap/test-support", - "dap_adapters/test-support", ] [dependencies] @@ -105,12 +104,10 @@ tracing.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } encoding_rs.workspace = true -db = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } context_server = { workspace = true, features = ["test-support"] } buffer_diff = { workspace = true, features = ["test-support"] } dap = { workspace = true, features = ["test-support"] } -dap_adapters = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } git2.workspace = true gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index 5149c6f7834474439bd6119511bb294b560fe4de..88d85c75f9e6452a72eb4181a94a8bf6395ba754 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -54,7 +54,6 @@ criterion.workspace = true editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } -remote_connection = { workspace = true, features = ["test-support"] } serde_json.workspace = true tempfile.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/proto/Cargo.toml b/crates/proto/Cargo.toml index 5b5b8b985cbc102cc451050403cff2e3699f612f..dfa4166f2077aea60aa87084af4918c92882f2df 100644 --- a/crates/proto/Cargo.toml +++ b/crates/proto/Cargo.toml @@ -7,7 +7,7 @@ publish.workspace = true license = "GPL-3.0-or-later" [features] -test-support = ["collections/test-support"] +test-support = [] [lints] workspace = true @@ -25,5 +25,3 @@ serde.workspace = true prost-build.workspace = true [dev-dependencies] -collections = { workspace = true, features = ["test-support"] } -typed-path = "0.11" diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 11daee79adc8099a8915b427394256eeed8b5e20..a2aa9f78a2a5edaf13a4f23f52f3695de636850f 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -59,7 +59,6 @@ indoc.workspace = true windows-registry = "0.6.0" [dev-dependencies] -dap.workspace = true editor = { workspace = true, features = ["test-support"] } extension.workspace = true fs.workspace = true diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index ee729a80eaa9eff56eee7f3bcb8fe6eaf31f0c41..36944261cded68b564df8093d5b7a7621a644c11 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -89,9 +89,7 @@ action_log.workspace = true agent = { workspace = true, features = ["test-support"] } client = { workspace = true, features = ["test-support"] } clock = { workspace = true, features = ["test-support"] } -dap = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } -workspace = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } @@ -103,7 +101,6 @@ remote = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } -prompt_store.workspace = true unindent.workspace = true serde_json.workspace = true zlog.workspace = true diff --git a/crates/repl/Cargo.toml b/crates/repl/Cargo.toml index c2d6f745d9272651bd90bcdfdc689263958b8b09..4329b29ada504cf536337c94b14790acea73ea11 100644 --- a/crates/repl/Cargo.toml +++ b/crates/repl/Cargo.toml @@ -62,7 +62,6 @@ zed_actions.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } -env_logger.workspace = true gpui = { workspace = true, features = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } indoc.workspace = true diff --git a/crates/reqwest_client/Cargo.toml b/crates/reqwest_client/Cargo.toml index 41fcd1f5d2f8ca1c78b0a2261a7c48566999e0de..105a3e7df81be5e125477968cf8e8751dfbb9e78 100644 --- a/crates/reqwest_client/Cargo.toml +++ b/crates/reqwest_client/Cargo.toml @@ -31,4 +31,3 @@ gpui_util.workspace = true http_client_tls.workspace = true [dev-dependencies] -gpui.workspace = true diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 9613bd720919d77f2e7c9421ed51a0b18edf7355..dea69a9a02f3761cec2d953285b178d41dd76d56 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -7,7 +7,7 @@ license = "GPL-3.0-or-later" [features] test-support = [ - "client/test-support", + "editor/test-support", "gpui/test-support", "workspace/test-support", @@ -47,7 +47,6 @@ ztracing.workspace = true tracing.workspace = true [dev-dependencies] -client = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } diff --git a/crates/settings_profile_selector/Cargo.toml b/crates/settings_profile_selector/Cargo.toml index 23ccac2e43dec6c1ab335eeb2ffb4d9159d85859..9fcce14b0434386068a9c94f47c9ed675210abbb 100644 --- a/crates/settings_profile_selector/Cargo.toml +++ b/crates/settings_profile_selector/Cargo.toml @@ -22,10 +22,8 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] -client = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } -language = { workspace = true, features = ["test-support"] } menu.workspace = true project = { workspace = true, features = ["test-support"] } serde_json.workspace = true diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 399534b968dfba941d17e2f6ce76261ca4e71859..66fefed910cc85e22e731fe9470d2ee511364336 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -59,20 +59,13 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] -assets.workspace = true -client.workspace = true fs = { workspace = true, features = ["test-support"] } futures.workspace = true gpui = { workspace = true, features = ["test-support"] } -language.workspace = true -node_runtime.workspace = true paths.workspace = true pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } -recent_projects = { workspace = true, features = ["test-support"] } serde_json.workspace = true -session.workspace = true settings = { workspace = true, features = ["test-support"] } title_bar = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } -zlog.workspace = true diff --git a/crates/tab_switcher/Cargo.toml b/crates/tab_switcher/Cargo.toml index 36e4ba77342796ae5967e81cd34e01b8d41aecf6..e2855aa1696c3af0c3efeb2b927f968783978332 100644 --- a/crates/tab_switcher/Cargo.toml +++ b/crates/tab_switcher/Cargo.toml @@ -29,10 +29,8 @@ util.workspace = true workspace.workspace = true [dev-dependencies] -anyhow.workspace = true ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } -language = { workspace = true, features = ["test-support"] } serde_json.workspace = true theme = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index ee29546b81c32038e85805850bc07111fca81af7..fcb637f14b3785cf2d11b68b8cbf60934f055df4 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -49,6 +49,5 @@ windows.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } rand.workspace = true -serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } util_macros.workspace = true diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index 08ffbf36263d11d4b73f02c212e571c7c11d29b8..6fc1d4ae710a342b2d275b6dd5713d37a14b1da6 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -48,11 +48,9 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] -client = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } -rand.workspace = true terminal = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 47c1dd768d19492e43231a3e8cd8270fb648f39c..4dc186b374719bdf0112243160d09c14e0bc5970 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -35,6 +35,4 @@ ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } rand.workspace = true util = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } zlog.workspace = true -proptest.workspace = true diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index a9988d498e463edb463175ec19867fa6624479e5..b5c10835c6bf85ea24db1ff9bad5abbbf3b517ee 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -18,9 +18,9 @@ stories = ["dep:story"] test-support = [ "call/test-support", "client/test-support", - "collections/test-support", + "gpui/test-support", - "http_client/test-support", + "project/test-support", "remote/test-support", "util/test-support", @@ -65,17 +65,13 @@ windows.workspace = true [dev-dependencies] call = { workspace = true, features = ["test-support"] } client = { workspace = true, features = ["test-support"] } -collections = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } notifications = { workspace = true, features = ["test-support"] } -pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } release_channel.workspace = true remote = { workspace = true, features = ["test-support"] } rpc = { workspace = true, features = ["test-support"] } semver.workspace = true settings = { workspace = true, features = ["test-support"] } -tree-sitter-md.workspace = true util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index 6a9b30d463af2d9407e8f4c9e3a81133a87c1bce..9f4c391ed01cc21e6e334d37407c8206ff1b3409 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -64,7 +64,6 @@ tendril = "0.4.3" [dev-dependencies] git2.workspace = true -indoc.workspace = true rand.workspace = true util_macros.workspace = true pretty_assertions.workspace = true diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 38bf9fed621aa3aa378cbcaa3479f7ecd7b60e11..7b4cff5ff9bdf37666076c403593c45131a63067 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -54,11 +54,9 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] -assets.workspace = true command_palette = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } git_ui = { workspace = true, features = ["test-support"] } -title_bar = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true language = { workspace = true, features = ["test-support"] } diff --git a/crates/watch/Cargo.toml b/crates/watch/Cargo.toml index 9d77eaeddec66a08dd2e9d5056249671c9b02670..aea8b0bbbda7d53d17400553407eceb7cb8253b2 100644 --- a/crates/watch/Cargo.toml +++ b/crates/watch/Cargo.toml @@ -19,5 +19,4 @@ parking_lot.workspace = true ctor.workspace = true futures.workspace = true gpui = { workspace = true, features = ["test-support"] } -rand.workspace = true zlog.workspace = true diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 84fd10c8c03e4f7411fc8c813b70255f5e00031d..e884b834af1294a368ad67d72057561b42876ce2 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -72,7 +72,6 @@ windows.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } -dap = { workspace = true, features = ["test-support"] } db = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/worktree/Cargo.toml b/crates/worktree/Cargo.toml index 788333b5e801f2a0bb22558945d2f142b50ef0a5..6d8faad3dc495a02e054f3fa652f5815f301cf3f 100644 --- a/crates/worktree/Cargo.toml +++ b/crates/worktree/Cargo.toml @@ -21,7 +21,7 @@ workspace = true [features] test-support = [ "gpui/test-support", - "http_client/test-support", + "language/test-support", "pretty_assertions", "settings/test-support", @@ -63,9 +63,7 @@ ztracing.workspace = true [dev-dependencies] clock = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } -git2.workspace = true gpui = { workspace = true, features = ["test-support"] } -http_client.workspace = true paths = { workspace = true, features = ["test-support"] } rand.workspace = true rpc = { workspace = true, features = ["test-support"] } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 5bec10439f75a4d3188ef977cf5f3e4c4733d8c6..9c0c892ad7105cc5be9b3dd548659aa1f12a7966 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -243,7 +243,6 @@ pkg-config = "0.3.22" [dev-dependencies] call = { workspace = true, features = ["test-support"] } -dap = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } image_viewer = { workspace = true, features = ["test-support"] } @@ -253,8 +252,6 @@ pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } semver.workspace = true terminal_view = { workspace = true, features = ["test-support"] } -tree-sitter-md.workspace = true -tree-sitter-rust.workspace = true title_bar = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } image.workspace = true From 0924bb887bf0f3c148dd51d67c0988ea6af6b7ee Mon Sep 17 00:00:00 2001 From: Oleksandr Kholiavko <43780952+HalavicH@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:57:14 +0100 Subject: [PATCH 065/219] ui: Extract `table_row` & `tests` modules to separate files (#51059) Extract data table modules into separate files This PR extracts the `tests` and `table_row` modules from `data_table.rs` into separate files to improve code organization. This is preparatory work for the upcoming column width API rework (#2 in the series), where separating mechanical changes from logical changes will make the review easier. The extraction was performed using rust-analyzer's "Extract module to file" command. **Context:** This is part 1 of a 3-PR series improving data table column width handling: 1. **This PR**: Extract modules into separate files (mechanical change) 2. [#51060](https://github.com/zed-industries/zed/pull/51060) - Introduce width config enum for redistributable column widths (API rework) 3. Implement independently resizable column widths (new feature) The series builds on previously merged infrastructure: - [#46341](https://github.com/zed-industries/zed/pull/46341) - Data table dynamic column support - [#46190](https://github.com/zed-industries/zed/pull/46190) - Variable row height mode for data tables Primary beneficiary: CSV preview feature ([#48207](https://github.com/zed-industries/zed/pull/48207)) Release Notes: - N/A --- crates/ui/src/components/data_table.rs | 540 +----------------- .../ui/src/components/data_table/table_row.rs | 208 +++++++ crates/ui/src/components/data_table/tests.rs | 318 +++++++++++ 3 files changed, 529 insertions(+), 537 deletions(-) create mode 100644 crates/ui/src/components/data_table/table_row.rs create mode 100644 crates/ui/src/components/data_table/tests.rs diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 76ed64850c92e274bd8aeca483dd197cfbccbf52..3da30838ca8313b68608e432ce1e76870157c1fd 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -18,216 +18,9 @@ use crate::{ }; use itertools::intersperse_with; -pub mod table_row { - //! A newtype for a table row that enforces a fixed column count at runtime. - //! - //! This type ensures that all rows in a table have the same width, preventing accidental creation or mutation of rows with inconsistent lengths. - //! It is especially useful for CSV or tabular data where rectangular invariants must be maintained, but the number of columns is only known at runtime. - //! By using `TableRow`, we gain stronger guarantees and safer APIs compared to a bare `Vec`, without requiring const generics. - - use std::{ - any::type_name, - ops::{ - Index, IndexMut, Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive, - }, - }; - - #[derive(Clone, Debug, PartialEq, Eq)] - pub struct TableRow(Vec); - - impl TableRow { - pub fn from_element(element: T, length: usize) -> Self - where - T: Clone, - { - Self::from_vec(vec![element; length], length) - } - - /// Constructs a `TableRow` from a `Vec`, panicking if the length does not match `expected_length`. - /// - /// Use this when you want to ensure at construction time that the row has the correct number of columns. - /// This enforces the rectangular invariant for table data, preventing accidental creation of malformed rows. - /// - /// # Panics - /// Panics if `data.len() != expected_length`. - pub fn from_vec(data: Vec, expected_length: usize) -> Self { - Self::try_from_vec(data, expected_length).unwrap_or_else(|e| { - let name = type_name::>(); - panic!("Expected {name} to be created successfully: {e}"); - }) - } - - /// Attempts to construct a `TableRow` from a `Vec`, returning an error if the length does not match `expected_len`. - /// - /// This is a fallible alternative to `from_vec`, allowing you to handle inconsistent row lengths gracefully. - /// Returns `Ok(TableRow)` if the length matches, or an `Err` with a descriptive message otherwise. - pub fn try_from_vec(data: Vec, expected_len: usize) -> Result { - if data.len() != expected_len { - Err(format!( - "Row length {} does not match expected {}", - data.len(), - expected_len - )) - } else { - Ok(Self(data)) - } - } - - /// Returns reference to element by column index. - /// - /// # Panics - /// Panics if `col` is out of bounds (i.e., `col >= self.cols()`). - pub fn expect_get(&self, col: impl Into) -> &T { - let col = col.into(); - self.0.get(col).unwrap_or_else(|| { - panic!( - "Expected table row of `{}` to have {col:?}", - type_name::() - ) - }) - } - - pub fn get(&self, col: impl Into) -> Option<&T> { - self.0.get(col.into()) - } - - pub fn as_slice(&self) -> &[T] { - &self.0 - } - - pub fn into_vec(self) -> Vec { - self.0 - } - - /// Like [`map`], but borrows the row and clones each element before mapping. - /// - /// This is useful when you want to map over a borrowed row without consuming it, - /// but your mapping function requires ownership of each element. - /// - /// # Difference - /// - `map_cloned` takes `&self`, clones each element, and applies `f(T) -> U`. - /// - [`map`] takes `self` by value and applies `f(T) -> U` directly, consuming the row. - /// - [`map_ref`] takes `&self` and applies `f(&T) -> U` to references of each element. - pub fn map_cloned(&self, f: F) -> TableRow - where - F: FnMut(T) -> U, - T: Clone, - { - self.clone().map(f) - } - - /// Consumes the row and transforms all elements within it in a length-safe way. - /// - /// # Difference - /// - `map` takes ownership of the row (`self`) and applies `f(T) -> U` to each element. - /// - Use this when you want to transform and consume the row in one step. - /// - See also [`map_cloned`] (for mapping over a borrowed row with cloning) and [`map_ref`] (for mapping over references). - pub fn map(self, f: F) -> TableRow - where - F: FnMut(T) -> U, - { - TableRow(self.0.into_iter().map(f).collect()) - } - - /// Borrows the row and transforms all elements by reference in a length-safe way. - /// - /// # Difference - /// - `map_ref` takes `&self` and applies `f(&T) -> U` to each element by reference. - /// - Use this when you want to map over a borrowed row without cloning or consuming it. - /// - See also [`map`] (for consuming the row) and [`map_cloned`] (for mapping with cloning). - pub fn map_ref(&self, f: F) -> TableRow - where - F: FnMut(&T) -> U, - { - TableRow(self.0.iter().map(f).collect()) - } - - /// Number of columns (alias to `len()` with more semantic meaning) - pub fn cols(&self) -> usize { - self.0.len() - } - } - - ///// Convenience traits ///// - pub trait IntoTableRow { - fn into_table_row(self, expected_length: usize) -> TableRow; - } - impl IntoTableRow for Vec { - fn into_table_row(self, expected_length: usize) -> TableRow { - TableRow::from_vec(self, expected_length) - } - } - - // Index implementations for convenient access - impl Index for TableRow { - type Output = T; - - fn index(&self, index: usize) -> &Self::Output { - &self.0[index] - } - } - - impl IndexMut for TableRow { - fn index_mut(&mut self, index: usize) -> &mut Self::Output { - &mut self.0[index] - } - } - - // Range indexing implementations for slice operations - impl Index> for TableRow { - type Output = [T]; - - fn index(&self, index: Range) -> &Self::Output { - as Index>>::index(&self.0, index) - } - } - - impl Index> for TableRow { - type Output = [T]; - - fn index(&self, index: RangeFrom) -> &Self::Output { - as Index>>::index(&self.0, index) - } - } - - impl Index> for TableRow { - type Output = [T]; - - fn index(&self, index: RangeTo) -> &Self::Output { - as Index>>::index(&self.0, index) - } - } - - impl Index> for TableRow { - type Output = [T]; - - fn index(&self, index: RangeToInclusive) -> &Self::Output { - as Index>>::index(&self.0, index) - } - } - - impl Index for TableRow { - type Output = [T]; - - fn index(&self, index: RangeFull) -> &Self::Output { - as Index>::index(&self.0, index) - } - } - - impl Index> for TableRow { - type Output = [T]; - - fn index(&self, index: RangeInclusive) -> &Self::Output { - as Index>>::index(&self.0, index) - } - } - - impl IndexMut> for TableRow { - fn index_mut(&mut self, index: RangeInclusive) -> &mut Self::Output { - as IndexMut>>::index_mut(&mut self.0, index) - } - } -} +pub mod table_row; +#[cfg(test)] +mod tests; const RESIZE_COLUMN_WIDTH: f32 = 8.0; @@ -1445,330 +1238,3 @@ impl Component for Table { ) } } - -#[cfg(test)] -mod test { - use super::*; - - fn is_almost_eq(a: &[f32], b: &[f32]) -> bool { - a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) - } - - fn cols_to_str(cols: &[f32], total_size: f32) -> String { - cols.iter() - .map(|f| "*".repeat(f32::round(f * total_size) as usize)) - .collect::>() - .join("|") - } - - fn parse_resize_behavior( - input: &str, - total_size: f32, - expected_cols: usize, - ) -> Vec { - let mut resize_behavior = Vec::with_capacity(expected_cols); - for col in input.split('|') { - if col.starts_with('X') || col.is_empty() { - resize_behavior.push(TableResizeBehavior::None); - } else if col.starts_with('*') { - resize_behavior.push(TableResizeBehavior::MinSize(col.len() as f32 / total_size)); - } else { - panic!("invalid test input: unrecognized resize behavior: {}", col); - } - } - - if resize_behavior.len() != expected_cols { - panic!( - "invalid test input: expected {} columns, got {}", - expected_cols, - resize_behavior.len() - ); - } - resize_behavior - } - - mod reset_column_size { - use super::*; - - fn parse(input: &str) -> (Vec, f32, Option) { - let mut widths = Vec::new(); - let mut column_index = None; - for (index, col) in input.split('|').enumerate() { - widths.push(col.len() as f32); - if col.starts_with('X') { - column_index = Some(index); - } - } - - for w in &widths { - assert!(w.is_finite(), "incorrect number of columns"); - } - let total = widths.iter().sum::(); - for width in &mut widths { - *width /= total; - } - (widths, total, column_index) - } - - #[track_caller] - fn check_reset_size( - initial_sizes: &str, - widths: &str, - expected: &str, - resize_behavior: &str, - ) { - let (initial_sizes, total_1, None) = parse(initial_sizes) else { - panic!("invalid test input: initial sizes should not be marked"); - }; - let (widths, total_2, Some(column_index)) = parse(widths) else { - panic!("invalid test input: widths should be marked"); - }; - assert_eq!( - total_1, total_2, - "invalid test input: total width not the same {total_1}, {total_2}" - ); - let (expected, total_3, None) = parse(expected) else { - panic!("invalid test input: expected should not be marked: {expected:?}"); - }; - assert_eq!( - total_2, total_3, - "invalid test input: total width not the same" - ); - let cols = initial_sizes.len(); - let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols); - let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols); - let result = TableColumnWidths::reset_to_initial_size( - column_index, - TableRow::from_vec(widths, cols), - TableRow::from_vec(initial_sizes, cols), - &resize_behavior, - ); - let result_slice = result.as_slice(); - let is_eq = is_almost_eq(result_slice, &expected); - if !is_eq { - let result_str = cols_to_str(result_slice, total_1); - let expected_str = cols_to_str(&expected, total_1); - panic!( - "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_slice:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}" - ); - } - } - - macro_rules! check_reset_size { - (columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => { - check_reset_size($initial, $current, $expected, $resizing); - }; - ($name:ident, columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => { - #[test] - fn $name() { - check_reset_size($initial, $current, $expected, $resizing); - } - }; - } - - check_reset_size!( - basic_right, - columns: 5, - starting: "**|**|**|**|**", - snapshot: "**|**|X|***|**", - expected: "**|**|**|**|**", - minimums: "X|*|*|*|*", - ); - - check_reset_size!( - basic_left, - columns: 5, - starting: "**|**|**|**|**", - snapshot: "**|**|***|X|**", - expected: "**|**|**|**|**", - minimums: "X|*|*|*|**", - ); - - check_reset_size!( - squashed_left_reset_col2, - columns: 6, - starting: "*|***|**|**|****|*", - snapshot: "*|*|X|*|*|********", - expected: "*|*|**|*|*|*******", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - grow_cascading_right, - columns: 6, - starting: "*|***|****|**|***|*", - snapshot: "*|***|X|**|**|*****", - expected: "*|***|****|*|*|****", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - squashed_right_reset_col4, - columns: 6, - starting: "*|***|**|**|****|*", - snapshot: "*|********|*|*|X|*", - expected: "*|*****|*|*|****|*", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - reset_col6_right, - columns: 6, - starting: "*|***|**|***|***|**", - snapshot: "*|***|**|***|**|XXX", - expected: "*|***|**|***|***|**", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - reset_col6_left, - columns: 6, - starting: "*|***|**|***|***|**", - snapshot: "*|***|**|***|****|X", - expected: "*|***|**|***|***|**", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - last_column_grow_cascading, - columns: 6, - starting: "*|***|**|**|**|***", - snapshot: "*|*******|*|**|*|X", - expected: "*|******|*|*|*|***", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - goes_left_when_left_has_extreme_diff, - columns: 6, - starting: "*|***|****|**|**|***", - snapshot: "*|********|X|*|**|**", - expected: "*|*****|****|*|**|**", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - basic_shrink_right, - columns: 6, - starting: "**|**|**|**|**|**", - snapshot: "**|**|XXX|*|**|**", - expected: "**|**|**|**|**|**", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - shrink_should_go_left, - columns: 6, - starting: "*|***|**|*|*|*", - snapshot: "*|*|XXX|**|*|*", - expected: "*|**|**|**|*|*", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - shrink_should_go_right, - columns: 6, - starting: "*|***|**|**|**|*", - snapshot: "*|****|XXX|*|*|*", - expected: "*|****|**|**|*|*", - minimums: "X|*|*|*|*|*", - ); - } - - mod drag_handle { - use super::*; - - fn parse(input: &str) -> (Vec, f32, Option) { - let mut widths = Vec::new(); - let column_index = input.replace("*", "").find("I"); - for col in input.replace("I", "|").split('|') { - widths.push(col.len() as f32); - } - - for w in &widths { - assert!(w.is_finite(), "incorrect number of columns"); - } - let total = widths.iter().sum::(); - for width in &mut widths { - *width /= total; - } - (widths, total, column_index) - } - - #[track_caller] - fn check(distance: i32, widths: &str, expected: &str, resize_behavior: &str) { - let (widths, total_1, Some(column_index)) = parse(widths) else { - panic!("invalid test input: widths should be marked"); - }; - let (expected, total_2, None) = parse(expected) else { - panic!("invalid test input: expected should not be marked: {expected:?}"); - }; - assert_eq!( - total_1, total_2, - "invalid test input: total width not the same" - ); - let cols = widths.len(); - let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols); - let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols); - - let distance = distance as f32 / total_1; - - let mut widths_table_row = TableRow::from_vec(widths, cols); - TableColumnWidths::drag_column_handle( - distance, - column_index, - &mut widths_table_row, - &resize_behavior, - ); - - let result_widths = widths_table_row.as_slice(); - let is_eq = is_almost_eq(result_widths, &expected); - if !is_eq { - let result_str = cols_to_str(result_widths, total_1); - let expected_str = cols_to_str(&expected, total_1); - panic!( - "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_widths:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}" - ); - } - } - - macro_rules! check { - (columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => { - check($dist, $current, $expected, $resizing); - }; - ($name:ident, columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => { - #[test] - fn $name() { - check($dist, $current, $expected, $resizing); - } - }; - } - - check!( - basic_right_drag, - columns: 3, - distance: 1, - snapshot: "**|**I**", - expected: "**|***|*", - minimums: "X|*|*", - ); - - check!( - drag_left_against_mins, - columns: 5, - distance: -1, - snapshot: "*|*|*|*I*******", - expected: "*|*|*|*|*******", - minimums: "X|*|*|*|*", - ); - - check!( - drag_left, - columns: 5, - distance: -2, - snapshot: "*|*|*|*****I***", - expected: "*|*|*|***|*****", - minimums: "X|*|*|*|*", - ); - } -} diff --git a/crates/ui/src/components/data_table/table_row.rs b/crates/ui/src/components/data_table/table_row.rs new file mode 100644 index 0000000000000000000000000000000000000000..9ef75e4cbbb72755294ae5c34724a55fbc40f8b8 --- /dev/null +++ b/crates/ui/src/components/data_table/table_row.rs @@ -0,0 +1,208 @@ +//! A newtype for a table row that enforces a fixed column count at runtime. +//! +//! This type ensures that all rows in a table have the same width, preventing accidental creation or mutation of rows with inconsistent lengths. +//! It is especially useful for CSV or tabular data where rectangular invariants must be maintained, but the number of columns is only known at runtime. +//! By using `TableRow`, we gain stronger guarantees and safer APIs compared to a bare `Vec`, without requiring const generics. + +use std::{ + any::type_name, + ops::{ + Index, IndexMut, Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive, + }, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TableRow(Vec); + +impl TableRow { + pub fn from_element(element: T, length: usize) -> Self + where + T: Clone, + { + Self::from_vec(vec![element; length], length) + } + + /// Constructs a `TableRow` from a `Vec`, panicking if the length does not match `expected_length`. + /// + /// Use this when you want to ensure at construction time that the row has the correct number of columns. + /// This enforces the rectangular invariant for table data, preventing accidental creation of malformed rows. + /// + /// # Panics + /// Panics if `data.len() != expected_length`. + pub fn from_vec(data: Vec, expected_length: usize) -> Self { + Self::try_from_vec(data, expected_length).unwrap_or_else(|e| { + let name = type_name::>(); + panic!("Expected {name} to be created successfully: {e}"); + }) + } + + /// Attempts to construct a `TableRow` from a `Vec`, returning an error if the length does not match `expected_len`. + /// + /// This is a fallible alternative to `from_vec`, allowing you to handle inconsistent row lengths gracefully. + /// Returns `Ok(TableRow)` if the length matches, or an `Err` with a descriptive message otherwise. + pub fn try_from_vec(data: Vec, expected_len: usize) -> Result { + if data.len() != expected_len { + Err(format!( + "Row length {} does not match expected {}", + data.len(), + expected_len + )) + } else { + Ok(Self(data)) + } + } + + /// Returns reference to element by column index. + /// + /// # Panics + /// Panics if `col` is out of bounds (i.e., `col >= self.cols()`). + pub fn expect_get(&self, col: impl Into) -> &T { + let col = col.into(); + self.0.get(col).unwrap_or_else(|| { + panic!( + "Expected table row of `{}` to have {col:?}", + type_name::() + ) + }) + } + + pub fn get(&self, col: impl Into) -> Option<&T> { + self.0.get(col.into()) + } + + pub fn as_slice(&self) -> &[T] { + &self.0 + } + + pub fn into_vec(self) -> Vec { + self.0 + } + + /// Like [`map`], but borrows the row and clones each element before mapping. + /// + /// This is useful when you want to map over a borrowed row without consuming it, + /// but your mapping function requires ownership of each element. + /// + /// # Difference + /// - `map_cloned` takes `&self`, clones each element, and applies `f(T) -> U`. + /// - [`map`] takes `self` by value and applies `f(T) -> U` directly, consuming the row. + /// - [`map_ref`] takes `&self` and applies `f(&T) -> U` to references of each element. + pub fn map_cloned(&self, f: F) -> TableRow + where + F: FnMut(T) -> U, + T: Clone, + { + self.clone().map(f) + } + + /// Consumes the row and transforms all elements within it in a length-safe way. + /// + /// # Difference + /// - `map` takes ownership of the row (`self`) and applies `f(T) -> U` to each element. + /// - Use this when you want to transform and consume the row in one step. + /// - See also [`map_cloned`] (for mapping over a borrowed row with cloning) and [`map_ref`] (for mapping over references). + pub fn map(self, f: F) -> TableRow + where + F: FnMut(T) -> U, + { + TableRow(self.0.into_iter().map(f).collect()) + } + + /// Borrows the row and transforms all elements by reference in a length-safe way. + /// + /// # Difference + /// - `map_ref` takes `&self` and applies `f(&T) -> U` to each element by reference. + /// - Use this when you want to map over a borrowed row without cloning or consuming it. + /// - See also [`map`] (for consuming the row) and [`map_cloned`] (for mapping with cloning). + pub fn map_ref(&self, f: F) -> TableRow + where + F: FnMut(&T) -> U, + { + TableRow(self.0.iter().map(f).collect()) + } + + /// Number of columns (alias to `len()` with more semantic meaning) + pub fn cols(&self) -> usize { + self.0.len() + } +} + +///// Convenience traits ///// +pub trait IntoTableRow { + fn into_table_row(self, expected_length: usize) -> TableRow; +} +impl IntoTableRow for Vec { + fn into_table_row(self, expected_length: usize) -> TableRow { + TableRow::from_vec(self, expected_length) + } +} + +// Index implementations for convenient access +impl Index for TableRow { + type Output = T; + + fn index(&self, index: usize) -> &Self::Output { + &self.0[index] + } +} + +impl IndexMut for TableRow { + fn index_mut(&mut self, index: usize) -> &mut Self::Output { + &mut self.0[index] + } +} + +// Range indexing implementations for slice operations +impl Index> for TableRow { + type Output = [T]; + + fn index(&self, index: Range) -> &Self::Output { + as Index>>::index(&self.0, index) + } +} + +impl Index> for TableRow { + type Output = [T]; + + fn index(&self, index: RangeFrom) -> &Self::Output { + as Index>>::index(&self.0, index) + } +} + +impl Index> for TableRow { + type Output = [T]; + + fn index(&self, index: RangeTo) -> &Self::Output { + as Index>>::index(&self.0, index) + } +} + +impl Index> for TableRow { + type Output = [T]; + + fn index(&self, index: RangeToInclusive) -> &Self::Output { + as Index>>::index(&self.0, index) + } +} + +impl Index for TableRow { + type Output = [T]; + + fn index(&self, index: RangeFull) -> &Self::Output { + as Index>::index(&self.0, index) + } +} + +impl Index> for TableRow { + type Output = [T]; + + fn index(&self, index: RangeInclusive) -> &Self::Output { + as Index>>::index(&self.0, index) + } +} + +impl IndexMut> for TableRow { + fn index_mut(&mut self, index: RangeInclusive) -> &mut Self::Output { + as IndexMut>>::index_mut(&mut self.0, index) + } +} diff --git a/crates/ui/src/components/data_table/tests.rs b/crates/ui/src/components/data_table/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..f0982a8aa5abe5f5a9351ebaaaf4072ca17839e6 --- /dev/null +++ b/crates/ui/src/components/data_table/tests.rs @@ -0,0 +1,318 @@ +use super::*; + +fn is_almost_eq(a: &[f32], b: &[f32]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) +} + +fn cols_to_str(cols: &[f32], total_size: f32) -> String { + cols.iter() + .map(|f| "*".repeat(f32::round(f * total_size) as usize)) + .collect::>() + .join("|") +} + +fn parse_resize_behavior( + input: &str, + total_size: f32, + expected_cols: usize, +) -> Vec { + let mut resize_behavior = Vec::with_capacity(expected_cols); + for col in input.split('|') { + if col.starts_with('X') || col.is_empty() { + resize_behavior.push(TableResizeBehavior::None); + } else if col.starts_with('*') { + resize_behavior.push(TableResizeBehavior::MinSize(col.len() as f32 / total_size)); + } else { + panic!("invalid test input: unrecognized resize behavior: {}", col); + } + } + + if resize_behavior.len() != expected_cols { + panic!( + "invalid test input: expected {} columns, got {}", + expected_cols, + resize_behavior.len() + ); + } + resize_behavior +} + +mod reset_column_size { + use super::*; + + fn parse(input: &str) -> (Vec, f32, Option) { + let mut widths = Vec::new(); + let mut column_index = None; + for (index, col) in input.split('|').enumerate() { + widths.push(col.len() as f32); + if col.starts_with('X') { + column_index = Some(index); + } + } + + for w in &widths { + assert!(w.is_finite(), "incorrect number of columns"); + } + let total = widths.iter().sum::(); + for width in &mut widths { + *width /= total; + } + (widths, total, column_index) + } + + #[track_caller] + fn check_reset_size(initial_sizes: &str, widths: &str, expected: &str, resize_behavior: &str) { + let (initial_sizes, total_1, None) = parse(initial_sizes) else { + panic!("invalid test input: initial sizes should not be marked"); + }; + let (widths, total_2, Some(column_index)) = parse(widths) else { + panic!("invalid test input: widths should be marked"); + }; + assert_eq!( + total_1, total_2, + "invalid test input: total width not the same {total_1}, {total_2}" + ); + let (expected, total_3, None) = parse(expected) else { + panic!("invalid test input: expected should not be marked: {expected:?}"); + }; + assert_eq!( + total_2, total_3, + "invalid test input: total width not the same" + ); + let cols = initial_sizes.len(); + let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols); + let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols); + let result = TableColumnWidths::reset_to_initial_size( + column_index, + TableRow::from_vec(widths, cols), + TableRow::from_vec(initial_sizes, cols), + &resize_behavior, + ); + let result_slice = result.as_slice(); + let is_eq = is_almost_eq(result_slice, &expected); + if !is_eq { + let result_str = cols_to_str(result_slice, total_1); + let expected_str = cols_to_str(&expected, total_1); + panic!( + "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_slice:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}" + ); + } + } + + macro_rules! check_reset_size { + (columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => { + check_reset_size($initial, $current, $expected, $resizing); + }; + ($name:ident, columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => { + #[test] + fn $name() { + check_reset_size($initial, $current, $expected, $resizing); + } + }; + } + + check_reset_size!( + basic_right, + columns: 5, + starting: "**|**|**|**|**", + snapshot: "**|**|X|***|**", + expected: "**|**|**|**|**", + minimums: "X|*|*|*|*", + ); + + check_reset_size!( + basic_left, + columns: 5, + starting: "**|**|**|**|**", + snapshot: "**|**|***|X|**", + expected: "**|**|**|**|**", + minimums: "X|*|*|*|**", + ); + + check_reset_size!( + squashed_left_reset_col2, + columns: 6, + starting: "*|***|**|**|****|*", + snapshot: "*|*|X|*|*|********", + expected: "*|*|**|*|*|*******", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + grow_cascading_right, + columns: 6, + starting: "*|***|****|**|***|*", + snapshot: "*|***|X|**|**|*****", + expected: "*|***|****|*|*|****", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + squashed_right_reset_col4, + columns: 6, + starting: "*|***|**|**|****|*", + snapshot: "*|********|*|*|X|*", + expected: "*|*****|*|*|****|*", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + reset_col6_right, + columns: 6, + starting: "*|***|**|***|***|**", + snapshot: "*|***|**|***|**|XXX", + expected: "*|***|**|***|***|**", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + reset_col6_left, + columns: 6, + starting: "*|***|**|***|***|**", + snapshot: "*|***|**|***|****|X", + expected: "*|***|**|***|***|**", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + last_column_grow_cascading, + columns: 6, + starting: "*|***|**|**|**|***", + snapshot: "*|*******|*|**|*|X", + expected: "*|******|*|*|*|***", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + goes_left_when_left_has_extreme_diff, + columns: 6, + starting: "*|***|****|**|**|***", + snapshot: "*|********|X|*|**|**", + expected: "*|*****|****|*|**|**", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + basic_shrink_right, + columns: 6, + starting: "**|**|**|**|**|**", + snapshot: "**|**|XXX|*|**|**", + expected: "**|**|**|**|**|**", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + shrink_should_go_left, + columns: 6, + starting: "*|***|**|*|*|*", + snapshot: "*|*|XXX|**|*|*", + expected: "*|**|**|**|*|*", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + shrink_should_go_right, + columns: 6, + starting: "*|***|**|**|**|*", + snapshot: "*|****|XXX|*|*|*", + expected: "*|****|**|**|*|*", + minimums: "X|*|*|*|*|*", + ); +} + +mod drag_handle { + use super::*; + + fn parse(input: &str) -> (Vec, f32, Option) { + let mut widths = Vec::new(); + let column_index = input.replace("*", "").find("I"); + for col in input.replace("I", "|").split('|') { + widths.push(col.len() as f32); + } + + for w in &widths { + assert!(w.is_finite(), "incorrect number of columns"); + } + let total = widths.iter().sum::(); + for width in &mut widths { + *width /= total; + } + (widths, total, column_index) + } + + #[track_caller] + fn check(distance: i32, widths: &str, expected: &str, resize_behavior: &str) { + let (widths, total_1, Some(column_index)) = parse(widths) else { + panic!("invalid test input: widths should be marked"); + }; + let (expected, total_2, None) = parse(expected) else { + panic!("invalid test input: expected should not be marked: {expected:?}"); + }; + assert_eq!( + total_1, total_2, + "invalid test input: total width not the same" + ); + let cols = widths.len(); + let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols); + let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols); + + let distance = distance as f32 / total_1; + + let mut widths_table_row = TableRow::from_vec(widths, cols); + TableColumnWidths::drag_column_handle( + distance, + column_index, + &mut widths_table_row, + &resize_behavior, + ); + + let result_widths = widths_table_row.as_slice(); + let is_eq = is_almost_eq(result_widths, &expected); + if !is_eq { + let result_str = cols_to_str(result_widths, total_1); + let expected_str = cols_to_str(&expected, total_1); + panic!( + "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_widths:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}" + ); + } + } + + macro_rules! check { + (columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => { + check($dist, $current, $expected, $resizing); + }; + ($name:ident, columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => { + #[test] + fn $name() { + check($dist, $current, $expected, $resizing); + } + }; + } + + check!( + basic_right_drag, + columns: 3, + distance: 1, + snapshot: "**|**I**", + expected: "**|***|*", + minimums: "X|*|*", + ); + + check!( + drag_left_against_mins, + columns: 5, + distance: -1, + snapshot: "*|*|*|*I*******", + expected: "*|*|*|*|*******", + minimums: "X|*|*|*|*", + ); + + check!( + drag_left, + columns: 5, + distance: -2, + snapshot: "*|*|*|*****I***", + expected: "*|*|*|***|*****", + minimums: "X|*|*|*|*", + ); +} From 26f81c481872c9f3a52e584eeb8140e76b1b6f85 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:03:15 -0300 Subject: [PATCH 066/219] sidebar: Improve project header truncation (#51096) Touching up the scenario in which the project header label is too big. This uses the same gradient overlay treatment we're using for the thread item component. Release Notes: - N/A --- crates/sidebar/src/sidebar.rs | 42 ++++++++++-- crates/ui/src/components/ai/thread_item.rs | 6 +- crates/ui/src/components/list/list_item.rs | 78 ++++++++++++++++------ 3 files changed, 99 insertions(+), 27 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 40ba738ba98ff4d77932eabeca9bdf0a7d0b8861..45a56f7af203e8ffe01b8590f916b439a57c52fb 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -7,8 +7,8 @@ use editor::{Editor, EditorElement, EditorStyle}; use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState, - Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px, - relative, rems, + Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, linear_color_stop, + linear_gradient, list, prelude::*, px, relative, rems, }; use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::Event as ProjectEvent; @@ -753,6 +753,7 @@ impl Sidebar { cx: &mut Context, ) -> AnyElement { let id = SharedString::from(format!("project-header-{}", ix)); + let group_name = SharedString::from(format!("header-group-{}", ix)); let ib_id = SharedString::from(format!("project-header-new-thread-{}", ix)); let is_collapsed = self.collapsed_groups.contains(path_list); @@ -786,11 +787,44 @@ impl Sidebar { .into_any_element() }; + let color = cx.theme().colors(); + let base_bg = color.panel_background; + let gradient_overlay = div() + .id("gradient_overlay") + .absolute() + .top_0() + .right_0() + .w_12() + .h_full() + .bg(linear_gradient( + 90., + linear_color_stop(base_bg, 0.6), + linear_color_stop(base_bg.opacity(0.0), 0.), + )) + .group_hover(group_name.clone(), |s| { + s.bg(linear_gradient( + 90., + linear_color_stop(color.element_hover, 0.6), + linear_color_stop(color.element_hover.opacity(0.0), 0.), + )) + }) + .group_active(group_name.clone(), |s| { + s.bg(linear_gradient( + 90., + linear_color_stop(color.element_active, 0.6), + linear_color_stop(color.element_active.opacity(0.0), 0.), + )) + }); + ListItem::new(id) + .group_name(group_name) .toggle_state(is_active_workspace) .focused(is_selected) .child( h_flex() + .relative() + .min_w_0() + .w_full() .p_1() .gap_1p5() .child( @@ -798,11 +832,11 @@ impl Sidebar { .size(IconSize::Small) .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))), ) - .child(label), + .child(label) + .child(gradient_overlay), ) .end_hover_slot( h_flex() - .gap_0p5() .when(workspace_count > 1, |this| { this.child( IconButton::new( diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 171a6968290b3239e21faf9cd669559b88f9a964..be27e6332ca500747e1836bbd577c7fd5ffb2507 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -224,17 +224,17 @@ impl RenderOnce for ThreadItem { .absolute() .top_0() .right(px(-10.0)) - .w_12() + .w_8() .h_full() .bg(linear_gradient( 90., - linear_color_stop(base_bg, 0.6), + linear_color_stop(base_bg, 0.8), linear_color_stop(base_bg.opacity(0.0), 0.), )) .group_hover("thread-item", |s| { s.bg(linear_gradient( 90., - linear_color_stop(color.element_hover, 0.6), + linear_color_stop(color.element_hover, 0.8), linear_color_stop(color.element_hover.opacity(0.0), 0.), )) }); diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index d581fad9453d9812f17b7bc9e0297fb9927c8188..0a1fbe7f40970f265513751090ed998a5521dfef 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -1,7 +1,10 @@ use std::sync::Arc; use component::{Component, ComponentScope, example_group_with_title, single_example}; -use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, px}; +use gpui::{ + AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, linear_color_stop, + linear_gradient, px, +}; use smallvec::SmallVec; use crate::{Disclosure, prelude::*}; @@ -209,6 +212,43 @@ impl ParentElement for ListItem { impl RenderOnce for ListItem { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let color = cx.theme().colors(); + + let base_bg = if self.selected { + color.element_active + } else { + color.panel_background + }; + + let end_hover_gradient_overlay = div() + .id("gradient_overlay") + .absolute() + .top_0() + .right_0() + .w_24() + .h_full() + .bg(linear_gradient( + 90., + linear_color_stop(base_bg, 0.6), + linear_color_stop(base_bg.opacity(0.0), 0.), + )) + .when_some(self.group_name.clone(), |s, group_name| { + s.group_hover(group_name.clone(), |s| { + s.bg(linear_gradient( + 90., + linear_color_stop(color.element_hover, 0.6), + linear_color_stop(color.element_hover.opacity(0.0), 0.), + )) + }) + .group_active(group_name, |s| { + s.bg(linear_gradient( + 90., + linear_color_stop(color.element_active, 0.6), + linear_color_stop(color.element_active.opacity(0.0), 0.), + )) + }) + }); + h_flex() .id(self.id) .when_some(self.group_name, |this, group| this.group(group)) @@ -220,25 +260,22 @@ impl RenderOnce for ListItem { .px(DynamicSpacing::Base04.rems(cx)) }) .when(!self.inset && !self.disabled, |this| { - this - // TODO: Add focus state - // .when(self.state == InteractionState::Focused, |this| { - .when_some(self.focused, |this, focused| { - if focused { - this.border_1() - .border_color(cx.theme().colors().border_focused) - } else { - this.border_1() - } - }) - .when(self.selectable, |this| { - this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) - .active(|style| style.bg(cx.theme().colors().ghost_element_active)) - .when(self.outlined, |this| this.rounded_sm()) - .when(self.selected, |this| { - this.bg(cx.theme().colors().ghost_element_selected) - }) - }) + this.when_some(self.focused, |this, focused| { + if focused { + this.border_1() + .border_color(cx.theme().colors().border_focused) + } else { + this.border_1() + } + }) + .when(self.selectable, |this| { + this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) + .active(|style| style.bg(cx.theme().colors().ghost_element_active)) + .when(self.outlined, |this| this.rounded_sm()) + .when(self.selected, |this| { + this.bg(cx.theme().colors().ghost_element_selected) + }) + }) }) .when(self.rounded, |this| this.rounded_sm()) .when_some(self.on_hover, |this, on_hover| this.on_hover(on_hover)) @@ -350,6 +387,7 @@ impl RenderOnce for ListItem { .right(DynamicSpacing::Base06.rems(cx)) .top_0() .visible_on_hover("list_item") + .child(end_hover_gradient_overlay) .child(end_hover_slot), ) }), From e9c691a1e19ed904af0f0d1817d1372a51fe2ee1 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 9 Mar 2026 08:33:42 -0500 Subject: [PATCH 067/219] ep: Add `<|no-edit|>` command to hashlines format (#51103) Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/edit_prediction/src/zeta.rs | 12 ++- crates/zeta_prompt/src/zeta_prompt.rs | 135 +++++++++++++++++++------- 2 files changed, 110 insertions(+), 37 deletions(-) diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 93fc6aa99a27f18436bc564fbaa39a15d3be0b44..1217cbd5ba6f8ecd5b13aa1eec3b1a88bf26dbc2 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -24,7 +24,7 @@ use zeta_prompt::{ParsedOutput, ZetaPromptInput}; use std::{env, ops::Range, path::Path, sync::Arc, time::Instant}; use zeta_prompt::{ CURSOR_MARKER, ZetaFormat, format_zeta_prompt, get_prefill, parse_zeta2_model_output, - prompt_input_contains_special_tokens, + prompt_input_contains_special_tokens, stop_tokens_for_format, zeta1::{self, EDITABLE_REGION_END_MARKER}, }; @@ -192,7 +192,10 @@ pub fn request_prediction_with_zeta( custom_settings, prompt, max_tokens, - vec![], + stop_tokens_for_format(zeta_version) + .iter() + .map(|token| token.to_string()) + .collect(), open_ai_compatible_api_key.clone(), &http_client, ) @@ -226,7 +229,10 @@ pub fn request_prediction_with_zeta( model: config.model_id.clone().unwrap_or_default(), prompt, temperature: None, - stop: vec![], + stop: stop_tokens_for_format(config.format) + .iter() + .map(|token| std::borrow::Cow::Borrowed(*token)) + .collect(), max_tokens: Some(2048), environment, }; diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index b7b67ed851419dcf0f125f46e5a17e7f9ac9aa92..3f7839305bd840f32a3f27182b0c5d02c1166099 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -222,6 +222,21 @@ pub fn special_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] } } +pub fn stop_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] { + match format { + ZetaFormat::v0226Hashline => &[hashline::NO_EDITS_COMMAND_MARKER], + ZetaFormat::V0112MiddleAtEnd + | ZetaFormat::V0113Ordered + | ZetaFormat::V0114180EditableRegion + | ZetaFormat::V0120GitMergeMarkers + | ZetaFormat::V0131GitMergeMarkersPrefix + | ZetaFormat::V0211Prefill + | ZetaFormat::V0211SeedCoder + | ZetaFormat::V0304VariableEdit + | ZetaFormat::V0304SeedNoEdits => &[], + } +} + pub fn excerpt_ranges_for_format( format: ZetaFormat, ranges: &ExcerptRanges, @@ -1010,12 +1025,14 @@ pub mod hashline { const SET_COMMAND_MARKER: &str = "<|set|>"; const INSERT_COMMAND_MARKER: &str = "<|insert|>"; + pub const NO_EDITS_COMMAND_MARKER: &str = "<|no_edits|>"; pub fn special_tokens() -> &'static [&'static str] { return &[ SET_COMMAND_MARKER, "<|set_range|>", INSERT_COMMAND_MARKER, + NO_EDITS_COMMAND_MARKER, CURSOR_MARKER, "<|file_sep|>", "<|fim_prefix|>", @@ -1109,6 +1126,7 @@ pub mod hashline { } prompt.push_str(END_MARKER); + prompt.push('\n'); } /// A single edit command parsed from the model output. @@ -1234,7 +1252,9 @@ pub mod hashline { } pub fn output_has_edit_commands(model_output: &str) -> bool { - model_output.contains(SET_COMMAND_MARKER) || model_output.contains(INSERT_COMMAND_MARKER) + model_output.contains(SET_COMMAND_MARKER) + || model_output.contains(INSERT_COMMAND_MARKER) + || model_output.contains(NO_EDITS_COMMAND_MARKER) } /// Apply `<|set|>` and `<|insert|>` edit commands from the model output to the @@ -1245,6 +1265,13 @@ pub mod hashline { /// /// Returns the full replacement text for the editable region. pub fn apply_edit_commands(editable_region: &str, model_output: &str) -> String { + if model_output + .trim_start() + .starts_with(NO_EDITS_COMMAND_MARKER) + { + return editable_region.to_string(); + } + let original_lines: Vec<&str> = editable_region.lines().collect(); let old_hashes: Vec = original_lines .iter() @@ -1549,6 +1576,10 @@ pub mod hashline { result.pop(); } + if result.is_empty() { + return Ok(NO_EDITS_COMMAND_MARKER.to_string()); + } + Ok(result) } @@ -1579,7 +1610,8 @@ pub mod hashline { <|fim_middle|>current 0:5c|hello<|user_cursor|> world <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "multiline_cursor_on_second_line", @@ -1594,7 +1626,8 @@ pub mod hashline { 1:26|b<|user_cursor|>bb 2:29|ccc <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "no_trailing_newline_in_context", @@ -1608,7 +1641,8 @@ pub mod hashline { 0:d9|lin<|user_cursor|>e1 1:da|line2 <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "leading_newline_in_editable_region", @@ -1622,7 +1656,8 @@ pub mod hashline { 0:00| 1:26|a<|user_cursor|>bc <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "with_suffix", @@ -1636,7 +1671,8 @@ pub mod hashline { 0:26|ab<|user_cursor|>c <|fim_suffix|> def - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "unicode_two_byte_chars", @@ -1649,7 +1685,8 @@ pub mod hashline { <|fim_middle|>current 0:1b|hé<|user_cursor|>llo <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "unicode_three_byte_chars", @@ -1662,7 +1699,8 @@ pub mod hashline { <|fim_middle|>current 0:80|日本<|user_cursor|>語 <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "unicode_four_byte_chars", @@ -1675,7 +1713,8 @@ pub mod hashline { <|fim_middle|>current 0:6b|a🌍<|user_cursor|>b <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "cursor_at_start_of_region_not_placed", @@ -1688,7 +1727,8 @@ pub mod hashline { <|fim_middle|>current 0:26|abc <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "cursor_at_end_of_line_not_placed", @@ -1702,7 +1742,8 @@ pub mod hashline { 0:26|abc 1:2f|def <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "cursor_offset_relative_to_context_not_editable_region", @@ -1721,7 +1762,8 @@ pub mod hashline { 1:26|b<|user_cursor|>bb <|fim_suffix|> suf - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, ]; @@ -1894,6 +1936,18 @@ pub mod hashline { world "}, }, + Case { + name: "no_edits_command_returns_original", + original: indoc! {" + hello + world + "}, + model_output: "<|no_edits|>", + expected: indoc! {" + hello + world + "}, + }, Case { name: "wrong_hash_set_ignored", original: indoc! {" @@ -2113,6 +2167,7 @@ pub mod hashline { ))); assert!(!hashline::output_has_edit_commands("just plain text")); assert!(!hashline::output_has_edit_commands("NO_EDITS")); + assert!(hashline::output_has_edit_commands("<|no_edits|>")); } // ---- hashline::patch_to_edit_commands round-trip tests ---- @@ -2350,35 +2405,47 @@ pub mod hashline { } "#}, patch: indoc! {r#" - @@ -1,3 +1,3 @@ - fn main() { - - println!(); - + eprintln!(""); - } - "#}, + @@ -1,3 +1,3 @@ + fn main() { + - println!(); + + eprintln!(""); + } + "#}, expected_new: indoc! {r#" - fn main() { - eprintln!("<|user_cursor|>"); - } - "#}, + fn main() { + eprintln!("<|user_cursor|>"); + } + "#}, }, Case { name: "non_local_hunk_header_pure_insertion_repro", old: indoc! {" - aaa - bbb - "}, + aaa + bbb + "}, patch: indoc! {" - @@ -20,2 +20,3 @@ - aaa - +xxx - bbb - "}, + @@ -20,2 +20,3 @@ + aaa + +xxx + bbb + "}, expected_new: indoc! {" - aaa - xxx - bbb - "}, + aaa + xxx + bbb + "}, + }, + Case { + name: "empty_patch_produces_no_edits_marker", + old: indoc! {" + aaa + bbb + "}, + patch: "@@ -20,2 +20,3 @@\n", + expected_new: indoc! {" + aaa + bbb + "}, }, ]; From 175707f95cc67a9f16e08728bcb9b43c78a96bb6 Mon Sep 17 00:00:00 2001 From: Neel Date: Mon, 9 Mar 2026 13:51:22 +0000 Subject: [PATCH 068/219] open_ai: Support reasoning summaries in OpenAI Responses API (#50959) Related to AI-79. Release Notes: - N/A --- crates/language_models/src/provider/cloud.rs | 5 +- .../language_models/src/provider/open_ai.rs | 202 +++++++++++++++++- crates/open_ai/src/responses.rs | 68 ++++++ 3 files changed, 267 insertions(+), 8 deletions(-) diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index d8ffdf8762e2360231deaf835b63f7e4f065af1a..4e705a8d62a5446b17bcc95a7dc75152b0c3269c 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -866,7 +866,10 @@ impl LanguageModel for CloudLanguageModel { ); if enable_thinking && let Some(effort) = effort { - request.reasoning = Some(open_ai::responses::ReasoningConfig { effort }); + request.reasoning = Some(open_ai::responses::ReasoningConfig { + effort, + summary: Some(open_ai::responses::ReasoningSummaryMode::Auto), + }); } let future = self.request_limiter.stream(async move { diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 9f4c6b4c5409406e6606250a847037a8543feb20..ce79de7cb2df22847a2666d7b4847e2c696fb12e 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -602,7 +602,10 @@ pub fn into_open_ai_response( } else { None }, - reasoning: reasoning_effort.map(|effort| open_ai::responses::ReasoningConfig { effort }), + reasoning: reasoning_effort.map(|effort| open_ai::responses::ReasoningConfig { + effort, + summary: Some(open_ai::responses::ReasoningSummaryMode::Auto), + }), } } @@ -963,10 +966,20 @@ impl OpenAiResponseEventMapper { self.function_calls_by_item.insert(item_id, entry); } } - ResponseOutputItem::Unknown => {} + ResponseOutputItem::Reasoning(_) | ResponseOutputItem::Unknown => {} } events } + ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. } => { + if delta.is_empty() { + Vec::new() + } else { + vec![Ok(LanguageModelCompletionEvent::Thinking { + text: delta, + signature: None, + })] + } + } ResponsesStreamEvent::OutputTextDelta { delta, .. } => { if delta.is_empty() { Vec::new() @@ -1075,10 +1088,22 @@ impl OpenAiResponseEventMapper { error.message )))] } - ResponsesStreamEvent::OutputTextDone { .. } => Vec::new(), - ResponsesStreamEvent::OutputItemDone { .. } + ResponsesStreamEvent::ReasoningSummaryPartAdded { summary_index, .. } => { + if summary_index > 0 { + vec![Ok(LanguageModelCompletionEvent::Thinking { + text: "\n\n".to_string(), + signature: None, + })] + } else { + Vec::new() + } + } + ResponsesStreamEvent::OutputTextDone { .. } + | ResponsesStreamEvent::OutputItemDone { .. } | ResponsesStreamEvent::ContentPartAdded { .. } | ResponsesStreamEvent::ContentPartDone { .. } + | ResponsesStreamEvent::ReasoningSummaryTextDone { .. } + | ResponsesStreamEvent::ReasoningSummaryPartDone { .. } | ResponsesStreamEvent::Created { .. } | ResponsesStreamEvent::InProgress { .. } | ResponsesStreamEvent::Unknown => Vec::new(), @@ -1416,8 +1441,9 @@ mod tests { use gpui::TestAppContext; use language_model::{LanguageModelRequestMessage, LanguageModelRequestTool}; use open_ai::responses::{ - ResponseFunctionToolCall, ResponseOutputItem, ResponseOutputMessage, ResponseStatusDetails, - ResponseSummary, ResponseUsage, StreamEvent as ResponsesStreamEvent, + ReasoningSummaryPart, ResponseFunctionToolCall, ResponseOutputItem, ResponseOutputMessage, + ResponseReasoningItem, ResponseStatusDetails, ResponseSummary, ResponseUsage, + StreamEvent as ResponsesStreamEvent, }; use pretty_assertions::assert_eq; use serde_json::json; @@ -1675,7 +1701,7 @@ mod tests { } ], "prompt_cache_key": "thread-123", - "reasoning": { "effort": "low" } + "reasoning": { "effort": "low", "summary": "auto" } }); assert_eq!(serialized, expected); @@ -2114,4 +2140,166 @@ mod tests { }) )); } + + #[test] + fn responses_stream_maps_reasoning_summary_deltas() { + let events = vec![ + ResponsesStreamEvent::OutputItemAdded { + output_index: 0, + sequence_number: None, + item: ResponseOutputItem::Reasoning(ResponseReasoningItem { + id: Some("rs_123".into()), + summary: vec![], + }), + }, + ResponsesStreamEvent::ReasoningSummaryPartAdded { + item_id: "rs_123".into(), + output_index: 0, + summary_index: 0, + }, + ResponsesStreamEvent::ReasoningSummaryTextDelta { + item_id: "rs_123".into(), + output_index: 0, + delta: "Thinking about".into(), + }, + ResponsesStreamEvent::ReasoningSummaryTextDelta { + item_id: "rs_123".into(), + output_index: 0, + delta: " the answer".into(), + }, + ResponsesStreamEvent::ReasoningSummaryTextDone { + item_id: "rs_123".into(), + output_index: 0, + text: "Thinking about the answer".into(), + }, + ResponsesStreamEvent::ReasoningSummaryPartDone { + item_id: "rs_123".into(), + output_index: 0, + summary_index: 0, + }, + ResponsesStreamEvent::ReasoningSummaryPartAdded { + item_id: "rs_123".into(), + output_index: 0, + summary_index: 1, + }, + ResponsesStreamEvent::ReasoningSummaryTextDelta { + item_id: "rs_123".into(), + output_index: 0, + delta: "Second part".into(), + }, + ResponsesStreamEvent::ReasoningSummaryTextDone { + item_id: "rs_123".into(), + output_index: 0, + text: "Second part".into(), + }, + ResponsesStreamEvent::ReasoningSummaryPartDone { + item_id: "rs_123".into(), + output_index: 0, + summary_index: 1, + }, + ResponsesStreamEvent::OutputItemDone { + output_index: 0, + sequence_number: None, + item: ResponseOutputItem::Reasoning(ResponseReasoningItem { + id: Some("rs_123".into()), + summary: vec![ + ReasoningSummaryPart::SummaryText { + text: "Thinking about the answer".into(), + }, + ReasoningSummaryPart::SummaryText { + text: "Second part".into(), + }, + ], + }), + }, + ResponsesStreamEvent::OutputItemAdded { + output_index: 1, + sequence_number: None, + item: response_item_message("msg_456"), + }, + ResponsesStreamEvent::OutputTextDelta { + item_id: "msg_456".into(), + output_index: 1, + content_index: Some(0), + delta: "The answer is 42".into(), + }, + ResponsesStreamEvent::Completed { + response: ResponseSummary::default(), + }, + ]; + + let mapped = map_response_events(events); + + let thinking_events: Vec<_> = mapped + .iter() + .filter(|e| matches!(e, LanguageModelCompletionEvent::Thinking { .. })) + .collect(); + assert_eq!( + thinking_events.len(), + 4, + "expected 4 thinking events (2 deltas + separator + second delta), got {:?}", + thinking_events, + ); + + assert!(matches!( + &thinking_events[0], + LanguageModelCompletionEvent::Thinking { text, .. } if text == "Thinking about" + )); + assert!(matches!( + &thinking_events[1], + LanguageModelCompletionEvent::Thinking { text, .. } if text == " the answer" + )); + assert!( + matches!( + &thinking_events[2], + LanguageModelCompletionEvent::Thinking { text, .. } if text == "\n\n" + ), + "expected separator between summary parts" + ); + assert!(matches!( + &thinking_events[3], + LanguageModelCompletionEvent::Thinking { text, .. } if text == "Second part" + )); + + assert!(mapped.iter().any(|e| matches!( + e, + LanguageModelCompletionEvent::Text(t) if t == "The answer is 42" + ))); + } + + #[test] + fn responses_stream_maps_reasoning_from_done_only() { + let events = vec![ + ResponsesStreamEvent::OutputItemAdded { + output_index: 0, + sequence_number: None, + item: ResponseOutputItem::Reasoning(ResponseReasoningItem { + id: Some("rs_789".into()), + summary: vec![], + }), + }, + ResponsesStreamEvent::OutputItemDone { + output_index: 0, + sequence_number: None, + item: ResponseOutputItem::Reasoning(ResponseReasoningItem { + id: Some("rs_789".into()), + summary: vec![ReasoningSummaryPart::SummaryText { + text: "Summary without deltas".into(), + }], + }), + }, + ResponsesStreamEvent::Completed { + response: ResponseSummary::default(), + }, + ]; + + let mapped = map_response_events(events); + + assert!( + !mapped + .iter() + .any(|e| matches!(e, LanguageModelCompletionEvent::Thinking { .. })), + "OutputItemDone reasoning should not produce Thinking events (no delta/done text events)" + ); + } } diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 9196b4a11fbaeeabb9ebe7e59cf106c4d260c267..fe97a438859e920313faa8cba0d335b7faeb75e0 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -78,6 +78,16 @@ pub enum ResponseInputContent { #[derive(Serialize, Debug)] pub struct ReasoningConfig { pub effort: ReasoningEffort, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + +#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ReasoningSummaryMode { + Auto, + Concise, + Detailed, } #[derive(Serialize, Debug)] @@ -150,6 +160,30 @@ pub enum StreamEvent { content_index: Option, text: String, }, + #[serde(rename = "response.reasoning_summary_part.added")] + ReasoningSummaryPartAdded { + item_id: String, + output_index: usize, + summary_index: usize, + }, + #[serde(rename = "response.reasoning_summary_text.delta")] + ReasoningSummaryTextDelta { + item_id: String, + output_index: usize, + delta: String, + }, + #[serde(rename = "response.reasoning_summary_text.done")] + ReasoningSummaryTextDone { + item_id: String, + output_index: usize, + text: String, + }, + #[serde(rename = "response.reasoning_summary_part.done")] + ReasoningSummaryPartDone { + item_id: String, + output_index: usize, + summary_index: usize, + }, #[serde(rename = "response.function_call_arguments.delta")] FunctionCallArgumentsDelta { item_id: String, @@ -219,6 +253,25 @@ pub struct ResponseUsage { pub enum ResponseOutputItem { Message(ResponseOutputMessage), FunctionCall(ResponseFunctionToolCall), + Reasoning(ResponseReasoningItem), + #[serde(other)] + Unknown, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct ResponseReasoningItem { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub summary: Vec, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ReasoningSummaryPart { + SummaryText { + text: String, + }, #[serde(other)] Unknown, } @@ -356,6 +409,21 @@ pub async fn stream_response( }); } } + ResponseOutputItem::Reasoning(reasoning) => { + if let Some(ref item_id) = reasoning.id { + for part in &reasoning.summary { + if let ReasoningSummaryPart::SummaryText { text } = part { + all_events.push( + StreamEvent::ReasoningSummaryTextDelta { + item_id: item_id.clone(), + output_index, + delta: text.clone(), + }, + ); + } + } + } + } ResponseOutputItem::Unknown => {} } From 6810f2363489e4068529d4a6523f3eb12fd6e605 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Mon, 9 Mar 2026 14:55:35 +0100 Subject: [PATCH 069/219] ci: Add source list and GPG key manually of ubuntu-toolchain-r (#51102) Release Notes: - N/A --- script/linux | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/script/linux b/script/linux index 706fa63b037e290cd7991d3adfa42fac0c0cfe25..c7922355342a7776202f81abf9e471cf32854085 100755 --- a/script/linux +++ b/script/linux @@ -60,12 +60,21 @@ if [[ -n $apt ]]; then # Ubuntu 20.04 ships clang-10 and libstdc++-10 which lack adequate C++20 # support for building webrtc-sys (requires -std=c++20, lambdas in # unevaluated contexts from clang 17+, and working std::ranges in the - # stdlib). clang-18 is available in focal-security/universe as an official - # backport, and libstdc++-11-dev from the ubuntu-toolchain-r PPA provides - # headers with working pointer_traits/contiguous_range. + # stdlib). # Note: the prebuilt libwebrtc.a is compiled with libstdc++, so we must # use libstdc++ (not libc++) to avoid ABI mismatches at link time. - $maysudo add-apt-repository -y ppa:ubuntu-toolchain-r/test + + # libstdc++-11-dev (headers with working pointer_traits/contiguous_range) + # is only available from the ubuntu-toolchain-r PPA. Add the source list + # and GPG key manually instead of using add-apt-repository, whose HKP + # keyserver lookups (port 11371) frequently time out in CI. + $maysudo "$apt" install -y curl gnupg + codename=$(lsb_release -cs) + echo "deb https://ppa.launchpadcontent.net/ubuntu-toolchain-r/test/ubuntu $codename main" | \ + $maysudo tee /etc/apt/sources.list.d/ubuntu-toolchain-r-test.list > /dev/null + curl -fsSL 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x1E9377A2BA9EF27F' | \ + sed -n '/-----BEGIN PGP PUBLIC KEY BLOCK-----/,/-----END PGP PUBLIC KEY BLOCK-----/p' | \ + $maysudo gpg --dearmor -o /etc/apt/trusted.gpg.d/ubuntu-toolchain-r-test.gpg deps+=( clang-18 libstdc++-11-dev ) fi From 171e7cb4a7e470f9cbd6580b2b268d445ffb620b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:59:32 -0300 Subject: [PATCH 070/219] sidebar: Improve behavior of "view more" button (#51105) This PR adjusts the "View More" button in the sidebar to expose threads in batches of 5. Once you've expanded the whole available set, a button to collapse the list back to the default number appears at the bottom. Similarly, as soon as you expand the list even once, a button in the group header shows up that does the same thing. No release notes because this is still under feature flag. Release Notes: - N/A --- assets/icons/list_collapse.svg | 8 +- crates/sidebar/src/sidebar.rs | 191 ++++++++++++++++++++++++++++----- 2 files changed, 169 insertions(+), 30 deletions(-) diff --git a/assets/icons/list_collapse.svg b/assets/icons/list_collapse.svg index f18bc550b90228c2f689848b86cfc5bea3d6ff50..dbdb2aaa4537c25ba1867d4957c23819af425835 100644 --- a/assets/icons/list_collapse.svg +++ b/assets/icons/list_collapse.svg @@ -1 +1,7 @@ - + + + + + + + diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 45a56f7af203e8ffe01b8590f916b439a57c52fb..d8bfae85bcd40654086c05d52d0004c618055c31 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -89,6 +89,7 @@ enum ListEntry { ViewMore { path_list: PathList, remaining_count: usize, + is_fully_expanded: bool, }, NewThread { path_list: PathList, @@ -174,7 +175,7 @@ pub struct Sidebar { focused_thread: Option, active_entry_index: Option, collapsed_groups: HashSet, - expanded_groups: HashSet, + expanded_groups: HashMap, } impl EventEmitter for Sidebar {} @@ -269,7 +270,7 @@ impl Sidebar { focused_thread: None, active_entry_index: None, collapsed_groups: HashSet::new(), - expanded_groups: HashSet::new(), + expanded_groups: HashMap::new(), } } @@ -579,21 +580,20 @@ impl Sidebar { } let total = threads.len(); - let show_view_more = - total > DEFAULT_THREADS_SHOWN && !self.expanded_groups.contains(&path_list); - let count = if show_view_more { - DEFAULT_THREADS_SHOWN - } else { - total - }; + let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0); + let threads_to_show = + DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN); + let count = threads_to_show.min(total); + let is_fully_expanded = count >= total; entries.extend(threads.into_iter().take(count)); - if show_view_more { + if total > DEFAULT_THREADS_SHOWN { entries.push(ListEntry::ViewMore { path_list: path_list.clone(), - remaining_count: total - DEFAULT_THREADS_SHOWN, + remaining_count: total.saturating_sub(count), + is_fully_expanded, }); } @@ -632,10 +632,13 @@ impl Sidebar { let had_notifications = self.has_notifications(cx); + let scroll_position = self.list_state.logical_scroll_top(); + self.rebuild_contents(cx); self.recompute_active_entry_index(cx); self.list_state.reset(self.contents.entries.len()); + self.list_state.scroll_to(scroll_position); if had_notifications != self.has_notifications(cx) { multi_workspace.update(cx, |_, cx| { @@ -720,7 +723,15 @@ impl Sidebar { ListEntry::ViewMore { path_list, remaining_count, - } => self.render_view_more(ix, path_list, *remaining_count, is_selected, cx), + is_fully_expanded, + } => self.render_view_more( + ix, + path_list, + *remaining_count, + *is_fully_expanded, + is_selected, + cx, + ), ListEntry::NewThread { path_list, workspace, @@ -765,7 +776,11 @@ impl Sidebar { let workspace_for_new_thread = workspace.clone(); let workspace_for_remove = workspace.clone(); // let workspace_for_activate = workspace.clone(); + let path_list_for_toggle = path_list.clone(); + let path_list_for_collapse = path_list.clone(); + let view_more_expanded = self.expanded_groups.contains_key(path_list); + let multi_workspace = self.multi_workspace.upgrade(); let workspace_count = multi_workspace .as_ref() @@ -853,6 +868,25 @@ impl Sidebar { )), ) }) + .when(view_more_expanded && !is_collapsed, |this| { + this.child( + IconButton::new( + SharedString::from(format!("project-header-collapse-{}", ix)), + IconName::ListCollapse, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Collapse Displayed Threads")) + .on_click(cx.listener({ + let path_list_for_collapse = path_list_for_collapse.clone(); + move |this, _, _window, cx| { + this.selection = None; + this.expanded_groups.remove(&path_list_for_collapse); + this.update_entries(cx); + } + })), + ) + }) .when(has_threads, |this| { this.child( IconButton::new(ib_id, IconName::NewThread) @@ -1031,9 +1065,18 @@ impl Sidebar { let workspace = workspace.clone(); self.activate_thread(session_info, &workspace, window, cx); } - ListEntry::ViewMore { path_list, .. } => { + ListEntry::ViewMore { + path_list, + is_fully_expanded, + .. + } => { let path_list = path_list.clone(); - self.expanded_groups.insert(path_list); + if *is_fully_expanded { + self.expanded_groups.remove(&path_list); + } else { + let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0); + self.expanded_groups.insert(path_list, current + 1); + } self.update_entries(cx); } ListEntry::NewThread { workspace, .. } => { @@ -1202,32 +1245,42 @@ impl Sidebar { ix: usize, path_list: &PathList, remaining_count: usize, + is_fully_expanded: bool, is_selected: bool, cx: &mut Context, ) -> AnyElement { let path_list = path_list.clone(); let id = SharedString::from(format!("view-more-{}", ix)); - let count = format!("({})", remaining_count); + let (icon, label) = if is_fully_expanded { + (IconName::ListCollapse, "Collapse List") + } else { + (IconName::Plus, "View More") + }; ListItem::new(id) .focused(is_selected) .child( h_flex() - .px_1() - .py_1p5() + .p_1() .gap_1p5() - .child( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child(Label::new("View More").color(Color::Muted)) - .child(Label::new(count).color(Color::Muted).size(LabelSize::Small)), + .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) + .child(Label::new(label).color(Color::Muted)) + .when(!is_fully_expanded, |this| { + this.child( + Label::new(format!("({})", remaining_count)) + .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))), + ) + }), ) .on_click(cx.listener(move |this, _, _window, cx| { this.selection = None; - this.expanded_groups.insert(path_list.clone()); + if is_fully_expanded { + this.expanded_groups.remove(&path_list); + } else { + let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0); + this.expanded_groups.insert(path_list.clone(), current + 1); + } this.update_entries(cx); })) .into_any_element() @@ -1660,9 +1713,15 @@ mod tests { ) } ListEntry::ViewMore { - remaining_count, .. + remaining_count, + is_fully_expanded, + .. } => { - format!(" + View More ({}){}", remaining_count, selected) + if *is_fully_expanded { + format!(" - Collapse{}", selected) + } else { + format!(" + View More ({}){}", remaining_count, selected) + } } ListEntry::NewThread { .. } => { format!(" [+ New Thread]{}", selected) @@ -1824,6 +1883,78 @@ mod tests { ); } + #[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 (12 remaining) + 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 (12)"))); + + // Focus and navigate to View More, then confirm to expand by one batch + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + for _ in 0..7 { + cx.dispatch_action(SelectNext); + } + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // Now shows 10 threads + View More (7 remaining) + 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 (7)"))); + + // 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 (2 remaining) + 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 (2)"))); + + // 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 (12 remaining) + 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 (12)"))); + } + #[gpui::test] async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; @@ -1984,6 +2115,7 @@ mod tests { ListEntry::ViewMore { path_list: expanded_path.clone(), remaining_count: 42, + is_fully_expanded: false, }, // Collapsed project header ListEntry::ProjectHeader { @@ -2237,10 +2369,11 @@ mod tests { cx.dispatch_action(Confirm); cx.run_until_parked(); - // All 8 threads should now be visible, no "View More" + // All 8 threads should now be visible with a "Collapse" button let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 9); // header + 8 threads + 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] From b54716dac1d022455e111ebb6ac6f00339247516 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Mon, 9 Mar 2026 16:42:15 +0200 Subject: [PATCH 071/219] ep: Skip context retrieval when already performed (#51100) Previously we didn't distinguish between an empty `.related_files[]` and a case where context collection hadn't run yet. As a result, context retrieval was always attempted for examples with empty `related_files`. Release Notes: - N/A --- crates/edit_prediction/src/fim.rs | 2 +- crates/edit_prediction/src/mercury.rs | 4 ++-- crates/edit_prediction/src/prediction.rs | 2 +- crates/edit_prediction/src/sweep_ai.rs | 2 +- crates/edit_prediction/src/zeta.rs | 2 +- .../edit_prediction_cli/src/format_prompt.rs | 5 ++++- .../edit_prediction_cli/src/load_project.rs | 3 +-- .../src/retrieve_context.rs | 20 +++++++------------ .../src/reversal_tracking.rs | 2 +- .../src/rate_prediction_modal.rs | 8 +++++++- crates/zeta_prompt/src/zeta_prompt.rs | 19 ++++++++++-------- 11 files changed, 37 insertions(+), 32 deletions(-) diff --git a/crates/edit_prediction/src/fim.rs b/crates/edit_prediction/src/fim.rs index 02053aae7154acdfa22a01a4f84d6b732a9ca696..79df739e60bc28ba5c6b9f53699dcf398fc8310e 100644 --- a/crates/edit_prediction/src/fim.rs +++ b/crates/edit_prediction/src/fim.rs @@ -73,7 +73,7 @@ pub fn request_prediction( let inputs = ZetaPromptInput { events, - related_files: Vec::new(), + related_files: Some(Vec::new()), cursor_offset_in_excerpt: cursor_offset - excerpt_offset_range.start, cursor_path: full_path.clone(), excerpt_start_row: Some(excerpt_range.start.row), diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index cbb4e027253bb4d69b684c0668ff0da60f4e6aaf..0d63005feb18acb9a434ff107811080a7bcf1f12 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -91,7 +91,7 @@ impl Mercury { let inputs = zeta_prompt::ZetaPromptInput { events, - related_files, + related_files: Some(related_files), cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) - context_offset_range.start, cursor_path: full_path.clone(), @@ -260,7 +260,7 @@ fn build_prompt(inputs: &ZetaPromptInput) -> String { &mut prompt, RECENTLY_VIEWED_SNIPPETS_START..RECENTLY_VIEWED_SNIPPETS_END, |prompt| { - for related_file in inputs.related_files.iter() { + for related_file in inputs.related_files.as_deref().unwrap_or_default().iter() { for related_excerpt in &related_file.excerpts { push_delimited( prompt, diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs index 263409043b397e2df1ac32514a0ce76656fbefe1..1c281453b93d0ab7c601f575b290c46fe63d2eae 100644 --- a/crates/edit_prediction/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -156,7 +156,7 @@ mod tests { model_version: None, inputs: ZetaPromptInput { events: vec![], - related_files: vec![], + related_files: Some(vec![]), cursor_path: Path::new("path.txt").into(), cursor_offset_in_excerpt: 0, cursor_excerpt: "".into(), diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs index d8ce180801aa8902bfff79044cabaae7570ed05f..ff5128e56e49191f308a574d5502f8139db9bc3f 100644 --- a/crates/edit_prediction/src/sweep_ai.rs +++ b/crates/edit_prediction/src/sweep_ai.rs @@ -212,7 +212,7 @@ impl SweepAi { let ep_inputs = zeta_prompt::ZetaPromptInput { events: inputs.events, - related_files: inputs.related_files.clone(), + related_files: Some(inputs.related_files.clone()), cursor_path: full_path.clone(), cursor_excerpt: request_body.file_contents.clone().into(), cursor_offset_in_excerpt: request_body.cursor_position, diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 1217cbd5ba6f8ecd5b13aa1eec3b1a88bf26dbc2..1a4d0b445a8c3d5876eb48646a0a1622a8b725a2 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -509,7 +509,7 @@ pub fn zeta2_prompt_input( cursor_offset_in_excerpt, excerpt_start_row: Some(full_context_start_row), events, - related_files, + related_files: Some(related_files), excerpt_ranges, experiment: preferred_experiment, in_open_source_repo: is_open_source, diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index fe7dff5935aed035e803b1451c8c06df8f79b810..324c297ba4c75d10a24b53c7961bd35e1f42e2cd 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -259,7 +259,10 @@ impl TeacherPrompt { } pub fn format_context(example: &Example) -> String { - let related_files = example.prompt_inputs.as_ref().map(|pi| &pi.related_files); + let related_files = example + .prompt_inputs + .as_ref() + .and_then(|pi| pi.related_files.as_deref()); let Some(related_files) = related_files else { return "(No context)".to_string(); }; diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs index df458770519be5accd72f33a56893bb13c9b88a9..f7e27ca432baacd38c468e5b4c6f97b62cb8ee3e 100644 --- a/crates/edit_prediction_cli/src/load_project.rs +++ b/crates/edit_prediction_cli/src/load_project.rs @@ -71,8 +71,7 @@ pub async fn run_load_project( let existing_related_files = example .prompt_inputs .take() - .map(|inputs| inputs.related_files) - .unwrap_or_default(); + .and_then(|inputs| inputs.related_files); let (prompt_inputs, language_name) = buffer.read_with(&cx, |buffer, _cx| { let snapshot = buffer.snapshot(); diff --git a/crates/edit_prediction_cli/src/retrieve_context.rs b/crates/edit_prediction_cli/src/retrieve_context.rs index a5fb00b39a67a15a7afcced897b4d109f1f3406f..971bdf24d3e8cd1d8184a9009903cec25d3000d1 100644 --- a/crates/edit_prediction_cli/src/retrieve_context.rs +++ b/crates/edit_prediction_cli/src/retrieve_context.rs @@ -20,18 +20,12 @@ pub async fn run_context_retrieval( example_progress: &ExampleProgress, mut cx: AsyncApp, ) -> anyhow::Result<()> { - if example.prompt_inputs.is_some() { - if example.spec.repository_url.is_empty() { - return Ok(()); - } - - if example - .prompt_inputs - .as_ref() - .is_some_and(|inputs| !inputs.related_files.is_empty()) - { - return Ok(()); - } + if example + .prompt_inputs + .as_ref() + .is_some_and(|inputs| inputs.related_files.is_some()) + { + return Ok(()); } run_load_project(example, app_state.clone(), example_progress, cx.clone()).await?; @@ -72,7 +66,7 @@ pub async fn run_context_retrieval( step_progress.set_info(format!("{} excerpts", excerpt_count), InfoStyle::Normal); if let Some(prompt_inputs) = example.prompt_inputs.as_mut() { - prompt_inputs.related_files = context_files; + prompt_inputs.related_files = Some(context_files); } Ok(()) } diff --git a/crates/edit_prediction_cli/src/reversal_tracking.rs b/crates/edit_prediction_cli/src/reversal_tracking.rs index cb955dbdf7dd2375395e8c0ecd52df849e33fb38..398ae24309bbb9368bb7947c94ad4f481c03ab9e 100644 --- a/crates/edit_prediction_cli/src/reversal_tracking.rs +++ b/crates/edit_prediction_cli/src/reversal_tracking.rs @@ -668,7 +668,7 @@ mod tests { cursor_offset_in_excerpt: 0, excerpt_start_row, events, - related_files: Vec::new(), + related_files: Some(Vec::new()), excerpt_ranges: ExcerptRanges { editable_150: 0..content.len(), editable_180: 0..content.len(), diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index d07dbe9bad72c2252ee2e33c8a014778d1331e96..1c4328d8a1d301b7cc01aa520c166bda4b40e32d 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -402,7 +402,13 @@ impl RatePredictionsModal { write!(&mut formatted_inputs, "## Related files\n\n").unwrap(); - for included_file in prediction.inputs.related_files.iter() { + for included_file in prediction + .inputs + .related_files + .as_deref() + .unwrap_or_default() + .iter() + { write!( &mut formatted_inputs, "### {}\n\n", diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 3f7839305bd840f32a3f27182b0c5d02c1166099..774ac7cb9baebb943c9223645aae8d16cd730998 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -51,7 +51,8 @@ pub struct ZetaPromptInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub excerpt_start_row: Option, pub events: Vec>, - pub related_files: Vec, + #[serde(default)] + pub related_files: Option>, /// These ranges let the server select model-appropriate subsets. pub excerpt_ranges: ExcerptRanges, /// The name of the edit prediction model experiment to use. @@ -350,17 +351,19 @@ pub fn format_prompt_with_budget_for_format( resolve_cursor_region(input, format); let path = &*input.cursor_path; + let empty_files = Vec::new(); + let input_related_files = input.related_files.as_deref().unwrap_or(&empty_files); let related_files = if let Some(cursor_excerpt_start_row) = input.excerpt_start_row { let relative_row_range = offset_range_to_row_range(&input.cursor_excerpt, context_range); let row_range = relative_row_range.start + cursor_excerpt_start_row ..relative_row_range.end + cursor_excerpt_start_row; &filter_redundant_excerpts( - input.related_files.clone(), + input_related_files.to_vec(), input.cursor_path.as_ref(), row_range, ) } else { - &input.related_files + input_related_files }; match format { @@ -3863,7 +3866,7 @@ mod tests { cursor_offset_in_excerpt: cursor_offset, excerpt_start_row: None, events: events.into_iter().map(Arc::new).collect(), - related_files, + related_files: Some(related_files), excerpt_ranges: ExcerptRanges { editable_150: editable_range.clone(), editable_180: editable_range.clone(), @@ -3892,7 +3895,7 @@ mod tests { cursor_offset_in_excerpt: cursor_offset, excerpt_start_row: None, events: vec![], - related_files: vec![], + related_files: Some(vec![]), excerpt_ranges: ExcerptRanges { editable_150: editable_range.clone(), editable_180: editable_range.clone(), @@ -4475,7 +4478,7 @@ mod tests { cursor_offset_in_excerpt: 30, excerpt_start_row: Some(0), events: vec![Arc::new(make_event("other.rs", "-old\n+new\n"))], - related_files: vec![], + related_files: Some(vec![]), excerpt_ranges: ExcerptRanges { editable_150: 15..41, editable_180: 15..41, @@ -4538,7 +4541,7 @@ mod tests { cursor_offset_in_excerpt: 15, excerpt_start_row: Some(10), events: vec![], - related_files: vec![], + related_files: Some(vec![]), excerpt_ranges: ExcerptRanges { editable_150: 0..28, editable_180: 0..28, @@ -4596,7 +4599,7 @@ mod tests { cursor_offset_in_excerpt: 25, excerpt_start_row: Some(0), events: vec![], - related_files: vec![], + related_files: Some(vec![]), excerpt_ranges: ExcerptRanges { editable_150: editable_range.clone(), editable_180: editable_range.clone(), From 8bc66b35aee2df2f869ff7f7b6598f2e5d5e65ec Mon Sep 17 00:00:00 2001 From: francesco-gaglione <94604837+francesco-gaglione@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:18:12 +0100 Subject: [PATCH 072/219] extensions_ui: Fix extension author list overflow (#51045) Closes #50995 Before you mark this PR as ready for review, make sure that you have: - [ ] 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 extension author's list overflow image --------- Co-authored-by: Danilo Leal --- crates/extensions_ui/src/extensions_ui.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 1458b2104f31f4d987319c87a41bfd5538b2727f..7343edcdef3851bfeb7a3aa80f3449ff06f55d9f 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -870,9 +870,12 @@ impl ExtensionsPage { ) .child( h_flex() + .min_w_0() + .w_full() .justify_between() .child( h_flex() + .min_w_0() .gap_1() .child( Icon::new(IconName::Person) @@ -889,6 +892,7 @@ impl ExtensionsPage { .child( h_flex() .gap_1() + .flex_shrink_0() .child({ let repo_url_for_tooltip = repository_url.clone(); From b06c0e0773361707169b4adcf6c77b1279cc9e3d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:31:16 -0300 Subject: [PATCH 073/219] ui: Add `GradientFade` component (#51113) Just adding this here as an utility component given we were doing similar things on the sidebar, thread item, and list item. It'd be probably useful, in the near future, to give this more methods so it's more flexible. Release Notes: - N/A --- crates/sidebar/src/sidebar.rs | 42 +++-------- crates/ui/src/components.rs | 2 + crates/ui/src/components/ai/thread_item.rs | 30 +++----- crates/ui/src/components/gradient_fade.rs | 88 ++++++++++++++++++++++ crates/ui/src/components/list/list_item.rs | 41 ++-------- 5 files changed, 118 insertions(+), 85 deletions(-) create mode 100644 crates/ui/src/components/gradient_fade.rs diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index d8bfae85bcd40654086c05d52d0004c618055c31..5b38afcd5ef3a576388996958b821f426922d322 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -7,8 +7,8 @@ use editor::{Editor, EditorElement, EditorStyle}; use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState, - Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, linear_color_stop, - linear_gradient, list, prelude::*, px, relative, rems, + Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px, + relative, rems, }; use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::Event as ProjectEvent; @@ -18,8 +18,8 @@ use std::mem; use theme::{ActiveTheme, ThemeSettings}; use ui::utils::TRAFFIC_LIGHT_PADDING; use ui::{ - AgentThreadStatus, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, Tab, ThreadItem, - Tooltip, WithScrollbar, prelude::*, + AgentThreadStatus, GradientFade, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, Tab, + ThreadItem, Tooltip, WithScrollbar, prelude::*, }; use util::path_list::PathList; use workspace::{ @@ -803,33 +803,13 @@ impl Sidebar { }; let color = cx.theme().colors(); - let base_bg = color.panel_background; - let gradient_overlay = div() - .id("gradient_overlay") - .absolute() - .top_0() - .right_0() - .w_12() - .h_full() - .bg(linear_gradient( - 90., - linear_color_stop(base_bg, 0.6), - linear_color_stop(base_bg.opacity(0.0), 0.), - )) - .group_hover(group_name.clone(), |s| { - s.bg(linear_gradient( - 90., - linear_color_stop(color.element_hover, 0.6), - linear_color_stop(color.element_hover.opacity(0.0), 0.), - )) - }) - .group_active(group_name.clone(), |s| { - s.bg(linear_gradient( - 90., - linear_color_stop(color.element_active, 0.6), - linear_color_stop(color.element_active.opacity(0.0), 0.), - )) - }); + let gradient_overlay = GradientFade::new( + color.panel_background, + color.element_hover, + color.element_active, + ) + .width(px(48.0)) + .group_name(group_name.clone()); ListItem::new(id) .group_name(group_name) diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index cce736e237e2c2500b56f13ae579dee4426b5bfb..ef344529cd92efcbf8f57d192c44bbb53befc25e 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -12,6 +12,7 @@ mod disclosure; mod divider; mod dropdown_menu; mod facepile; +mod gradient_fade; mod group; mod icon; mod image; @@ -54,6 +55,7 @@ pub use disclosure::*; pub use divider::*; pub use dropdown_menu::*; pub use facepile::*; +pub use gradient_fade::*; pub use group::*; pub use icon::*; pub use image::*; diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index be27e6332ca500747e1836bbd577c7fd5ffb2507..3c08bd946710f76ccf49f933b82091a3bcb06e08 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -1,9 +1,9 @@ use crate::{ - DecoratedIcon, DiffStat, HighlightedLabel, IconDecoration, IconDecorationKind, SpinnerLabel, - prelude::*, + DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration, IconDecorationKind, + SpinnerLabel, prelude::*, }; -use gpui::{AnyView, ClickEvent, Hsla, SharedString, linear_color_stop, linear_gradient}; +use gpui::{AnyView, ClickEvent, Hsla, SharedString}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AgentThreadStatus { @@ -220,24 +220,12 @@ impl RenderOnce for ThreadItem { color.panel_background }; - let gradient_overlay = div() - .absolute() - .top_0() - .right(px(-10.0)) - .w_8() - .h_full() - .bg(linear_gradient( - 90., - linear_color_stop(base_bg, 0.8), - linear_color_stop(base_bg.opacity(0.0), 0.), - )) - .group_hover("thread-item", |s| { - s.bg(linear_gradient( - 90., - linear_color_stop(color.element_hover, 0.8), - linear_color_stop(color.element_hover.opacity(0.0), 0.), - )) - }); + let gradient_overlay = + GradientFade::new(base_bg, color.element_hover, color.element_active) + .width(px(32.0)) + .right(px(-10.0)) + .gradient_stop(0.8) + .group_name("thread-item"); v_flex() .id(self.id.clone()) diff --git a/crates/ui/src/components/gradient_fade.rs b/crates/ui/src/components/gradient_fade.rs new file mode 100644 index 0000000000000000000000000000000000000000..2173fdf06ea8c07c947f092066c2a12d716d4b44 --- /dev/null +++ b/crates/ui/src/components/gradient_fade.rs @@ -0,0 +1,88 @@ +use gpui::{Hsla, Pixels, SharedString, linear_color_stop, linear_gradient, px}; + +use crate::prelude::*; + +/// A gradient overlay that fades from a solid color to transparent. +#[derive(IntoElement)] +pub struct GradientFade { + base_bg: Hsla, + hover_bg: Hsla, + active_bg: Hsla, + width: Pixels, + right: Pixels, + gradient_stop: f32, + group_name: Option, +} + +impl GradientFade { + pub fn new(base_bg: Hsla, hover_bg: Hsla, active_bg: Hsla) -> Self { + Self { + base_bg, + hover_bg, + active_bg, + width: px(48.0), + right: px(0.0), + gradient_stop: 0.6, + group_name: None, + } + } + + pub fn width(mut self, width: Pixels) -> Self { + self.width = width; + self + } + + pub fn right(mut self, right: Pixels) -> Self { + self.right = right; + self + } + + pub fn gradient_stop(mut self, stop: f32) -> Self { + self.gradient_stop = stop; + self + } + + pub fn group_name(mut self, name: impl Into) -> Self { + self.group_name = Some(name.into()); + self + } +} + +impl RenderOnce for GradientFade { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let stop = self.gradient_stop; + let hover_bg = self.hover_bg; + let active_bg = self.active_bg; + + div() + .id("gradient_fade") + .absolute() + .top_0() + .right(self.right) + .w(self.width) + .h_full() + .bg(linear_gradient( + 90., + linear_color_stop(self.base_bg, stop), + linear_color_stop(self.base_bg.opacity(0.0), 0.), + )) + .when_some(self.group_name.clone(), |element, group_name| { + element.group_hover(group_name, move |s| { + s.bg(linear_gradient( + 90., + linear_color_stop(hover_bg, stop), + linear_color_stop(hover_bg.opacity(0.0), 0.), + )) + }) + }) + .when_some(self.group_name, |element, group_name| { + element.group_active(group_name, move |s| { + s.bg(linear_gradient( + 90., + linear_color_stop(active_bg, stop), + linear_color_stop(active_bg.opacity(0.0), 0.), + )) + }) + }) + } +} diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 0a1fbe7f40970f265513751090ed998a5521dfef..dc2fc76a06c29c72457d385effd06ea71e5f9625 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -1,13 +1,10 @@ use std::sync::Arc; use component::{Component, ComponentScope, example_group_with_title, single_example}; -use gpui::{ - AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, linear_color_stop, - linear_gradient, px, -}; +use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, px}; use smallvec::SmallVec; -use crate::{Disclosure, prelude::*}; +use crate::{Disclosure, GradientFade, prelude::*}; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] pub enum ListItemSpacing { @@ -220,34 +217,12 @@ impl RenderOnce for ListItem { color.panel_background }; - let end_hover_gradient_overlay = div() - .id("gradient_overlay") - .absolute() - .top_0() - .right_0() - .w_24() - .h_full() - .bg(linear_gradient( - 90., - linear_color_stop(base_bg, 0.6), - linear_color_stop(base_bg.opacity(0.0), 0.), - )) - .when_some(self.group_name.clone(), |s, group_name| { - s.group_hover(group_name.clone(), |s| { - s.bg(linear_gradient( - 90., - linear_color_stop(color.element_hover, 0.6), - linear_color_stop(color.element_hover.opacity(0.0), 0.), - )) - }) - .group_active(group_name, |s| { - s.bg(linear_gradient( - 90., - linear_color_stop(color.element_active, 0.6), - linear_color_stop(color.element_active.opacity(0.0), 0.), - )) - }) - }); + let end_hover_gradient_overlay = + GradientFade::new(base_bg, color.element_hover, color.element_active) + .width(px(96.0)) + .when_some(self.group_name.clone(), |fade, group| { + fade.group_name(group) + }); h_flex() .id(self.id) From d788673f1ef161ad616e19331e2d24e8f6604eb4 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 9 Mar 2026 17:50:44 +0200 Subject: [PATCH 074/219] Do not derive symbol highlights if they do not fit into multi buffer (#50948) Release Notes: - N/A --------- Co-authored-by: Conrad Irwin --- crates/diagnostics/src/diagnostic_renderer.rs | 2 +- crates/diagnostics/src/diagnostics.rs | 2 +- crates/editor/src/display_map.rs | 50 +--- crates/editor/src/document_symbols.rs | 215 ++++++------------ crates/editor/src/editor.rs | 9 +- crates/editor/src/split.rs | 4 +- crates/git_ui/src/conflict_view.rs | 6 +- crates/go_to_line/src/go_to_line.rs | 4 +- crates/gpui/src/elements/text.rs | 7 +- crates/multi_buffer/src/multi_buffer.rs | 8 +- crates/multi_buffer/src/multi_buffer_tests.rs | 2 +- crates/outline_panel/src/outline_panel.rs | 2 +- 12 files changed, 104 insertions(+), 207 deletions(-) diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index 920bf4bc880c347c640d3dbf7106f3545bba3444..89cebf8fb237a032866e14c36d3097e18388e6ab 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -297,7 +297,7 @@ impl DiagnosticBlock { return; }; - for (excerpt_id, range) in multibuffer.excerpts_for_buffer(buffer_id, cx) { + for (excerpt_id, _, range) in multibuffer.excerpts_for_buffer(buffer_id, cx) { if range.context.overlaps(&diagnostic.range, &snapshot) { Self::jump_to( editor, diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 57ce6f03d2b56c9441bee763a28dcc7010f8311e..b200d01669a90c1e439338b9b01118cce8b8bb0c 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -583,7 +583,7 @@ impl ProjectDiagnosticsEditor { RetainExcerpts::All | RetainExcerpts::Dirty => multi_buffer .excerpts_for_buffer(buffer_id, cx) .into_iter() - .map(|(_, range)| range) + .map(|(_, _, range)| range) .sorted_by(|a, b| cmp_excerpts(&buffer_snapshot, a, b)) .collect(), } diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 00a48a9ab3d249850b9749d64267d8274e7eaa79..b11832faa3f9bb8294c6ea054a335292b1422b02 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -107,7 +107,7 @@ use project::{InlayId, lsp_store::LspFoldingRange, lsp_store::TokenType}; use serde::Deserialize; use smallvec::SmallVec; use sum_tree::{Bias, TreeMap}; -use text::{BufferId, LineIndent, Patch, ToOffset as _}; +use text::{BufferId, LineIndent, Patch}; use ui::{SharedString, px}; use unicode_segmentation::UnicodeSegmentation; use ztracing::instrument; @@ -1977,57 +1977,11 @@ impl DisplaySnapshot { /// Returned ranges are 0-based relative to `buffer_range.start`. pub(super) fn combined_highlights( &self, - buffer_id: BufferId, - buffer_range: Range, + multibuffer_range: Range, syntax_theme: &theme::SyntaxTheme, ) -> Vec<(Range, HighlightStyle)> { let multibuffer = self.buffer_snapshot(); - let multibuffer_range = multibuffer - .excerpts() - .find_map(|(excerpt_id, buffer, range)| { - if buffer.remote_id() != buffer_id { - return None; - } - let context_start = range.context.start.to_offset(buffer); - let context_end = range.context.end.to_offset(buffer); - if buffer_range.start < context_start || buffer_range.end > context_end { - return None; - } - let start_anchor = buffer.anchor_before(buffer_range.start); - let end_anchor = buffer.anchor_after(buffer_range.end); - let mb_range = - multibuffer.anchor_range_in_excerpt(excerpt_id, start_anchor..end_anchor)?; - Some(mb_range.start.to_offset(multibuffer)..mb_range.end.to_offset(multibuffer)) - }); - - let Some(multibuffer_range) = multibuffer_range else { - // Range is outside all excerpts (e.g. symbol name not in a - // multi-buffer excerpt). Fall back to buffer-level syntax highlights. - let buffer_snapshot = multibuffer.excerpts().find_map(|(_, buffer, _)| { - (buffer.remote_id() == buffer_id).then(|| buffer.clone()) - }); - let Some(buffer_snapshot) = buffer_snapshot else { - return Vec::new(); - }; - let mut highlights = Vec::new(); - let mut offset = 0usize; - for chunk in buffer_snapshot.chunks(buffer_range, true) { - let chunk_len = chunk.text.len(); - if chunk_len == 0 { - continue; - } - if let Some(style) = chunk - .syntax_highlight_id - .and_then(|id| id.style(syntax_theme)) - { - highlights.push((offset..offset + chunk_len, style)); - } - offset += chunk_len; - } - return highlights; - }; - let chunks = custom_highlights::CustomHighlightsChunks::new( multibuffer_range, true, diff --git a/crates/editor/src/document_symbols.rs b/crates/editor/src/document_symbols.rs index 94d53eb19621cbe4d84734e2e77286180a59adf7..b73c1abbfb9bfec86093eed72082232275388faf 100644 --- a/crates/editor/src/document_symbols.rs +++ b/crates/editor/src/document_symbols.rs @@ -1,4 +1,4 @@ -use std::{cmp, ops::Range}; +use std::ops::Range; use collections::HashMap; use futures::FutureExt; @@ -6,10 +6,15 @@ use futures::future::join_all; use gpui::{App, Context, HighlightStyle, Task}; use itertools::Itertools as _; use language::language_settings::language_settings; -use language::{Buffer, BufferSnapshot, OutlineItem}; -use multi_buffer::{Anchor, MultiBufferSnapshot}; -use text::{Bias, BufferId, OffsetRangeExt as _, ToOffset as _}; +use language::{Buffer, OutlineItem}; +use multi_buffer::{ + Anchor, AnchorRangeExt as _, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot, + ToOffset as _, +}; +use text::BufferId; use theme::{ActiveTheme as _, SyntaxTheme}; +use unicode_segmentation::UnicodeSegmentation as _; +use util::maybe; use crate::display_map::DisplaySnapshot; use crate::{Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT}; @@ -215,16 +220,13 @@ impl Editor { let display_snapshot = editor.display_map.update(cx, |map, cx| map.snapshot(cx)); let mut highlighted_results = results; - for (buffer_id, items) in &mut highlighted_results { - if let Some(buffer) = editor.buffer.read(cx).buffer(*buffer_id) { - let snapshot = buffer.read(cx).snapshot(); - apply_highlights( - items, - *buffer_id, - &snapshot, - &display_snapshot, - &syntax, - ); + for items in highlighted_results.values_mut() { + for item in items { + if let Some(highlights) = + highlights_from_buffer(&display_snapshot, &item, &syntax) + { + item.highlight_ranges = highlights; + } } } editor.lsp_document_symbols.extend(highlighted_results); @@ -242,34 +244,6 @@ fn lsp_symbols_enabled(buffer: &Buffer, cx: &App) -> bool { .lsp_enabled() } -/// Applies combined syntax + semantic token highlights to LSP document symbol -/// outline items that were built without highlights by the project layer. -fn apply_highlights( - items: &mut [OutlineItem], - buffer_id: BufferId, - buffer_snapshot: &BufferSnapshot, - display_snapshot: &DisplaySnapshot, - syntax_theme: &SyntaxTheme, -) { - for item in items { - let symbol_range = item.range.to_offset(buffer_snapshot); - let selection_start = item.source_range_for_text.start.to_offset(buffer_snapshot); - - if let Some(highlights) = highlights_from_buffer( - &item.text, - 0, - buffer_id, - buffer_snapshot, - display_snapshot, - symbol_range, - selection_start, - syntax_theme, - ) { - item.highlight_ranges = highlights; - } - } -} - /// Finds where the symbol name appears in the buffer and returns combined /// (tree-sitter + semantic token) highlights for those positions. /// @@ -278,117 +252,78 @@ fn apply_highlights( /// to word-by-word matching for cases like `impl Trait for Type` /// where the LSP name doesn't appear verbatim in the buffer. fn highlights_from_buffer( - name: &str, - name_offset_in_text: usize, - buffer_id: BufferId, - buffer_snapshot: &BufferSnapshot, display_snapshot: &DisplaySnapshot, - symbol_range: Range, - selection_start_offset: usize, + item: &OutlineItem, syntax_theme: &SyntaxTheme, ) -> Option, HighlightStyle)>> { - if name.is_empty() { + let outline_text = &item.text; + if outline_text.is_empty() { return None; } - let range_start_offset = symbol_range.start; - let range_end_offset = symbol_range.end; - - // Try to find the name verbatim in the buffer near the selection range. - let search_start = buffer_snapshot.clip_offset( - selection_start_offset - .saturating_sub(name.len()) - .max(range_start_offset), - Bias::Right, - ); - let search_end = buffer_snapshot.clip_offset( - cmp::min(selection_start_offset + name.len() * 2, range_end_offset), - Bias::Left, - ); - - if search_start < search_end { - let buffer_text: String = buffer_snapshot - .text_for_range(search_start..search_end) - .collect(); - if let Some(found_at) = buffer_text.find(name) { - let name_start_offset = search_start + found_at; - let name_end_offset = name_start_offset + name.len(); - let result = highlights_for_buffer_range( - name_offset_in_text, - name_start_offset..name_end_offset, - buffer_id, - display_snapshot, - syntax_theme, + let multi_buffer_snapshot = display_snapshot.buffer(); + let multi_buffer_source_range_anchors = + multi_buffer_snapshot.text_anchors_to_visible_anchors([ + item.source_range_for_text.start, + item.source_range_for_text.end, + ]); + let Some(anchor_range) = maybe!({ + Some( + (*multi_buffer_source_range_anchors.get(0)?)? + ..(*multi_buffer_source_range_anchors.get(1)?)?, + ) + }) else { + return None; + }; + + let selection_point_range = anchor_range.to_point(multi_buffer_snapshot); + let mut search_start = selection_point_range.start; + search_start.column = 0; + let search_start_offset = search_start.to_offset(&multi_buffer_snapshot); + let mut search_end = selection_point_range.end; + search_end.column = multi_buffer_snapshot.line_len(MultiBufferRow(search_end.row)); + + let search_text = multi_buffer_snapshot + .text_for_range(search_start..search_end) + .collect::(); + + let mut outline_text_highlights = Vec::new(); + match search_text.find(outline_text) { + Some(start_index) => { + let multibuffer_start = search_start_offset + MultiBufferOffset(start_index); + let multibuffer_end = multibuffer_start + MultiBufferOffset(outline_text.len()); + outline_text_highlights.extend( + display_snapshot + .combined_highlights(multibuffer_start..multibuffer_end, syntax_theme), ); - if result.is_some() { - return result; - } } - } - - // Fallback: match word-by-word. Split the name on whitespace and find - // each word sequentially in the buffer's symbol range. - let range_start_offset = buffer_snapshot.clip_offset(range_start_offset, Bias::Right); - let range_end_offset = buffer_snapshot.clip_offset(range_end_offset, Bias::Left); - - let mut highlights = Vec::new(); - let mut got_any = false; - let buffer_text: String = buffer_snapshot - .text_for_range(range_start_offset..range_end_offset) - .collect(); - let mut buf_search_from = 0usize; - let mut name_search_from = 0usize; - for word in name.split_whitespace() { - let name_word_start = name[name_search_from..] - .find(word) - .map(|pos| name_search_from + pos) - .unwrap_or(name_search_from); - if let Some(found_in_buf) = buffer_text[buf_search_from..].find(word) { - let buf_word_start = range_start_offset + buf_search_from + found_in_buf; - let buf_word_end = buf_word_start + word.len(); - let text_cursor = name_offset_in_text + name_word_start; - if let Some(mut word_highlights) = highlights_for_buffer_range( - text_cursor, - buf_word_start..buf_word_end, - buffer_id, - display_snapshot, - syntax_theme, - ) { - got_any = true; - highlights.append(&mut word_highlights); + None => { + for (outline_text_word_start, outline_word) in outline_text.split_word_bound_indices() { + if let Some(start_index) = search_text.find(outline_word) { + let multibuffer_start = search_start_offset + MultiBufferOffset(start_index); + let multibuffer_end = multibuffer_start + MultiBufferOffset(outline_word.len()); + outline_text_highlights.extend( + display_snapshot + .combined_highlights(multibuffer_start..multibuffer_end, syntax_theme) + .into_iter() + .map(|(range_in_word, style)| { + ( + outline_text_word_start + range_in_word.start + ..outline_text_word_start + range_in_word.end, + style, + ) + }), + ); + } } - buf_search_from = buf_search_from + found_in_buf + word.len(); } - name_search_from = name_word_start + word.len(); } - got_any.then_some(highlights) -} - -/// Gets combined (tree-sitter + semantic token) highlights for a buffer byte -/// range via the editor's display snapshot, then shifts the returned ranges -/// so they start at `text_cursor_start` (the position in the outline item text). -fn highlights_for_buffer_range( - text_cursor_start: usize, - buffer_range: Range, - buffer_id: BufferId, - display_snapshot: &DisplaySnapshot, - syntax_theme: &SyntaxTheme, -) -> Option, HighlightStyle)>> { - let raw = display_snapshot.combined_highlights(buffer_id, buffer_range, syntax_theme); - if raw.is_empty() { - return None; + if outline_text_highlights.is_empty() { + None + } else { + Some(outline_text_highlights) } - Some( - raw.into_iter() - .map(|(range, style)| { - ( - range.start + text_cursor_start..range.end + text_cursor_start, - style, - ) - }) - .collect(), - ) } #[cfg(test)] diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index cb63e5f85d766637f5775bb864d79998ada9c254..40cfb8caf01a0343cb27104d7b23a24e999e9334 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -7500,7 +7500,8 @@ impl Editor { let mut read_ranges = Vec::new(); for highlight in highlights { let buffer_id = cursor_buffer.read(cx).remote_id(); - for (excerpt_id, excerpt_range) in buffer.excerpts_for_buffer(buffer_id, cx) + for (excerpt_id, _, excerpt_range) in + buffer.excerpts_for_buffer(buffer_id, cx) { let start = highlight .range @@ -20539,7 +20540,7 @@ impl Editor { let mut all_folded_excerpt_ids = Vec::new(); for buffer_id in &ids_to_fold { let folded_excerpts = self.buffer().read(cx).excerpts_for_buffer(*buffer_id, cx); - all_folded_excerpt_ids.extend(folded_excerpts.into_iter().map(|(id, _)| id)); + all_folded_excerpt_ids.extend(folded_excerpts.into_iter().map(|(id, _, _)| id)); } self.display_map.update(cx, |display_map, cx| { @@ -20569,7 +20570,7 @@ impl Editor { display_map.unfold_buffers([buffer_id], cx); }); cx.emit(EditorEvent::BufferFoldToggled { - ids: unfolded_excerpts.iter().map(|&(id, _)| id).collect(), + ids: unfolded_excerpts.iter().map(|&(id, _, _)| id).collect(), folded: false, }); cx.notify(); @@ -22941,7 +22942,7 @@ impl Editor { .snapshot(); let mut handled = false; - for (id, ExcerptRange { context, .. }) in + for (id, _, ExcerptRange { context, .. }) in self.buffer.read(cx).excerpts_for_buffer(buffer_id, cx) { if context.start.cmp(&position, &snapshot).is_ge() diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index cff98f474487b52e55ab3f53bff250de24cf2d80..b3511915be42ae9816bfb8fece19d28a2a6ca6e3 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -1165,8 +1165,8 @@ impl SplittableEditor { let lhs_ranges: Vec> = rhs_multibuffer .excerpts_for_buffer(main_buffer_snapshot.remote_id(), cx) .into_iter() - .filter(|(id, _)| rhs_excerpt_ids.contains(id)) - .map(|(_, excerpt_range)| { + .filter(|(id, _, _)| rhs_excerpt_ids.contains(id)) + .map(|(_, _, excerpt_range)| { let to_base_text = |range: Range| { let start = diff_snapshot .buffer_point_to_base_text_range( diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 82571b541e692141f843a4c3ef6e082c72e55e48..67b39618eaaaa2f7704e100d98621f53b725ff43 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -182,7 +182,7 @@ fn conflicts_updated( let excerpts = multibuffer.excerpts_for_buffer(buffer_id, cx); let Some(buffer_snapshot) = excerpts .first() - .and_then(|(excerpt_id, _)| snapshot.buffer_for_excerpt(*excerpt_id)) + .and_then(|(excerpt_id, _, _)| snapshot.buffer_for_excerpt(*excerpt_id)) else { return; }; @@ -221,7 +221,7 @@ fn conflicts_updated( let mut removed_highlighted_ranges = Vec::new(); let mut removed_block_ids = HashSet::default(); for (conflict_range, block_id) in old_conflicts { - let Some((excerpt_id, _)) = excerpts.iter().find(|(_, range)| { + let Some((excerpt_id, _, _)) = excerpts.iter().find(|(_, _, range)| { let precedes_start = range .context .start @@ -263,7 +263,7 @@ fn conflicts_updated( let new_conflicts = &conflict_set.conflicts[event.new_range.clone()]; let mut blocks = Vec::new(); for conflict in new_conflicts { - let Some((excerpt_id, _)) = excerpts.iter().find(|(_, range)| { + let Some((excerpt_id, _, _)) = excerpts.iter().find(|(_, _, range)| { let precedes_start = range .context .start diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index 662bf2a98d84ba434da98aeca71791c028f6018c..79c4e54700ccec7575c825ecae6a1bb05419b6fb 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -94,7 +94,9 @@ impl GoToLine { .read(cx) .excerpts_for_buffer(snapshot.remote_id(), cx) .into_iter() - .map(move |(_, range)| text::ToPoint::to_point(&range.context.end, &snapshot).row) + .map(move |(_, _, range)| { + text::ToPoint::to_point(&range.context.end, &snapshot).row + }) .max() .unwrap_or(0); diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index ded0f596dcea2f6c992961906503adb6829e885f..49036abfec1cb3145ce72d2aabe7683e308f1ed0 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -246,7 +246,12 @@ impl StyledText { pub fn with_runs(mut self, runs: Vec) -> Self { let mut text = &**self.text; for run in &runs { - text = text.get(run.len..).expect("invalid text run"); + text = text.get(run.len..).unwrap_or_else(|| { + #[cfg(debug_assertions)] + panic!("invalid text run. Text: '{text}', run: {run:?}"); + #[cfg(not(debug_assertions))] + panic!("invalid text run"); + }); } assert!(text.is_empty(), "invalid text run"); self.runs = Some(runs); diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index c991fd9a5cbfe451b3f86ff016f8467395373564..32898f1515a0c457260a7a9c89ce17c9dddf8cd9 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -1987,7 +1987,7 @@ impl MultiBuffer { &self, buffer_id: BufferId, cx: &App, - ) -> Vec<(ExcerptId, ExcerptRange)> { + ) -> Vec<(ExcerptId, Arc, ExcerptRange)> { let mut excerpts = Vec::new(); let snapshot = self.read(cx); let mut cursor = snapshot.excerpts.cursor::>(()); @@ -1997,7 +1997,7 @@ impl MultiBuffer { if let Some(excerpt) = cursor.item() && excerpt.locator == *locator { - excerpts.push((excerpt.id, excerpt.range.clone())); + excerpts.push((excerpt.id, excerpt.buffer.clone(), excerpt.range.clone())); } } } @@ -2128,7 +2128,7 @@ impl MultiBuffer { ) -> Option { let mut found = None; let snapshot = buffer.read(cx).snapshot(); - for (excerpt_id, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) { + for (excerpt_id, _, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) { let start = range.context.start.to_point(&snapshot); let end = range.context.end.to_point(&snapshot); if start <= point && point < end { @@ -2157,7 +2157,7 @@ impl MultiBuffer { cx: &App, ) -> Option { let snapshot = buffer.read(cx).snapshot(); - for (excerpt_id, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) { + for (excerpt_id, _, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) { if range.context.start.cmp(&anchor, &snapshot).is_le() && range.context.end.cmp(&anchor, &snapshot).is_ge() { diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 7e27786a76a14783f54e42c73850a888e87a3ac7..41e475a554b99485a86ffb0d7147414f8b9ef46a 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -1285,7 +1285,7 @@ fn test_resolving_anchors_after_replacing_their_excerpts(cx: &mut App) { let mut ids = multibuffer .excerpts_for_buffer(buffer_2.read(cx).remote_id(), cx) .into_iter() - .map(|(id, _)| id); + .map(|(id, _, _)| id); (ids.next().unwrap(), ids.next().unwrap()) }); let snapshot_2 = multibuffer.read(cx).snapshot(cx); diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 445f63fa1cdc38cb358cf033cc49f404aa6e6d94..ec85fc14a2eefe280afd0d44ed92b4b8502f460c 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -1143,7 +1143,7 @@ impl OutlinePanel { .excerpts_for_buffer(buffer.read(cx).remote_id(), cx) }) .and_then(|excerpts| { - let (excerpt_id, excerpt_range) = excerpts.first()?; + let (excerpt_id, _, excerpt_range) = excerpts.first()?; multi_buffer_snapshot .anchor_in_excerpt(*excerpt_id, excerpt_range.context.start) }) From 850188fb4cf39fcc00c7f8eb6df77765e256701f Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Mon, 9 Mar 2026 15:58:02 +0000 Subject: [PATCH 075/219] workspace: Include threads in matched workspaces (#51114) --- crates/sidebar/src/sidebar.rs | 45 +++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 5b38afcd5ef3a576388996958b821f426922d322..4b56efb81a90f30ab75cb567ab07e28deef424a2 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -530,6 +530,11 @@ impl Sidebar { if !query.is_empty() { let has_threads = !threads.is_empty(); + + let workspace_highlight_positions = + fuzzy_match_positions(&query, &label).unwrap_or_default(); + let workspace_matched = !workspace_highlight_positions.is_empty(); + let mut matched_threads = Vec::new(); for mut thread in threads { if let ListEntry::Thread { @@ -545,15 +550,14 @@ impl Sidebar { .unwrap_or(""); if let Some(positions) = fuzzy_match_positions(&query, title) { *highlight_positions = positions; + } + if workspace_matched || !highlight_positions.is_empty() { matched_threads.push(thread); } } } - let workspace_highlight_positions = - fuzzy_match_positions(&query, &label).unwrap_or_default(); - - if matched_threads.is_empty() && workspace_highlight_positions.is_empty() { + if matched_threads.is_empty() && !workspace_matched { continue; } @@ -743,6 +747,7 @@ impl Sidebar { if is_group_header_after_first { v_flex() .w_full() + .pt_2() .border_t_1() .border_color(cx.theme().colors().border_variant) .child(rendered) @@ -2906,12 +2911,16 @@ mod tests { vec!["v [Empty Workspace]", " Fix typo in README <== selected",] ); - // "project-a" matches the first workspace name — the header appears alone - // without any child threads (none of them match "project-a"). + // "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] <== selected"] + vec![ + "v [project-a]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] ); } @@ -2971,11 +2980,15 @@ mod tests { cx.run_until_parked(); // "alpha" matches the workspace name "alpha-project" but no thread titles. - // The workspace header should appear with no child threads. + // 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] <== selected"] + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] ); // "sidebar" matches thread titles in both workspaces but not workspace names. @@ -3007,11 +3020,15 @@ mod tests { ); // A query that matches a workspace name AND a thread in that same workspace. - // Both the header (highlighted) and the matching thread should appear. + // 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] <== selected"] + 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 @@ -3020,7 +3037,11 @@ mod tests { type_in_search(&sidebar, "alp", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project] <== selected"] + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] ); } From e0b1f8a525e6c88bcf7f52f66e1b2b40e8b97c7c Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Mon, 9 Mar 2026 17:02:09 +0100 Subject: [PATCH 076/219] zed: Read ZED_COMMIT_SHA from env var when building (#51115) Quality-of-life improvement for us Nix users - Zed built via `nix build` will now correctly the git commit sha in its version image Release Notes: - N/A --- crates/zed/build.rs | 26 +++++++++++++++++++++----- nix/build.nix | 7 ++++++- nix/toolchain.nix | 1 + 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/zed/build.rs b/crates/zed/build.rs index e169760acf16d6caa44aeb2004cd823a355f36ee..9b9ed59bf4de65220f36c1fd53421fdf44c1e529 100644 --- a/crates/zed/build.rs +++ b/crates/zed/build.rs @@ -43,12 +43,28 @@ fn main() { "cargo:rustc-env=TARGET={}", std::env::var("TARGET").unwrap() ); - if let Ok(output) = Command::new("git").args(["rev-parse", "HEAD"]).output() - && output.status.success() - { - let git_sha = String::from_utf8_lossy(&output.stdout); - let git_sha = git_sha.trim(); + let git_sha = match std::env::var("ZED_COMMIT_SHA").ok() { + Some(git_sha) => { + // In deterministic build environments such as Nix, we inject the commit sha into the build script. + Some(git_sha) + } + None => { + if let Some(output) = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + && output.status.success() + { + let git_sha = String::from_utf8_lossy(&output.stdout); + Some(git_sha.trim().to_string()) + } else { + None + } + } + }; + + if let Some(git_sha) = git_sha { println!("cargo:rustc-env=ZED_COMMIT_SHA={git_sha}"); if let Some(build_identifier) = option_env!("GITHUB_RUN_NUMBER") { diff --git a/nix/build.nix b/nix/build.nix index 68f8a4acdbe83f7e8981659dd0376ec87ef52dfe..d96a7e51ca08d23572b01f0c387d6ef9e4f2dd70 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -52,6 +52,7 @@ withGLES ? false, profile ? "release", + commitSha ? null, }: assert withGLES -> stdenv.hostPlatform.isLinux; let @@ -84,7 +85,10 @@ let in rec { pname = "zed-editor"; - version = zedCargoLock.package.version + "-nightly"; + version = + zedCargoLock.package.version + + "-nightly" + + lib.optionalString (commitSha != null) "+${builtins.substring 0 7 commitSha}"; src = builtins.path { path = ../.; filter = mkIncludeFilter ../.; @@ -220,6 +224,7 @@ let }; ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled."; RELEASE_VERSION = version; + ZED_COMMIT_SHA = commitSha; LK_CUSTOM_WEBRTC = pkgs.callPackage ./livekit-libwebrtc/package.nix { }; PROTOC = "${protobuf}/bin/protoc"; diff --git a/nix/toolchain.nix b/nix/toolchain.nix index 6ef22e2a6b06882940c553b2a774f4c6f73e9ea0..2e32f00f6b56570ab9863ab0b5975e603b68f5fa 100644 --- a/nix/toolchain.nix +++ b/nix/toolchain.nix @@ -6,4 +6,5 @@ in pkgs.callPackage ./build.nix { crane = inputs.crane.mkLib pkgs; rustToolchain = rustBin.fromRustupToolchainFile ../rust-toolchain.toml; + commitSha = inputs.self.rev or null; } From 7cd0c5d72d46bb447518e9f2d04e82530bea9744 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Mon, 9 Mar 2026 16:16:09 +0000 Subject: [PATCH 077/219] agent: Fix inline assistant keymap in agent panel (#51117) Fixes a bug that causes the new large agent panel message editor overrides the ctrl-enter keyboard shortcut to trigger the inline assistant, rather than sending a message Release Notes: - N/A *or* Added/Fixed/Improved ... --- assets/keymaps/default-linux.json | 2 +- assets/keymaps/default-macos.json | 2 +- assets/keymaps/default-windows.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 55903cdd1532a4b8a1f5a28b97b650367cd44603..cb5cef24c50f9f9ac637f3ac70adb24d37e56d61 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -819,7 +819,7 @@ }, }, { - "context": "!ContextEditor > Editor && mode == full", + "context": "!ContextEditor && !AcpThread > Editor && mode == full", "bindings": { "alt-enter": "editor::OpenExcerpts", "shift-enter": "editor::ExpandExcerpts", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index f023c0dee408d58e50853e5d1ad27637c870bbb4..08fb63868be875f41f6c461354b46f1081a2026f 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -882,7 +882,7 @@ }, }, { - "context": "!ContextEditor > Editor && mode == full", + "context": "!ContextEditor && !AcpThread > Editor && mode == full", "use_key_equivalents": true, "bindings": { "alt-enter": "editor::OpenExcerpts", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 83fda88f398aba1d72d2c93bbe77239dbbad360b..600025e2069978f3020afb5cb978d05a53317682 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -821,7 +821,7 @@ }, }, { - "context": "!ContextEditor > Editor && mode == full", + "context": "!ContextEditor && !AcpThread > Editor && mode == full", "use_key_equivalents": true, "bindings": { "alt-enter": "editor::OpenExcerpts", From 429f4587a6d7722e26d309c2df9cfcade12a6396 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 9 Mar 2026 18:04:50 +0100 Subject: [PATCH 078/219] zed: Fix file logging being disabled accidentally (#51121) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/zed/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 3bf3fd190f61ffead59d08d4da556468e2bb1fcf..f98d51061630fefba33f7703eac68670cde67502 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -276,7 +276,7 @@ fn main() { zlog::init(); - if true { + if stdout_is_a_pty() { zlog::init_output_stdout(); } else { let result = zlog::init_output_file(paths::log_file(), Some(paths::old_log_file())); From ef08470d2fc7543ddbf5f5a4347b1f986797439c Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 9 Mar 2026 18:08:33 +0100 Subject: [PATCH 079/219] Remove unused `rich_text` crate (#50950) --- Cargo.lock | 14 - Cargo.toml | 1 - crates/rich_text/Cargo.toml | 29 --- crates/rich_text/LICENSE-GPL | 1 - crates/rich_text/src/rich_text.rs | 418 ------------------------------ 5 files changed, 463 deletions(-) delete mode 100644 crates/rich_text/Cargo.toml delete mode 120000 crates/rich_text/LICENSE-GPL delete mode 100644 crates/rich_text/src/rich_text.rs diff --git a/Cargo.lock b/Cargo.lock index ed028d2de80dcd05487f2621102d8b3e8de8512d..c549c3b6bfd932bfbec26cebfac3ede79df4d256 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14477,20 +14477,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "rich_text" -version = "0.1.0" -dependencies = [ - "futures 0.3.31", - "gpui", - "language", - "linkify", - "pulldown-cmark 0.13.0", - "theme", - "ui", - "util", -] - [[package]] name = "ring" version = "0.17.14" diff --git a/Cargo.toml b/Cargo.toml index 9541d9e45b17f5ea92029082ab715a3c068067ac..b6760fa917da7e051fd60a1375be49d516fcf113 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -159,7 +159,6 @@ members = [ "crates/remote_server", "crates/repl", "crates/reqwest_client", - "crates/rich_text", "crates/rope", "crates/rpc", "crates/rules_library", diff --git a/crates/rich_text/Cargo.toml b/crates/rich_text/Cargo.toml deleted file mode 100644 index 17bd8d2a4b8977b2bf0079b84dc8f27a9999974b..0000000000000000000000000000000000000000 --- a/crates/rich_text/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "rich_text" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/rich_text.rs" -doctest = false - -[features] -test-support = [ - "gpui/test-support", - "util/test-support", -] - -[dependencies] -futures.workspace = true -gpui.workspace = true -language.workspace = true -linkify.workspace = true -pulldown-cmark.workspace = true -theme.workspace = true -ui.workspace = true -util.workspace = true diff --git a/crates/rich_text/LICENSE-GPL b/crates/rich_text/LICENSE-GPL deleted file mode 120000 index 89e542f750cd3860a0598eff0dc34b56d7336dc4..0000000000000000000000000000000000000000 --- a/crates/rich_text/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/rich_text/src/rich_text.rs b/crates/rich_text/src/rich_text.rs deleted file mode 100644 index 2af9988f032c5dc9651e1da6e8c3b52c6c668866..0000000000000000000000000000000000000000 --- a/crates/rich_text/src/rich_text.rs +++ /dev/null @@ -1,418 +0,0 @@ -use futures::FutureExt; -use gpui::{ - AnyElement, AnyView, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText, - IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window, -}; -use language::{HighlightId, Language, LanguageRegistry}; -use std::{ops::Range, sync::Arc}; -use theme::ActiveTheme; -use ui::LinkPreview; -use util::RangeExt; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Highlight { - Code, - Id(HighlightId), - InlineCode(bool), - Highlight(HighlightStyle), - Mention, - SelfMention, -} - -impl From for Highlight { - fn from(style: HighlightStyle) -> Self { - Self::Highlight(style) - } -} - -impl From for Highlight { - fn from(style: HighlightId) -> Self { - Self::Id(style) - } -} - -#[derive(Clone, Default)] -pub struct RichText { - pub text: SharedString, - pub highlights: Vec<(Range, Highlight)>, - pub link_ranges: Vec>, - pub link_urls: Arc<[String]>, - - pub custom_ranges: Vec>, - custom_ranges_tooltip_fn: - Option, &mut Window, &mut App) -> Option>>, -} - -/// Allows one to specify extra links to the rendered markdown, which can be used -/// for e.g. mentions. -#[derive(Debug)] -pub struct Mention { - pub range: Range, - pub is_self_mention: bool, -} - -impl RichText { - pub fn new( - block: String, - mentions: &[Mention], - language_registry: &Arc, - ) -> Self { - let mut text = String::new(); - let mut highlights = Vec::new(); - let mut link_ranges = Vec::new(); - let mut link_urls = Vec::new(); - render_markdown_mut( - &block, - mentions, - language_registry, - None, - &mut text, - &mut highlights, - &mut link_ranges, - &mut link_urls, - ); - text.truncate(text.trim_end().len()); - - RichText { - text: SharedString::from(text), - link_urls: link_urls.into(), - link_ranges, - highlights, - custom_ranges: Vec::new(), - custom_ranges_tooltip_fn: None, - } - } - - pub fn set_tooltip_builder_for_custom_ranges( - &mut self, - f: impl Fn(usize, Range, &mut Window, &mut App) -> Option + 'static, - ) { - self.custom_ranges_tooltip_fn = Some(Arc::new(f)); - } - - pub fn element(&self, id: ElementId, window: &mut Window, cx: &mut App) -> AnyElement { - let theme = cx.theme(); - let code_background = theme.colors().surface_background; - - InteractiveText::new( - id, - StyledText::new(self.text.clone()).with_default_highlights( - &window.text_style(), - self.highlights.iter().map(|(range, highlight)| { - ( - range.clone(), - match highlight { - Highlight::Code => HighlightStyle { - background_color: Some(code_background), - ..Default::default() - }, - Highlight::Id(id) => HighlightStyle { - background_color: Some(code_background), - ..id.style(theme.syntax()).unwrap_or_default() - }, - Highlight::InlineCode(link) => { - if *link { - HighlightStyle { - background_color: Some(code_background), - underline: Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }), - ..Default::default() - } - } else { - HighlightStyle { - background_color: Some(code_background), - ..Default::default() - } - } - } - Highlight::Highlight(highlight) => *highlight, - Highlight::Mention => HighlightStyle { - font_weight: Some(FontWeight::BOLD), - ..Default::default() - }, - Highlight::SelfMention => HighlightStyle { - font_weight: Some(FontWeight::BOLD), - ..Default::default() - }, - }, - ) - }), - ), - ) - .on_click(self.link_ranges.clone(), { - let link_urls = self.link_urls.clone(); - move |ix, _, cx| { - let url = &link_urls[ix]; - if url.starts_with("http") { - cx.open_url(url); - } - } - }) - .tooltip({ - let link_ranges = self.link_ranges.clone(); - let link_urls = self.link_urls.clone(); - let custom_tooltip_ranges = self.custom_ranges.clone(); - let custom_tooltip_fn = self.custom_ranges_tooltip_fn.clone(); - move |idx, window, cx| { - for (ix, range) in link_ranges.iter().enumerate() { - if range.contains(&idx) { - return Some(LinkPreview::new(&link_urls[ix], cx)); - } - } - for range in &custom_tooltip_ranges { - if range.contains(&idx) - && let Some(f) = &custom_tooltip_fn - { - return f(idx, range.clone(), window, cx); - } - } - None - } - }) - .into_any_element() - } -} - -pub fn render_markdown_mut( - block: &str, - mut mentions: &[Mention], - language_registry: &Arc, - language: Option<&Arc>, - text: &mut String, - highlights: &mut Vec<(Range, Highlight)>, - link_ranges: &mut Vec>, - link_urls: &mut Vec, -) { - use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; - - let mut bold_depth = 0; - let mut italic_depth = 0; - let mut strikethrough_depth = 0; - let mut link_url = None; - let mut current_language = None; - let mut list_stack = Vec::new(); - - let mut options = Options::all(); - options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST); - - for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() { - let prev_len = text.len(); - match event { - Event::Text(t) => { - if let Some(language) = ¤t_language { - render_code(text, highlights, t.as_ref(), language); - } else { - while let Some(mention) = mentions.first() { - if !source_range.contains_inclusive(&mention.range) { - break; - } - mentions = &mentions[1..]; - let range = (prev_len + mention.range.start - source_range.start) - ..(prev_len + mention.range.end - source_range.start); - highlights.push(( - range.clone(), - if mention.is_self_mention { - Highlight::SelfMention - } else { - Highlight::Mention - }, - )); - } - - text.push_str(t.as_ref()); - let mut style = HighlightStyle::default(); - if bold_depth > 0 { - style.font_weight = Some(FontWeight::BOLD); - } - if italic_depth > 0 { - style.font_style = Some(FontStyle::Italic); - } - if strikethrough_depth > 0 { - style.strikethrough = Some(StrikethroughStyle { - thickness: 1.0.into(), - ..Default::default() - }); - } - let last_run_len = if let Some(link_url) = link_url.clone() { - link_ranges.push(prev_len..text.len()); - link_urls.push(link_url); - style.underline = Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }); - prev_len - } else { - // Manually scan for links - let mut finder = linkify::LinkFinder::new(); - finder.kinds(&[linkify::LinkKind::Url]); - let mut last_link_len = prev_len; - for link in finder.links(&t) { - let start = link.start(); - let end = link.end(); - let range = (prev_len + start)..(prev_len + end); - link_ranges.push(range.clone()); - link_urls.push(link.as_str().to_string()); - - // If there is a style before we match a link, we have to add this to the highlighted ranges - if style != HighlightStyle::default() && last_link_len < link.start() { - highlights.push(( - last_link_len..link.start(), - Highlight::Highlight(style), - )); - } - - highlights.push(( - range, - Highlight::Highlight(HighlightStyle { - underline: Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }), - ..style - }), - )); - - last_link_len = end; - } - last_link_len - }; - - if style != HighlightStyle::default() && last_run_len < text.len() { - let mut new_highlight = true; - if let Some((last_range, last_style)) = highlights.last_mut() - && last_range.end == last_run_len - && last_style == &Highlight::Highlight(style) - { - last_range.end = text.len(); - new_highlight = false; - } - if new_highlight { - highlights - .push((last_run_len..text.len(), Highlight::Highlight(style))); - } - } - } - } - Event::Code(t) => { - text.push_str(t.as_ref()); - let is_link = link_url.is_some(); - - if let Some(link_url) = link_url.clone() { - link_ranges.push(prev_len..text.len()); - link_urls.push(link_url); - } - - highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link))) - } - Event::Start(tag) => match tag { - Tag::Paragraph => new_paragraph(text, &mut list_stack), - Tag::Heading { .. } => { - new_paragraph(text, &mut list_stack); - bold_depth += 1; - } - Tag::CodeBlock(kind) => { - new_paragraph(text, &mut list_stack); - current_language = if let CodeBlockKind::Fenced(language) = kind { - language_registry - .language_for_name(language.as_ref()) - .now_or_never() - .and_then(Result::ok) - } else { - language.cloned() - } - } - Tag::Emphasis => italic_depth += 1, - Tag::Strong => bold_depth += 1, - Tag::Strikethrough => strikethrough_depth += 1, - Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()), - Tag::List(number) => { - list_stack.push((number, false)); - } - Tag::Item => { - let len = list_stack.len(); - if let Some((list_number, has_content)) = list_stack.last_mut() { - *has_content = false; - if !text.is_empty() && !text.ends_with('\n') { - text.push('\n'); - } - for _ in 0..len - 1 { - text.push_str(" "); - } - if let Some(number) = list_number { - text.push_str(&format!("{}. ", number)); - *number += 1; - *has_content = false; - } else { - text.push_str("- "); - } - } - } - _ => {} - }, - Event::End(tag) => match tag { - TagEnd::Heading(_) => bold_depth -= 1, - TagEnd::CodeBlock => current_language = None, - TagEnd::Emphasis => italic_depth -= 1, - TagEnd::Strong => bold_depth -= 1, - TagEnd::Strikethrough => strikethrough_depth -= 1, - TagEnd::Link => link_url = None, - TagEnd::List(_) => drop(list_stack.pop()), - _ => {} - }, - Event::HardBreak => text.push('\n'), - Event::SoftBreak => text.push('\n'), - _ => {} - } - } -} - -pub fn render_code( - text: &mut String, - highlights: &mut Vec<(Range, Highlight)>, - content: &str, - language: &Arc, -) { - let prev_len = text.len(); - text.push_str(content); - let mut offset = 0; - for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) { - if range.start > offset { - highlights.push((prev_len + offset..prev_len + range.start, Highlight::Code)); - } - highlights.push(( - prev_len + range.start..prev_len + range.end, - Highlight::Id(highlight_id), - )); - offset = range.end; - } - if offset < content.len() { - highlights.push((prev_len + offset..prev_len + content.len(), Highlight::Code)); - } -} - -pub fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option, bool)>) { - let mut is_subsequent_paragraph_of_list = false; - if let Some((_, has_content)) = list_stack.last_mut() { - if *has_content { - is_subsequent_paragraph_of_list = true; - } else { - *has_content = true; - return; - } - } - - if !text.is_empty() { - if !text.ends_with('\n') { - text.push('\n'); - } - text.push('\n'); - } - for _ in 0..list_stack.len().saturating_sub(1) { - text.push_str(" "); - } - if is_subsequent_paragraph_of_list { - text.push_str(" "); - } -} From aa5c1ff84e9f7e8920dee5750c1f1e2b24d29cf3 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 9 Mar 2026 10:21:35 -0700 Subject: [PATCH 080/219] Optimize update_entries (#51122) 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: - N/A --- crates/agent/src/thread_store.rs | 32 ++- crates/sidebar/src/sidebar.rs | 370 ++++++++++++++----------------- 2 files changed, 189 insertions(+), 213 deletions(-) diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index 961be1da4c09890691adbd5448d7678b2808fe7b..dd1f650de2f59a0e681e15e7eae3fad1a49ccc41 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -2,6 +2,7 @@ use crate::{DbThread, DbThreadMetadata, ThreadsDatabase}; use agent_client_protocol as acp; use anyhow::{Result, anyhow}; use gpui::{App, Context, Entity, Global, Task, prelude::*}; +use std::collections::HashMap; use util::path_list::PathList; struct GlobalThreadStore(Entity); @@ -10,6 +11,7 @@ impl Global for GlobalThreadStore {} pub struct ThreadStore { threads: Vec, + threads_by_paths: HashMap>, } impl ThreadStore { @@ -29,6 +31,7 @@ impl ThreadStore { pub fn new(cx: &mut Context) -> Self { let this = Self { threads: Vec::new(), + threads_by_paths: HashMap::default(), }; this.reload(cx); this @@ -91,14 +94,21 @@ impl ThreadStore { let database_connection = ThreadsDatabase::connect(cx); cx.spawn(async move |this, cx| { let database = database_connection.await.map_err(|err| anyhow!(err))?; - let threads = database - .list_threads() - .await? - .into_iter() - .filter(|thread| thread.parent_session_id.is_none()) - .collect::>(); + let all_threads = database.list_threads().await?; this.update(cx, |this, cx| { - this.threads = threads; + this.threads.clear(); + this.threads_by_paths.clear(); + for thread in all_threads { + if thread.parent_session_id.is_some() { + continue; + } + let index = this.threads.len(); + this.threads_by_paths + .entry(thread.folder_paths.clone()) + .or_default() + .push(index); + this.threads.push(thread); + } cx.notify(); }) }) @@ -114,10 +124,12 @@ impl ThreadStore { } /// Returns threads whose folder_paths match the given paths exactly. + /// Uses a cached index for O(1) lookup per path list. pub fn threads_for_paths(&self, paths: &PathList) -> impl Iterator { - self.threads - .iter() - .filter(move |thread| &thread.folder_paths == paths) + self.threads_by_paths + .get(paths) + .into_iter() + .flat_map(|indices| indices.iter().map(|&index| &self.threads[index])) } } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 4b56efb81a90f30ab75cb567ab07e28deef424a2..1e50a75e2841fb471b2d630b71c2df59200c5bea 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -65,8 +65,19 @@ impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo { } } -#[derive(Clone, Debug)] -#[allow(dead_code)] +#[derive(Clone)] +struct ThreadEntry { + session_info: acp_thread::AgentSessionInfo, + icon: IconName, + icon_from_external_svg: Option, + status: AgentThreadStatus, + workspace: Entity, + is_live: bool, + is_background: bool, + highlight_positions: Vec, +} + +#[derive(Clone)] enum ListEntry { ProjectHeader { path_list: PathList, @@ -75,17 +86,7 @@ enum ListEntry { highlight_positions: Vec, has_threads: bool, }, - Thread { - session_info: acp_thread::AgentSessionInfo, - icon: IconName, - icon_from_external_svg: Option, - status: AgentThreadStatus, - diff_stats: Option<(usize, usize)>, - workspace: Entity, - is_live: bool, - is_background: bool, - highlight_positions: Vec, - }, + Thread(ThreadEntry), ViewMore { path_list: PathList, remaining_count: usize, @@ -97,6 +98,12 @@ enum ListEntry { }, } +impl From for ListEntry { + fn from(thread: ThreadEntry) -> Self { + ListEntry::Thread(thread) + } +} + #[derive(Default)] struct SidebarContents { entries: Vec, @@ -227,7 +234,7 @@ impl Sidebar { .contents .entries .iter() - .position(|entry| matches!(entry, ListEntry::Thread { .. })) + .position(|entry| matches!(entry, ListEntry::Thread(_))) .or_else(|| { if this.contents.entries.is_empty() { None @@ -416,18 +423,20 @@ impl Sidebar { .entries .iter() .filter_map(|entry| match entry { - ListEntry::Thread { - session_info, - status, - is_live: true, - .. - } => Some((session_info.session_id.clone(), *status)), + ListEntry::Thread(thread) if thread.is_live => { + Some((thread.session_info.session_id.clone(), thread.status)) + } _ => None, }) .collect(); let mut entries = Vec::new(); let mut notified_threads = previous.notified_threads; + // Track all session IDs we add to entries so we can prune stale + // notifications without a separate pass at the end. + let mut current_session_ids: HashSet = HashSet::new(); + // Compute active_entry_index inline during the build pass. + let mut active_entry_index: Option = None; for workspace in workspaces.iter() { let (path_list, label) = workspace_path_list_and_label(workspace, cx); @@ -435,17 +444,16 @@ impl Sidebar { let is_collapsed = self.collapsed_groups.contains(&path_list); let should_load_threads = !is_collapsed || !query.is_empty(); - let mut threads: Vec = Vec::new(); + let mut threads: Vec = Vec::new(); if should_load_threads { if let Some(ref thread_store) = thread_store { for meta in thread_store.read(cx).threads_for_paths(&path_list) { - threads.push(ListEntry::Thread { + threads.push(ThreadEntry { session_info: meta.into(), icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::default(), - diff_stats: None, workspace: workspace.clone(), is_live: false, is_background: false, @@ -456,76 +464,50 @@ impl Sidebar { let live_infos = Self::all_thread_infos_for_workspace(workspace, cx); - for info in &live_infos { - let Some(existing) = threads.iter_mut().find(|t| { - matches!(t, ListEntry::Thread { session_info, .. } if session_info.session_id == info.session_id) - }) else { - continue; - }; - - if let ListEntry::Thread { - session_info, - status, - icon, - icon_from_external_svg, - workspace: _, - is_live, - is_background, - .. - } = existing - { - session_info.title = Some(info.title.clone()); - *status = info.status; - *icon = info.icon; - *icon_from_external_svg = info.icon_from_external_svg.clone(); - *is_live = true; - *is_background = info.is_background; + if !live_infos.is_empty() { + let thread_index_by_session: HashMap = threads + .iter() + .enumerate() + .map(|(i, t)| (t.session_info.session_id.clone(), i)) + .collect(); + + for info in &live_infos { + let Some(&idx) = thread_index_by_session.get(&info.session_id) else { + continue; + }; + + let thread = &mut threads[idx]; + 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; } } - // Update notification state for live threads. + // Update notification state for live threads in the same pass. + let is_active_workspace = active_workspace + .as_ref() + .is_some_and(|active| active == workspace); + for thread in &threads { - if let ListEntry::Thread { - workspace: thread_workspace, - session_info, - status, - is_background, - .. - } = thread + let session_id = &thread.session_info.session_id; + if thread.is_background && thread.status == AgentThreadStatus::Completed { + notified_threads.insert(session_id.clone()); + } else if thread.status == AgentThreadStatus::Completed + && !is_active_workspace + && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running) { - let session_id = &session_info.session_id; - if *is_background && *status == AgentThreadStatus::Completed { - notified_threads.insert(session_id.clone()); - } else if *status == AgentThreadStatus::Completed - && active_workspace - .as_ref() - .is_none_or(|active| active != thread_workspace) - && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running) - { - notified_threads.insert(session_id.clone()); - } + notified_threads.insert(session_id.clone()); + } - if active_workspace - .as_ref() - .is_some_and(|active| active == thread_workspace) - && !*is_background - { - notified_threads.remove(session_id); - } + if is_active_workspace && !thread.is_background { + notified_threads.remove(session_id); } } - threads.sort_by(|a, b| { - let a_time = match a { - ListEntry::Thread { session_info, .. } => session_info.updated_at, - _ => unreachable!(), - }; - let b_time = match b { - ListEntry::Thread { session_info, .. } => session_info.updated_at, - _ => unreachable!(), - }; - b_time.cmp(&a_time) - }); + threads.sort_by(|a, b| b.session_info.updated_at.cmp(&a.session_info.updated_at)); } if !query.is_empty() { @@ -535,25 +517,19 @@ impl Sidebar { fuzzy_match_positions(&query, &label).unwrap_or_default(); let workspace_matched = !workspace_highlight_positions.is_empty(); - let mut matched_threads = Vec::new(); + let mut matched_threads: Vec = Vec::new(); for mut thread in threads { - if let ListEntry::Thread { - session_info, - highlight_positions, - .. - } = &mut thread - { - let title = session_info - .title - .as_ref() - .map(|s| s.as_ref()) - .unwrap_or(""); - if let Some(positions) = fuzzy_match_positions(&query, title) { - *highlight_positions = positions; - } - if workspace_matched || !highlight_positions.is_empty() { - matched_threads.push(thread); - } + let title = thread + .session_info + .title + .as_ref() + .map(|s| s.as_ref()) + .unwrap_or(""); + if let Some(positions) = fuzzy_match_positions(&query, title) { + thread.highlight_positions = positions; + } + if workspace_matched || !thread.highlight_positions.is_empty() { + matched_threads.push(thread); } } @@ -561,6 +537,15 @@ impl Sidebar { continue; } + if active_entry_index.is_none() + && self.focused_thread.is_none() + && active_workspace + .as_ref() + .is_some_and(|active| active == workspace) + { + active_entry_index = Some(entries.len()); + } + entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, @@ -568,9 +553,33 @@ impl Sidebar { highlight_positions: workspace_highlight_positions, has_threads, }); - entries.extend(matched_threads); + + // Track session IDs and compute active_entry_index as we add + // thread entries. + for thread in matched_threads { + current_session_ids.insert(thread.session_info.session_id.clone()); + if active_entry_index.is_none() { + if let Some(focused) = &self.focused_thread { + if &thread.session_info.session_id == focused { + active_entry_index = Some(entries.len()); + } + } + } + entries.push(thread.into()); + } } else { let has_threads = !threads.is_empty(); + + // Check if this header is the active entry before pushing it. + if active_entry_index.is_none() + && self.focused_thread.is_none() + && active_workspace + .as_ref() + .is_some_and(|active| active == workspace) + { + active_entry_index = Some(entries.len()); + } + entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, @@ -591,7 +600,19 @@ impl Sidebar { let count = threads_to_show.min(total); let is_fully_expanded = count >= total; - entries.extend(threads.into_iter().take(count)); + // Track session IDs and compute active_entry_index as we add + // thread entries. + for thread in threads.into_iter().take(count) { + current_session_ids.insert(thread.session_info.session_id.clone()); + if active_entry_index.is_none() { + if let Some(focused) = &self.focused_thread { + if &thread.session_info.session_id == focused { + active_entry_index = Some(entries.len()); + } + } + } + entries.push(thread.into()); + } if total > DEFAULT_THREADS_SHOWN { entries.push(ListEntry::ViewMore { @@ -610,16 +631,11 @@ impl Sidebar { } } - // Prune stale entries from notified_threads. - let current_session_ids: HashSet<&acp::SessionId> = entries - .iter() - .filter_map(|e| match e { - ListEntry::Thread { session_info, .. } => Some(&session_info.session_id), - _ => None, - }) - .collect(); + // Prune stale notifications using the session IDs we collected during + // the build pass (no extra scan needed). notified_threads.retain(|id| current_session_ids.contains(id)); + self.active_entry_index = active_entry_index; self.contents = SidebarContents { entries, notified_threads, @@ -639,7 +655,6 @@ impl Sidebar { let scroll_position = self.list_state.logical_scroll_top(); self.rebuild_contents(cx); - self.recompute_active_entry_index(cx); self.list_state.reset(self.contents.entries.len()); self.list_state.scroll_to(scroll_position); @@ -653,24 +668,6 @@ impl Sidebar { cx.notify(); } - fn recompute_active_entry_index(&mut self, cx: &App) { - self.active_entry_index = if let Some(session_id) = &self.focused_thread { - self.contents.entries.iter().position(|entry| { - matches!(entry, ListEntry::Thread { session_info, .. } if &session_info.session_id == session_id) - }) - } else { - let active_workspace = self - .multi_workspace - .upgrade() - .map(|mw| mw.read(cx).workspace().clone()); - active_workspace.and_then(|active| { - self.contents.entries.iter().position(|entry| { - matches!(entry, ListEntry::ProjectHeader { workspace, .. } if workspace == &active) - }) - }) - }; - } - fn render_list_entry( &mut self, ix: usize, @@ -705,25 +702,7 @@ impl Sidebar { is_selected, cx, ), - ListEntry::Thread { - session_info, - icon, - icon_from_external_svg, - status, - workspace, - highlight_positions, - .. - } => self.render_thread( - ix, - session_info, - *icon, - icon_from_external_svg.clone(), - *status, - workspace, - highlight_positions, - is_selected, - cx, - ), + ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx), ListEntry::ViewMore { path_list, remaining_count, @@ -975,8 +954,8 @@ impl Sidebar { }) } - fn filter_query(&self, cx: &App) -> String { - self.filter_editor.read(cx).text(cx) + fn has_filter_query(&self, cx: &App) -> bool { + self.filter_editor.read(cx).buffer().read(cx).is_empty() } fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { @@ -1041,13 +1020,9 @@ impl Sidebar { let workspace = workspace.clone(); self.activate_workspace(&workspace, window, cx); } - ListEntry::Thread { - session_info, - workspace, - .. - } => { - let session_info = session_info.clone(); - let workspace = workspace.clone(); + ListEntry::Thread(thread) => { + let session_info = thread.session_info.clone(); + let workspace = thread.workspace.clone(); self.activate_thread(session_info, &workspace, window, cx); } ListEntry::ViewMore { @@ -1144,7 +1119,7 @@ impl Sidebar { } } Some( - ListEntry::Thread { .. } | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. }, + ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. }, ) => { for i in (0..ix).rev() { if let Some(ListEntry::ProjectHeader { path_list, .. }) = @@ -1165,32 +1140,30 @@ impl Sidebar { fn render_thread( &self, ix: usize, - session_info: &acp_thread::AgentSessionInfo, - icon: IconName, - icon_from_external_svg: Option, - status: AgentThreadStatus, - workspace: &Entity, - highlight_positions: &[usize], + thread: &ThreadEntry, is_selected: bool, cx: &mut Context, ) -> AnyElement { - let has_notification = self.contents.is_thread_notified(&session_info.session_id); + let has_notification = self + .contents + .is_thread_notified(&thread.session_info.session_id); - let title: SharedString = session_info + let title: SharedString = thread + .session_info .title .clone() .unwrap_or_else(|| "Untitled".into()); - let session_info = session_info.clone(); - let workspace = workspace.clone(); + let session_info = thread.session_info.clone(); + let workspace = thread.workspace.clone(); let id = SharedString::from(format!("thread-entry-{}", ix)); ThreadItem::new(id, title) - .icon(icon) - .when_some(icon_from_external_svg, |this, svg| { + .icon(thread.icon) + .when_some(thread.icon_from_external_svg.clone(), |this, svg| { this.custom_icon_from_external_svg(svg) }) - .highlight_positions(highlight_positions.to_vec()) - .status(status) + .highlight_positions(thread.highlight_positions.to_vec()) + .status(thread.status) .notified(has_notification) .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) .focused(is_selected) @@ -1356,7 +1329,7 @@ impl Render for Sidebar { let ui_font = theme::setup_ui_font(window, cx); let is_focused = self.focus_handle.is_focused(window) || self.filter_editor.focus_handle(cx).is_focused(window); - let has_query = !self.filter_query(cx).is_empty(); + let has_query = self.has_filter_query(cx); let focus_tooltip_label = if is_focused { "Focus Workspace" @@ -1666,19 +1639,15 @@ mod tests { }; format!("{} [{}]{}", icon, label, selected) } - ListEntry::Thread { - session_info, - status, - is_live, - .. - } => { - let title = session_info + ListEntry::Thread(thread) => { + let title = thread + .session_info .title .as_ref() .map(|s| s.as_ref()) .unwrap_or("Untitled"); - let active = if *is_live { " *" } else { "" }; - let status_str = match status { + let active = if thread.is_live { " *" } else { "" }; + let status_str = match thread.status { AgentThreadStatus::Running => " (running)", AgentThreadStatus::Error => " (error)", AgentThreadStatus::WaitingForConfirmation => " (waiting)", @@ -1686,7 +1655,7 @@ mod tests { }; let notified = if sidebar .contents - .is_thread_notified(&session_info.session_id) + .is_thread_notified(&thread.session_info.session_id) { " (!)" } else { @@ -2007,7 +1976,7 @@ mod tests { has_threads: true, }, // Thread with default (Completed) status, not active - ListEntry::Thread { + ListEntry::Thread(ThreadEntry { session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-1")), cwd: None, @@ -2018,14 +1987,13 @@ mod tests { icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::Completed, - diff_stats: None, workspace: workspace.clone(), is_live: false, is_background: false, highlight_positions: Vec::new(), - }, + }), // Active thread with Running status - ListEntry::Thread { + ListEntry::Thread(ThreadEntry { session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-2")), cwd: None, @@ -2036,14 +2004,13 @@ mod tests { icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::Running, - diff_stats: None, workspace: workspace.clone(), is_live: true, is_background: false, highlight_positions: Vec::new(), - }, + }), // Active thread with Error status - ListEntry::Thread { + ListEntry::Thread(ThreadEntry { session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-3")), cwd: None, @@ -2054,14 +2021,13 @@ mod tests { icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::Error, - diff_stats: None, workspace: workspace.clone(), is_live: true, is_background: false, highlight_positions: Vec::new(), - }, + }), // Thread with WaitingForConfirmation status, not active - ListEntry::Thread { + ListEntry::Thread(ThreadEntry { session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-4")), cwd: None, @@ -2072,14 +2038,13 @@ mod tests { icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::WaitingForConfirmation, - diff_stats: None, workspace: workspace.clone(), is_live: false, is_background: false, highlight_positions: Vec::new(), - }, + }), // Background thread that completed (should show notification) - ListEntry::Thread { + ListEntry::Thread(ThreadEntry { session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-5")), cwd: None, @@ -2090,12 +2055,11 @@ mod tests { icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::Completed, - diff_stats: None, workspace: workspace.clone(), is_live: true, is_background: true, highlight_positions: Vec::new(), - }, + }), // View More entry ListEntry::ViewMore { path_list: expanded_path.clone(), @@ -3475,7 +3439,7 @@ mod tests { let active_entry = sidebar.active_entry_index .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( - matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_a), + matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a), "Active entry should be the clicked thread" ); }); @@ -3531,7 +3495,7 @@ mod tests { .active_entry_index .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( - matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_b), + matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b), "Active entry should be the cross-workspace thread" ); }); @@ -3626,7 +3590,7 @@ mod tests { .active_entry_index .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( - matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_b2), + matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2), "Active entry should be the focused thread" ); }); From 503741ddca5b0ce1f867ede26f638293de8461dd Mon Sep 17 00:00:00 2001 From: Om Chillure Date: Mon, 9 Mar 2026 23:13:39 +0530 Subject: [PATCH 081/219] workspace: Hide "View AI Settings" when AI is disabled (#50941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #50835 ### Problem : The "View AI Settings" button on the Welcome page was always rendered regardless of the disable_ai setting. This made it visible (and non-functional) for users who had AI disabled, which was confusing. ### Fix : - Adds an optional visibility: Option bool> predicate field to SectionEntry - At render time, Section::render uses filter_map to skip entries whose predicate returns false. - The "View AI Settings" entry is given a predicate that checks !DisableAiSettings::get_global(cx).disable_ai, matching the same pattern used in `title_bar.rs` and `quick_action_bar.rs`. - All other entries have visibility: None, meaning they are always shown — no behaviour change for them. ### Video : [Screencast from 2026-03-06 20-18-43.webm](https://github.com/user-attachments/assets/cbfab423-3ef3-41dd-a9ab-cbae055eef6e) Release Notes: - Fixed the "View AI Settings" button being visible on the Welcome page despite AI features being disabled in settings. --- crates/workspace/src/welcome.rs | 51 +++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index 1a16b731b44db9e1678bba9c316e388139d39058..92f1cb4840731bedda5b0b6751f44bfdcdb8ea52 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -10,8 +10,10 @@ use gpui::{ ParentElement, Render, Styled, Task, Window, actions, }; use menu::{SelectNext, SelectPrevious}; +use project::DisableAiSettings; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use settings::Settings; use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*}; use util::ResultExt; use zed_actions::{Extensions, OpenOnboarding, OpenSettings, agent, command_palette}; @@ -121,21 +123,43 @@ impl RenderOnce for SectionButton { } } +enum SectionVisibility { + Always, + Conditional(fn(&App) -> bool), +} + +impl SectionVisibility { + fn is_visible(&self, cx: &App) -> bool { + match self { + SectionVisibility::Always => true, + SectionVisibility::Conditional(f) => f(cx), + } + } +} + struct SectionEntry { icon: IconName, title: &'static str, action: &'static dyn Action, + visibility_guard: SectionVisibility, } impl SectionEntry { - fn render(&self, button_index: usize, focus: &FocusHandle, _cx: &App) -> impl IntoElement { - SectionButton::new( - self.title, - self.icon, - self.action, - button_index, - focus.clone(), - ) + fn render( + &self, + button_index: usize, + focus: &FocusHandle, + cx: &App, + ) -> Option { + self.visibility_guard.is_visible(cx).then(|| { + SectionButton::new( + self.title, + self.icon, + self.action, + button_index, + focus.clone(), + ) + }) } } @@ -147,21 +171,25 @@ const CONTENT: (Section<4>, Section<3>) = ( icon: IconName::Plus, title: "New File", action: &NewFile, + visibility_guard: SectionVisibility::Always, }, SectionEntry { icon: IconName::FolderOpen, title: "Open Project", action: &Open::DEFAULT, + visibility_guard: SectionVisibility::Always, }, SectionEntry { icon: IconName::CloudDownload, title: "Clone Repository", action: &GitClone, + visibility_guard: SectionVisibility::Always, }, SectionEntry { icon: IconName::ListCollapse, title: "Open Command Palette", action: &command_palette::Toggle, + visibility_guard: SectionVisibility::Always, }, ], }, @@ -172,11 +200,15 @@ const CONTENT: (Section<4>, Section<3>) = ( icon: IconName::Settings, title: "Open Settings", action: &OpenSettings, + visibility_guard: SectionVisibility::Always, }, SectionEntry { icon: IconName::ZedAssistant, title: "View AI Settings", action: &agent::OpenSettings, + visibility_guard: SectionVisibility::Conditional(|cx| { + !DisableAiSettings::get_global(cx).disable_ai + }), }, SectionEntry { icon: IconName::Blocks, @@ -185,6 +217,7 @@ const CONTENT: (Section<4>, Section<3>) = ( category_filter: None, id: None, }, + visibility_guard: SectionVisibility::Always, }, ], }, @@ -204,7 +237,7 @@ impl Section { self.entries .iter() .enumerate() - .map(|(index, entry)| entry.render(index_offset + index, focus, cx)), + .filter_map(|(index, entry)| entry.render(index_offset + index, focus, cx)), ) } } From fbeffc4f37d17da189ec3812bef13ff46f51fd4c Mon Sep 17 00:00:00 2001 From: Dino Date: Mon, 9 Mar 2026 17:45:36 +0000 Subject: [PATCH 082/219] Fix expand/collapse all button for splittable editor (#50859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Expand All Files"/"Collapse All Files" button in `BufferSearchBar` was broken for `SplittableEditor`, which is used in the project diff view. It was happening because `ProjectDiff::as_searchable` returns an handle to the `SplittableEditor`, which the search bar implementation then tries to downcast to an `Editor`, which the `SplittableEditor` did not support, so both the expand/collapse all buttons, as well as the collapse state were broken. Unfortunately this was accidentally introduced in https://github.com/zed-industries/zed/pull/48773 , so this Pull Request updates the `Item` implementation for `SplittableEditor` in order for it to be able to act as an `Editor`. Release Notes: - Fix the "Expand All Files"/"Collapse All Files" button in the project diff view --------- Co-authored-by: Tom Houlé --- crates/editor/src/split.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index b3511915be42ae9816bfb8fece19d28a2a6ca6e3..4e5f8ebf2793f6807e0a9108e12c276a7ab45427 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -1857,6 +1857,21 @@ impl Item for SplittableEditor { fn pixel_position_of_cursor(&self, cx: &App) -> Option> { self.focused_editor().read(cx).pixel_position_of_cursor(cx) } + + fn act_as_type<'a>( + &'a self, + type_id: std::any::TypeId, + self_handle: &'a Entity, + _: &'a App, + ) -> Option { + if type_id == std::any::TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == std::any::TypeId::of::() { + Some(self.rhs_editor.clone().into()) + } else { + None + } + } } impl SearchableItem for SplittableEditor { @@ -2064,7 +2079,7 @@ impl Render for SplittableEditor { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::{any::TypeId, sync::Arc}; use buffer_diff::BufferDiff; use collections::{HashMap, HashSet}; @@ -2080,14 +2095,14 @@ mod tests { use settings::{DiffViewStyle, SettingsStore}; use ui::{VisualContext as _, div, px}; use util::rel_path::rel_path; - use workspace::MultiWorkspace; + use workspace::{Item, MultiWorkspace}; - use crate::SplittableEditor; use crate::display_map::{ BlockPlacement, BlockProperties, BlockStyle, Crease, FoldPlaceholder, }; use crate::inlays::Inlay; use crate::test::{editor_content_with_blocks_and_width, set_block_content_for_tests}; + use crate::{Editor, SplittableEditor}; use multi_buffer::MultiBufferOffset; async fn init_test( @@ -6025,4 +6040,17 @@ mod tests { cx.run_until_parked(); } + + #[gpui::test] + async fn test_act_as_type(cx: &mut gpui::TestAppContext) { + let (splittable_editor, cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await; + let editor = splittable_editor.read_with(cx, |editor, cx| { + editor.act_as_type(TypeId::of::(), &splittable_editor, cx) + }); + + assert!( + editor.is_some(), + "SplittableEditor should be able to act as Editor" + ); + } } From cfa703d89a04a2d07d1a6f0da553f68f9c48710a Mon Sep 17 00:00:00 2001 From: "John D. Swanson" Date: Mon, 9 Mar 2026 14:02:05 -0400 Subject: [PATCH 083/219] PR Review Assignment Workflow Round Two (#51123) This pull request adds a new GitHub Actions workflow to automate reviewer assignment for pull requests. The workflow leverages the `codeowner-coordinator` repository to intelligently assign the most relevant teams as reviewers based on the changes in the PR. This should streamline the review process and ensure the right teams are notified. **Automated Reviewer Assignment Workflow:** * Introduced `.github/workflows/assign-reviewers.yml`, a workflow that triggers on PR open and ready-for-review events to assign 1-2 relevant teams as reviewers using a script from the `zed-industries/codeowner-coordinator` repository. * The workflow checks out the coordinator repo, sets up Python, installs dependencies, and runs the assignment script with the necessary environment variables. * Reviewer assignment is only performed for PRs originating from within the organization for now. * The output of the reviewer assignment step is maintained as an Actions artifact for later inspection or debugging. Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] ~~Added a solid test coverage and/or screenshots from doing manual testing~~ - [x] Done a self-review taking into account security and performance aspects - [ ] ~~Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)~~ Release Notes: - N/A --- .github/workflows/assign-reviewers.yml | 78 ++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/assign-reviewers.yml diff --git a/.github/workflows/assign-reviewers.yml b/.github/workflows/assign-reviewers.yml new file mode 100644 index 0000000000000000000000000000000000000000..4853c1c63f438192e6c07bb3cc8a9bae74912904 --- /dev/null +++ b/.github/workflows/assign-reviewers.yml @@ -0,0 +1,78 @@ +# Assign Reviewers — Smart team assignment based on diff weight +# +# Triggers on PR open and ready_for_review events. Checks out the coordinator +# repo (zed-industries/codeowner-coordinator) to access the assignment script and rules, +# then assigns the 1-2 most relevant teams as reviewers. +# +# NOTE: This file is stored in the codeowner-coordinator repo but must be deployed to +# the zed repo at .github/workflows/assign-reviewers.yml. See INSTALL.md. +# +# AUTH NOTE: Uses a GitHub App (COORDINATOR_APP_ID + COORDINATOR_APP_PRIVATE_KEY) +# to generate an ephemeral token scoped to read-only on the coordinator repo. +# PR operations (team review requests, assignee) use the default GITHUB_TOKEN. + +name: Assign Reviewers + +on: + pull_request: + types: [opened, ready_for_review] + +permissions: + pull-requests: write + issues: write + +# Only run for PRs from within the org (not forks) — fork PRs don't have +# write access to request team reviewers with GITHUB_TOKEN. +jobs: + assign-reviewers: + if: github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Generate coordinator repo token + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + with: + app-id: ${{ vars.COORDINATOR_APP_ID }} + private-key: ${{ secrets.COORDINATOR_APP_PRIVATE_KEY }} + repositories: codeowner-coordinator + + - name: Checkout coordinator repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + repository: zed-industries/codeowner-coordinator + ref: main + path: codeowner-coordinator + token: ${{ steps.app-token.outputs.token }} + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + + - name: Install dependencies + run: pip install pyyaml==6.0.3 + + - name: Assign reviewers + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ github.event.pull_request.html_url }} + TARGET_REPO: ${{ github.repository }} + run: | + cd codeowner-coordinator + python .github/scripts/assign-reviewers.py \ + --pr "$PR_URL" \ + --apply \ + --rules-file team-membership-rules.yml \ + --repo "$TARGET_REPO" \ + --org zed-industries \ + --min-association member \ + 2>&1 | tee /tmp/assign-reviewers-output.txt + + - name: Upload output + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: assign-reviewers-output + path: /tmp/assign-reviewers-output.txt + retention-days: 30 From a5ba1219090627b9098d5fb0898eb9d9dcc844bf Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 9 Mar 2026 19:10:29 +0100 Subject: [PATCH 084/219] agent_ui: Handle legacy agent enum variants during deserialization (#51125) Add custom `Deserialize` implementations for `AgentType` and `ExternalAgent` to map old built-in variant names to current custom agent names, while still accepting current serialized formats. Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 132 ++++++++++++++++++++++++++++- crates/agent_ui/src/agent_ui.rs | 97 ++++++++++++++++++++- 2 files changed, 227 insertions(+), 2 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index be12610a82f571edf140f8a30e8775fa377aac60..c49b7f668ab12ad4d2b04e8ec48488f7afab3c1c 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -400,7 +400,7 @@ enum WhichFontSize { } // TODO unify this with ExternalAgent -#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Serialize)] pub enum AgentType { #[default] NativeAgent, @@ -410,6 +410,63 @@ pub enum AgentType { }, } +// Custom impl handles legacy variant names from before the built-in agents were moved to +// the registry: "ClaudeAgent" -> Custom { name: "claude-acp" }, "Codex" -> Custom { name: +// "codex-acp" }, "Gemini" -> Custom { name: "gemini" }. +// Can be removed at some point in the future and go back to #[derive(Deserialize)]. +impl<'de> Deserialize<'de> for AgentType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + + if let Some(s) = value.as_str() { + return match s { + "NativeAgent" => Ok(Self::NativeAgent), + "TextThread" => Ok(Self::TextThread), + "ClaudeAgent" | "ClaudeCode" => Ok(Self::Custom { + name: CLAUDE_AGENT_NAME.into(), + }), + "Codex" => Ok(Self::Custom { + name: CODEX_NAME.into(), + }), + "Gemini" => Ok(Self::Custom { + name: GEMINI_NAME.into(), + }), + other => Err(serde::de::Error::unknown_variant( + other, + &[ + "NativeAgent", + "TextThread", + "Custom", + "ClaudeAgent", + "ClaudeCode", + "Codex", + "Gemini", + ], + )), + }; + } + + if let Some(obj) = value.as_object() { + if let Some(inner) = obj.get("Custom") { + #[derive(Deserialize)] + struct CustomFields { + name: SharedString, + } + let fields: CustomFields = + serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?; + return Ok(Self::Custom { name: fields.name }); + } + } + + Err(serde::de::Error::custom( + "expected a string variant or {\"Custom\": {\"name\": ...}}", + )) + } +} + impl AgentType { pub fn is_native(&self) -> bool { matches!(self, Self::NativeAgent) @@ -5310,4 +5367,77 @@ mod tests { ); }); } + + #[test] + fn test_deserialize_legacy_agent_type_variants() { + assert_eq!( + serde_json::from_str::(r#""ClaudeAgent""#).unwrap(), + AgentType::Custom { + name: CLAUDE_AGENT_NAME.into(), + }, + ); + assert_eq!( + serde_json::from_str::(r#""ClaudeCode""#).unwrap(), + AgentType::Custom { + name: CLAUDE_AGENT_NAME.into(), + }, + ); + assert_eq!( + serde_json::from_str::(r#""Codex""#).unwrap(), + AgentType::Custom { + name: CODEX_NAME.into(), + }, + ); + assert_eq!( + serde_json::from_str::(r#""Gemini""#).unwrap(), + AgentType::Custom { + name: GEMINI_NAME.into(), + }, + ); + } + + #[test] + fn test_deserialize_current_agent_type_variants() { + assert_eq!( + serde_json::from_str::(r#""NativeAgent""#).unwrap(), + AgentType::NativeAgent, + ); + assert_eq!( + serde_json::from_str::(r#""TextThread""#).unwrap(), + AgentType::TextThread, + ); + assert_eq!( + serde_json::from_str::(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(), + AgentType::Custom { + name: "my-agent".into(), + }, + ); + } + + #[test] + fn test_deserialize_legacy_serialized_panel() { + let json = serde_json::json!({ + "width": 300.0, + "selected_agent": "ClaudeAgent", + "last_active_thread": { + "session_id": "test-session", + "agent_type": "Codex", + }, + }); + + let panel: SerializedAgentPanel = serde_json::from_value(json).unwrap(); + assert_eq!( + panel.selected_agent, + Some(AgentType::Custom { + name: CLAUDE_AGENT_NAME.into(), + }), + ); + let thread = panel.last_active_thread.unwrap(); + assert_eq!( + thread.agent_type, + AgentType::Custom { + name: CODEX_NAME.into(), + }, + ); + } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 8cf18a872e8c3f2332c1633d34833d7a09ad5c95..8583e8977a719987b12770eec2d77408187a4e1f 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -212,13 +212,70 @@ pub struct NewNativeAgentThreadFromSummary { } // TODO unify this with AgentType -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ExternalAgent { NativeAgent, Custom { name: SharedString }, } +// Custom impl handles legacy variant names from before the built-in agents were moved to +// the registry: "claude_code" -> Custom { name: "claude-acp" }, "codex" -> Custom { name: +// "codex-acp" }, "gemini" -> Custom { name: "gemini" }. +// Can be removed at some point in the future and go back to #[derive(Deserialize)]. +impl<'de> serde::Deserialize<'de> for ExternalAgent { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use project::agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME}; + + let value = serde_json::Value::deserialize(deserializer)?; + + if let Some(s) = value.as_str() { + return match s { + "native_agent" => Ok(Self::NativeAgent), + "claude_code" | "claude_agent" => Ok(Self::Custom { + name: CLAUDE_AGENT_NAME.into(), + }), + "codex" => Ok(Self::Custom { + name: CODEX_NAME.into(), + }), + "gemini" => Ok(Self::Custom { + name: GEMINI_NAME.into(), + }), + other => Err(serde::de::Error::unknown_variant( + other, + &[ + "native_agent", + "custom", + "claude_agent", + "claude_code", + "codex", + "gemini", + ], + )), + }; + } + + if let Some(obj) = value.as_object() { + if let Some(inner) = obj.get("custom") { + #[derive(serde::Deserialize)] + struct CustomFields { + name: SharedString, + } + let fields: CustomFields = + serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?; + return Ok(Self::Custom { name: fields.name }); + } + } + + Err(serde::de::Error::custom( + "expected a string variant or {\"custom\": {\"name\": ...}}", + )) + } +} + impl ExternalAgent { pub fn server( &self, @@ -685,4 +742,42 @@ mod tests { ); }); } + + #[test] + fn test_deserialize_legacy_external_agent_variants() { + use project::agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME}; + + assert_eq!( + serde_json::from_str::(r#""claude_code""#).unwrap(), + ExternalAgent::Custom { + name: CLAUDE_AGENT_NAME.into(), + }, + ); + assert_eq!( + serde_json::from_str::(r#""codex""#).unwrap(), + ExternalAgent::Custom { + name: CODEX_NAME.into(), + }, + ); + assert_eq!( + serde_json::from_str::(r#""gemini""#).unwrap(), + ExternalAgent::Custom { + name: GEMINI_NAME.into(), + }, + ); + } + + #[test] + fn test_deserialize_current_external_agent_variants() { + assert_eq!( + serde_json::from_str::(r#""native_agent""#).unwrap(), + ExternalAgent::NativeAgent, + ); + assert_eq!( + serde_json::from_str::(r#"{"custom":{"name":"my-agent"}}"#).unwrap(), + ExternalAgent::Custom { + name: "my-agent".into(), + }, + ); + } } From bf6313231621a5486e1eba91b982a8a856d8a0a5 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Mon, 9 Mar 2026 20:50:52 +0100 Subject: [PATCH 085/219] livekit_client: Route selected audio input/output devices into legacy audio (#51128) Release Notes: - Fixed ability to select audio input/output devices for legacy (non-experimental/rodio-enabled) audio. --- crates/audio/src/audio.rs | 30 +++++++++++++------ crates/audio/src/audio_settings.rs | 4 --- crates/livekit_client/src/lib.rs | 24 ++++++--------- crates/livekit_client/src/livekit_client.rs | 5 +++- .../src/livekit_client/playback.rs | 18 ++++++++--- crates/livekit_client/src/record.rs | 7 +++-- 6 files changed, 53 insertions(+), 35 deletions(-) diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs index f9a635a16a2eaf2a4facbd1f25bf6eb0f9fe7a87..2165cf39136a1ed7268fbf6ea670d825b2b50bcc 100644 --- a/crates/audio/src/audio.rs +++ b/crates/audio/src/audio.rs @@ -384,17 +384,29 @@ pub fn open_input_stream( Ok(stream) } -pub fn open_output_stream(device_id: Option) -> anyhow::Result { - let output_handle = if let Some(id) = device_id { - if let Some(device) = default_host().device_by_id(&id) { - DeviceSinkBuilder::from_device(device)?.open_stream() - } else { - DeviceSinkBuilder::open_default_sink() +pub fn resolve_device(device_id: Option<&DeviceId>, input: bool) -> anyhow::Result { + if let Some(id) = device_id { + if let Some(device) = default_host().device_by_id(id) { + return Ok(device); } + log::warn!("Selected audio device not found, falling back to default"); + } + if input { + default_host() + .default_input_device() + .context("no audio input device available") } else { - DeviceSinkBuilder::open_default_sink() - }; - let mut output_handle = output_handle.context("Could not open output stream")?; + default_host() + .default_output_device() + .context("no audio output device available") + } +} + +pub fn open_output_stream(device_id: Option) -> anyhow::Result { + let device = resolve_device(device_id.as_ref(), false)?; + let mut output_handle = DeviceSinkBuilder::from_device(device)? + .open_stream() + .context("Could not open output stream")?; output_handle.log_on_drop(false); log::info!("Output stream: {:?}", output_handle); Ok(output_handle) diff --git a/crates/audio/src/audio_settings.rs b/crates/audio/src/audio_settings.rs index 4f60a6d63aef1d2c2d7fb4761a6fc2e2eaf3d8c7..8425ed5eaa713053f44b26e199a66b76bf9b57a6 100644 --- a/crates/audio/src/audio_settings.rs +++ b/crates/audio/src/audio_settings.rs @@ -42,12 +42,8 @@ pub struct AudioSettings { /// /// You need to rejoin a call for this setting to apply pub legacy_audio_compatible: bool, - /// Requires 'rodio_audio: true' - /// /// Select specific output audio device. pub output_audio_device: Option, - /// Requires 'rodio_audio: true' - /// /// Select specific input audio device. pub input_audio_device: Option, } diff --git a/crates/livekit_client/src/lib.rs b/crates/livekit_client/src/lib.rs index be008d8db5108fb087415edb9d2de91bad19ab97..352776cf6bbe02381957a197eca9a64fff094892 100644 --- a/crates/livekit_client/src/lib.rs +++ b/crates/livekit_client/src/lib.rs @@ -1,8 +1,8 @@ use anyhow::Context as _; use collections::HashMap; +use cpal::DeviceId; mod remote_video_track_view; -use cpal::traits::HostTrait as _; pub use remote_video_track_view::{RemoteVideoTrackView, RemoteVideoTrackViewEvent}; use rodio::DeviceTrait as _; @@ -192,24 +192,18 @@ pub enum RoomEvent { pub(crate) fn default_device( input: bool, + device_id: Option<&DeviceId>, ) -> anyhow::Result<(cpal::Device, cpal::SupportedStreamConfig)> { - let device; - let config; - if input { - device = cpal::default_host() - .default_input_device() - .context("no audio input device available")?; - config = device + let device = audio::resolve_device(device_id, input)?; + let config = if input { + device .default_input_config() - .context("failed to get default input config")?; + .context("failed to get default input config")? } else { - device = cpal::default_host() - .default_output_device() - .context("no audio output device available")?; - config = device + device .default_output_config() - .context("failed to get default output config")?; - } + .context("failed to get default output config")? + }; Ok((device, config)) } diff --git a/crates/livekit_client/src/livekit_client.rs b/crates/livekit_client/src/livekit_client.rs index 1db9a12ef2b7f3b4f3de1cba6c61a30db12a5bd9..863cf0dc527300f1e85df6867d99e367b5c7fa15 100644 --- a/crates/livekit_client/src/livekit_client.rs +++ b/crates/livekit_client/src/livekit_client.rs @@ -150,7 +150,10 @@ impl Room { info!("Using experimental.rodio_audio audio pipeline for output"); playback::play_remote_audio_track(&track.0, speaker, cx) } else if speaker.sends_legacy_audio { - Ok(self.playback.play_remote_audio_track(&track.0)) + let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); + Ok(self + .playback + .play_remote_audio_track(&track.0, output_audio_device)) } else { Err(anyhow!("Client version too old to play audio in call")) } diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index df62479f022be5295a3de44f40fabf48aed515f2..0ebb282dd7ec494886fe1ffc90fe1f8688a762da 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -1,6 +1,7 @@ use anyhow::{Context as _, Result}; use audio::{AudioSettings, CHANNEL_COUNT, LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE, SAMPLE_RATE}; +use cpal::DeviceId; use cpal::traits::{DeviceTrait, StreamTrait as _}; use futures::channel::mpsc::UnboundedSender; use futures::{Stream, StreamExt as _}; @@ -91,8 +92,9 @@ impl AudioStack { pub(crate) fn play_remote_audio_track( &self, track: &livekit::track::RemoteAudioTrack, + output_audio_device: Option, ) -> AudioStream { - let output_task = self.start_output(); + let output_task = self.start_output(output_audio_device); let next_ssrc = self.next_ssrc.fetch_add(1, Ordering::Relaxed); let source = AudioMixerSource { @@ -130,7 +132,7 @@ impl AudioStack { } } - fn start_output(&self) -> Arc> { + fn start_output(&self, output_audio_device: Option) -> Arc> { if let Some(task) = self._output_task.borrow().upgrade() { return task; } @@ -143,6 +145,7 @@ impl AudioStack { mixer, LEGACY_SAMPLE_RATE.get(), LEGACY_CHANNEL_COUNT.get().into(), + output_audio_device, ) .await .log_err(); @@ -219,12 +222,16 @@ impl AudioStack { Ok(()) }) } else { + let input_audio_device = + AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone()) + .flatten(); self.executor.spawn(async move { Self::capture_input( apm, frame_tx, LEGACY_SAMPLE_RATE.get(), LEGACY_CHANNEL_COUNT.get().into(), + input_audio_device, ) .await }) @@ -247,6 +254,7 @@ impl AudioStack { mixer: Arc>, sample_rate: u32, num_channels: u32, + output_audio_device: Option, ) -> Result<()> { // Prevent App Nap from throttling audio playback on macOS. // This guard is held for the entire duration of audio output. @@ -255,7 +263,8 @@ impl AudioStack { loop { let mut device_change_listener = DeviceChangeListener::new(false)?; - let (output_device, output_config) = crate::default_device(false)?; + let (output_device, output_config) = + crate::default_device(false, output_audio_device.as_ref())?; let (end_on_drop_tx, end_on_drop_rx) = std::sync::mpsc::channel::<()>(); let mixer = mixer.clone(); let apm = apm.clone(); @@ -327,10 +336,11 @@ impl AudioStack { frame_tx: UnboundedSender>, sample_rate: u32, num_channels: u32, + input_audio_device: Option, ) -> Result<()> { loop { let mut device_change_listener = DeviceChangeListener::new(true)?; - let (device, config) = crate::default_device(true)?; + let (device, config) = crate::default_device(true, input_audio_device.as_ref())?; let (end_on_drop_tx, end_on_drop_rx) = std::sync::mpsc::channel::<()>(); let apm = apm.clone(); let frame_tx = frame_tx.clone(); diff --git a/crates/livekit_client/src/record.rs b/crates/livekit_client/src/record.rs index c23ab2b938178e9b634f8e0d4d298f2c86450b51..c0fe9eb7218ad8550f7b63042d0e11c2cb53ee20 100644 --- a/crates/livekit_client/src/record.rs +++ b/crates/livekit_client/src/record.rs @@ -7,20 +7,22 @@ use std::{ }; use anyhow::{Context, Result}; +use cpal::DeviceId; use cpal::traits::{DeviceTrait, StreamTrait}; use rodio::{buffer::SamplesBuffer, conversions::SampleTypeConverter}; use util::ResultExt; pub struct CaptureInput { pub name: String, + pub input_device: Option, config: cpal::SupportedStreamConfig, samples: Arc>>, _stream: cpal::Stream, } impl CaptureInput { - pub fn start() -> anyhow::Result { - let (device, config) = crate::default_device(true)?; + pub fn start(input_device: Option) -> anyhow::Result { + let (device, config) = crate::default_device(true, input_device.as_ref())?; let name = device .description() .map(|desc| desc.name().to_string()) @@ -32,6 +34,7 @@ impl CaptureInput { Ok(Self { name, + input_device, _stream: stream, config, samples, From 0634ddb960be2ec6d3fadf4e1e6a5ba703237d0f Mon Sep 17 00:00:00 2001 From: "John D. Swanson" Date: Mon, 9 Mar 2026 15:51:50 -0400 Subject: [PATCH 086/219] Fix permission and filtering issues for PR review assignments (#51132) This PR takes a different approach to permissions for assign-reviewers.yml and better filters external PRs for now. Before you mark this PR as ready for review, make sure that you have: - ~~[ ] Added a solid test coverage and/or screenshots from doing manual testing~~ - [x] Done a self-review taking into account security and performance aspects - ~~[ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)~~ Release Notes: - N/A *or* Added/Fixed/Improved ... --- .github/workflows/assign-reviewers.yml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/assign-reviewers.yml b/.github/workflows/assign-reviewers.yml index 4853c1c63f438192e6c07bb3cc8a9bae74912904..a77f1812d06330b4635fe173583f0f1ce93e4e17 100644 --- a/.github/workflows/assign-reviewers.yml +++ b/.github/workflows/assign-reviewers.yml @@ -8,8 +8,8 @@ # the zed repo at .github/workflows/assign-reviewers.yml. See INSTALL.md. # # AUTH NOTE: Uses a GitHub App (COORDINATOR_APP_ID + COORDINATOR_APP_PRIVATE_KEY) -# to generate an ephemeral token scoped to read-only on the coordinator repo. -# PR operations (team review requests, assignee) use the default GITHUB_TOKEN. +# for all API operations: cloning the private coordinator repo, requesting team +# reviewers, and setting PR assignees. GITHUB_TOKEN is not used. name: Assign Reviewers @@ -17,24 +17,27 @@ on: pull_request: types: [opened, ready_for_review] -permissions: - pull-requests: write - issues: write +# GITHUB_TOKEN is not used — all operations use the GitHub App token. +# Declare minimal permissions so the default token has no write access. +permissions: {} # Only run for PRs from within the org (not forks) — fork PRs don't have -# write access to request team reviewers with GITHUB_TOKEN. +# write access to request team reviewers. jobs: assign-reviewers: - if: github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.draft == false + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.draft == false && + contains(fromJSON('["MEMBER", "OWNER"]'), github.event.pull_request.author_association) runs-on: ubuntu-latest steps: - - name: Generate coordinator repo token + - name: Generate app token id: app-token uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ vars.COORDINATOR_APP_ID }} private-key: ${{ secrets.COORDINATOR_APP_PRIVATE_KEY }} - repositories: codeowner-coordinator + repositories: codeowner-coordinator,zed - name: Checkout coordinator repo uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -55,7 +58,7 @@ jobs: - name: Assign reviewers env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_URL: ${{ github.event.pull_request.html_url }} TARGET_REPO: ${{ github.repository }} run: | From 4e9e94435751b173e5f3b6c576f4a28638070ce8 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 10 Mar 2026 03:56:45 +0530 Subject: [PATCH 087/219] fs: Fix no-overwrite rename races (#51090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #46661 This PR changes `fs.rename` to use the platform’s atomic no-overwrite rename on all platforms when `overwrite` is `false`. This fixes a case where concurrent renames to the same target could race past a separate metadata check and end up overwriting each other. In Project Panel, we can still rename entries in parallel without worrying about OS internals not handling it correctly or making these renames sequential. Release Notes: - Fixed an issue in the Project Panel where conflicting file moves could overwrite each other instead of leaving the losing file in place. --- crates/fs/src/fs.rs | 118 ++++++++++++++++++++++++++++-- crates/fs/tests/integration/fs.rs | 59 +++++++++++++++ 2 files changed, 170 insertions(+), 7 deletions(-) diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 0fde444171042eda859edcac7915c456ab91e265..6c7074d2139068d2ea581ea6343de4d4c1f09030 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -15,10 +15,14 @@ use gpui::Global; use gpui::ReadGlobal as _; use gpui::SharedString; use std::borrow::Cow; +#[cfg(unix)] +use std::ffi::CString; use util::command::new_command; #[cfg(unix)] use std::os::fd::{AsFd, AsRawFd}; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt}; @@ -506,6 +510,63 @@ impl RealFs { } } +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn rename_without_replace(source: &Path, target: &Path) -> io::Result<()> { + let source = path_to_c_string(source)?; + let target = path_to_c_string(target)?; + + #[cfg(target_os = "macos")] + let result = unsafe { libc::renamex_np(source.as_ptr(), target.as_ptr(), libc::RENAME_EXCL) }; + + #[cfg(target_os = "linux")] + let result = unsafe { + libc::syscall( + libc::SYS_renameat2, + libc::AT_FDCWD, + source.as_ptr(), + libc::AT_FDCWD, + target.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +#[cfg(target_os = "windows")] +fn rename_without_replace(source: &Path, target: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + + use windows::Win32::Storage::FileSystem::{MOVE_FILE_FLAGS, MoveFileExW}; + use windows::core::PCWSTR; + + let source: Vec = source.as_os_str().encode_wide().chain(Some(0)).collect(); + let target: Vec = target.as_os_str().encode_wide().chain(Some(0)).collect(); + + unsafe { + MoveFileExW( + PCWSTR(source.as_ptr()), + PCWSTR(target.as_ptr()), + MOVE_FILE_FLAGS::default(), + ) + } + .map_err(|_| io::Error::last_os_error()) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn path_to_c_string(path: &Path) -> io::Result { + CString::new(path.as_os_str().as_bytes()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("path contains interior NUL: {}", path.display()), + ) + }) +} + #[async_trait::async_trait] impl Fs for RealFs { async fn create_dir(&self, path: &Path) -> Result<()> { @@ -588,7 +649,56 @@ impl Fs for RealFs { } async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()> { - if !options.overwrite && smol::fs::metadata(target).await.is_ok() { + if options.create_parents { + if let Some(parent) = target.parent() { + self.create_dir(parent).await?; + } + } + + if options.overwrite { + smol::fs::rename(source, target).await?; + return Ok(()); + } + + let use_metadata_fallback = { + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + { + let source = source.to_path_buf(); + let target = target.to_path_buf(); + match self + .executor + .spawn(async move { rename_without_replace(&source, &target) }) + .await + { + Ok(()) => return Ok(()), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + if options.ignore_if_exists { + return Ok(()); + } + return Err(error.into()); + } + Err(error) + if error.raw_os_error().is_some_and(|code| { + code == libc::ENOSYS + || code == libc::ENOTSUP + || code == libc::EOPNOTSUPP + }) => + { + // For case when filesystem or kernel does not support atomic no-overwrite rename. + true + } + Err(error) => return Err(error.into()), + } + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + // For platforms which do not have an atomic no-overwrite rename yet. + true + } + }; + + if use_metadata_fallback && smol::fs::metadata(target).await.is_ok() { if options.ignore_if_exists { return Ok(()); } else { @@ -596,12 +706,6 @@ impl Fs for RealFs { } } - if options.create_parents { - if let Some(parent) = target.parent() { - self.create_dir(parent).await?; - } - } - smol::fs::rename(source, target).await?; Ok(()) } diff --git a/crates/fs/tests/integration/fs.rs b/crates/fs/tests/integration/fs.rs index dd5e694e23c99716a81b27afd487e3a6ea648209..b688d5e2c243ede5eb3f499ad2956feaec01a965 100644 --- a/crates/fs/tests/integration/fs.rs +++ b/crates/fs/tests/integration/fs.rs @@ -523,6 +523,65 @@ async fn test_rename(executor: BackgroundExecutor) { ); } +#[gpui::test] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +async fn test_realfs_parallel_rename_without_overwrite_preserves_losing_source( + executor: BackgroundExecutor, +) { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + let source_a = root.join("dir_a/shared.txt"); + let source_b = root.join("dir_b/shared.txt"); + let target = root.join("shared.txt"); + + std::fs::create_dir_all(source_a.parent().unwrap()).unwrap(); + std::fs::create_dir_all(source_b.parent().unwrap()).unwrap(); + std::fs::write(&source_a, "from a").unwrap(); + std::fs::write(&source_b, "from b").unwrap(); + + let fs = RealFs::new(None, executor); + let (first_result, second_result) = futures::future::join( + fs.rename(&source_a, &target, RenameOptions::default()), + fs.rename(&source_b, &target, RenameOptions::default()), + ) + .await; + + assert_ne!(first_result.is_ok(), second_result.is_ok()); + assert!(target.exists()); + assert_eq!(source_a.exists() as u8 + source_b.exists() as u8, 1); +} + +#[gpui::test] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +async fn test_realfs_rename_ignore_if_exists_leaves_source_and_target_unchanged( + executor: BackgroundExecutor, +) { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + let source = root.join("source.txt"); + let target = root.join("target.txt"); + + std::fs::write(&source, "from source").unwrap(); + std::fs::write(&target, "from target").unwrap(); + + let fs = RealFs::new(None, executor); + let result = fs + .rename( + &source, + &target, + RenameOptions { + ignore_if_exists: true, + ..Default::default() + }, + ) + .await; + + assert!(result.is_ok()); + + assert_eq!(std::fs::read_to_string(&source).unwrap(), "from source"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "from target"); +} + #[gpui::test] #[cfg(unix)] async fn test_realfs_broken_symlink_metadata(executor: BackgroundExecutor) { From a99366a940cacd9276f7dc2fccc0d5b0b5db837c Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 9 Mar 2026 23:41:59 +0100 Subject: [PATCH 088/219] agent_servers: Use correct default settings (#51136) These are edge cases, but there are a few ways you can get into a state where you are setting favorites for registry agents and we don't have the setting yet. This prioritizes `type: registry` for agents that we have in the registry, especially the previous built-ins. Release Notes: - N/A --- crates/agent_servers/src/custom.rs | 334 +++++++++++++++------ crates/project/src/agent_registry_store.rs | 16 + 2 files changed, 266 insertions(+), 84 deletions(-) diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index b0669d1fb69e110f0ba206a3579f16738de5e7e2..0a1830717217872868e66a8222902c49eeaabf9c 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -84,19 +84,12 @@ impl AgentServer for CustomAgentServer { let config_id = config_id.to_string(); let value_id = value_id.to_string(); - update_settings_file(fs, cx, move |settings, _| { + update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(name.to_string()) - .or_insert_with(|| settings::CustomAgentServerSettings::Extension { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }); + .or_insert_with(|| default_settings_for_agent(&name, cx)); match settings { settings::CustomAgentServerSettings::Custom { @@ -132,19 +125,12 @@ impl AgentServer for CustomAgentServer { fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { let name = self.name(); - update_settings_file(fs, cx, move |settings, _| { + update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(name.to_string()) - .or_insert_with(|| settings::CustomAgentServerSettings::Extension { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }); + .or_insert_with(|| default_settings_for_agent(&name, cx)); match settings { settings::CustomAgentServerSettings::Custom { default_mode, .. } @@ -171,19 +157,12 @@ impl AgentServer for CustomAgentServer { fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { let name = self.name(); - update_settings_file(fs, cx, move |settings, _| { + update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(name.to_string()) - .or_insert_with(|| settings::CustomAgentServerSettings::Extension { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }); + .or_insert_with(|| default_settings_for_agent(&name, cx)); match settings { settings::CustomAgentServerSettings::Custom { default_model, .. } @@ -222,19 +201,12 @@ impl AgentServer for CustomAgentServer { cx: &App, ) { let name = self.name(); - update_settings_file(fs, cx, move |settings, _| { + update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(name.to_string()) - .or_insert_with(|| settings::CustomAgentServerSettings::Extension { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }); + .or_insert_with(|| default_settings_for_agent(&name, cx)); let favorite_models = match settings { settings::CustomAgentServerSettings::Custom { @@ -282,19 +254,12 @@ impl AgentServer for CustomAgentServer { let name = self.name(); let config_id = config_id.to_string(); let value_id = value_id.map(|s| s.to_string()); - update_settings_file(fs, cx, move |settings, _| { + update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(name.to_string()) - .or_insert_with(|| settings::CustomAgentServerSettings::Extension { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }); + .or_insert_with(|| default_settings_for_agent(&name, cx)); match settings { settings::CustomAgentServerSettings::Custom { @@ -332,45 +297,27 @@ impl AgentServer for CustomAgentServer { .unwrap_or_else(|| name.clone()); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); - let is_previous_built_in = - matches!(name.as_ref(), CLAUDE_AGENT_NAME | CODEX_NAME | GEMINI_NAME); - let (default_config_options, is_registry_agent) = - cx.read_global(|settings: &SettingsStore, _| { - let agent_settings = settings - .get::(None) - .get(self.name().as_ref()); - - let is_registry = agent_settings - .map(|s| { - matches!( - s, - project::agent_server_store::CustomAgentServerSettings::Registry { .. } - ) - }) - .unwrap_or(false); - - let config_options = agent_settings - .map(|s| match s { - project::agent_server_store::CustomAgentServerSettings::Custom { - default_config_options, - .. - } - | project::agent_server_store::CustomAgentServerSettings::Extension { - default_config_options, - .. - } - | project::agent_server_store::CustomAgentServerSettings::Registry { - default_config_options, - .. - } => default_config_options.clone(), - }) - .unwrap_or_default(); - - (config_options, is_registry) - }); - - // Intermediate step to allow for previous built-ins to also be triggered if they aren't in settings yet. - let is_registry_agent = is_registry_agent || is_previous_built_in; + let is_registry_agent = is_registry_agent(&name, cx); + let default_config_options = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .get(self.name().as_ref()) + .map(|s| match s { + project::agent_server_store::CustomAgentServerSettings::Custom { + default_config_options, + .. + } + | project::agent_server_store::CustomAgentServerSettings::Extension { + default_config_options, + .. + } + | project::agent_server_store::CustomAgentServerSettings::Registry { + default_config_options, + .. + } => default_config_options.clone(), + }) + .unwrap_or_default() + }); if is_registry_agent { if let Some(registry_store) = project::AgentRegistryStore::try_global(cx) { @@ -458,3 +405,222 @@ fn api_key_for_gemini_cli(cx: &mut App) -> Task> { ) }) } + +fn is_registry_agent(name: &str, cx: &App) -> bool { + let is_previous_built_in = matches!(name, CLAUDE_AGENT_NAME | CODEX_NAME | GEMINI_NAME); + let is_in_registry = project::AgentRegistryStore::try_global(cx) + .map(|store| store.read(cx).agent(name).is_some()) + .unwrap_or(false); + let is_settings_registry = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .get(name) + .is_some_and(|s| { + matches!( + s, + project::agent_server_store::CustomAgentServerSettings::Registry { .. } + ) + }) + }); + is_previous_built_in || is_in_registry || is_settings_registry +} + +fn default_settings_for_agent(name: &str, cx: &App) -> settings::CustomAgentServerSettings { + if is_registry_agent(name, cx) { + settings::CustomAgentServerSettings::Registry { + default_model: None, + default_mode: None, + env: Default::default(), + favorite_models: Vec::new(), + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), + } + } else { + settings::CustomAgentServerSettings::Extension { + default_model: None, + default_mode: None, + env: Default::default(), + favorite_models: Vec::new(), + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use collections::HashMap; + use gpui::TestAppContext; + use project::agent_registry_store::{ + AgentRegistryStore, RegistryAgent, RegistryAgentMetadata, RegistryNpxAgent, + }; + use settings::Settings as _; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + } + + fn init_registry_with_agents(cx: &mut TestAppContext, agent_ids: &[&str]) { + let agents: Vec = agent_ids + .iter() + .map(|id| { + let id = SharedString::from(id.to_string()); + RegistryAgent::Npx(RegistryNpxAgent { + metadata: RegistryAgentMetadata { + id: id.clone(), + name: id.clone(), + description: SharedString::from(""), + version: SharedString::from("1.0.0"), + repository: None, + icon_path: None, + }, + package: id, + args: Vec::new(), + env: HashMap::default(), + }) + }) + .collect(); + cx.update(|cx| { + AgentRegistryStore::init_test_global(cx, agents); + }); + } + + fn set_agent_server_settings( + cx: &mut TestAppContext, + entries: Vec<(&str, settings::CustomAgentServerSettings)>, + ) { + cx.update(|cx| { + AllAgentServersSettings::override_global( + project::agent_server_store::AllAgentServersSettings( + entries + .into_iter() + .map(|(name, settings)| (name.to_string(), settings.into())) + .collect(), + ), + cx, + ); + }); + } + + #[gpui::test] + fn test_previous_builtins_are_registry(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + assert!(is_registry_agent(CLAUDE_AGENT_NAME, cx)); + assert!(is_registry_agent(CODEX_NAME, cx)); + assert!(is_registry_agent(GEMINI_NAME, cx)); + }); + } + + #[gpui::test] + fn test_unknown_agent_is_not_registry(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + assert!(!is_registry_agent("my-custom-agent", cx)); + }); + } + + #[gpui::test] + fn test_agent_in_registry_store_is_registry(cx: &mut TestAppContext) { + init_test(cx); + init_registry_with_agents(cx, &["some-new-registry-agent"]); + cx.update(|cx| { + assert!(is_registry_agent("some-new-registry-agent", cx)); + assert!(!is_registry_agent("not-in-registry", cx)); + }); + } + + #[gpui::test] + fn test_agent_with_registry_settings_type_is_registry(cx: &mut TestAppContext) { + init_test(cx); + set_agent_server_settings( + cx, + vec![( + "agent-from-settings", + settings::CustomAgentServerSettings::Registry { + env: HashMap::default(), + default_mode: None, + default_model: None, + favorite_models: Vec::new(), + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + }, + )], + ); + cx.update(|cx| { + assert!(is_registry_agent("agent-from-settings", cx)); + }); + } + + #[gpui::test] + fn test_agent_with_extension_settings_type_is_not_registry(cx: &mut TestAppContext) { + init_test(cx); + set_agent_server_settings( + cx, + vec![( + "my-extension-agent", + settings::CustomAgentServerSettings::Extension { + env: HashMap::default(), + default_mode: None, + default_model: None, + favorite_models: Vec::new(), + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + }, + )], + ); + cx.update(|cx| { + assert!(!is_registry_agent("my-extension-agent", cx)); + }); + } + + #[gpui::test] + fn test_default_settings_for_builtin_agent(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + assert!(matches!( + default_settings_for_agent(CODEX_NAME, cx), + settings::CustomAgentServerSettings::Registry { .. } + )); + assert!(matches!( + default_settings_for_agent(CLAUDE_AGENT_NAME, cx), + settings::CustomAgentServerSettings::Registry { .. } + )); + assert!(matches!( + default_settings_for_agent(GEMINI_NAME, cx), + settings::CustomAgentServerSettings::Registry { .. } + )); + }); + } + + #[gpui::test] + fn test_default_settings_for_extension_agent(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + assert!(matches!( + default_settings_for_agent("some-extension-agent", cx), + settings::CustomAgentServerSettings::Extension { .. } + )); + }); + } + + #[gpui::test] + fn test_default_settings_for_agent_in_registry(cx: &mut TestAppContext) { + init_test(cx); + init_registry_with_agents(cx, &["new-registry-agent"]); + cx.update(|cx| { + assert!(matches!( + default_settings_for_agent("new-registry-agent", cx), + settings::CustomAgentServerSettings::Registry { .. } + )); + assert!(matches!( + default_settings_for_agent("not-in-registry", cx), + settings::CustomAgentServerSettings::Extension { .. } + )); + }); + } +} diff --git a/crates/project/src/agent_registry_store.rs b/crates/project/src/agent_registry_store.rs index 155badc4ac7da22921b121428cc34a0d46f5b982..79d6e52097d17cadc0271cb09de4ab283c6d93b8 100644 --- a/crates/project/src/agent_registry_store.rs +++ b/crates/project/src/agent_registry_store.rs @@ -147,6 +147,22 @@ impl AgentRegistryStore { .map(|store| store.0.clone()) } + #[cfg(any(test, feature = "test-support"))] + pub fn init_test_global(cx: &mut App, agents: Vec) -> Entity { + let fs: Arc = fs::FakeFs::new(cx.background_executor().clone()); + let store = cx.new(|_cx| Self { + fs, + http_client: http_client::FakeHttpClient::with_404_response(), + agents, + is_fetching: false, + fetch_error: None, + pending_refresh: None, + last_refresh: None, + }); + cx.set_global(GlobalAgentRegistryStore(store.clone())); + store + } + pub fn agents(&self) -> &[RegistryAgent] { &self.agents } From 7a4aaff8ea332b69835fd20dfef88ec991f63990 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Tue, 10 Mar 2026 07:22:36 +0800 Subject: [PATCH 089/219] markdown: Fix code block scrollbars flashing on vertical scroll (#50817) Release Notes: - Fixed code block scrollbars flashing on vertical scroll before: When there are many code blocks, scrolling through markdown will display a horizontal scrollbar (when the mouse is not inside a code block). https://github.com/user-attachments/assets/1fae36ec-5a3f-4283-b54f-e5cb4f45646b after: When scrolling markdown, do not display the horizontal scrollbar when the mouse is not in a code block. https://github.com/user-attachments/assets/0c0f2016-9b18-4055-87a6-4f508dbfd193 --------- Signed-off-by: Xiaobo Liu --- crates/ui/src/components/scrollbar.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index 21d6aa46d0f90a0d48e267e935b00d9f263a30c5..d0c720d5081d3ab7ad700df798b931933e03db28 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -1041,7 +1041,18 @@ impl ScrollbarLayout { impl PartialEq for ScrollbarLayout { fn eq(&self, other: &Self) -> bool { - self.axis == other.axis && self.thumb_bounds == other.thumb_bounds + if self.axis != other.axis { + return false; + } + + let axis = self.axis; + let thumb_offset = + self.thumb_bounds.origin.along(axis) - self.track_bounds.origin.along(axis); + let other_thumb_offset = + other.thumb_bounds.origin.along(axis) - other.track_bounds.origin.along(axis); + + thumb_offset == other_thumb_offset + && self.thumb_bounds.size.along(axis) == other.thumb_bounds.size.along(axis) } } From cb8088049e1885a017f3bab1d05a73daa1224f8a Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 10 Mar 2026 04:57:00 +0530 Subject: [PATCH 090/219] project_panel: Add notifications for drag-and-drop rename conflicts (#51138) Follow-up https://github.com/zed-industries/zed/pull/51090 Adds workspace error notifications for project panel drag-and-drop moves that fail on rename conflicts. Release Notes: - N/A --- crates/project_panel/src/project_panel.rs | 14 +++- .../project_panel/src/project_panel_tests.rs | 84 +++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index d647676834e9847ac697f1b51fc61bc1b2425adf..55f440852ada15505831c78035d9362c91b4a204 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -4415,16 +4415,24 @@ impl ProjectPanel { return; } + let workspace = self.workspace.clone(); if folded_selection_info.is_empty() { for (_, task) in move_tasks { - task.detach_and_log_err(cx); + let workspace = workspace.clone(); + cx.spawn_in(window, async move |_, mut cx| { + task.await.notify_workspace_async_err(workspace, &mut cx); + }) + .detach(); } } else { - cx.spawn_in(window, async move |project_panel, cx| { + cx.spawn_in(window, async move |project_panel, mut cx| { // Await all move tasks and collect successful results let mut move_results: Vec<(ProjectEntryId, Entry)> = Vec::new(); for (entry_id, task) in move_tasks { - if let Some(CreatedEntry::Included(new_entry)) = task.await.log_err() { + if let Some(CreatedEntry::Included(new_entry)) = task + .await + .notify_workspace_async_err(workspace.clone(), &mut cx) + { move_results.push((entry_id, new_entry)); } } diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index af84a7f522a60abf2608bf1f3435b367d24f6bdc..64e96fee700aea8277fe1b69121abf71599c4d30 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -4412,6 +4412,90 @@ async fn test_drag_marked_entries_in_folded_directories(cx: &mut gpui::TestAppCo ); } +#[gpui::test] +async fn test_dragging_same_named_files_preserves_one_source_on_conflict( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "dir_a": { + "shared.txt": "from a" + }, + "dir_b": { + "shared.txt": "from b" + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + let (root_entry_id, worktree_id, entry_a_id, entry_b_id) = { + let worktree = panel.project.read(cx).visible_worktrees(cx).next().unwrap(); + let worktree = worktree.read(cx); + let root_entry_id = worktree.root_entry().unwrap().id; + let worktree_id = worktree.id(); + let entry_a_id = worktree + .entry_for_path(rel_path("dir_a/shared.txt")) + .unwrap() + .id; + let entry_b_id = worktree + .entry_for_path(rel_path("dir_b/shared.txt")) + .unwrap() + .id; + (root_entry_id, worktree_id, entry_a_id, entry_b_id) + }; + + let drag = DraggedSelection { + active_selection: SelectedEntry { + worktree_id, + entry_id: entry_a_id, + }, + marked_selections: Arc::new([ + SelectedEntry { + worktree_id, + entry_id: entry_a_id, + }, + SelectedEntry { + worktree_id, + entry_id: entry_b_id, + }, + ]), + }; + + panel.drag_onto(&drag, root_entry_id, false, window, cx); + }); + cx.executor().run_until_parked(); + + let files = fs.files(); + assert!(files.contains(&PathBuf::from(path!("/root/shared.txt")))); + + let remaining_sources = [ + PathBuf::from(path!("/root/dir_a/shared.txt")), + PathBuf::from(path!("/root/dir_b/shared.txt")), + ] + .into_iter() + .filter(|path| files.contains(path)) + .count(); + + assert_eq!( + remaining_sources, 1, + "one conflicting source file should remain in place" + ); +} + #[gpui::test] async fn test_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppContext) { init_test(cx); From 2bd5c218552923ece73e5c7e9afccfa8877d904c Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 9 Mar 2026 16:58:31 -0700 Subject: [PATCH 091/219] zeta: Allow the server to select the editable and context ranges more flexibly (#50975) Release Notes: - N/A --------- Co-authored-by: Ben Kunkle --- Cargo.lock | 1 + crates/codestral/Cargo.toml | 1 + crates/codestral/src/codestral.rs | 45 +- crates/edit_prediction/src/capture_example.rs | 42 +- crates/edit_prediction/src/cursor_excerpt.rs | 517 +++++------------- .../src/edit_prediction_tests.rs | 1 + crates/edit_prediction/src/fim.rs | 36 +- crates/edit_prediction/src/mercury.rs | 65 +-- crates/edit_prediction/src/prediction.rs | 1 + crates/edit_prediction/src/sweep_ai.rs | 1 + crates/edit_prediction/src/zeta.rs | 38 +- .../edit_prediction_cli/src/load_project.rs | 22 +- .../src/retrieve_context.rs | 1 + .../src/reversal_tracking.rs | 1 + crates/zeta_prompt/src/excerpt_ranges.rs | 443 +++++++++++++++ crates/zeta_prompt/src/zeta_prompt.rs | 73 ++- 16 files changed, 761 insertions(+), 527 deletions(-) create mode 100644 crates/zeta_prompt/src/excerpt_ranges.rs diff --git a/Cargo.lock b/Cargo.lock index c549c3b6bfd932bfbec26cebfac3ede79df4d256..3d2ade2ddeab584b8a7ea45590248bdf97e89e57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3202,6 +3202,7 @@ dependencies = [ "serde", "serde_json", "text", + "zeta_prompt", ] [[package]] diff --git a/crates/codestral/Cargo.toml b/crates/codestral/Cargo.toml index 2addcf110a7c8194538523077d09af9d5104bd0d..0daaee8fb1420c76757ca898655e8dd1a5244d7e 100644 --- a/crates/codestral/Cargo.toml +++ b/crates/codestral/Cargo.toml @@ -22,5 +22,6 @@ log.workspace = true serde.workspace = true serde_json.workspace = true text.workspace = true +zeta_prompt.workspace = true [dev-dependencies] diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index 32436ecc374bef86e3e9a7587acab72741264796..3930e2e873a91618bfae456bc188bbd90ffa64b9 100644 --- a/crates/codestral/src/codestral.rs +++ b/crates/codestral/src/codestral.rs @@ -8,7 +8,7 @@ use gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task}; use http_client::HttpClient; use icons::IconName; use language::{ - Anchor, Buffer, BufferSnapshot, EditPreview, ToPoint, language_settings::all_language_settings, + Anchor, Buffer, BufferSnapshot, EditPreview, language_settings::all_language_settings, }; use language_model::{ApiKeyState, AuthenticateError, EnvVar, env_var}; use serde::{Deserialize, Serialize}; @@ -18,7 +18,7 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -use text::{OffsetRangeExt as _, ToOffset}; +use text::ToOffset; pub const CODESTRAL_API_URL: &str = "https://codestral.mistral.ai"; pub const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(150); @@ -259,28 +259,31 @@ impl EditPredictionDelegate for CodestralEditPredictionDelegate { } let cursor_offset = cursor_position.to_offset(&snapshot); - let cursor_point = cursor_offset.to_point(&snapshot); + const MAX_EDITABLE_TOKENS: usize = 350; const MAX_CONTEXT_TOKENS: usize = 150; - const MAX_REWRITE_TOKENS: usize = 350; - - let (_, context_range) = - cursor_excerpt::editable_and_context_ranges_for_cursor_position( - cursor_point, - &snapshot, - MAX_REWRITE_TOKENS, - MAX_CONTEXT_TOKENS, - ); - - let context_range = context_range.to_offset(&snapshot); - let excerpt_text = snapshot - .text_for_range(context_range.clone()) - .collect::(); - let cursor_within_excerpt = cursor_offset + + let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) = + cursor_excerpt::compute_cursor_excerpt(&snapshot, cursor_offset); + let syntax_ranges = cursor_excerpt::compute_syntax_ranges( + &snapshot, + cursor_offset, + &excerpt_offset_range, + ); + let excerpt_text: String = snapshot.text_for_range(excerpt_point_range).collect(); + let (_, context_range) = zeta_prompt::compute_editable_and_context_ranges( + &excerpt_text, + cursor_offset_in_excerpt, + &syntax_ranges, + MAX_EDITABLE_TOKENS, + MAX_CONTEXT_TOKENS, + ); + let context_text = &excerpt_text[context_range.clone()]; + let cursor_within_excerpt = cursor_offset_in_excerpt .saturating_sub(context_range.start) - .min(excerpt_text.len()); - let prompt = excerpt_text[..cursor_within_excerpt].to_string(); - let suffix = excerpt_text[cursor_within_excerpt..].to_string(); + .min(context_text.len()); + let prompt = context_text[..cursor_within_excerpt].to_string(); + let suffix = context_text[cursor_within_excerpt..].to_string(); let completion_text = match Self::fetch_completion( http_client, diff --git a/crates/edit_prediction/src/capture_example.rs b/crates/edit_prediction/src/capture_example.rs index e0df8cf957747256f86fe5d7f0d63d2ec873d9ca..d21df7868162d279cb18aeea3ef04d4ea9d7be7f 100644 --- a/crates/edit_prediction/src/capture_example.rs +++ b/crates/edit_prediction/src/capture_example.rs @@ -1,12 +1,9 @@ -use crate::{ - StoredEvent, cursor_excerpt::editable_and_context_ranges_for_cursor_position, - example_spec::ExampleSpec, -}; +use crate::{StoredEvent, example_spec::ExampleSpec}; use anyhow::Result; use buffer_diff::BufferDiffSnapshot; use collections::HashMap; use gpui::{App, Entity, Task}; -use language::{Buffer, ToPoint as _}; +use language::Buffer; use project::{Project, WorktreeId}; use std::{collections::hash_map, fmt::Write as _, ops::Range, path::Path, sync::Arc}; use text::{BufferSnapshot as TextBufferSnapshot, Point}; @@ -157,17 +154,34 @@ fn compute_cursor_excerpt( cursor_anchor: language::Anchor, ) -> (String, usize, Range) { use text::ToOffset as _; + use text::ToPoint as _; - let cursor_point = cursor_anchor.to_point(snapshot); - let (_editable_range, context_range) = - editable_and_context_ranges_for_cursor_position(cursor_point, snapshot, 100, 50); - let context_start_offset = context_range.start.to_offset(snapshot); let cursor_offset = cursor_anchor.to_offset(snapshot); - let cursor_offset_in_excerpt = cursor_offset.saturating_sub(context_start_offset); - let excerpt = snapshot - .text_for_range(context_range.clone()) - .collect::(); - (excerpt, cursor_offset_in_excerpt, context_range) + let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) = + crate::cursor_excerpt::compute_cursor_excerpt(snapshot, cursor_offset); + let syntax_ranges = crate::cursor_excerpt::compute_syntax_ranges( + snapshot, + cursor_offset, + &excerpt_offset_range, + ); + let excerpt_text: String = snapshot.text_for_range(excerpt_point_range).collect(); + let (_, context_range) = zeta_prompt::compute_editable_and_context_ranges( + &excerpt_text, + cursor_offset_in_excerpt, + &syntax_ranges, + 100, + 50, + ); + let context_text = excerpt_text[context_range.clone()].to_string(); + let cursor_in_context = cursor_offset_in_excerpt.saturating_sub(context_range.start); + let context_buffer_start = + (excerpt_offset_range.start + context_range.start).to_point(snapshot); + let context_buffer_end = (excerpt_offset_range.start + context_range.end).to_point(snapshot); + ( + context_text, + cursor_in_context, + context_buffer_start..context_buffer_end, + ) } async fn collect_snapshots( diff --git a/crates/edit_prediction/src/cursor_excerpt.rs b/crates/edit_prediction/src/cursor_excerpt.rs index 690e7001bd45ab3d9a995b4dfd43c2e8e297dbe9..27e6cd987a292c71842377226052b665d5a51fbe 100644 --- a/crates/edit_prediction/src/cursor_excerpt.rs +++ b/crates/edit_prediction/src/cursor_excerpt.rs @@ -1,150 +1,30 @@ -use language::{BufferSnapshot, Point}; +use language::{BufferSnapshot, Point, ToPoint as _}; use std::ops::Range; use text::OffsetRangeExt as _; -use zeta_prompt::ExcerptRanges; -/// Computes all range variants for a cursor position: editable ranges at 150, 180, and 350 -/// token budgets, plus their corresponding context expansions. Returns the full excerpt range -/// (union of all context ranges) and the individual sub-ranges as Points. -pub fn compute_excerpt_ranges( - position: Point, - snapshot: &BufferSnapshot, -) -> (Range, Range, ExcerptRanges) { - let editable_150 = compute_editable_range(snapshot, position, 150); - let editable_180 = compute_editable_range(snapshot, position, 180); - let editable_350 = compute_editable_range(snapshot, position, 350); - let editable_512 = compute_editable_range(snapshot, position, 512); - - let editable_150_context_350 = - expand_context_syntactically_then_linewise(snapshot, editable_150.clone(), 350); - let editable_180_context_350 = - expand_context_syntactically_then_linewise(snapshot, editable_180.clone(), 350); - let editable_350_context_150 = - expand_context_syntactically_then_linewise(snapshot, editable_350.clone(), 150); - let editable_350_context_512 = - expand_context_syntactically_then_linewise(snapshot, editable_350.clone(), 512); - let editable_350_context_1024 = - expand_context_syntactically_then_linewise(snapshot, editable_350.clone(), 1024); - let context_4096 = expand_context_syntactically_then_linewise( - snapshot, - editable_350_context_1024.clone(), - 4096 - 1024, - ); - let context_8192 = - expand_context_syntactically_then_linewise(snapshot, context_4096.clone(), 8192 - 4096); - - let full_start_row = context_8192.start.row; - let full_end_row = context_8192.end.row; - - let full_context = - Point::new(full_start_row, 0)..Point::new(full_end_row, snapshot.line_len(full_end_row)); - - let full_context_offset_range = full_context.to_offset(snapshot); - - let to_offset = |range: &Range| -> Range { - let start = range.start.to_offset(snapshot); - let end = range.end.to_offset(snapshot); - (start - full_context_offset_range.start)..(end - full_context_offset_range.start) - }; - - let ranges = ExcerptRanges { - editable_150: to_offset(&editable_150), - editable_180: to_offset(&editable_180), - editable_350: to_offset(&editable_350), - editable_512: Some(to_offset(&editable_512)), - editable_150_context_350: to_offset(&editable_150_context_350), - editable_180_context_350: to_offset(&editable_180_context_350), - editable_350_context_150: to_offset(&editable_350_context_150), - editable_350_context_512: Some(to_offset(&editable_350_context_512)), - editable_350_context_1024: Some(to_offset(&editable_350_context_1024)), - context_4096: Some(to_offset(&context_4096)), - context_8192: Some(to_offset(&context_8192)), - }; - - (full_context, full_context_offset_range, ranges) -} - -pub fn editable_and_context_ranges_for_cursor_position( - position: Point, - snapshot: &BufferSnapshot, - editable_region_token_limit: usize, - context_token_limit: usize, -) -> (Range, Range) { - let editable_range = compute_editable_range(snapshot, position, editable_region_token_limit); - - let context_range = expand_context_syntactically_then_linewise( - snapshot, - editable_range.clone(), - context_token_limit, - ); - - (editable_range, context_range) -} +const CURSOR_EXCERPT_TOKEN_BUDGET: usize = 8192; -/// Computes the editable range using a three-phase approach: -/// 1. Expand symmetrically from cursor (75% of budget) -/// 2. Expand to syntax boundaries -/// 3. Continue line-wise in the least-expanded direction -fn compute_editable_range( +/// Computes a cursor excerpt as the largest linewise symmetric region around +/// the cursor that fits within an 8192-token budget. Returns the point range, +/// byte offset range, and the cursor offset relative to the excerpt start. +pub fn compute_cursor_excerpt( snapshot: &BufferSnapshot, - cursor: Point, - token_limit: usize, -) -> Range { - // Phase 1: Expand symmetrically from cursor using 75% of budget. - let initial_budget = (token_limit * 3) / 4; - let (mut start_row, mut end_row, mut remaining_tokens) = - expand_symmetric_from_cursor(snapshot, cursor.row, initial_budget); - - // Add remaining budget from phase 1. - remaining_tokens += token_limit.saturating_sub(initial_budget); - - let original_start = start_row; - let original_end = end_row; - - // Phase 2: Expand to syntax boundaries that fit within budget. - for (boundary_start, boundary_end) in containing_syntax_boundaries(snapshot, start_row, end_row) - { - let tokens_for_start = if boundary_start < start_row { - estimate_tokens_for_rows(snapshot, boundary_start, start_row) - } else { - 0 - }; - let tokens_for_end = if boundary_end > end_row { - estimate_tokens_for_rows(snapshot, end_row + 1, boundary_end + 1) - } else { - 0 - }; - - let total_needed = tokens_for_start + tokens_for_end; - - if total_needed <= remaining_tokens { - if boundary_start < start_row { - start_row = boundary_start; - } - if boundary_end > end_row { - end_row = boundary_end; - } - remaining_tokens = remaining_tokens.saturating_sub(total_needed); - } else { - break; - } - } - - // Phase 3: Continue line-wise in the direction we expanded least during syntax phase. - let expanded_up = original_start.saturating_sub(start_row); - let expanded_down = end_row.saturating_sub(original_end); - - (start_row, end_row, _) = expand_linewise_biased( - snapshot, - start_row, - end_row, - remaining_tokens, - expanded_up <= expanded_down, // prefer_up if we expanded less upward - ); - - let start = Point::new(start_row, 0); - let end = Point::new(end_row, snapshot.line_len(end_row)); - start..end + cursor_offset: usize, +) -> (Range, Range, usize) { + let cursor_point = cursor_offset.to_point(snapshot); + let cursor_row = cursor_point.row; + let (start_row, end_row, _) = + expand_symmetric_from_cursor(snapshot, cursor_row, CURSOR_EXCERPT_TOKEN_BUDGET); + + let excerpt_range = Point::new(start_row, 0)..Point::new(end_row, snapshot.line_len(end_row)); + let excerpt_offset_range = excerpt_range.to_offset(snapshot); + let cursor_offset_in_excerpt = cursor_offset - excerpt_offset_range.start; + + ( + excerpt_range, + excerpt_offset_range, + cursor_offset_in_excerpt, + ) } /// Expands symmetrically from cursor, one line at a time, alternating down then up. @@ -157,7 +37,6 @@ fn expand_symmetric_from_cursor( let mut start_row = cursor_row; let mut end_row = cursor_row; - // Account for the cursor's line. let cursor_line_tokens = line_token_count(snapshot, cursor_row); token_budget = token_budget.saturating_sub(cursor_line_tokens); @@ -169,7 +48,6 @@ fn expand_symmetric_from_cursor( break; } - // Expand down first (slight forward bias for edit prediction). if can_expand_down { let next_row = end_row + 1; let line_tokens = line_token_count(snapshot, next_row); @@ -181,7 +59,6 @@ fn expand_symmetric_from_cursor( } } - // Then expand up. if can_expand_up && token_budget > 0 { let next_row = start_row - 1; let line_tokens = line_token_count(snapshot, next_row); @@ -197,74 +74,6 @@ fn expand_symmetric_from_cursor( (start_row, end_row, token_budget) } -/// Expands line-wise with a bias toward one direction. -/// Returns (start_row, end_row, remaining_tokens). -fn expand_linewise_biased( - snapshot: &BufferSnapshot, - mut start_row: u32, - mut end_row: u32, - mut remaining_tokens: usize, - prefer_up: bool, -) -> (u32, u32, usize) { - loop { - let can_expand_up = start_row > 0; - let can_expand_down = end_row < snapshot.max_point().row; - - if remaining_tokens == 0 || (!can_expand_up && !can_expand_down) { - break; - } - - let mut expanded = false; - - // Try preferred direction first. - if prefer_up { - if can_expand_up { - let next_row = start_row - 1; - let line_tokens = line_token_count(snapshot, next_row); - if line_tokens <= remaining_tokens { - start_row = next_row; - remaining_tokens = remaining_tokens.saturating_sub(line_tokens); - expanded = true; - } - } - if can_expand_down && remaining_tokens > 0 { - let next_row = end_row + 1; - let line_tokens = line_token_count(snapshot, next_row); - if line_tokens <= remaining_tokens { - end_row = next_row; - remaining_tokens = remaining_tokens.saturating_sub(line_tokens); - expanded = true; - } - } - } else { - if can_expand_down { - let next_row = end_row + 1; - let line_tokens = line_token_count(snapshot, next_row); - if line_tokens <= remaining_tokens { - end_row = next_row; - remaining_tokens = remaining_tokens.saturating_sub(line_tokens); - expanded = true; - } - } - if can_expand_up && remaining_tokens > 0 { - let next_row = start_row - 1; - let line_tokens = line_token_count(snapshot, next_row); - if line_tokens <= remaining_tokens { - start_row = next_row; - remaining_tokens = remaining_tokens.saturating_sub(line_tokens); - expanded = true; - } - } - } - - if !expanded { - break; - } - } - - (start_row, end_row, remaining_tokens) -} - /// Typical number of string bytes per token for the purposes of limiting model input. This is /// intentionally low to err on the side of underestimating limits. pub(crate) const BYTES_PER_TOKEN_GUESS: usize = 3; @@ -277,113 +86,50 @@ fn line_token_count(snapshot: &BufferSnapshot, row: u32) -> usize { guess_token_count(snapshot.line_len(row) as usize).max(1) } -/// Estimates token count for rows in range [start_row, end_row). -fn estimate_tokens_for_rows(snapshot: &BufferSnapshot, start_row: u32, end_row: u32) -> usize { - let mut tokens = 0; - for row in start_row..end_row { - tokens += line_token_count(snapshot, row); - } - tokens -} - -/// Returns an iterator of (start_row, end_row) for successively larger syntax nodes -/// containing the given row range. Smallest containing node first. -fn containing_syntax_boundaries( +/// Computes the byte offset ranges of all syntax nodes containing the cursor, +/// ordered from innermost to outermost. The offsets are relative to +/// `excerpt_offset_range.start`. +pub fn compute_syntax_ranges( snapshot: &BufferSnapshot, - start_row: u32, - end_row: u32, -) -> impl Iterator { - let range = Point::new(start_row, 0)..Point::new(end_row, snapshot.line_len(end_row)); + cursor_offset: usize, + excerpt_offset_range: &Range, +) -> Vec> { + let cursor_point = cursor_offset.to_point(snapshot); + let range = cursor_point..cursor_point; let mut current = snapshot.syntax_ancestor(range); - let mut last_rows: Option<(u32, u32)> = None; - - std::iter::from_fn(move || { - while let Some(node) = current.take() { - let node_start_row = node.start_position().row as u32; - let node_end_row = node.end_position().row as u32; - let rows = (node_start_row, node_end_row); - - current = node.parent(); - - // Skip nodes that don't extend beyond our range. - if node_start_row >= start_row && node_end_row <= end_row { - continue; - } + let mut ranges = Vec::new(); + let mut last_range: Option<(usize, usize)> = None; - // Skip if same as last returned (some nodes have same span). - if last_rows == Some(rows) { - continue; - } + while let Some(node) = current.take() { + let node_start = node.start_byte(); + let node_end = node.end_byte(); + let key = (node_start, node_end); - last_rows = Some(rows); - return Some(rows); - } - None - }) -} + current = node.parent(); -/// Expands context by first trying to reach syntax boundaries, -/// then expanding line-wise only if no syntax expansion occurred. -fn expand_context_syntactically_then_linewise( - snapshot: &BufferSnapshot, - editable_range: Range, - context_token_limit: usize, -) -> Range { - let mut start_row = editable_range.start.row; - let mut end_row = editable_range.end.row; - let mut remaining_tokens = context_token_limit; - let mut did_syntax_expand = false; - - // Phase 1: Try to expand to containing syntax boundaries, picking the largest that fits. - for (boundary_start, boundary_end) in containing_syntax_boundaries(snapshot, start_row, end_row) - { - let tokens_for_start = if boundary_start < start_row { - estimate_tokens_for_rows(snapshot, boundary_start, start_row) - } else { - 0 - }; - let tokens_for_end = if boundary_end > end_row { - estimate_tokens_for_rows(snapshot, end_row + 1, boundary_end + 1) - } else { - 0 - }; - - let total_needed = tokens_for_start + tokens_for_end; - - if total_needed <= remaining_tokens { - if boundary_start < start_row { - start_row = boundary_start; - } - if boundary_end > end_row { - end_row = boundary_end; - } - remaining_tokens = remaining_tokens.saturating_sub(total_needed); - did_syntax_expand = true; - } else { - break; + if last_range == Some(key) { + continue; } - } + last_range = Some(key); - // Phase 2: Only expand line-wise if no syntax expansion occurred. - if !did_syntax_expand { - (start_row, end_row, _) = - expand_linewise_biased(snapshot, start_row, end_row, remaining_tokens, true); + let start = node_start.saturating_sub(excerpt_offset_range.start); + let end = node_end + .min(excerpt_offset_range.end) + .saturating_sub(excerpt_offset_range.start); + ranges.push(start..end); } - let start = Point::new(start_row, 0); - let end = Point::new(end_row, snapshot.line_len(end_row)); - start..end + ranges } -use language::ToOffset as _; - #[cfg(test)] mod tests { use super::*; - use gpui::{App, AppContext}; + use gpui::{App, AppContext as _}; use indoc::indoc; use language::{Buffer, rust_lang}; use util::test::{TextRangeMarker, marked_text_ranges_by}; + use zeta_prompt::compute_editable_and_context_ranges; struct TestCase { name: &'static str, @@ -400,7 +146,18 @@ mod tests { // [ ] = expected context range let test_cases = vec![ TestCase { - name: "cursor near end of function - expands to syntax boundaries", + name: "small function fits entirely in editable and context", + marked_text: indoc! {r#" + [«fn foo() { + let x = 1;ˇ + let y = 2; + }»] + "#}, + editable_token_limit: 30, + context_token_limit: 60, + }, + TestCase { + name: "cursor near end of function - editable expands to syntax boundaries", marked_text: indoc! {r#" [fn first() { let a = 1; @@ -413,12 +170,11 @@ mod tests { println!("{}", x + y);ˇ }»] "#}, - // 18 tokens - expands symmetrically then to syntax boundaries editable_token_limit: 18, context_token_limit: 35, }, TestCase { - name: "cursor at function start - expands to syntax boundaries", + name: "cursor at function start - editable expands to syntax boundaries", marked_text: indoc! {r#" [fn before() { « let a = 1; @@ -434,12 +190,11 @@ mod tests { let b = 2; }] "#}, - // 25 tokens - expands symmetrically then to syntax boundaries editable_token_limit: 25, context_token_limit: 50, }, TestCase { - name: "tiny budget - just lines around cursor", + name: "tiny budget - just lines around cursor, no syntax expansion", marked_text: indoc! {r#" fn outer() { [ let line1 = 1; @@ -451,22 +206,9 @@ mod tests { let line7 = 7; } "#}, - // 12 tokens (~36 bytes) = just the cursor line with tiny budget editable_token_limit: 12, context_token_limit: 24, }, - TestCase { - name: "small function fits entirely", - marked_text: indoc! {r#" - [«fn foo() { - let x = 1;ˇ - let y = 2; - }»] - "#}, - // Plenty of budget for this small function - editable_token_limit: 30, - context_token_limit: 60, - }, TestCase { name: "context extends beyond editable", marked_text: indoc! {r#" @@ -476,13 +218,11 @@ mod tests { fn fourth() { let d = 4; }» fn fifth() { let e = 5; }] "#}, - // Small editable, larger context editable_token_limit: 25, context_token_limit: 45, }, - // Tests for syntax-aware editable and context expansion TestCase { - name: "cursor in first if-statement - expands to syntax boundaries", + name: "cursor in first if-block - editable expands to syntax boundaries", marked_text: indoc! {r#" [«fn before() { } @@ -503,13 +243,11 @@ mod tests { fn after() { }] "#}, - // 35 tokens allows expansion to include function header and first two if blocks editable_token_limit: 35, - // 60 tokens allows context to include the whole file context_token_limit: 60, }, TestCase { - name: "cursor in middle if-statement - expands to syntax boundaries", + name: "cursor in middle if-block - editable spans surrounding blocks", marked_text: indoc! {r#" [fn before() { } @@ -530,13 +268,11 @@ mod tests { fn after() { }] "#}, - // 40 tokens allows expansion to surrounding if blocks editable_token_limit: 40, - // 60 tokens allows context to include the whole file context_token_limit: 60, }, TestCase { - name: "cursor near bottom of long function - editable expands toward syntax, context reaches function", + name: "cursor near bottom of long function - context reaches function boundary", marked_text: indoc! {r#" [fn other() { } @@ -556,11 +292,30 @@ mod tests { fn another() { }»] "#}, - // 40 tokens for editable - allows several lines plus syntax expansion editable_token_limit: 40, - // 55 tokens - enough for function but not whole file context_token_limit: 55, }, + TestCase { + name: "zero context budget - context equals editable", + marked_text: indoc! {r#" + fn before() { + let p = 1; + let q = 2; + [«} + + fn foo() { + let x = 1;ˇ + let y = 2; + } + »] + fn after() { + let r = 3; + let s = 4; + } + "#}, + editable_token_limit: 15, + context_token_limit: 0, + }, ]; for test_case in test_cases { @@ -580,75 +335,63 @@ mod tests { let cursor_ranges = ranges.remove(&cursor_marker).unwrap_or_default(); let expected_editable = ranges.remove(&editable_marker).unwrap_or_default(); let expected_context = ranges.remove(&context_marker).unwrap_or_default(); - assert_eq!(expected_editable.len(), 1); - assert_eq!(expected_context.len(), 1); + assert_eq!(expected_editable.len(), 1, "{}", test_case.name); + assert_eq!(expected_context.len(), 1, "{}", test_case.name); - cx.new(|cx| { + cx.new(|cx: &mut gpui::Context| { let text = text.trim_end_matches('\n'); let buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); let snapshot = buffer.snapshot(); let cursor_offset = cursor_ranges[0].start; - let cursor_point = snapshot.offset_to_point(cursor_offset); - let expected_editable_start = snapshot.offset_to_point(expected_editable[0].start); - let expected_editable_end = snapshot.offset_to_point(expected_editable[0].end); - let expected_context_start = snapshot.offset_to_point(expected_context[0].start); - let expected_context_end = snapshot.offset_to_point(expected_context[0].end); - - let (actual_editable, actual_context) = - editable_and_context_ranges_for_cursor_position( - cursor_point, - &snapshot, - test_case.editable_token_limit, - test_case.context_token_limit, - ); - - let range_text = |start: Point, end: Point| -> String { - snapshot.text_for_range(start..end).collect() + + let (_, excerpt_offset_range, cursor_offset_in_excerpt) = + compute_cursor_excerpt(&snapshot, cursor_offset); + let excerpt_text: String = snapshot + .text_for_range(excerpt_offset_range.clone()) + .collect(); + let syntax_ranges = + compute_syntax_ranges(&snapshot, cursor_offset, &excerpt_offset_range); + + let (actual_editable, actual_context) = compute_editable_and_context_ranges( + &excerpt_text, + cursor_offset_in_excerpt, + &syntax_ranges, + test_case.editable_token_limit, + test_case.context_token_limit, + ); + + let to_buffer_range = |range: Range| -> Range { + (excerpt_offset_range.start + range.start) + ..(excerpt_offset_range.start + range.end) }; - let editable_match = actual_editable.start == expected_editable_start - && actual_editable.end == expected_editable_end; - let context_match = actual_context.start == expected_context_start - && actual_context.end == expected_context_end; + let actual_editable = to_buffer_range(actual_editable); + let actual_context = to_buffer_range(actual_context); + + let expected_editable_range = expected_editable[0].clone(); + let expected_context_range = expected_context[0].clone(); + + let editable_match = actual_editable == expected_editable_range; + let context_match = actual_context == expected_context_range; if !editable_match || !context_match { + let range_text = |range: &Range| { + snapshot.text_for_range(range.clone()).collect::() + }; + println!("\n=== FAILED: {} ===", test_case.name); if !editable_match { - println!( - "\nExpected editable ({:?}..{:?}):", - expected_editable_start, expected_editable_end - ); - println!( - "---\n{}---", - range_text(expected_editable_start, expected_editable_end) - ); - println!( - "\nActual editable ({:?}..{:?}):", - actual_editable.start, actual_editable.end - ); - println!( - "---\n{}---", - range_text(actual_editable.start, actual_editable.end) - ); + println!("\nExpected editable ({:?}):", expected_editable_range); + println!("---\n{}---", range_text(&expected_editable_range)); + println!("\nActual editable ({:?}):", actual_editable); + println!("---\n{}---", range_text(&actual_editable)); } if !context_match { - println!( - "\nExpected context ({:?}..{:?}):", - expected_context_start, expected_context_end - ); - println!( - "---\n{}---", - range_text(expected_context_start, expected_context_end) - ); - println!( - "\nActual context ({:?}..{:?}):", - actual_context.start, actual_context.end - ); - println!( - "---\n{}---", - range_text(actual_context.start, actual_context.end) - ); + println!("\nExpected context ({:?}):", expected_context_range); + println!("---\n{}---", range_text(&expected_context_range)); + println!("\nActual context ({:?}):", actual_context); + println!("---\n{}---", range_text(&actual_context)); } panic!("Test '{}' failed - see output above", test_case.name); } diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 1ff77fd900db80894b973e79d8fe69e9d65a1e3b..66d9c940dda21d7068ad6dec0976520dee2750e7 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1890,6 +1890,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { cursor_offset_in_excerpt: 0, excerpt_start_row: None, excerpt_ranges: Default::default(), + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, diff --git a/crates/edit_prediction/src/fim.rs b/crates/edit_prediction/src/fim.rs index 79df739e60bc28ba5c6b9f53699dcf398fc8310e..1a64506f00285791a83c38943253157137d592f1 100644 --- a/crates/edit_prediction/src/fim.rs +++ b/crates/edit_prediction/src/fim.rs @@ -6,12 +6,12 @@ use crate::{ use anyhow::{Context as _, Result, anyhow}; use gpui::{App, AppContext as _, Entity, Task}; use language::{ - Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, ToOffset, ToPoint as _, + Anchor, Buffer, BufferSnapshot, ToOffset, ToPoint as _, language_settings::all_language_settings, }; use settings::EditPredictionPromptFormat; use std::{path::Path, sync::Arc, time::Instant}; -use zeta_prompt::ZetaPromptInput; +use zeta_prompt::{ZetaPromptInput, compute_editable_and_context_ranges}; const FIM_CONTEXT_TOKENS: usize = 512; @@ -62,34 +62,42 @@ pub fn request_prediction( let api_key = load_open_ai_compatible_api_key_if_needed(provider, cx); let result = cx.background_spawn(async move { - let (excerpt_range, _) = cursor_excerpt::editable_and_context_ranges_for_cursor_position( - cursor_point, - &snapshot, + let cursor_offset = cursor_point.to_offset(&snapshot); + let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) = + cursor_excerpt::compute_cursor_excerpt(&snapshot, cursor_offset); + let cursor_excerpt: Arc = snapshot + .text_for_range(excerpt_point_range.clone()) + .collect::() + .into(); + let syntax_ranges = + cursor_excerpt::compute_syntax_ranges(&snapshot, cursor_offset, &excerpt_offset_range); + let (editable_range, _) = compute_editable_and_context_ranges( + &cursor_excerpt, + cursor_offset_in_excerpt, + &syntax_ranges, FIM_CONTEXT_TOKENS, 0, ); - let excerpt_offset_range = excerpt_range.to_offset(&snapshot); - let cursor_offset = cursor_point.to_offset(&snapshot); let inputs = ZetaPromptInput { events, related_files: Some(Vec::new()), cursor_offset_in_excerpt: cursor_offset - excerpt_offset_range.start, cursor_path: full_path.clone(), - excerpt_start_row: Some(excerpt_range.start.row), - cursor_excerpt: snapshot - .text_for_range(excerpt_range) - .collect::() - .into(), + excerpt_start_row: Some(excerpt_point_range.start.row), + cursor_excerpt, excerpt_ranges: Default::default(), + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, repo_url: None, }; - let prefix = inputs.cursor_excerpt[..inputs.cursor_offset_in_excerpt].to_string(); - let suffix = inputs.cursor_excerpt[inputs.cursor_offset_in_excerpt..].to_string(); + let editable_text = &inputs.cursor_excerpt[editable_range.clone()]; + let cursor_in_editable = cursor_offset_in_excerpt.saturating_sub(editable_range.start); + let prefix = editable_text[..cursor_in_editable].to_string(); + let suffix = editable_text[cursor_in_editable..].to_string(); let prompt = format_fim_prompt(prompt_format, &prefix, &suffix); let stop_tokens = get_fim_stop_tokens(); diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index 0d63005feb18acb9a434ff107811080a7bcf1f12..1a88ba1f1f83a11f89ac282db46f91a3ee752f58 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -10,17 +10,14 @@ use gpui::{ App, AppContext as _, Entity, Global, SharedString, Task, http_client::{self, AsyncBody, HttpClient, Method}, }; -use language::{OffsetRangeExt as _, ToOffset, ToPoint as _}; +use language::{ToOffset, ToPoint as _}; use language_model::{ApiKeyState, EnvVar, env_var}; use release_channel::AppVersion; use serde::Serialize; use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant}; - -use zeta_prompt::{ExcerptRanges, ZetaPromptInput}; +use zeta_prompt::ZetaPromptInput; const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions"; -const MAX_REWRITE_TOKENS: usize = 150; -const MAX_CONTEXT_TOKENS: usize = 350; pub struct Mercury { pub api_token: Entity, @@ -64,52 +61,46 @@ impl Mercury { let active_buffer = buffer.clone(); let result = cx.background_spawn(async move { - let (editable_range, context_range) = - crate::cursor_excerpt::editable_and_context_ranges_for_cursor_position( - cursor_point, - &snapshot, - MAX_CONTEXT_TOKENS, - MAX_REWRITE_TOKENS, - ); + let cursor_offset = cursor_point.to_offset(&snapshot); + let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) = + crate::cursor_excerpt::compute_cursor_excerpt(&snapshot, cursor_offset); let related_files = zeta_prompt::filter_redundant_excerpts( related_files, full_path.as_ref(), - context_range.start.row..context_range.end.row, + excerpt_point_range.start.row..excerpt_point_range.end.row, ); - let context_offset_range = context_range.to_offset(&snapshot); - let context_start_row = context_range.start.row; - - let editable_offset_range = editable_range.to_offset(&snapshot); + let cursor_excerpt: Arc = snapshot + .text_for_range(excerpt_point_range.clone()) + .collect::() + .into(); + let syntax_ranges = crate::cursor_excerpt::compute_syntax_ranges( + &snapshot, + cursor_offset, + &excerpt_offset_range, + ); + let excerpt_ranges = zeta_prompt::compute_legacy_excerpt_ranges( + &cursor_excerpt, + cursor_offset_in_excerpt, + &syntax_ranges, + ); - let editable_range_in_excerpt = (editable_offset_range.start - - context_offset_range.start) - ..(editable_offset_range.end - context_offset_range.start); - let context_range_in_excerpt = - 0..(context_offset_range.end - context_offset_range.start); + let editable_offset_range = (excerpt_offset_range.start + + excerpt_ranges.editable_350.start) + ..(excerpt_offset_range.start + excerpt_ranges.editable_350.end); let inputs = zeta_prompt::ZetaPromptInput { events, related_files: Some(related_files), cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) - - context_offset_range.start, + - excerpt_offset_range.start, cursor_path: full_path.clone(), - cursor_excerpt: snapshot - .text_for_range(context_range) - .collect::() - .into(), + cursor_excerpt, experiment: None, - excerpt_start_row: Some(context_start_row), - excerpt_ranges: ExcerptRanges { - editable_150: editable_range_in_excerpt.clone(), - editable_180: editable_range_in_excerpt.clone(), - editable_350: editable_range_in_excerpt.clone(), - editable_150_context_350: context_range_in_excerpt.clone(), - editable_180_context_350: context_range_in_excerpt.clone(), - editable_350_context_150: context_range_in_excerpt.clone(), - ..Default::default() - }, + excerpt_start_row: Some(excerpt_point_range.start.row), + excerpt_ranges, + syntax_ranges: Some(syntax_ranges), in_open_source_repo: false, can_collect_data: false, repo_url: None, diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs index 1c281453b93d0ab7c601f575b290c46fe63d2eae..ec4694c862be3ff1937dadc08ebab11b115b4cac 100644 --- a/crates/edit_prediction/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -162,6 +162,7 @@ mod tests { cursor_excerpt: "".into(), excerpt_start_row: None, excerpt_ranges: Default::default(), + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs index ff5128e56e49191f308a574d5502f8139db9bc3f..d4e59885c86f44ceff98487940f2aaa435085e4d 100644 --- a/crates/edit_prediction/src/sweep_ai.rs +++ b/crates/edit_prediction/src/sweep_ai.rs @@ -226,6 +226,7 @@ impl SweepAi { editable_350_context_150: 0..inputs.snapshot.len(), ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 1a4d0b445a8c3d5876eb48646a0a1622a8b725a2..9362425c24df4199e40f97ac1278753c954a2f36 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -1,7 +1,8 @@ use crate::{ CurrentEditPrediction, DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, StoredEvent, - ZedUpdateRequiredError, cursor_excerpt::compute_excerpt_ranges, + ZedUpdateRequiredError, + cursor_excerpt::{compute_cursor_excerpt, compute_syntax_ranges}, prediction::EditPredictionResult, }; use anyhow::Result; @@ -11,8 +12,7 @@ use cloud_llm_client::{ use edit_prediction_types::PredictedCursorPosition; use gpui::{App, AppContext as _, Entity, Task, WeakEntity, prelude::*}; use language::{ - Buffer, BufferSnapshot, ToOffset as _, ToPoint, language_settings::all_language_settings, - text_diff, + Buffer, BufferSnapshot, ToOffset as _, language_settings::all_language_settings, text_diff, }; use release_channel::AppVersion; use settings::EditPredictionPromptFormat; @@ -490,33 +490,35 @@ pub fn zeta2_prompt_input( can_collect_data: bool, repo_url: Option, ) -> (Range, zeta_prompt::ZetaPromptInput) { - let cursor_point = cursor_offset.to_point(snapshot); - - let (full_context, full_context_offset_range, excerpt_ranges) = - compute_excerpt_ranges(cursor_point, snapshot); - - let full_context_start_offset = full_context_offset_range.start; - let full_context_start_row = full_context.start.row; - - let cursor_offset_in_excerpt = cursor_offset - full_context_start_offset; + let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) = + compute_cursor_excerpt(snapshot, cursor_offset); + + let cursor_excerpt: Arc = snapshot + .text_for_range(excerpt_point_range.clone()) + .collect::() + .into(); + let syntax_ranges = compute_syntax_ranges(snapshot, cursor_offset, &excerpt_offset_range); + let excerpt_ranges = zeta_prompt::compute_legacy_excerpt_ranges( + &cursor_excerpt, + cursor_offset_in_excerpt, + &syntax_ranges, + ); let prompt_input = zeta_prompt::ZetaPromptInput { cursor_path: excerpt_path, - cursor_excerpt: snapshot - .text_for_range(full_context) - .collect::() - .into(), + cursor_excerpt, cursor_offset_in_excerpt, - excerpt_start_row: Some(full_context_start_row), + excerpt_start_row: Some(excerpt_point_range.start.row), events, related_files: Some(related_files), excerpt_ranges, + syntax_ranges: Some(syntax_ranges), experiment: preferred_experiment, in_open_source_repo: is_open_source, can_collect_data, repo_url, }; - (full_context_offset_range, prompt_input) + (excerpt_offset_range, prompt_input) } pub(crate) fn edit_prediction_accepted( diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs index f7e27ca432baacd38c468e5b4c6f97b62cb8ee3e..a9303451e8b6c6ae798be976a11d3b71fae99758 100644 --- a/crates/edit_prediction_cli/src/load_project.rs +++ b/crates/edit_prediction_cli/src/load_project.rs @@ -7,12 +7,12 @@ use crate::{ use anyhow::{Context as _, Result}; use edit_prediction::{ EditPredictionStore, - cursor_excerpt::compute_excerpt_ranges, + cursor_excerpt::{compute_cursor_excerpt, compute_syntax_ranges}, udiff::{OpenedBuffers, refresh_worktree_entries, strip_diff_path_prefix}, }; use futures::AsyncWriteExt as _; use gpui::{AsyncApp, Entity}; -use language::{Anchor, Buffer, LanguageNotFound, ToOffset, ToPoint}; +use language::{Anchor, Buffer, LanguageNotFound, ToOffset}; use project::{Project, ProjectPath, buffer_store::BufferStoreEvent}; use std::{fs, path::PathBuf, sync::Arc}; use zeta_prompt::ZetaPromptInput; @@ -75,32 +75,36 @@ pub async fn run_load_project( let (prompt_inputs, language_name) = buffer.read_with(&cx, |buffer, _cx| { let snapshot = buffer.snapshot(); - let cursor_point = cursor_position.to_point(&snapshot); let cursor_offset = cursor_position.to_offset(&snapshot); let language_name = buffer .language() .map(|l| l.name().to_string()) .unwrap_or_else(|| "Unknown".to_string()); - let (full_context_point_range, full_context_offset_range, excerpt_ranges) = - compute_excerpt_ranges(cursor_point, &snapshot); + let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) = + compute_cursor_excerpt(&snapshot, cursor_offset); let cursor_excerpt: Arc = buffer - .text_for_range(full_context_offset_range.clone()) + .text_for_range(excerpt_offset_range.clone()) .collect::() .into(); - let cursor_offset_in_excerpt = cursor_offset - full_context_offset_range.start; - let excerpt_start_row = Some(full_context_point_range.start.row); + let syntax_ranges = compute_syntax_ranges(&snapshot, cursor_offset, &excerpt_offset_range); + let excerpt_ranges = zeta_prompt::compute_legacy_excerpt_ranges( + &cursor_excerpt, + cursor_offset_in_excerpt, + &syntax_ranges, + ); ( ZetaPromptInput { cursor_path: example.spec.cursor_path.clone(), cursor_excerpt, cursor_offset_in_excerpt, - excerpt_start_row, + excerpt_start_row: Some(excerpt_point_range.start.row), events, related_files: existing_related_files, excerpt_ranges, + syntax_ranges: Some(syntax_ranges), in_open_source_repo: false, can_collect_data: false, experiment: None, diff --git a/crates/edit_prediction_cli/src/retrieve_context.rs b/crates/edit_prediction_cli/src/retrieve_context.rs index 971bdf24d3e8cd1d8184a9009903cec25d3000d1..f02509ceb061db078d2a9a98b4322cf246b87594 100644 --- a/crates/edit_prediction_cli/src/retrieve_context.rs +++ b/crates/edit_prediction_cli/src/retrieve_context.rs @@ -24,6 +24,7 @@ pub async fn run_context_retrieval( .prompt_inputs .as_ref() .is_some_and(|inputs| inputs.related_files.is_some()) + || example.spec.repository_url.is_empty() { return Ok(()); } diff --git a/crates/edit_prediction_cli/src/reversal_tracking.rs b/crates/edit_prediction_cli/src/reversal_tracking.rs index 398ae24309bbb9368bb7947c94ad4f481c03ab9e..7623041a091acd3c726ba0281f090496255f8014 100644 --- a/crates/edit_prediction_cli/src/reversal_tracking.rs +++ b/crates/edit_prediction_cli/src/reversal_tracking.rs @@ -678,6 +678,7 @@ mod tests { editable_350_context_150: 0..content.len(), ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, diff --git a/crates/zeta_prompt/src/excerpt_ranges.rs b/crates/zeta_prompt/src/excerpt_ranges.rs new file mode 100644 index 0000000000000000000000000000000000000000..40621fe98a13bfa9195293ad29ba549240532a2e --- /dev/null +++ b/crates/zeta_prompt/src/excerpt_ranges.rs @@ -0,0 +1,443 @@ +use std::ops::Range; + +use serde::{Deserialize, Serialize}; + +use crate::estimate_tokens; + +/// Pre-computed byte offset ranges within `cursor_excerpt` for different +/// editable and context token budgets. Allows the server to select the +/// appropriate ranges for whichever model it uses. +#[derive(Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize)] +pub struct ExcerptRanges { + /// Editable region computed with a 150-token budget. + pub editable_150: Range, + /// Editable region computed with a 180-token budget. + pub editable_180: Range, + /// Editable region computed with a 350-token budget. + pub editable_350: Range, + /// Editable region computed with a 350-token budget. + pub editable_512: Option>, + /// Context boundary when using editable_150 with 350 tokens of additional context. + pub editable_150_context_350: Range, + /// Context boundary when using editable_180 with 350 tokens of additional context. + pub editable_180_context_350: Range, + /// Context boundary when using editable_350 with 150 tokens of additional context. + pub editable_350_context_150: Range, + pub editable_350_context_512: Option>, + pub editable_350_context_1024: Option>, + pub context_4096: Option>, + pub context_8192: Option>, +} + +/// Builds an `ExcerptRanges` by computing editable and context ranges for each +/// budget combination, using the syntax-aware logic in +/// `compute_editable_and_context_ranges`. +pub fn compute_legacy_excerpt_ranges( + cursor_excerpt: &str, + cursor_offset: usize, + syntax_ranges: &[Range], +) -> ExcerptRanges { + let compute = |editable_tokens, context_tokens| { + compute_editable_and_context_ranges( + cursor_excerpt, + cursor_offset, + syntax_ranges, + editable_tokens, + context_tokens, + ) + }; + + let (editable_150, editable_150_context_350) = compute(150, 350); + let (editable_180, editable_180_context_350) = compute(180, 350); + let (editable_350, editable_350_context_150) = compute(350, 150); + let (editable_512, _) = compute(512, 0); + let (_, editable_350_context_512) = compute(350, 512); + let (_, editable_350_context_1024) = compute(350, 1024); + let (_, context_4096) = compute(350, 4096); + let (_, context_8192) = compute(350, 8192); + + ExcerptRanges { + editable_150, + editable_180, + editable_350, + editable_512: Some(editable_512), + editable_150_context_350, + editable_180_context_350, + editable_350_context_150, + editable_350_context_512: Some(editable_350_context_512), + editable_350_context_1024: Some(editable_350_context_1024), + context_4096: Some(context_4096), + context_8192: Some(context_8192), + } +} + +/// Given the cursor excerpt text, cursor offset, and the syntax node ranges +/// containing the cursor (innermost to outermost), compute the editable range +/// and context range as byte offset ranges within `cursor_excerpt`. +/// +/// This is the server-side equivalent of `compute_excerpt_ranges` in +/// `edit_prediction::cursor_excerpt`, but operates on plain text with +/// pre-computed syntax boundaries instead of a `BufferSnapshot`. +pub fn compute_editable_and_context_ranges( + cursor_excerpt: &str, + cursor_offset: usize, + syntax_ranges: &[Range], + editable_token_limit: usize, + context_token_limit: usize, +) -> (Range, Range) { + let line_starts = compute_line_starts(cursor_excerpt); + let cursor_row = offset_to_row(&line_starts, cursor_offset); + let max_row = line_starts.len().saturating_sub(1) as u32; + + let editable_range = compute_editable_range_from_text( + cursor_excerpt, + &line_starts, + cursor_row, + max_row, + syntax_ranges, + editable_token_limit, + ); + + let context_range = expand_context_from_text( + cursor_excerpt, + &line_starts, + max_row, + &editable_range, + syntax_ranges, + context_token_limit, + ); + + (editable_range, context_range) +} + +fn compute_line_starts(text: &str) -> Vec { + let mut starts = vec![0]; + for (index, byte) in text.bytes().enumerate() { + if byte == b'\n' { + starts.push(index + 1); + } + } + starts +} + +fn offset_to_row(line_starts: &[usize], offset: usize) -> u32 { + match line_starts.binary_search(&offset) { + Ok(row) => row as u32, + Err(row) => (row.saturating_sub(1)) as u32, + } +} + +fn row_start_offset(line_starts: &[usize], row: u32) -> usize { + line_starts.get(row as usize).copied().unwrap_or(0) +} + +fn row_end_offset(text: &str, line_starts: &[usize], row: u32) -> usize { + if let Some(&next_start) = line_starts.get(row as usize + 1) { + // End before the newline of this row. + next_start.saturating_sub(1).min(text.len()) + } else { + text.len() + } +} + +fn row_range_to_byte_range( + text: &str, + line_starts: &[usize], + start_row: u32, + end_row: u32, +) -> Range { + let start = row_start_offset(line_starts, start_row); + let end = row_end_offset(text, line_starts, end_row); + start..end +} + +fn estimate_tokens_for_row_range( + text: &str, + line_starts: &[usize], + start_row: u32, + end_row: u32, +) -> usize { + let mut tokens = 0; + for row in start_row..end_row { + let row_len = row_end_offset(text, line_starts, row) + .saturating_sub(row_start_offset(line_starts, row)); + tokens += estimate_tokens(row_len).max(1); + } + tokens +} + +fn line_token_count_from_text(text: &str, line_starts: &[usize], row: u32) -> usize { + let row_len = + row_end_offset(text, line_starts, row).saturating_sub(row_start_offset(line_starts, row)); + estimate_tokens(row_len).max(1) +} + +/// Returns syntax boundaries (as row ranges) that contain the given row range +/// and extend beyond it, ordered from smallest to largest. +fn containing_syntax_boundaries_from_ranges( + line_starts: &[usize], + syntax_ranges: &[Range], + start_row: u32, + end_row: u32, +) -> Vec<(u32, u32)> { + let mut boundaries = Vec::new(); + let mut last: Option<(u32, u32)> = None; + + // syntax_ranges is innermost to outermost, so iterate in order. + for range in syntax_ranges { + let node_start_row = offset_to_row(line_starts, range.start); + let node_end_row = offset_to_row(line_starts, range.end); + + // Skip nodes that don't extend beyond the current range. + if node_start_row >= start_row && node_end_row <= end_row { + continue; + } + + let rows = (node_start_row, node_end_row); + if last == Some(rows) { + continue; + } + + last = Some(rows); + boundaries.push(rows); + } + + boundaries +} + +fn compute_editable_range_from_text( + text: &str, + line_starts: &[usize], + cursor_row: u32, + max_row: u32, + syntax_ranges: &[Range], + token_limit: usize, +) -> Range { + // Phase 1: Expand symmetrically from cursor using 75% of budget. + let initial_budget = (token_limit * 3) / 4; + let (mut start_row, mut end_row, mut remaining_tokens) = + expand_symmetric(text, line_starts, cursor_row, max_row, initial_budget); + + remaining_tokens += token_limit.saturating_sub(initial_budget); + + let original_start = start_row; + let original_end = end_row; + + // Phase 2: Expand to syntax boundaries that fit within budget. + let boundaries = + containing_syntax_boundaries_from_ranges(line_starts, syntax_ranges, start_row, end_row); + for (boundary_start, boundary_end) in &boundaries { + let tokens_for_start = if *boundary_start < start_row { + estimate_tokens_for_row_range(text, line_starts, *boundary_start, start_row) + } else { + 0 + }; + let tokens_for_end = if *boundary_end > end_row { + estimate_tokens_for_row_range(text, line_starts, end_row + 1, *boundary_end + 1) + } else { + 0 + }; + + let total_needed = tokens_for_start + tokens_for_end; + if total_needed <= remaining_tokens { + if *boundary_start < start_row { + start_row = *boundary_start; + } + if *boundary_end > end_row { + end_row = *boundary_end; + } + remaining_tokens = remaining_tokens.saturating_sub(total_needed); + } else { + break; + } + } + + // Phase 3: Continue line-wise in the direction we expanded least. + let expanded_up = original_start.saturating_sub(start_row); + let expanded_down = end_row.saturating_sub(original_end); + let prefer_up = expanded_up <= expanded_down; + + (start_row, end_row, _) = expand_linewise( + text, + line_starts, + start_row, + end_row, + max_row, + remaining_tokens, + prefer_up, + ); + + row_range_to_byte_range(text, line_starts, start_row, end_row) +} + +fn expand_context_from_text( + text: &str, + line_starts: &[usize], + max_row: u32, + editable_range: &Range, + syntax_ranges: &[Range], + context_token_limit: usize, +) -> Range { + let mut start_row = offset_to_row(line_starts, editable_range.start); + let mut end_row = offset_to_row(line_starts, editable_range.end); + let mut remaining_tokens = context_token_limit; + let mut did_syntax_expand = false; + + let boundaries = + containing_syntax_boundaries_from_ranges(line_starts, syntax_ranges, start_row, end_row); + for (boundary_start, boundary_end) in &boundaries { + let tokens_for_start = if *boundary_start < start_row { + estimate_tokens_for_row_range(text, line_starts, *boundary_start, start_row) + } else { + 0 + }; + let tokens_for_end = if *boundary_end > end_row { + estimate_tokens_for_row_range(text, line_starts, end_row + 1, *boundary_end + 1) + } else { + 0 + }; + + let total_needed = tokens_for_start + tokens_for_end; + if total_needed <= remaining_tokens { + if *boundary_start < start_row { + start_row = *boundary_start; + } + if *boundary_end > end_row { + end_row = *boundary_end; + } + remaining_tokens = remaining_tokens.saturating_sub(total_needed); + did_syntax_expand = true; + } else { + break; + } + } + + // Only expand line-wise if no syntax expansion occurred. + if !did_syntax_expand { + (start_row, end_row, _) = expand_linewise( + text, + line_starts, + start_row, + end_row, + max_row, + remaining_tokens, + true, + ); + } + + row_range_to_byte_range(text, line_starts, start_row, end_row) +} + +fn expand_symmetric( + text: &str, + line_starts: &[usize], + cursor_row: u32, + max_row: u32, + mut token_budget: usize, +) -> (u32, u32, usize) { + let mut start_row = cursor_row; + let mut end_row = cursor_row; + + let cursor_line_tokens = line_token_count_from_text(text, line_starts, cursor_row); + token_budget = token_budget.saturating_sub(cursor_line_tokens); + + loop { + let can_expand_up = start_row > 0; + let can_expand_down = end_row < max_row; + + if token_budget == 0 || (!can_expand_up && !can_expand_down) { + break; + } + + if can_expand_down { + let next_row = end_row + 1; + let line_tokens = line_token_count_from_text(text, line_starts, next_row); + if line_tokens <= token_budget { + end_row = next_row; + token_budget = token_budget.saturating_sub(line_tokens); + } else { + break; + } + } + + if can_expand_up && token_budget > 0 { + let next_row = start_row - 1; + let line_tokens = line_token_count_from_text(text, line_starts, next_row); + if line_tokens <= token_budget { + start_row = next_row; + token_budget = token_budget.saturating_sub(line_tokens); + } else { + break; + } + } + } + + (start_row, end_row, token_budget) +} + +fn expand_linewise( + text: &str, + line_starts: &[usize], + mut start_row: u32, + mut end_row: u32, + max_row: u32, + mut remaining_tokens: usize, + prefer_up: bool, +) -> (u32, u32, usize) { + loop { + let can_expand_up = start_row > 0; + let can_expand_down = end_row < max_row; + + if remaining_tokens == 0 || (!can_expand_up && !can_expand_down) { + break; + } + + let mut expanded = false; + + if prefer_up { + if can_expand_up { + let next_row = start_row - 1; + let line_tokens = line_token_count_from_text(text, line_starts, next_row); + if line_tokens <= remaining_tokens { + start_row = next_row; + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + } + if can_expand_down && remaining_tokens > 0 { + let next_row = end_row + 1; + let line_tokens = line_token_count_from_text(text, line_starts, next_row); + if line_tokens <= remaining_tokens { + end_row = next_row; + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + } + } else { + if can_expand_down { + let next_row = end_row + 1; + let line_tokens = line_token_count_from_text(text, line_starts, next_row); + if line_tokens <= remaining_tokens { + end_row = next_row; + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + } + if can_expand_up && remaining_tokens > 0 { + let next_row = start_row - 1; + let line_tokens = line_token_count_from_text(text, line_starts, next_row); + if line_tokens <= remaining_tokens { + start_row = next_row; + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + } + } + + if !expanded { + break; + } + } + + (start_row, end_row, remaining_tokens) +} diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 774ac7cb9baebb943c9223645aae8d16cd730998..87218f037f8ce61630b3a7505056d87f7af33376 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -1,3 +1,5 @@ +pub mod excerpt_ranges; + use anyhow::{Result, anyhow}; use serde::{Deserialize, Serialize}; use std::fmt::Write; @@ -6,6 +8,10 @@ use std::path::Path; use std::sync::Arc; use strum::{EnumIter, IntoEnumIterator as _, IntoStaticStr}; +pub use crate::excerpt_ranges::{ + ExcerptRanges, compute_editable_and_context_ranges, compute_legacy_excerpt_ranges, +}; + pub const CURSOR_MARKER: &str = "<|user_cursor|>"; pub const MAX_PROMPT_TOKENS: usize = 4096; @@ -18,31 +24,6 @@ fn estimate_tokens(bytes: usize) -> usize { bytes / 3 } -/// Pre-computed byte offset ranges within `cursor_excerpt` for different -/// editable and context token budgets. Allows the server to select the -/// appropriate ranges for whichever model it uses. -#[derive(Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize)] -pub struct ExcerptRanges { - /// Editable region computed with a 150-token budget. - pub editable_150: Range, - /// Editable region computed with a 180-token budget. - pub editable_180: Range, - /// Editable region computed with a 350-token budget. - pub editable_350: Range, - /// Editable region computed with a 350-token budget. - pub editable_512: Option>, - /// Context boundary when using editable_150 with 350 tokens of additional context. - pub editable_150_context_350: Range, - /// Context boundary when using editable_180 with 350 tokens of additional context. - pub editable_180_context_350: Range, - /// Context boundary when using editable_350 with 150 tokens of additional context. - pub editable_350_context_150: Range, - pub editable_350_context_512: Option>, - pub editable_350_context_1024: Option>, - pub context_4096: Option>, - pub context_8192: Option>, -} - #[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)] pub struct ZetaPromptInput { pub cursor_path: Arc, @@ -55,6 +36,12 @@ pub struct ZetaPromptInput { pub related_files: Option>, /// These ranges let the server select model-appropriate subsets. pub excerpt_ranges: ExcerptRanges, + /// Byte offset ranges within `cursor_excerpt` for all syntax nodes that + /// contain `cursor_offset_in_excerpt`, ordered from innermost to outermost. + /// When present, the server uses these to compute editable/context ranges + /// instead of `excerpt_ranges`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub syntax_ranges: Option>>, /// The name of the edit prediction model experiment to use. #[serde(default, skip_serializing_if = "Option::is_none")] pub experiment: Option, @@ -223,6 +210,21 @@ pub fn special_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] } } +/// Returns the (editable_token_limit, context_token_limit) for a given format. +pub fn token_limits_for_format(format: ZetaFormat) -> (usize, usize) { + match format { + ZetaFormat::V0112MiddleAtEnd | ZetaFormat::V0113Ordered => (150, 350), + ZetaFormat::V0114180EditableRegion => (180, 350), + ZetaFormat::V0120GitMergeMarkers + | ZetaFormat::V0131GitMergeMarkersPrefix + | ZetaFormat::V0211Prefill + | ZetaFormat::V0211SeedCoder + | ZetaFormat::v0226Hashline + | ZetaFormat::V0304SeedNoEdits => (350, 150), + ZetaFormat::V0304VariableEdit => (1024, 0), + } +} + pub fn stop_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] { match format { ZetaFormat::v0226Hashline => &[hashline::NO_EDITS_COMMAND_MARKER], @@ -262,8 +264,9 @@ pub fn excerpt_ranges_for_format( ), ZetaFormat::V0304VariableEdit => { let context = ranges - .context_8192 + .editable_350_context_1024 .clone() + .or(ranges.editable_350_context_512.clone()) .unwrap_or_else(|| ranges.editable_350_context_150.clone()); (context.clone(), context) } @@ -552,7 +555,18 @@ pub fn resolve_cursor_region( input: &ZetaPromptInput, format: ZetaFormat, ) -> (&str, Range, Range, usize) { - let (editable_range, context_range) = excerpt_range_for_format(format, &input.excerpt_ranges); + let (editable_range, context_range) = if let Some(syntax_ranges) = &input.syntax_ranges { + let (editable_tokens, context_tokens) = token_limits_for_format(format); + compute_editable_and_context_ranges( + &input.cursor_excerpt, + input.cursor_offset_in_excerpt, + syntax_ranges, + editable_tokens, + context_tokens, + ) + } else { + excerpt_range_for_format(format, &input.excerpt_ranges) + }; let context_start = context_range.start; let context_text = &input.cursor_excerpt[context_range.clone()]; let adjusted_editable = @@ -3876,6 +3890,7 @@ mod tests { editable_350_context_150: context_range, ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, @@ -3905,6 +3920,7 @@ mod tests { editable_350_context_150: context_range, ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, @@ -4488,6 +4504,7 @@ mod tests { editable_350_context_150: 0..excerpt.len(), ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, @@ -4551,6 +4568,7 @@ mod tests { editable_350_context_150: 0..28, ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, @@ -4609,6 +4627,7 @@ mod tests { editable_350_context_150: context_range.clone(), ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, From 147577496de54cc5bcd22705ae16b43cc1046a7b Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 9 Mar 2026 19:24:41 -0500 Subject: [PATCH 092/219] ep: Include diagnostics in `ZetaPromptInput` (#51141) Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/edit_prediction/src/cursor_excerpt.rs | 166 +++++++++++ .../src/edit_prediction_tests.rs | 268 ++++++++++++------ crates/edit_prediction/src/fim.rs | 1 + crates/edit_prediction/src/mercury.rs | 1 + crates/edit_prediction/src/prediction.rs | 1 + crates/edit_prediction/src/sweep_ai.rs | 1 + crates/edit_prediction/src/zeta.rs | 53 +++- .../edit_prediction_cli/src/load_project.rs | 1 + .../src/reversal_tracking.rs | 1 + crates/zeta_prompt/src/zeta_prompt.rs | 16 ++ 10 files changed, 413 insertions(+), 96 deletions(-) diff --git a/crates/edit_prediction/src/cursor_excerpt.rs b/crates/edit_prediction/src/cursor_excerpt.rs index 27e6cd987a292c71842377226052b665d5a51fbe..2badcab07a90fd1c96634b4de1581758afc95deb 100644 --- a/crates/edit_prediction/src/cursor_excerpt.rs +++ b/crates/edit_prediction/src/cursor_excerpt.rs @@ -122,6 +122,172 @@ pub fn compute_syntax_ranges( ranges } +/// Expands context by first trying to reach syntax boundaries, +/// then expanding line-wise only if no syntax expansion occurred. +pub fn expand_context_syntactically_then_linewise( + snapshot: &BufferSnapshot, + editable_range: Range, + context_token_limit: usize, +) -> Range { + let mut start_row = editable_range.start.row; + let mut end_row = editable_range.end.row; + let mut remaining_tokens = context_token_limit; + let mut did_syntax_expand = false; + + // Phase 1: Try to expand to containing syntax boundaries, picking the largest that fits. + for (boundary_start, boundary_end) in containing_syntax_boundaries(snapshot, start_row, end_row) + { + let tokens_for_start = if boundary_start < start_row { + estimate_tokens_for_rows(snapshot, boundary_start, start_row) + } else { + 0 + }; + let tokens_for_end = if boundary_end > end_row { + estimate_tokens_for_rows(snapshot, end_row + 1, boundary_end + 1) + } else { + 0 + }; + + let total_needed = tokens_for_start + tokens_for_end; + + if total_needed <= remaining_tokens { + if boundary_start < start_row { + start_row = boundary_start; + } + if boundary_end > end_row { + end_row = boundary_end; + } + remaining_tokens = remaining_tokens.saturating_sub(total_needed); + did_syntax_expand = true; + } else { + break; + } + } + + // Phase 2: Only expand line-wise if no syntax expansion occurred. + if !did_syntax_expand { + (start_row, end_row, _) = + expand_linewise_biased(snapshot, start_row, end_row, remaining_tokens, true); + } + + let start = Point::new(start_row, 0); + let end = Point::new(end_row, snapshot.line_len(end_row)); + start..end +} + +/// Returns an iterator of (start_row, end_row) for successively larger syntax nodes +/// containing the given row range. Smallest containing node first. +fn containing_syntax_boundaries( + snapshot: &BufferSnapshot, + start_row: u32, + end_row: u32, +) -> impl Iterator { + let range = Point::new(start_row, 0)..Point::new(end_row, snapshot.line_len(end_row)); + let mut current = snapshot.syntax_ancestor(range); + let mut last_rows: Option<(u32, u32)> = None; + + std::iter::from_fn(move || { + while let Some(node) = current.take() { + let node_start_row = node.start_position().row as u32; + let node_end_row = node.end_position().row as u32; + let rows = (node_start_row, node_end_row); + + current = node.parent(); + + // Skip nodes that don't extend beyond our range. + if node_start_row >= start_row && node_end_row <= end_row { + continue; + } + + // Skip if same as last returned (some nodes have same span). + if last_rows == Some(rows) { + continue; + } + + last_rows = Some(rows); + return Some(rows); + } + None + }) +} + +/// Expands line-wise with a bias toward one direction. +/// Returns (start_row, end_row, remaining_tokens). +fn expand_linewise_biased( + snapshot: &BufferSnapshot, + mut start_row: u32, + mut end_row: u32, + mut remaining_tokens: usize, + prefer_up: bool, +) -> (u32, u32, usize) { + loop { + let can_expand_up = start_row > 0; + let can_expand_down = end_row < snapshot.max_point().row; + + if remaining_tokens == 0 || (!can_expand_up && !can_expand_down) { + break; + } + + let mut expanded = false; + + // Try preferred direction first. + if prefer_up { + if can_expand_up { + let next_row = start_row - 1; + let line_tokens = line_token_count(snapshot, next_row); + if line_tokens <= remaining_tokens { + start_row = next_row; + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + } + if can_expand_down && remaining_tokens > 0 { + let next_row = end_row + 1; + let line_tokens = line_token_count(snapshot, next_row); + if line_tokens <= remaining_tokens { + end_row = next_row; + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + } + } else { + if can_expand_down { + let next_row = end_row + 1; + let line_tokens = line_token_count(snapshot, next_row); + if line_tokens <= remaining_tokens { + end_row = next_row; + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + } + if can_expand_up && remaining_tokens > 0 { + let next_row = start_row - 1; + let line_tokens = line_token_count(snapshot, next_row); + if line_tokens <= remaining_tokens { + start_row = next_row; + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + } + } + + if !expanded { + break; + } + } + + (start_row, end_row, remaining_tokens) +} + +/// Estimates token count for rows in range [start_row, end_row). +fn estimate_tokens_for_rows(snapshot: &BufferSnapshot, start_row: u32, end_row: u32) -> usize { + let mut tokens = 0; + for row in start_row..end_row { + tokens += line_token_count(snapshot, row); + } + tokens +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 66d9c940dda21d7068ad6dec0976520dee2750e7..ad237e6f8fb31708dbabc6e8332ce0c164877004 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -17,7 +17,10 @@ use gpui::{ http_client::{FakeHttpClient, Response}, }; use indoc::indoc; -use language::{Anchor, Buffer, CursorShape, Operation, Point, Selection, SelectionGoal}; +use language::{ + Anchor, Buffer, CursorShape, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSeverity, + Operation, Point, Selection, SelectionGoal, +}; use lsp::LanguageServerId; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_matches}; @@ -25,7 +28,10 @@ use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; use std::{path::Path, sync::Arc, time::Duration}; -use util::path; +use util::{ + path, + test::{TextRangeMarker, marked_text_ranges_by}, +}; use uuid::Uuid; use zeta_prompt::ZetaPromptInput; @@ -1656,97 +1662,172 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { assert_eq!(reject_request.rejections[1].request_id, "retry-2"); } -// Skipped until we start including diagnostics in prompt -// #[gpui::test] -// async fn test_request_diagnostics(cx: &mut TestAppContext) { -// let (ep_store, mut req_rx) = init_test_with_fake_client(cx); -// let fs = FakeFs::new(cx.executor()); -// fs.insert_tree( -// "/root", -// json!({ -// "foo.md": "Hello!\nBye" -// }), -// ) -// .await; -// let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; - -// let path_to_buffer_uri = lsp::Uri::from_file_path(path!("/root/foo.md")).unwrap(); -// let diagnostic = lsp::Diagnostic { -// range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), -// severity: Some(lsp::DiagnosticSeverity::ERROR), -// message: "\"Hello\" deprecated. Use \"Hi\" instead".to_string(), -// ..Default::default() -// }; - -// project.update(cx, |project, cx| { -// project.lsp_store().update(cx, |lsp_store, cx| { -// // Create some diagnostics -// lsp_store -// .update_diagnostics( -// LanguageServerId(0), -// lsp::PublishDiagnosticsParams { -// uri: path_to_buffer_uri.clone(), -// diagnostics: vec![diagnostic], -// version: None, -// }, -// None, -// language::DiagnosticSourceKind::Pushed, -// &[], -// cx, -// ) -// .unwrap(); -// }); -// }); - -// let buffer = project -// .update(cx, |project, cx| { -// let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); -// project.open_buffer(path, cx) -// }) -// .await -// .unwrap(); - -// let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); -// let position = snapshot.anchor_before(language::Point::new(0, 0)); - -// let _prediction_task = ep_store.update(cx, |ep_store, cx| { -// ep_store.request_prediction(&project, &buffer, position, cx) -// }); - -// let (request, _respond_tx) = req_rx.next().await.unwrap(); - -// assert_eq!(request.diagnostic_groups.len(), 1); -// let value = serde_json::from_str::(request.diagnostic_groups[0].0.get()) -// .unwrap(); -// // We probably don't need all of this. TODO define a specific diagnostic type in predict_edits_v3 -// assert_eq!( -// value, -// json!({ -// "entries": [{ -// "range": { -// "start": 8, -// "end": 10 -// }, -// "diagnostic": { -// "source": null, -// "code": null, -// "code_description": null, -// "severity": 1, -// "message": "\"Hello\" deprecated. Use \"Hi\" instead", -// "markdown": null, -// "group_id": 0, -// "is_primary": true, -// "is_disk_based": false, -// "is_unnecessary": false, -// "source_kind": "Pushed", -// "data": null, -// "underline": true -// } -// }], -// "primary_ix": 0 -// }) -// ); -// } +#[gpui::test] +fn test_active_buffer_diagnostics_fetching(cx: &mut TestAppContext) { + let diagnostic_marker: TextRangeMarker = ('«', '»').into(); + let search_range_marker: TextRangeMarker = ('[', ']').into(); + + let (text, mut ranges) = marked_text_ranges_by( + indoc! {r#" + fn alpha() { + let «first_value» = 1; + } + + [fn beta() { + let «second_value» = 2; + let third_value = second_value + missing_symbol; + }ˇ] + + fn gamma() { + let «fourth_value» = missing_other_symbol; + } + "#}, + vec![diagnostic_marker.clone(), search_range_marker.clone()], + ); + + let diagnostic_ranges = ranges.remove(&diagnostic_marker).unwrap_or_default(); + let search_ranges = ranges.remove(&search_range_marker).unwrap_or_default(); + + let buffer = cx.new(|cx| Buffer::local(&text, cx)); + + buffer.update(cx, |buffer, cx| { + let snapshot = buffer.snapshot(); + let diagnostics = DiagnosticSet::new( + diagnostic_ranges + .iter() + .enumerate() + .map(|(index, range)| DiagnosticEntry { + range: snapshot.offset_to_point_utf16(range.start) + ..snapshot.offset_to_point_utf16(range.end), + diagnostic: Diagnostic { + severity: match index { + 0 => DiagnosticSeverity::WARNING, + 1 => DiagnosticSeverity::ERROR, + _ => DiagnosticSeverity::HINT, + }, + message: match index { + 0 => "first warning".to_string(), + 1 => "second error".to_string(), + _ => "third hint".to_string(), + }, + group_id: index + 1, + is_primary: true, + source_kind: language::DiagnosticSourceKind::Pushed, + ..Diagnostic::default() + }, + }), + &snapshot, + ); + buffer.update_diagnostics(LanguageServerId(0), diagnostics, cx); + }); + + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let search_range = snapshot.offset_to_point(search_ranges[0].start) + ..snapshot.offset_to_point(search_ranges[0].end); + + let active_buffer_diagnostics = zeta::active_buffer_diagnostics(&snapshot, search_range, 100); + + assert_eq!( + active_buffer_diagnostics, + vec![zeta_prompt::ActiveBufferDiagnostic { + severity: Some(1), + message: "second error".to_string(), + snippet: text, + snippet_buffer_row_range: 5..5, + diagnostic_range_in_snippet: 61..73, + }] + ); + + let buffer = cx.new(|cx| { + Buffer::local( + indoc! {" + one + two + three + four + five + "}, + cx, + ) + }); + + buffer.update(cx, |buffer, cx| { + let snapshot = buffer.snapshot(); + let diagnostics = DiagnosticSet::new( + vec![ + DiagnosticEntry { + range: text::PointUtf16::new(0, 0)..text::PointUtf16::new(0, 3), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::ERROR, + message: "row zero".to_string(), + group_id: 1, + is_primary: true, + source_kind: language::DiagnosticSourceKind::Pushed, + ..Diagnostic::default() + }, + }, + DiagnosticEntry { + range: text::PointUtf16::new(2, 0)..text::PointUtf16::new(2, 5), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::WARNING, + message: "row two".to_string(), + group_id: 2, + is_primary: true, + source_kind: language::DiagnosticSourceKind::Pushed, + ..Diagnostic::default() + }, + }, + DiagnosticEntry { + range: text::PointUtf16::new(4, 0)..text::PointUtf16::new(4, 4), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::INFORMATION, + message: "row four".to_string(), + group_id: 3, + is_primary: true, + source_kind: language::DiagnosticSourceKind::Pushed, + ..Diagnostic::default() + }, + }, + ], + &snapshot, + ); + buffer.update_diagnostics(LanguageServerId(0), diagnostics, cx); + }); + + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + + let active_buffer_diagnostics = + zeta::active_buffer_diagnostics(&snapshot, Point::new(2, 0)..Point::new(4, 0), 100); + + assert_eq!( + active_buffer_diagnostics + .iter() + .map(|diagnostic| ( + diagnostic.severity, + diagnostic.message.clone(), + diagnostic.snippet.clone(), + diagnostic.snippet_buffer_row_range.clone(), + diagnostic.diagnostic_range_in_snippet.clone(), + )) + .collect::>(), + vec![ + ( + Some(2), + "row two".to_string(), + "one\ntwo\nthree\nfour\nfive\n".to_string(), + 2..2, + 8..13, + ), + ( + Some(3), + "row four".to_string(), + "one\ntwo\nthree\nfour\nfive\n".to_string(), + 4..4, + 19..23, + ), + ] + ); +} // Generate a model response that would apply the given diff to the active file. fn model_response(request: &PredictEditsV3Request, diff_to_apply: &str) -> PredictEditsV3Response { @@ -1885,6 +1966,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { inputs: ZetaPromptInput { events: Default::default(), related_files: Default::default(), + active_buffer_diagnostics: vec![], cursor_path: Path::new("").into(), cursor_excerpt: "".into(), cursor_offset_in_excerpt: 0, diff --git a/crates/edit_prediction/src/fim.rs b/crates/edit_prediction/src/fim.rs index 1a64506f00285791a83c38943253157137d592f1..8de58b9b2e52502519a362d9502ddc1b3cdffde4 100644 --- a/crates/edit_prediction/src/fim.rs +++ b/crates/edit_prediction/src/fim.rs @@ -82,6 +82,7 @@ pub fn request_prediction( let inputs = ZetaPromptInput { events, related_files: Some(Vec::new()), + active_buffer_diagnostics: Vec::new(), cursor_offset_in_excerpt: cursor_offset - excerpt_offset_range.start, cursor_path: full_path.clone(), excerpt_start_row: Some(excerpt_point_range.start.row), diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index 1a88ba1f1f83a11f89ac282db46f91a3ee752f58..0a952f0869b46f626c231e11f8a61370c50490fa 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -101,6 +101,7 @@ impl Mercury { excerpt_start_row: Some(excerpt_point_range.start.row), excerpt_ranges, syntax_ranges: Some(syntax_ranges), + active_buffer_diagnostics: vec![], in_open_source_repo: false, can_collect_data: false, repo_url: None, diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs index ec4694c862be3ff1937dadc08ebab11b115b4cac..0db47b0ec93b69ceebeee1989d8196642385bdd0 100644 --- a/crates/edit_prediction/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -157,6 +157,7 @@ mod tests { inputs: ZetaPromptInput { events: vec![], related_files: Some(vec![]), + active_buffer_diagnostics: vec![], cursor_path: Path::new("path.txt").into(), cursor_offset_in_excerpt: 0, cursor_excerpt: "".into(), diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs index d4e59885c86f44ceff98487940f2aaa435085e4d..99ddd9b86d238c2e56331f52f9fad51438ee1f71 100644 --- a/crates/edit_prediction/src/sweep_ai.rs +++ b/crates/edit_prediction/src/sweep_ai.rs @@ -213,6 +213,7 @@ impl SweepAi { let ep_inputs = zeta_prompt::ZetaPromptInput { events: inputs.events, related_files: Some(inputs.related_files.clone()), + active_buffer_diagnostics: vec![], cursor_path: full_path.clone(), cursor_excerpt: request_body.file_contents.clone().into(), cursor_offset_in_excerpt: request_body.cursor_position, diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 9362425c24df4199e40f97ac1278753c954a2f36..fa93e681b66cb44a554f725d4a1c6dee11f0b1f1 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -2,7 +2,7 @@ use crate::{ CurrentEditPrediction, DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, StoredEvent, ZedUpdateRequiredError, - cursor_excerpt::{compute_cursor_excerpt, compute_syntax_ranges}, + cursor_excerpt::{self, compute_cursor_excerpt, compute_syntax_ranges}, prediction::EditPredictionResult, }; use anyhow::Result; @@ -12,11 +12,12 @@ use cloud_llm_client::{ use edit_prediction_types::PredictedCursorPosition; use gpui::{App, AppContext as _, Entity, Task, WeakEntity, prelude::*}; use language::{ - Buffer, BufferSnapshot, ToOffset as _, language_settings::all_language_settings, text_diff, + Buffer, BufferSnapshot, DiagnosticSeverity, OffsetRangeExt as _, ToOffset as _, + language_settings::all_language_settings, text_diff, }; use release_channel::AppVersion; use settings::EditPredictionPromptFormat; -use text::{Anchor, Bias}; +use text::{Anchor, Bias, Point}; use ui::SharedString; use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; use zeta_prompt::{ParsedOutput, ZetaPromptInput}; @@ -43,6 +44,7 @@ pub fn request_prediction_with_zeta( debug_tx, trigger, project, + diagnostic_search_range, can_collect_data, is_open_source, .. @@ -115,6 +117,7 @@ pub fn request_prediction_with_zeta( &snapshot, related_files, events, + diagnostic_search_range, excerpt_path, cursor_offset, preferred_experiment, @@ -479,10 +482,50 @@ fn handle_api_response( } } +pub(crate) fn active_buffer_diagnostics( + snapshot: &language::BufferSnapshot, + diagnostic_search_range: Range, + additional_context_token_count: usize, +) -> Vec { + snapshot + .diagnostics_in_range::(diagnostic_search_range, false) + .map(|entry| { + let severity = match entry.diagnostic.severity { + DiagnosticSeverity::ERROR => Some(1), + DiagnosticSeverity::WARNING => Some(2), + DiagnosticSeverity::INFORMATION => Some(3), + DiagnosticSeverity::HINT => Some(4), + _ => None, + }; + let diagnostic_point_range = entry.range.clone(); + let snippet_point_range = cursor_excerpt::expand_context_syntactically_then_linewise( + snapshot, + diagnostic_point_range.clone(), + additional_context_token_count, + ); + let snippet = snapshot + .text_for_range(snippet_point_range.clone()) + .collect::(); + let snippet_start_offset = snippet_point_range.start.to_offset(snapshot); + let diagnostic_offset_range = diagnostic_point_range.to_offset(snapshot); + zeta_prompt::ActiveBufferDiagnostic { + severity, + message: entry.diagnostic.message.clone(), + snippet, + snippet_buffer_row_range: diagnostic_point_range.start.row + ..diagnostic_point_range.end.row, + diagnostic_range_in_snippet: diagnostic_offset_range.start - snippet_start_offset + ..diagnostic_offset_range.end - snippet_start_offset, + } + }) + .collect() +} + pub fn zeta2_prompt_input( snapshot: &language::BufferSnapshot, related_files: Vec, events: Vec>, + diagnostic_search_range: Range, excerpt_path: Arc, cursor_offset: usize, preferred_experiment: Option, @@ -504,6 +547,9 @@ pub fn zeta2_prompt_input( &syntax_ranges, ); + let active_buffer_diagnostics = + active_buffer_diagnostics(snapshot, diagnostic_search_range, 100); + let prompt_input = zeta_prompt::ZetaPromptInput { cursor_path: excerpt_path, cursor_excerpt, @@ -511,6 +557,7 @@ pub fn zeta2_prompt_input( excerpt_start_row: Some(excerpt_point_range.start.row), events, related_files: Some(related_files), + active_buffer_diagnostics, excerpt_ranges, syntax_ranges: Some(syntax_ranges), experiment: preferred_experiment, diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs index a9303451e8b6c6ae798be976a11d3b71fae99758..d9138482767b2c49bb21bf7ed7c349ec6c9af3ff 100644 --- a/crates/edit_prediction_cli/src/load_project.rs +++ b/crates/edit_prediction_cli/src/load_project.rs @@ -103,6 +103,7 @@ pub async fn run_load_project( excerpt_start_row: Some(excerpt_point_range.start.row), events, related_files: existing_related_files, + active_buffer_diagnostics: vec![], excerpt_ranges, syntax_ranges: Some(syntax_ranges), in_open_source_repo: false, diff --git a/crates/edit_prediction_cli/src/reversal_tracking.rs b/crates/edit_prediction_cli/src/reversal_tracking.rs index 7623041a091acd3c726ba0281f090496255f8014..60661cea04beae4aba4713ac86b51fab42c91979 100644 --- a/crates/edit_prediction_cli/src/reversal_tracking.rs +++ b/crates/edit_prediction_cli/src/reversal_tracking.rs @@ -669,6 +669,7 @@ mod tests { excerpt_start_row, events, related_files: Some(Vec::new()), + active_buffer_diagnostics: Vec::new(), excerpt_ranges: ExcerptRanges { editable_150: 0..content.len(), editable_180: 0..content.len(), diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 87218f037f8ce61630b3a7505056d87f7af33376..1dd675e8b39ccab8403682beb040a075381aaf1d 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -34,6 +34,8 @@ pub struct ZetaPromptInput { pub events: Vec>, #[serde(default)] pub related_files: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub active_buffer_diagnostics: Vec, /// These ranges let the server select model-appropriate subsets. pub excerpt_ranges: ExcerptRanges, /// Byte offset ranges within `cursor_excerpt` for all syntax nodes that @@ -168,6 +170,15 @@ pub fn write_event(prompt: &mut String, event: &Event) { } } +#[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)] +pub struct ActiveBufferDiagnostic { + pub severity: Option, + pub message: String, + pub snippet: String, + pub snippet_buffer_row_range: Range, + pub diagnostic_range_in_snippet: Range, +} + #[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)] pub struct RelatedFile { pub path: Arc, @@ -3881,6 +3892,7 @@ mod tests { excerpt_start_row: None, events: events.into_iter().map(Arc::new).collect(), related_files: Some(related_files), + active_buffer_diagnostics: vec![], excerpt_ranges: ExcerptRanges { editable_150: editable_range.clone(), editable_180: editable_range.clone(), @@ -3911,6 +3923,7 @@ mod tests { excerpt_start_row: None, events: vec![], related_files: Some(vec![]), + active_buffer_diagnostics: vec![], excerpt_ranges: ExcerptRanges { editable_150: editable_range.clone(), editable_180: editable_range.clone(), @@ -4495,6 +4508,7 @@ mod tests { excerpt_start_row: Some(0), events: vec![Arc::new(make_event("other.rs", "-old\n+new\n"))], related_files: Some(vec![]), + active_buffer_diagnostics: vec![], excerpt_ranges: ExcerptRanges { editable_150: 15..41, editable_180: 15..41, @@ -4559,6 +4573,7 @@ mod tests { excerpt_start_row: Some(10), events: vec![], related_files: Some(vec![]), + active_buffer_diagnostics: vec![], excerpt_ranges: ExcerptRanges { editable_150: 0..28, editable_180: 0..28, @@ -4618,6 +4633,7 @@ mod tests { excerpt_start_row: Some(0), events: vec![], related_files: Some(vec![]), + active_buffer_diagnostics: vec![], excerpt_ranges: ExcerptRanges { editable_150: editable_range.clone(), editable_180: editable_range.clone(), From a26f0f8b6025e65525db2b0831d488e177290058 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:01:40 -0300 Subject: [PATCH 093/219] sidebar: Adjust design for the "Open Project" button (#51145) This PR makes the "Open Project" button in the sidebar also open the "Recent Projects" popover, while also anchoring that popover to the the button on the sidebar instead. Release Notes: - N/A --- Cargo.lock | 1 + crates/sidebar/Cargo.toml | 1 + crates/sidebar/src/sidebar.rs | 81 ++++++++++++++++++------- crates/title_bar/src/title_bar.rs | 67 +++++++++++++++++++- crates/workspace/src/multi_workspace.rs | 26 ++++++++ 5 files changed, 151 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d2ade2ddeab584b8a7ea45590248bdf97e89e57..b9b048468cbc4f52b86b1cd0f1b0a9d3d0f4d9e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15825,6 +15825,7 @@ dependencies = [ "language_model", "menu", "project", + "recent_projects", "serde_json", "settings", "theme", diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index d835e9a602d7610eb412d8e3fc4135cb55d5a634..36a8d1cf085e544d38d903fe63f514539287dcc5 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -26,6 +26,7 @@ fs.workspace = true gpui.workspace = true menu.workspace = true project.workspace = true +recent_projects.workspace = true settings.workspace = true theme.workspace = true ui.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 1e50a75e2841fb471b2d630b71c2df59200c5bea..4dbc2f811a62c266bc34708cd3b8bd1377938d4d 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -12,20 +12,23 @@ use gpui::{ }; use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::Event as ProjectEvent; +use recent_projects::RecentProjects; use settings::Settings; use std::collections::{HashMap, HashSet}; use std::mem; use theme::{ActiveTheme, ThemeSettings}; use ui::utils::TRAFFIC_LIGHT_PADDING; use ui::{ - AgentThreadStatus, GradientFade, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, Tab, - ThreadItem, Tooltip, WithScrollbar, prelude::*, + AgentThreadStatus, ButtonStyle, GradientFade, HighlightedLabel, IconButtonShape, KeyBinding, + ListItem, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, + prelude::*, }; use util::path_list::PathList; use workspace::{ FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar, SidebarEvent, ToggleWorkspaceSidebar, Workspace, }; +use zed_actions::OpenRecent; use zed_actions::editor::{MoveDown, MoveUp}; actions!( @@ -183,6 +186,7 @@ pub struct Sidebar { active_entry_index: Option, collapsed_groups: HashSet, expanded_groups: HashMap, + recent_projects_popover_handle: PopoverMenuHandle, } impl EventEmitter for Sidebar {} @@ -278,6 +282,7 @@ impl Sidebar { active_entry_index: None, collapsed_groups: HashSet::new(), expanded_groups: HashMap::new(), + recent_projects_popover_handle: PopoverMenuHandle::default(), } } @@ -1174,6 +1179,48 @@ impl Sidebar { .into_any_element() } + fn render_recent_projects_button(&self, cx: &mut Context) -> impl IntoElement { + let workspace = self + .multi_workspace + .upgrade() + .map(|mw| mw.read(cx).workspace().downgrade()); + + let focus_handle = workspace + .as_ref() + .and_then(|ws| ws.upgrade()) + .map(|w| w.read(cx).focus_handle(cx)) + .unwrap_or_else(|| cx.focus_handle()); + + let popover_handle = self.recent_projects_popover_handle.clone(); + + PopoverMenu::new("sidebar-recent-projects-menu") + .with_handle(popover_handle) + .menu(move |window, cx| { + workspace.as_ref().map(|ws| { + RecentProjects::popover(ws.clone(), false, focus_handle.clone(), window, cx) + }) + }) + .trigger_with_tooltip( + IconButton::new("open-project", IconName::OpenFolder) + .icon_size(IconSize::Small) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)), + |_window, cx| { + Tooltip::for_action( + "Recent Projects", + &OpenRecent { + create_new_window: false, + }, + cx, + ) + }, + ) + .anchor(gpui::Corner::TopLeft) + .offset(gpui::Point { + x: px(0.0), + y: px(2.0), + }) + } + fn render_filter_input(&self, cx: &mut Context) -> impl IntoElement { let settings = ThemeSettings::get_global(cx); let text_style = TextStyle { @@ -1315,6 +1362,14 @@ impl WorkspaceSidebar for Sidebar { fn has_notifications(&self, _cx: &App) -> bool { !self.contents.notified_threads.is_empty() } + + fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) { + self.recent_projects_popover_handle.toggle(window, cx); + } + + fn is_recent_projects_popover_deployed(&self) -> bool { + self.recent_projects_popover_handle.is_deployed() + } } impl Focusable for Sidebar { @@ -1412,27 +1467,7 @@ impl Render for Sidebar { cx.emit(SidebarEvent::Close); })) }) - .child( - IconButton::new("open-project", IconName::OpenFolder) - .icon_size(IconSize::Small) - .tooltip(|_window, cx| { - Tooltip::for_action( - "Open Project", - &workspace::Open { - create_new_window: false, - }, - cx, - ) - }) - .on_click(|_event, window, cx| { - window.dispatch_action( - Box::new(workspace::Open { - create_new_window: false, - }), - cx, - ); - }), - ), + .child(self.render_recent_projects_button(cx)), ) .child( h_flex() diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 3566d6210769c09a8a6de1706cb258ff2b119ce9..96cc929c06039c14a9ce4eaa05fd067fbd95b7d0 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -151,6 +151,7 @@ pub struct TitleBar { user_store: Entity, client: Arc, workspace: WeakEntity, + multi_workspace: Option>, application_menu: Option>, _subscriptions: Vec, banner: Entity, @@ -188,7 +189,7 @@ impl Render for TitleBar { .when(title_bar_settings.show_project_items, |title_bar| { title_bar .children(self.render_project_host(cx)) - .child(self.render_project_name(cx)) + .child(self.render_project_name(window, cx)) }) .when(title_bar_settings.show_branch_name, |title_bar| { title_bar.children(self.render_project_branch(cx)) @@ -389,6 +390,7 @@ impl TitleBar { if let Some(this) = this.upgrade() { this.update(cx, |this, _| { this._subscriptions.push(subscription); + this.multi_workspace = Some(multi_workspace.downgrade()); }); } }); @@ -400,6 +402,7 @@ impl TitleBar { platform_titlebar, application_menu, workspace: workspace.weak_handle(), + multi_workspace: None, project, user_store, client, @@ -718,7 +721,11 @@ impl TitleBar { ) } - pub fn render_project_name(&self, cx: &mut Context) -> impl IntoElement { + pub fn render_project_name( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { let workspace = self.workspace.clone(); let name = self.effective_active_worktree(cx).map(|worktree| { @@ -734,6 +741,19 @@ impl TitleBar { "Open Recent Project".to_string() }; + let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open(); + + if is_sidebar_open { + return self + .render_project_name_with_sidebar_popover( + window, + display_name, + is_project_selected, + cx, + ) + .into_any_element(); + } + let focus_handle = workspace .upgrade() .map(|w| w.read(cx).focus_handle(cx)) @@ -773,6 +793,49 @@ impl TitleBar { .into_any_element() } + fn render_project_name_with_sidebar_popover( + &self, + _window: &Window, + display_name: String, + is_project_selected: bool, + cx: &mut Context, + ) -> impl IntoElement { + let multi_workspace = self.multi_workspace.clone(); + + let is_popover_deployed = multi_workspace + .as_ref() + .and_then(|mw| mw.upgrade()) + .map(|mw| mw.read(cx).is_recent_projects_popover_deployed(cx)) + .unwrap_or(false); + + Button::new("project_name_trigger", display_name) + .label_size(LabelSize::Small) + .when(self.worktree_count(cx) > 1, |this| { + this.icon(IconName::ChevronDown) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + }) + .toggle_state(is_popover_deployed) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .when(!is_project_selected, |s| s.color(Color::Muted)) + .tooltip(move |_window, cx| { + Tooltip::for_action( + "Recent Projects", + &zed_actions::OpenRecent { + create_new_window: false, + }, + cx, + ) + }) + .on_click(move |_, window, cx| { + if let Some(mw) = multi_workspace.as_ref().and_then(|mw| mw.upgrade()) { + mw.update(cx, |mw, cx| { + mw.toggle_recent_projects_popover(window, cx); + }); + } + }) + } + pub fn render_project_branch(&self, cx: &mut Context) -> Option { let effective_worktree = self.effective_active_worktree(cx)?; let repository = self.get_repository_for_worktree(&effective_worktree, cx)?; diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 3f5981178fe118f41196538e1a22960bd55644d0..26af1ce27ecc28b7b541625a16731d0d721a7fc9 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -50,6 +50,8 @@ pub trait Sidebar: EventEmitter + Focusable + Render + Sized { fn width(&self, cx: &App) -> Pixels; fn set_width(&mut self, width: Option, cx: &mut Context); fn has_notifications(&self, cx: &App) -> bool; + fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App); + fn is_recent_projects_popover_deployed(&self) -> bool; } pub trait SidebarHandle: 'static + Send + Sync { @@ -60,6 +62,8 @@ pub trait SidebarHandle: 'static + Send + Sync { fn has_notifications(&self, cx: &App) -> bool; fn to_any(&self) -> AnyView; fn entity_id(&self) -> EntityId; + fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App); + fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool; } #[derive(Clone)] @@ -100,6 +104,16 @@ impl SidebarHandle for Entity { fn entity_id(&self) -> EntityId { Entity::entity_id(self) } + + fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| { + this.toggle_recent_projects_popover(window, cx); + }); + } + + fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool { + self.read(cx).is_recent_projects_popover_deployed() + } } pub struct MultiWorkspace { @@ -187,6 +201,18 @@ impl MultiWorkspace { .map_or(false, |s| s.has_notifications(cx)) } + pub fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) { + if let Some(sidebar) = &self.sidebar { + sidebar.toggle_recent_projects_popover(window, cx); + } + } + + pub fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool { + self.sidebar + .as_ref() + .map_or(false, |s| s.is_recent_projects_popover_deployed(cx)) + } + pub fn multi_workspace_enabled(&self, cx: &App) -> bool { cx.has_flag::() && !DisableAiSettings::get_global(cx).disable_ai } From d1a323b4ac8972d523723c6b1a25ed8d25e8d3d6 Mon Sep 17 00:00:00 2001 From: hagz0r <48390403+hagz0r@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:57:43 -0700 Subject: [PATCH 094/219] Fix parsing of filenames like main (1).log (#50770) ## Summary Fixes Windows file-open parsing for names like `main (1).log`. `PathWithPosition::parse_str` could treat `(1)` in a normal filename as a position suffix and drop the extension/path tail. The regex is now anchored so parenthesized row/column parsing only applies at the end of the filename (with optional trailing `:` and optional range suffix). ## Testing - `cargo test -p util path_with_position_parse_` Closes #50597 Release Notes: - Fixed opening files with names like `main (1).log` on Windows. --- crates/util/src/paths.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index 39b4064a1bd9d3c4c240abf9665b17151066e9ef..3ff07c67a8d2def75e4e7f756c4a466ea2b68ed0 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -601,6 +601,7 @@ const ROW_COL_CAPTURE_REGEX: &str = r"(?xs) | \((\d+)\)() # filename(row) ) + \:*$ | (.+?)(?: \:+(\d+)\:(\d+)\:*$ # filename:row:column @@ -2097,6 +2098,15 @@ mod tests { column: Some(9), } ); + + assert_eq!( + PathWithPosition::parse_str("main (1).log"), + PathWithPosition { + path: PathBuf::from("main (1).log"), + row: None, + column: None + } + ); } #[perf] @@ -2175,6 +2185,15 @@ mod tests { column: None } ); + + assert_eq!( + PathWithPosition::parse_str("C:\\Users\\someone\\main (1).log"), + PathWithPosition { + path: PathBuf::from("C:\\Users\\someone\\main (1).log"), + row: None, + column: None + } + ); } #[perf] From 8fa257bbe0c975d18ffce2edabfe4a0298f75bfe Mon Sep 17 00:00:00 2001 From: loadingalias <138315197+loadingalias@users.noreply.github.com> Date: Tue, 10 Mar 2026 05:12:32 -0400 Subject: [PATCH 095/219] project_panel: Reveal in file manager when no entry is selected (#50866) Closes #48284 ## Summary - Fix `project_panel::RevealInFileManager` when no project panel entry is selected. - Preserve existing selected entry behavior. - Add fallback to reveal the last visible worktree root when selection is empty. - Add regression test cov. ## Root Cause `RevealInFileManager` previously depended on `selected_sub_entry()`. When selection is cleared (e.g. click project panel background), Command Palette dispatch had no target and no-op'd. ## Verification - `cargo fmt --all -- --check` - `./script/check-keymaps` - `./script/clippy -p project_panel` - `cargo test -p project_panel -- --nocapture` ## Manual Testing - Reproduced issue steps from #48284. - Confirmed Command Palette `Project panel: Reveal in file manager` now opens project root when selection is empty. - Confirmed selected file reveal behavior remains unchanged. - Confirmed context menu reveal behavior remains unchanged. Release Notes: - Fixed `Project panel: Reveal in file manager` to work even when no project panel entry is selected. --- crates/project_panel/src/project_panel.rs | 17 ++++++- .../project_panel/src/project_panel_tests.rs | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 55f440852ada15505831c78035d9362c91b4a204..068fb8d71fa883e9d2b518c7d19adacea74fadcb 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -3403,8 +3403,7 @@ impl ProjectPanel { _: &mut Window, cx: &mut Context, ) { - if let Some((worktree, entry)) = self.selected_sub_entry(cx) { - let path = worktree.read(cx).absolutize(&entry.path); + if let Some(path) = self.reveal_in_file_manager_path(cx) { self.project .update(cx, |project, cx| project.reveal_path(&path, cx)); } @@ -3761,6 +3760,20 @@ impl ProjectPanel { } Some((worktree, entry)) } + + fn reveal_in_file_manager_path(&self, cx: &App) -> Option { + if let Some((worktree, entry)) = self.selected_sub_entry(cx) { + return Some(worktree.read(cx).absolutize(&entry.path)); + } + + let root_entry_id = self.state.last_worktree_root_id?; + let project = self.project.read(cx); + let worktree = project.worktree_for_entry(root_entry_id, cx)?; + let worktree = worktree.read(cx); + let root_entry = worktree.entry_for_id(root_entry_id)?; + Some(worktree.absolutize(&root_entry.path)) + } + fn selected_entry_handle<'a>( &self, cx: &'a App, diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 64e96fee700aea8277fe1b69121abf71599c4d30..720ac04fdd2a656a32668add23e7af021a71ef00 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -8670,6 +8670,55 @@ async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) { } } +#[gpui::test] +async fn test_reveal_in_file_manager_path_falls_back_to_worktree_root( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "content", + "dir": {}, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + select_path(&panel, "root/file.txt", cx); + let selected_reveal_path = panel + .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx)) + .expect("selected entry should produce a reveal path"); + assert!( + selected_reveal_path.ends_with(Path::new("file.txt")), + "Expected selected file path, got {:?}", + selected_reveal_path + ); + + panel.update(cx, |panel, _| { + panel.selection = None; + panel.marked_entries.clear(); + }); + let fallback_reveal_path = panel + .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx)) + .expect("project root should be used when selection is empty"); + assert!( + fallback_reveal_path.ends_with(Path::new("root")), + "Expected worktree root path, got {:?}", + fallback_reveal_path + ); +} + #[gpui::test] async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) { init_test(cx); From f2586fbc19bd8a73eb949dabda3bd7e52b5ef48c Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Tue, 10 Mar 2026 11:50:02 +0100 Subject: [PATCH 096/219] livekit_client: Apply correct priority for audio threads (#51178) Release Notes: - N/A Co-authored-by: Piotr Osiewicz --- .../src/livekit_client/playback.rs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index 0ebb282dd7ec494886fe1ffc90fe1f8688a762da..4933b05fc51592535c1f729ae8038a62103511ba 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -29,7 +29,7 @@ use std::cell::RefCell; use std::sync::Weak; use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::time::Duration; -use std::{borrow::Cow, collections::VecDeque, sync::Arc, thread}; +use std::{borrow::Cow, collections::VecDeque, sync::Arc}; use util::{ResultExt as _, maybe}; mod source; @@ -139,8 +139,10 @@ impl AudioStack { let task = Arc::new(self.executor.spawn({ let apm = self.apm.clone(); let mixer = self.mixer.clone(); + let executor = self.executor.clone(); async move { Self::play_output( + executor, apm, mixer, LEGACY_SAMPLE_RATE.get(), @@ -225,8 +227,10 @@ impl AudioStack { let input_audio_device = AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone()) .flatten(); + let executor = self.executor.clone(); self.executor.spawn(async move { Self::capture_input( + executor, apm, frame_tx, LEGACY_SAMPLE_RATE.get(), @@ -250,6 +254,7 @@ impl AudioStack { } async fn play_output( + executor: BackgroundExecutor, apm: Arc>, mixer: Arc>, sample_rate: u32, @@ -271,9 +276,8 @@ impl AudioStack { let mut resampler = audio_resampler::AudioResampler::default(); let mut buf = Vec::new(); - thread::Builder::new() - .name("AudioPlayback".to_owned()) - .spawn(move || { + executor + .spawn_with_priority(Priority::RealtimeAudio, async move { let output_stream = output_device.build_output_stream( &output_config.config(), { @@ -324,7 +328,7 @@ impl AudioStack { // Block forever to keep the output stream alive end_on_drop_rx.recv().ok(); }) - .unwrap(); + .detach(); device_change_listener.next().await; drop(end_on_drop_tx) @@ -332,6 +336,7 @@ impl AudioStack { } async fn capture_input( + executor: BackgroundExecutor, apm: Arc>, frame_tx: UnboundedSender>, sample_rate: u32, @@ -346,9 +351,8 @@ impl AudioStack { let frame_tx = frame_tx.clone(); let mut resampler = audio_resampler::AudioResampler::default(); - thread::Builder::new() - .name("AudioCapture".to_owned()) - .spawn(move || { + executor + .spawn_with_priority(Priority::RealtimeAudio, async move { maybe!({ if let Some(desc) = device.description().ok() { log::info!("Using microphone: {}", desc.name()) @@ -420,7 +424,7 @@ impl AudioStack { }) .log_err(); }) - .unwrap(); + .detach(); device_change_listener.next().await; drop(end_on_drop_tx) From 376d410b83cd20acd156230fa55ca003e55da301 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Tue, 10 Mar 2026 13:43:27 +0200 Subject: [PATCH 097/219] ep: Add multi-region format (#51185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This format generates fewer token while maintaining the quality: ``` Model Generated tokens ↓ DeltaChrF ↑ 0306-seed-multi-regions 46,239 80.62 0304-seed-no-edits 110,871 80.61 0303-seed 271,457 79.62 ``` In addition to the student format, this change adds a new teacher prompt. It seems to be worse than the original, but I haven't optimized it at all. Keeping it for now as a base for potential improvements. Release Notes: - N/A --- .../edit_prediction_cli/src/format_prompt.rs | 270 ++++++++- crates/edit_prediction_cli/src/main.rs | 62 +- .../edit_prediction_cli/src/parse_output.rs | 6 +- crates/edit_prediction_cli/src/predict.rs | 96 ++- .../src/prompts/teacher_multi_region.md | 366 ++++++++++++ crates/zeta_prompt/src/multi_region.rs | 557 ++++++++++++++++++ crates/zeta_prompt/src/zeta_prompt.rs | 110 +++- 7 files changed, 1442 insertions(+), 25 deletions(-) create mode 100644 crates/edit_prediction_cli/src/prompts/teacher_multi_region.md create mode 100644 crates/zeta_prompt/src/multi_region.rs diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index 324c297ba4c75d10a24b53c7961bd35e1f42e2cd..af955a05dce01fd34c37eb55d15b76b4a4592745 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -13,7 +13,7 @@ use std::ops::Range; use std::sync::Arc; use zeta_prompt::{ ZetaFormat, encode_patch_as_output_for_format, excerpt_range_for_format, format_zeta_prompt, - output_end_marker_for_format, resolve_cursor_region, + multi_region, output_end_marker_for_format, resolve_cursor_region, }; pub async fn run_format_prompt( @@ -49,6 +49,24 @@ pub async fn run_format_prompt( provider: args.provider, }); } + PredictionProvider::TeacherMultiRegion(_) + | PredictionProvider::TeacherMultiRegionNonBatching(_) => { + step_progress.set_substatus("formatting teacher multi-region prompt"); + + let zeta_format = ZetaFormat::default(); + let (editable_range, context_range) = + excerpt_range_for_format(zeta_format, &prompt_inputs.excerpt_ranges); + + let prompt = + TeacherMultiRegionPrompt::format_prompt(example, editable_range, context_range); + example.prompt = Some(ExamplePrompt { + input: prompt, + expected_output: String::new(), + rejected_output: None, + prefill: None, + provider: args.provider, + }); + } PredictionProvider::Zeta2(zeta_format) => { step_progress.set_substatus("formatting zeta2 prompt"); @@ -108,7 +126,7 @@ pub fn zeta2_output_for_patch( return Ok(encoded_output); } - let (mut result, first_hunk_offset) = + let (result, first_hunk_offset) = udiff::apply_diff_to_string_with_hunk_offset(patch, &old_editable_region).with_context( || { format!( @@ -118,6 +136,22 @@ pub fn zeta2_output_for_patch( }, )?; + if version == ZetaFormat::V0306SeedMultiRegions { + let cursor_in_new = cursor_offset.map(|cursor_offset| { + let hunk_start = first_hunk_offset.unwrap_or(0); + result.floor_char_boundary((hunk_start + cursor_offset).min(result.len())) + }); + return multi_region::encode_from_old_and_new( + &old_editable_region, + &result, + cursor_in_new, + zeta_prompt::CURSOR_MARKER, + zeta_prompt::seed_coder::END_MARKER, + zeta_prompt::seed_coder::NO_EDITS, + ); + } + + let mut result = result; if let Some(cursor_offset) = cursor_offset { // The cursor_offset is relative to the start of the hunk's new text (context + additions). // We need to add where the hunk context matched in the editable region to compute @@ -211,7 +245,6 @@ impl TeacherPrompt { .context("editable region not found in prompt content")?; let editable_region_start_line = excerpt[..editable_region_offset].matches('\n').count(); - // Use full context so cursor offset (relative to editable region start) aligns with diff content let editable_region_lines = old_editable_region.lines().count() as u32; let diff = language::unified_diff_with_context( &old_editable_region, @@ -263,6 +296,7 @@ impl TeacherPrompt { .prompt_inputs .as_ref() .and_then(|pi| pi.related_files.as_deref()); + let Some(related_files) = related_files else { return "(No context)".to_string(); }; @@ -317,6 +351,202 @@ impl TeacherPrompt { } } +pub struct TeacherMultiRegionPrompt; + +impl TeacherMultiRegionPrompt { + pub(crate) const USER_CURSOR_MARKER: &str = "<|user_cursor|>"; + pub(crate) const NO_EDITS: &str = "NO_EDITS"; + + /// Truncate edit history to this number of last lines + const MAX_HISTORY_LINES: usize = 128; + + pub fn format_prompt( + example: &Example, + editable_range: Range, + context_range: Range, + ) -> String { + let edit_history = Self::format_edit_history(&example.spec.edit_history); + let context = Self::format_context(example); + let cursor_excerpt = Self::format_cursor_excerpt(example, editable_range, context_range); + + let prompt_template = crate::prompt_assets::get_prompt("teacher_multi_region.md"); + let prompt = prompt_template + .replace("{{context}}", &context) + .replace("{{edit_history}}", &edit_history) + .replace("{{cursor_excerpt}}", &cursor_excerpt); + + prompt + } + + pub fn parse(example: &Example, response: &str) -> Result<(String, Option)> { + let no_edits = (String::new(), None); + if let Some(last_codeblock) = extract_last_codeblock(&response) { + if last_codeblock.trim() == Self::NO_EDITS { + return Ok(no_edits); + } + } + + if response.trim().ends_with(Self::NO_EDITS) { + return Ok(no_edits); + } + + let prompt_inputs = example + .prompt_inputs + .as_ref() + .context("example is missing prompt inputs")?; + + let zeta_format = ZetaFormat::default(); + let (editable_range, _) = + excerpt_range_for_format(zeta_format, &prompt_inputs.excerpt_ranges); + let excerpt = prompt_inputs.cursor_excerpt.as_ref(); + let old_editable_region = &excerpt[editable_range.clone()]; + let marker_offsets = multi_region::compute_marker_offsets(old_editable_region); + + let codeblock = + extract_last_codeblock(&response).context("no codeblock found in model response")?; + let (start_num, end_num, raw_new_span) = multi_region::extract_marker_span(&codeblock)?; + + let start_idx = start_num + .checked_sub(1) + .context("marker numbers are 1-indexed")?; + let end_idx = end_num + .checked_sub(1) + .context("marker numbers are 1-indexed")?; + let start_byte = *marker_offsets + .get(start_idx) + .context("start marker number out of range")?; + let end_byte = *marker_offsets + .get(end_idx) + .context("end marker number out of range")?; + + if start_byte > end_byte { + return Err(anyhow!("start marker must come before end marker")); + } + + let cursor_in_span = raw_new_span.find(Self::USER_CURSOR_MARKER); + let new_span = raw_new_span.replace(Self::USER_CURSOR_MARKER, ""); + + let old_span = &old_editable_region[start_byte..end_byte]; + let mut new_span = new_span; + if old_span.ends_with('\n') && !new_span.ends_with('\n') && !new_span.is_empty() { + new_span.push('\n'); + } + if !old_span.ends_with('\n') && new_span.ends_with('\n') { + new_span.pop(); + } + + let mut new_editable_region = String::new(); + new_editable_region.push_str(&old_editable_region[..start_byte]); + new_editable_region.push_str(&new_span); + new_editable_region.push_str(&old_editable_region[end_byte..]); + + let cursor_offset = cursor_in_span.map(|pos| start_byte + pos); + + if old_editable_region.starts_with('\n') && !new_editable_region.starts_with('\n') { + new_editable_region.insert(0, '\n'); + } + + let editable_region_offset = editable_range.start; + let editable_region_start_line = excerpt[..editable_region_offset].matches('\n').count(); + + let editable_region_lines = old_editable_region.lines().count() as u32; + let diff = language::unified_diff_with_context( + old_editable_region, + &new_editable_region, + editable_region_start_line as u32, + editable_region_start_line as u32, + editable_region_lines, + ); + + let diff = indoc::formatdoc! {" + --- a/{path} + +++ b/{path} + {diff}", + path = example.spec.cursor_path.to_string_lossy(), + diff = diff, + }; + + let actual_cursor = cursor_offset.map(|editable_region_cursor_offset| { + ActualCursor::from_editable_region( + &example.spec.cursor_path, + editable_region_cursor_offset, + &new_editable_region, + excerpt, + editable_region_offset, + editable_region_start_line, + ) + }); + + Ok((diff, actual_cursor)) + } + + fn format_edit_history(edit_history: &str) -> String { + let lines: Vec<&str> = edit_history.lines().collect(); + + if lines.is_empty() { + return "(No edit history)".to_string(); + } + + if lines.len() > Self::MAX_HISTORY_LINES { + let truncated = lines[lines.len() - Self::MAX_HISTORY_LINES..].join("\n"); + format!("{truncated}\n[...truncated...]") + } else { + lines.join("\n") + } + } + + pub fn format_context(example: &Example) -> String { + let related_files = example + .prompt_inputs + .as_ref() + .and_then(|pi| pi.related_files.as_deref()); + let Some(related_files) = related_files else { + return "(No context)".to_string(); + }; + + if related_files.is_empty() { + return "(No context)".to_string(); + } + + let prefix = "`````"; + let suffix = "`````\n\n"; + let max_tokens = 1024; + zeta_prompt::format_related_files_within_budget(related_files, &prefix, &suffix, max_tokens) + } + + fn format_cursor_excerpt( + example: &Example, + editable_range: Range, + context_range: Range, + ) -> String { + let mut result = String::new(); + + let prompt_inputs = example.prompt_inputs.as_ref().unwrap(); + let excerpt = prompt_inputs.cursor_excerpt.as_ref(); + let cursor_offset = prompt_inputs.cursor_offset_in_excerpt; + + let editable_text = &excerpt[editable_range.clone()]; + let cursor_in_editable = cursor_offset - editable_range.start; + + let path_str = example.spec.cursor_path.to_string_lossy(); + result.push_str(&format!("`````{path_str}\n")); + + result.push_str(&excerpt[context_range.start..editable_range.start]); + + multi_region::write_editable_with_markers( + &mut result, + editable_text, + cursor_in_editable, + Self::USER_CURSOR_MARKER, + ); + + result.push_str(&excerpt[editable_range.end..context_range.end]); + result.push_str("\n`````"); + + result + } +} + /// Extract the cursor excerpt from an example. /// First tries to extract from an existing prompt, then falls back to constructing from prompt_inputs. pub fn extract_cursor_excerpt_from_example(example: &Example) -> Option { @@ -461,7 +691,7 @@ mod tests { } #[test] - fn test_extract_editable_region() { + fn test_extract_editable_region_old_format() { let text = indoc::indoc! {" some lines are @@ -483,6 +713,38 @@ mod tests { ); } + #[test] + fn test_extract_editable_region_marker_format() { + let text = indoc::indoc! {" + some context + <|marker_1|> + one + two three + <|marker_2|> + more context + "}; + let parsed = multi_region::extract_editable_region_from_markers(text).unwrap(); + assert_eq!(parsed, "one\ntwo three"); + } + + #[test] + fn test_extract_editable_region_multi_markers() { + let text = indoc::indoc! {" + prefix + <|marker_1|> + aaa + bbb + <|marker_2|> + ccc + ddd + <|marker_3|> + suffix + "}; + let parsed = multi_region::extract_editable_region_from_markers(text).unwrap(); + // Intermediate marker and its trailing \n are stripped + assert_eq!(parsed, "aaa\nbbb\nccc\nddd"); + } + #[test] fn test_extract_last_codeblock_nested_bibtex() { let text = indoc::indoc! {r#" diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs index afe25c5badcfff03babd5e951ae66839ce0f790b..1dcd1d4aa3ad34df853e9d7b193c246f151a61b2 100644 --- a/crates/edit_prediction_cli/src/main.rs +++ b/crates/edit_prediction_cli/src/main.rs @@ -360,7 +360,9 @@ enum PredictionProvider { Zeta2(ZetaFormat), Baseten(ZetaFormat), Teacher(TeacherBackend), + TeacherMultiRegion(TeacherBackend), TeacherNonBatching(TeacherBackend), + TeacherMultiRegionNonBatching(TeacherBackend), Repair, } @@ -379,9 +381,15 @@ impl std::fmt::Display for PredictionProvider { PredictionProvider::Zeta2(format) => write!(f, "zeta2:{format}"), PredictionProvider::Baseten(format) => write!(f, "baseten:{format}"), PredictionProvider::Teacher(backend) => write!(f, "teacher:{backend}"), + PredictionProvider::TeacherMultiRegion(backend) => { + write!(f, "teacher-multi-region:{backend}") + } PredictionProvider::TeacherNonBatching(backend) => { write!(f, "teacher-non-batching:{backend}") } + PredictionProvider::TeacherMultiRegionNonBatching(backend) => { + write!(f, "teacher-multi-region-non-batching:{backend}") + } PredictionProvider::Repair => write!(f, "repair"), } } @@ -409,13 +417,27 @@ impl std::str::FromStr for PredictionProvider { .unwrap_or(TeacherBackend::default()); Ok(PredictionProvider::Teacher(backend)) } - "teacher-non-batching" | "teacher_non_batching" | "teachernonbatching" => { + "teacher-multi-region" | "teacher_multi_region" => { + let backend = arg + .map(|a| a.parse()) + .transpose()? + .unwrap_or(TeacherBackend::default()); + Ok(PredictionProvider::TeacherMultiRegion(backend)) + } + "teacher-non-batching" | "teacher_non_batching" => { let backend = arg .map(|a| a.parse()) .transpose()? .unwrap_or(TeacherBackend::default()); Ok(PredictionProvider::TeacherNonBatching(backend)) } + "teacher-multi-region-non-batching" | "teacher_multi_region_non_batching" => { + let backend = arg + .map(|a| a.parse()) + .transpose()? + .unwrap_or(TeacherBackend::default()); + Ok(PredictionProvider::TeacherMultiRegionNonBatching(backend)) + } "repair" => Ok(PredictionProvider::Repair), "baseten" => { let format = arg @@ -426,9 +448,9 @@ impl std::str::FromStr for PredictionProvider { } _ => { anyhow::bail!( - "unknown provider `{provider}`. Valid options: sweep, mercury, zeta1, zeta2, zeta2:, teacher, teacher:, teacher-non-batching, repair\n\ + "unknown provider `{provider}`. Valid options: sweep, mercury, zeta1, zeta2, zeta2:, teacher, teacher:, teacher-multi-region, teacher-multi-region:, teacher-non-batching, teacher-multi-region-non-batching, repair\n\ For zeta2, you can optionally specify a version like `zeta2:ordered` or `zeta2:V0113_Ordered`.\n\ - For teacher, you can specify a backend like `teacher:sonnet46` or `teacher:gpt52`.\n\ + For teacher providers, you can specify a backend like `teacher:sonnet46`, `teacher-multi-region:sonnet46`, `teacher-multi-region-non-batching:sonnet46`, or `teacher:gpt52`.\n\ Available zeta versions:\n{}", ZetaFormat::options_as_string() ) @@ -491,6 +513,40 @@ enum BatchProvider { Openai, } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prediction_provider_multi_region_non_batched_round_trips_to_primary_spelling() { + let provider: PredictionProvider = "teacher-multi-region-non-batching:sonnet46" + .parse() + .unwrap(); + assert_eq!( + provider, + PredictionProvider::TeacherMultiRegionNonBatching(TeacherBackend::Sonnet46) + ); + assert_eq!( + provider.to_string(), + "teacher-multi-region-non-batching:sonnet46" + ); + } + + #[test] + fn prediction_provider_multi_region_non_batched_alias_round_trips_to_primary_spelling() { + let provider: PredictionProvider = + "teacher_multi_region_non_batching:gpt52".parse().unwrap(); + assert_eq!( + provider, + PredictionProvider::TeacherMultiRegionNonBatching(TeacherBackend::Gpt52) + ); + assert_eq!( + provider.to_string(), + "teacher-multi-region-non-batching:gpt52" + ); + } +} + impl EpArgs { fn output_path(&self) -> Option { if self.in_place { diff --git a/crates/edit_prediction_cli/src/parse_output.rs b/crates/edit_prediction_cli/src/parse_output.rs index 94058efd92ca4a166ba4976819963ef5d3286f5d..2b41384e176ac7a6cc5c3dc7f93ddbba3cf027ae 100644 --- a/crates/edit_prediction_cli/src/parse_output.rs +++ b/crates/edit_prediction_cli/src/parse_output.rs @@ -1,7 +1,7 @@ use crate::{ PredictionProvider, example::{ActualCursor, Example}, - format_prompt::TeacherPrompt, + format_prompt::{TeacherMultiRegionPrompt, TeacherPrompt}, repair, }; use anyhow::{Context as _, Result}; @@ -41,6 +41,10 @@ pub fn parse_prediction_output( PredictionProvider::Teacher(_) | PredictionProvider::TeacherNonBatching(_) => { TeacherPrompt::parse(example, actual_output) } + PredictionProvider::TeacherMultiRegion(_) + | PredictionProvider::TeacherMultiRegionNonBatching(_) => { + TeacherMultiRegionPrompt::parse(example, actual_output) + } PredictionProvider::Zeta2(version) => parse_zeta2_output(example, actual_output, version), PredictionProvider::Repair => repair::parse(example, actual_output), _ => anyhow::bail!( diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index 94e28d00da2d61f63b59364304c3b9b4276e15f7..9f70861b5ef7298141441ec09606fa77e341cbfd 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -2,7 +2,7 @@ use crate::{ FormatPromptArgs, PredictArgs, PredictionProvider, TeacherBackend, anthropic_client::AnthropicClient, example::{Example, ExamplePrediction, ExamplePrompt}, - format_prompt::{TeacherPrompt, run_format_prompt}, + format_prompt::{TeacherMultiRegionPrompt, TeacherPrompt, run_format_prompt}, headless::EpAppState, load_project::run_load_project, openai_client::OpenAiClient, @@ -57,8 +57,10 @@ pub async fn run_prediction( ); }; - if let PredictionProvider::Teacher(backend) | PredictionProvider::TeacherNonBatching(backend) = - provider + if let PredictionProvider::Teacher(backend) + | PredictionProvider::TeacherMultiRegion(backend) + | PredictionProvider::TeacherNonBatching(backend) + | PredictionProvider::TeacherMultiRegionNonBatching(backend) = provider { run_context_retrieval(example, app_state.clone(), example_progress, cx.clone()).await?; run_format_prompt( @@ -71,7 +73,10 @@ pub async fn run_prediction( .await?; let step_progress = example_progress.start(Step::Predict); - let batched = matches!(provider, PredictionProvider::Teacher(..)); + let batched = matches!( + provider, + PredictionProvider::Teacher(..) | PredictionProvider::TeacherMultiRegion(..) + ); return predict_teacher( example, backend, @@ -135,7 +140,9 @@ pub async fn run_prediction( PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep, PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury, PredictionProvider::Teacher(..) + | PredictionProvider::TeacherMultiRegion(..) | PredictionProvider::TeacherNonBatching(..) + | PredictionProvider::TeacherMultiRegionNonBatching(..) | PredictionProvider::Repair | PredictionProvider::Baseten(_) => { unreachable!() @@ -403,7 +410,29 @@ async fn predict_anthropic( .collect::>() .join("\n"); - let (actual_patch, actual_cursor) = TeacherPrompt::parse(example, &actual_output)?; + let parser_provider = if batched { + example + .prompt + .as_ref() + .map(|prompt| prompt.provider) + .unwrap_or(PredictionProvider::Teacher(backend)) + } else { + match example.prompt.as_ref().map(|prompt| prompt.provider) { + Some(PredictionProvider::TeacherMultiRegion(_)) + | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => { + PredictionProvider::TeacherMultiRegionNonBatching(backend) + } + _ => PredictionProvider::TeacherNonBatching(backend), + } + }; + + let (actual_patch, actual_cursor) = match parser_provider { + PredictionProvider::TeacherMultiRegion(_) + | PredictionProvider::TeacherMultiRegionNonBatching(_) => { + TeacherMultiRegionPrompt::parse(example, &actual_output)? + } + _ => TeacherPrompt::parse(example, &actual_output)?, + }; let prediction = ExamplePrediction { actual_patch: Some(actual_patch), @@ -411,9 +440,20 @@ async fn predict_anthropic( actual_cursor, error: None, provider: if batched { - PredictionProvider::Teacher(backend) + match example.prompt.as_ref().map(|prompt| prompt.provider) { + Some(PredictionProvider::TeacherMultiRegion(_)) => { + PredictionProvider::TeacherMultiRegion(backend) + } + _ => PredictionProvider::Teacher(backend), + } } else { - PredictionProvider::TeacherNonBatching(backend) + match example.prompt.as_ref().map(|prompt| prompt.provider) { + Some(PredictionProvider::TeacherMultiRegion(_)) + | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => { + PredictionProvider::TeacherMultiRegionNonBatching(backend) + } + _ => PredictionProvider::TeacherNonBatching(backend), + } }, }; @@ -487,7 +527,29 @@ async fn predict_openai( .collect::>() .join("\n"); - let (actual_patch, actual_cursor) = TeacherPrompt::parse(example, &actual_output)?; + let parser_provider = if batched { + example + .prompt + .as_ref() + .map(|prompt| prompt.provider) + .unwrap_or(PredictionProvider::Teacher(backend)) + } else { + match example.prompt.as_ref().map(|prompt| prompt.provider) { + Some(PredictionProvider::TeacherMultiRegion(_)) + | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => { + PredictionProvider::TeacherMultiRegionNonBatching(backend) + } + _ => PredictionProvider::TeacherNonBatching(backend), + } + }; + + let (actual_patch, actual_cursor) = match parser_provider { + PredictionProvider::TeacherMultiRegion(_) + | PredictionProvider::TeacherMultiRegionNonBatching(_) => { + TeacherMultiRegionPrompt::parse(example, &actual_output)? + } + _ => TeacherPrompt::parse(example, &actual_output)?, + }; let prediction = ExamplePrediction { actual_patch: Some(actual_patch), @@ -495,9 +557,20 @@ async fn predict_openai( actual_cursor, error: None, provider: if batched { - PredictionProvider::Teacher(backend) + match example.prompt.as_ref().map(|prompt| prompt.provider) { + Some(PredictionProvider::TeacherMultiRegion(_)) => { + PredictionProvider::TeacherMultiRegion(backend) + } + _ => PredictionProvider::Teacher(backend), + } } else { - PredictionProvider::TeacherNonBatching(backend) + match example.prompt.as_ref().map(|prompt| prompt.provider) { + Some(PredictionProvider::TeacherMultiRegion(_)) + | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => { + PredictionProvider::TeacherMultiRegionNonBatching(backend) + } + _ => PredictionProvider::TeacherNonBatching(backend), + } }, }; @@ -591,7 +664,8 @@ pub async fn predict_baseten( pub async fn sync_batches(provider: Option<&PredictionProvider>) -> anyhow::Result<()> { match provider { - Some(PredictionProvider::Teacher(backend)) => match backend { + Some(PredictionProvider::Teacher(backend)) + | Some(PredictionProvider::TeacherMultiRegion(backend)) => match backend { TeacherBackend::Sonnet45 | TeacherBackend::Sonnet46 => { let llm_client = ANTHROPIC_CLIENT.get_or_init(|| { AnthropicClient::batch(&crate::paths::LLM_CACHE_DB) diff --git a/crates/edit_prediction_cli/src/prompts/teacher_multi_region.md b/crates/edit_prediction_cli/src/prompts/teacher_multi_region.md new file mode 100644 index 0000000000000000000000000000000000000000..61c5c8f3837a321cb565d5c2b089eec94fcc3dc5 --- /dev/null +++ b/crates/edit_prediction_cli/src/prompts/teacher_multi_region.md @@ -0,0 +1,366 @@ +# Instructions + +You are an edit prediction assistant in a code editor. Your task is to predict the next edit to a given region of code surrounding the user's cursor. + +1. Analyze the edit history to understand what the programmer is trying to achieve +2. Identify any incomplete refactoring or changes that need to be finished +3. Make the remaining edits that a human programmer would logically make next (by rewriting a region of code near their cursor) + +## Focus on + +- Completing any partially-applied changes made +- Ensuring consistency with the programming style and patterns already established +- Making edits that maintain or improve code quality + +## Rules + +- **NEVER undo or revert the user's recent edits.** Examine the diff in the edit history carefully: + - If a line was removed (starts with `-`), do NOT restore that content—even if the code now appears incomplete or broken without it + - If a line was added (starts with `+`), do NOT delete or significantly modify it + - If code appears broken or incomplete after the user's edit, output `NO_EDITS` rather than "fixing" it by reverting + - Only add NEW content that extends the user's work forward; never restore what they removed + - **Key test**: if your prediction would make the code more similar to what it was BEFORE the user's edit, output `NO_EDITS` instead + - **Never assume a deletion was accidental.** Even if removing content breaks the code, breaks a pattern, or leaves text looking "incomplete", respect it. The user may be mid-rewrite. Do NOT "complete" partial text by restoring what was deleted. +- Auto-generated code can be modified: Hunks marked with `// User accepted prediction:` contain code from a previous prediction the user accepted. Unlike user-typed content, these hunks CAN be edited, corrected, or replaced if it improves the code. The "never undo/revert" rule protects the user's *current typing intent*—auto-generated code doesn't carry this protection +- Do not just mechanically apply patterns - reason about what changes make sense given the context and the programmer's apparent goals. +- Do not just fix syntax errors - look for the broader refactoring pattern and apply it systematically throughout the code. +- Keep existing formatting unless it's absolutely necessary +- When edit history and surrounding code suggest different edits, prioritize the most recent edits in the history as they best reflect current intent. +- Treat partial text at or near the cursor as the beginning of something the user is actively typing. Complete the code the user appears to be creating based on context. +- When completing partial code, prefer predictions that save meaningful keystrokes, even if this requires making educated guesses about the user's intent. +- For code, it's better to make a substantive prediction that might be rejected than to make a minimal prediction that saves only a few keystrokes. +- When the user is editing prose or documentation (e.g. Markdown, comments, plain text), predict conservatively. Complete the current fragment or sentence, but do not generate additional lines of free-form content since prose is less constrained than code and more prone to incorrect continuations. + +# Input Format + +You will be provided with: +1. The user's *edit history*, in chronological order. Use this to infer the user's trajectory and predict the next most logical edit. + - Hunks preceded by `// User accepted prediction:` indicate code that was auto-generated by a previous prediction and accepted by the user. These are treated differently than user-typed edits (see Rules). +2. A set of *related excerpts* from the user's codebase. Some of these may be needed for correctly predicting the next edit. + - `…` may appear within a related file to indicate that some code has been skipped. +3. An excerpt from the user's *current file*. + - The excerpt contains numbered *marker* tags (`<|marker_1|>`, `<|marker_2|>`, etc.) placed at block boundaries throughout the code. These markers divide the excerpt into spans that you can target for editing. + - Code that appears before the first marker or after the last marker is read-only context and cannot be edited. + - The `<|user_cursor|>` tag marks the user's current cursor position, as it stands after the last edit in the history. + +# Output Format + +- Briefly explain the user's current intent based on the edit history and their current cursor location. +- Output a markdown codeblock containing your predicted edit as a **marker-bounded span**: + - The codeblock must **start** with a marker tag (e.g. `<|marker_2|>`) and **end** with a marker tag (e.g. `<|marker_4|>`). + - The content between these two markers is the full replacement for that span in the original file. + - Choose the **narrowest** pair of markers that fully contains your predicted edits, to minimize unnecessary output. + - Reproduce any unchanged lines within the chosen span faithfully — do not omit or alter them. + - Do not include any intermediate marker tags in your output — only the start and end markers. +- If no edit is needed (the code is already complete and correct, or there is no clear next edit to make), output a codeblock containing only `NO_EDITS`: + ````` + NO_EDITS + ````` +- If there is a specific place in the predicted output where the user is likely to edit next, indicate it using the `<|user_cursor|>` tag. + +## Example 1 + +There is code missing at the cursor location. The related excerpts includes the definition of a relevant type. You should fill in the missing code. + +### Related Excerpts + +````` +struct Product { + name: String, + price: u32, +} +````` + +### User Edit History + +````` +--- a/src/calculate.rs ++++ b/src/calculate.rs +@@ -100,6 +100,7 @@ + fn calculate_total(products: &[Product]) -> u32 { + let mut total = 0; + for product in products { ++ total += ; + } + total + } +````` + +### Current File + +`````src/calculate.rs +fn calculate_total(products: &[Product]) -> u32 { +<|marker_1|> + let mut total = 0; + for product in products { + total += <|user_cursor|>; + } + total +<|marker_2|> +} +````` + +### Output + +The user is computing a sum based on a list of products. The only numeric field on `Product` is `price`, so they must intend to sum the prices. + +````` +<|marker_1|> + let mut total = 0; + for product in products { + total += product.price; + } + total +<|marker_2|> +````` + +## Example 2 + +The user appears to be in the process of typing an eprintln call. Rather than fixing the spelling issue by deleting the newly-inserted content, you must continue the user's trajectory. It's not clear what data they intend to print. You should fill in as much code as is obviously intended, and position the cursor so that the user can fill in the rest. + +### User Edit History + +````` +--- a/src/modal.rs ++++ b/src/modal.rs +@@ -100,4 +100,4 @@ + fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) { + modal_state.close(); +- modal_state.dismiss(); ++ eprmodal_state.dismiss(); + } +````` + +### Current File + +`````src/modal.rs +<|marker_1|> +// handle the close button click +fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) { +<|marker_2|> + modal_state.close(); + epr<|user_cursor|>modal_state.dismiss(); +} +<|marker_3|> +````` + +### Output + +The user is clearly starting to type `eprintln!()`, however, what they intend to print is not obvious. I should fill in the print call and string literal, with the cursor positioned inside the string literal so the user can print whatever they want. + +````` +<|marker_2|> + modal_state.close(); + eprintln!("<|user_cursor|>"); + modal_state.dismiss(); +} +<|marker_3|> +````` + +## Example 3 + +Here, the user is adding a function. There's no way to tell for sure what the function's name will be. In this situation, you should make a reasonable guess at the function's name and signature, and place the user's cursor in the function body. This way, if you guess correctly, it will save the user a meaningful number of keystrokes, and the file will be left in a coherent state. + +### User Edit History + +````` +--- a/src/modal.rs ++++ b/src/modal.rs +@@ -100,4 +100,4 @@ + fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) { + modal_state.close(); + modal_state.dismiss(); + } ++ ++fn + + fn handle_keystroke(modal_state: &mut ModalState, evt: &Event) { +````` + +### Current File + +`````src/modal.rs +// handle the close button click +fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) { + modal_state.close(); +<|marker_1|> + modal_state.dismiss(); +} + +fn<|user_cursor|> + +<|marker_2|> +fn handle_keystroke(modal_state: &mut ModalState, evt: &Event) { + modal_state.begin_edit(); +<|marker_3|> +````` + +### Output + +The user is adding a new function. The existing functions I see are `handle_close_button_click` and `handle_keystroke`, which have similar signatures. One possible function they might be adding is `handle_submit`. + +````` +<|marker_1|> + modal_state.dismiss(); +} + +fn handle_submit(modal_state: &mut ModalState, evt: &Event) { + <|user_cursor|> +} + +<|marker_2|> +````` + +## Example 4 + +The code is already complete and there is no clear next edit to make. You should output NO_EDITS. + +### User Edit History + +````` +--- a/src/utils.rs ++++ b/src/utils.rs +@@ -10,7 +10,7 @@ + fn add(a: i32, b: i32) -> i32 { +- a - b ++ a + b + } +````` + +### Current File + +`````src/utils.rs +<|marker_1|> +fn add(a: i32, b: i32) -> i32 { + a + b<|user_cursor|> +} +<|marker_2|> +````` + +### Output + +The user just fixed a bug in the `add` function, changing subtraction to addition. The code is now correct and complete. There is no clear next edit to make. + +````` +NO_EDITS +````` + +## Example 5 + +The user just deleted code, leaving behind what looks incomplete. You must NOT "complete" it by restoring deleted content—that would undo their edit. Output NO_EDITS. **This is the correct response even though the code appears broken.** + +### User Edit History + +````` +--- a/config.nix ++++ b/config.nix +@@ -10,7 +10,7 @@ + # /etc/modular/crashdb needs to be mutable +- ln -s /tmp/crashdb $out/etc/modular/crashdb ++ ln -s /tmp/cr $out/etc/modular/crashdb + ''; +````` + +### Current File + +`````config.nix +<|marker_1|> + # /etc/modular/crashdb needs to be mutable + ln -s /tmp/cr<|user_cursor|> $out/etc/modular/crashdb + ''; +<|marker_2|> +````` + +### Output + +The user deleted `ashdb` from `/tmp/crashdb`, leaving `/tmp/cr`. Although this looks like incomplete text that I could "complete", doing so would restore deleted content. The user intentionally removed that text—I must not undo their deletion. + +````` +NO_EDITS +````` + +## Example 6 + +The user accepted a prediction for a function, then started renaming it. The original arguments were auto-generated (marked with `// User accepted prediction:`), so they CAN be updated to match the new function name. This is NOT reverting user input—it's improving auto-generated scaffolding. + +### User Edit History + +````` +--- a/math_utils.py ++++ b/math_utils.py +@@ -3,3 +3,5 @@ + def calculate_rectangle_area(width, height): + return width * height + + ++de + +// User accepted prediction: +--- a/math_utils.py ++++ b/math_utils.py +@@ -3,5 +3,7 @@ + def calculate_rectangle_area(width, height): + return width * height + +-de ++def calculate_rectangle_perimeter(width, height): ++ + +--- a/math_utils.py ++++ b/math_utils.py +@@ -5,5 +5,5 @@ + return width * height + +-def calculate_rectangle_perimeter(width, height): ++def calculate_sq_perimeter(width, height): + +````` + +### Current File + +`````math_utils.py +<|marker_1|> +def calculate_rectangle_area(width, height): + return width * height + +<|marker_2|> +def calculate_sq<|user_cursor|>_perimeter(width, height): + +<|marker_3|> +````` + +### Output + +The user accepted a prediction for `calculate_rectangle_perimeter(width, height)`, then started renaming `rectangle` to `square`. Since squares have equal sides, the arguments should change from `(width, height)` to `(side)`. The arguments were auto-generated (from an accepted prediction), so modifying them is appropriate. + +````` +<|marker_2|> +def calculate_square_perimeter(side): + <|user_cursor|> +<|marker_3|> +````` + + + +# Your task: + +# 1. User Edit History + +````` +{{edit_history}} +````` + +# 2. Related excerpts + +{{context}} + +# 3. Current File + +{{cursor_excerpt}} + + + + +----- + +Based on the edit history and context above, predict the user's next edit within the marker-bounded spans. diff --git a/crates/zeta_prompt/src/multi_region.rs b/crates/zeta_prompt/src/multi_region.rs new file mode 100644 index 0000000000000000000000000000000000000000..1bac794b1d71fdf5ca8e086b748b8aa426bad1bd --- /dev/null +++ b/crates/zeta_prompt/src/multi_region.rs @@ -0,0 +1,557 @@ +use anyhow::{Context as _, Result, anyhow}; + +pub const MARKER_TAG_PREFIX: &str = "<|marker_"; +pub const MARKER_TAG_SUFFIX: &str = "|>"; +const MIN_BLOCK_LINES: usize = 3; +const MAX_BLOCK_LINES: usize = 8; + +pub fn marker_tag(number: usize) -> String { + format!("{MARKER_TAG_PREFIX}{number}{MARKER_TAG_SUFFIX}") +} + +/// Compute byte offsets within `editable_text` where marker boundaries should +/// be placed. +/// +/// Returns a sorted `Vec` that always starts with `0` and ends with +/// `editable_text.len()`. Interior offsets are placed at line boundaries +/// (right after a `\n`), preferring blank-line boundaries when available and +/// respecting `MIN_BLOCK_LINES` / `MAX_BLOCK_LINES` constraints. +pub fn compute_marker_offsets(editable_text: &str) -> Vec { + if editable_text.is_empty() { + return vec![0, 0]; + } + + let mut offsets = vec![0usize]; + let mut lines_since_last_marker = 0usize; + let mut byte_offset = 0usize; + + for line in editable_text.split('\n') { + let line_end = byte_offset + line.len() + 1; + let is_past_end = line_end > editable_text.len(); + let actual_line_end = line_end.min(editable_text.len()); + lines_since_last_marker += 1; + + let is_blank = line.trim().is_empty(); + + if !is_past_end && lines_since_last_marker >= MIN_BLOCK_LINES { + if is_blank { + // Blank-line boundary found. We'll place the marker when we + // find the next non-blank line (handled below). + } else if lines_since_last_marker >= MAX_BLOCK_LINES { + offsets.push(actual_line_end); + lines_since_last_marker = 0; + } + } + + // Non-blank line immediately following blank line(s): split here so + // the new block starts with this line. + if !is_blank && byte_offset > 0 && lines_since_last_marker >= MIN_BLOCK_LINES { + let before = &editable_text[..byte_offset]; + let has_preceding_blank_line = before + .strip_suffix('\n') + .map(|stripped| { + let last_line = match stripped.rfind('\n') { + Some(pos) => &stripped[pos + 1..], + None => stripped, + }; + last_line.trim().is_empty() + }) + .unwrap_or(false); + + if has_preceding_blank_line { + offsets.push(byte_offset); + lines_since_last_marker = 1; + } + } + + byte_offset = actual_line_end; + + // Re-check after blank-line logic since lines_since_last_marker may + // have been reset. + if !is_past_end && lines_since_last_marker >= MAX_BLOCK_LINES { + if *offsets.last().unwrap_or(&0) != actual_line_end { + offsets.push(actual_line_end); + lines_since_last_marker = 0; + } + } + } + + let end = editable_text.len(); + if *offsets.last().unwrap_or(&0) != end { + offsets.push(end); + } + + offsets +} + +/// Write the editable region content with marker tags, inserting the cursor +/// marker at the given offset within the editable text. +pub fn write_editable_with_markers( + output: &mut String, + editable_text: &str, + cursor_offset_in_editable: usize, + cursor_marker: &str, +) { + let marker_offsets = compute_marker_offsets(editable_text); + let mut cursor_placed = false; + for (i, &offset) in marker_offsets.iter().enumerate() { + let marker_num = i + 1; + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str(&marker_tag(marker_num)); + + if let Some(&next_offset) = marker_offsets.get(i + 1) { + output.push('\n'); + let block = &editable_text[offset..next_offset]; + if !cursor_placed + && cursor_offset_in_editable >= offset + && cursor_offset_in_editable <= next_offset + { + cursor_placed = true; + let cursor_in_block = cursor_offset_in_editable - offset; + output.push_str(&block[..cursor_in_block]); + output.push_str(cursor_marker); + output.push_str(&block[cursor_in_block..]); + } else { + output.push_str(block); + } + } + } +} + +/// Strip any `<|marker_N|>` tags from `text`. +/// +/// When a marker tag sits on its own line (followed by `\n`), the trailing +/// newline is also removed so the surrounding lines stay joined naturally. +fn strip_marker_tags(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut pos = 0; + let bytes = text.as_bytes(); + while let Some(rel) = text[pos..].find(MARKER_TAG_PREFIX) { + result.push_str(&text[pos..pos + rel]); + let num_start = pos + rel + MARKER_TAG_PREFIX.len(); + if let Some(suffix_rel) = text[num_start..].find(MARKER_TAG_SUFFIX) { + let mut tag_end = num_start + suffix_rel + MARKER_TAG_SUFFIX.len(); + if bytes.get(tag_end) == Some(&b'\n') { + tag_end += 1; + } + pos = tag_end; + } else { + result.push_str(MARKER_TAG_PREFIX); + pos = num_start; + } + } + result.push_str(&text[pos..]); + result +} + +/// Parse model output that uses the marker format. +/// +/// Returns `(start_marker_num, end_marker_num, content_between_markers)`. +/// The leading format-level newline after the start marker is stripped. +/// Trailing newlines are preserved so blank-line endings in the editable +/// region are not lost. +/// +/// Any extra intermediate marker tags that the model may have inserted +/// between the first and last markers are stripped from the returned content. +pub fn extract_marker_span(text: &str) -> Result<(usize, usize, String)> { + let first_tag_start = text + .find(MARKER_TAG_PREFIX) + .context("no start marker found in output")?; + let first_num_start = first_tag_start + MARKER_TAG_PREFIX.len(); + let first_num_end = text[first_num_start..] + .find(MARKER_TAG_SUFFIX) + .map(|i| i + first_num_start) + .context("malformed start marker tag")?; + let start_num: usize = text[first_num_start..first_num_end] + .parse() + .context("start marker number is not a valid integer")?; + let first_tag_end = first_num_end + MARKER_TAG_SUFFIX.len(); + + let last_tag_start = text + .rfind(MARKER_TAG_PREFIX) + .context("no end marker found in output")?; + let last_num_start = last_tag_start + MARKER_TAG_PREFIX.len(); + let last_num_end = text[last_num_start..] + .find(MARKER_TAG_SUFFIX) + .map(|i| i + last_num_start) + .context("malformed end marker tag")?; + let end_num: usize = text[last_num_start..last_num_end] + .parse() + .context("end marker number is not a valid integer")?; + + if start_num == end_num { + return Err(anyhow!( + "start and end markers are the same (marker {})", + start_num + )); + } + + let mut content_start = first_tag_end; + if text.as_bytes().get(content_start) == Some(&b'\n') { + content_start += 1; + } + let content_end = last_tag_start; + + let content = &text[content_start..content_end.max(content_start)]; + let content = strip_marker_tags(content); + Ok((start_num, end_num, content)) +} + +/// Given old editable text and model output with marker span, reconstruct the +/// full new editable region. +pub fn apply_marker_span(old_editable: &str, output: &str) -> Result { + let (start_num, end_num, raw_new_span) = extract_marker_span(output)?; + let marker_offsets = compute_marker_offsets(old_editable); + + let start_idx = start_num + .checked_sub(1) + .context("marker numbers are 1-indexed")?; + let end_idx = end_num + .checked_sub(1) + .context("marker numbers are 1-indexed")?; + let start_byte = *marker_offsets + .get(start_idx) + .context("start marker number out of range")?; + let end_byte = *marker_offsets + .get(end_idx) + .context("end marker number out of range")?; + + if start_byte > end_byte { + return Err(anyhow!("start marker must come before end marker")); + } + + let old_span = &old_editable[start_byte..end_byte]; + let mut new_span = raw_new_span; + if old_span.ends_with('\n') && !new_span.ends_with('\n') && !new_span.is_empty() { + new_span.push('\n'); + } + if !old_span.ends_with('\n') && new_span.ends_with('\n') { + new_span.pop(); + } + + let mut result = String::new(); + result.push_str(&old_editable[..start_byte]); + result.push_str(&new_span); + result.push_str(&old_editable[end_byte..]); + + Ok(result) +} + +/// Compare old and new editable text, find the minimal marker span that covers +/// all changes, and encode the result with marker tags. +pub fn encode_from_old_and_new( + old_editable: &str, + new_editable: &str, + cursor_offset_in_new: Option, + cursor_marker: &str, + end_marker: &str, + no_edits_marker: &str, +) -> Result { + if old_editable == new_editable { + return Ok(format!("{no_edits_marker}{end_marker}")); + } + + let marker_offsets = compute_marker_offsets(old_editable); + + let common_prefix = old_editable + .bytes() + .zip(new_editable.bytes()) + .take_while(|(a, b)| a == b) + .count(); + + let old_remaining = old_editable.len() - common_prefix; + let new_remaining = new_editable.len() - common_prefix; + let max_suffix = old_remaining.min(new_remaining); + let common_suffix = old_editable.as_bytes()[old_editable.len() - max_suffix..] + .iter() + .rev() + .zip( + new_editable.as_bytes()[new_editable.len() - max_suffix..] + .iter() + .rev(), + ) + .take_while(|(a, b)| a == b) + .count(); + + let change_end_in_old = old_editable.len() - common_suffix; + + let start_marker_idx = marker_offsets + .iter() + .rposition(|&offset| offset <= common_prefix) + .unwrap_or(0); + let end_marker_idx = marker_offsets + .iter() + .position(|&offset| offset >= change_end_in_old) + .unwrap_or(marker_offsets.len() - 1); + + let old_start = marker_offsets[start_marker_idx]; + let old_end = marker_offsets[end_marker_idx]; + + let new_start = old_start; + let new_end = new_editable + .len() + .saturating_sub(old_editable.len().saturating_sub(old_end)); + + let new_span = &new_editable[new_start..new_end]; + + let start_marker_num = start_marker_idx + 1; + let end_marker_num = end_marker_idx + 1; + + let mut result = String::new(); + result.push_str(&marker_tag(start_marker_num)); + result.push('\n'); + + if let Some(cursor_offset) = cursor_offset_in_new { + if cursor_offset >= new_start && cursor_offset <= new_end { + let cursor_in_span = cursor_offset - new_start; + let bounded = cursor_in_span.min(new_span.len()); + result.push_str(&new_span[..bounded]); + result.push_str(cursor_marker); + result.push_str(&new_span[bounded..]); + } else { + result.push_str(new_span); + } + } else { + result.push_str(new_span); + } + + if !result.ends_with('\n') { + result.push('\n'); + } + result.push_str(&marker_tag(end_marker_num)); + result.push('\n'); + result.push_str(end_marker); + + Ok(result) +} + +/// Extract the full editable region from text that uses marker tags. +/// +/// Returns the concatenation of all block contents between the first and last +/// markers, with intermediate marker tags stripped. +pub fn extract_editable_region_from_markers(text: &str) -> Option { + let first_marker_start = text.find(MARKER_TAG_PREFIX)?; + + let mut markers: Vec<(usize, usize)> = Vec::new(); + let mut search_start = first_marker_start; + while let Some(rel_pos) = text[search_start..].find(MARKER_TAG_PREFIX) { + let tag_start = search_start + rel_pos; + let num_start = tag_start + MARKER_TAG_PREFIX.len(); + let num_end = text[num_start..].find(MARKER_TAG_SUFFIX)?; + let tag_end = num_start + num_end + MARKER_TAG_SUFFIX.len(); + markers.push((tag_start, tag_end)); + search_start = tag_end; + } + + if markers.len() < 2 { + return None; + } + + let (_, first_tag_end) = markers[0]; + let (last_tag_start, _) = markers[markers.len() - 1]; + + let mut content_start = first_tag_end; + if text.as_bytes().get(content_start) == Some(&b'\n') { + content_start += 1; + } + let mut content_end = last_tag_start; + if content_end > content_start && text.as_bytes().get(content_end - 1) == Some(&b'\n') { + content_end -= 1; + } + + let raw = &text[content_start..content_end]; + let result = strip_marker_tags(raw); + let result = result.strip_suffix('\n').unwrap_or(&result).to_string(); + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_marker_offsets_small_block() { + let text = "aaa\nbbb\nccc\n"; + let offsets = compute_marker_offsets(text); + assert_eq!(offsets, vec![0, text.len()]); + } + + #[test] + fn test_compute_marker_offsets_blank_line_split() { + let text = "aaa\nbbb\nccc\n\nddd\neee\nfff\n"; + let offsets = compute_marker_offsets(text); + assert_eq!(offsets[0], 0); + assert!(offsets.contains(&13), "offsets: {:?}", offsets); + assert_eq!(*offsets.last().unwrap(), text.len()); + } + + #[test] + fn test_compute_marker_offsets_max_lines_split() { + let text = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n"; + let offsets = compute_marker_offsets(text); + assert!(offsets.len() >= 3, "offsets: {:?}", offsets); + } + + #[test] + fn test_compute_marker_offsets_empty() { + let offsets = compute_marker_offsets(""); + assert_eq!(offsets, vec![0, 0]); + } + + #[test] + fn test_extract_marker_span() { + let text = "<|marker_2|>\n new content\n<|marker_3|>\n"; + let (start, end, content) = extract_marker_span(text).unwrap(); + assert_eq!(start, 2); + assert_eq!(end, 3); + assert_eq!(content, " new content\n"); + } + + #[test] + fn test_extract_marker_span_multi_line() { + let text = "<|marker_1|>\nline1\nline2\nline3\n<|marker_4|>"; + let (start, end, content) = extract_marker_span(text).unwrap(); + assert_eq!(start, 1); + assert_eq!(end, 4); + assert_eq!(content, "line1\nline2\nline3\n"); + } + + #[test] + fn test_apply_marker_span_basic() { + let old = "aaa\nbbb\nccc\n"; + let output = "<|marker_1|>\naaa\nBBB\nccc\n<|marker_2|>"; + let result = apply_marker_span(old, output).unwrap(); + assert_eq!(result, "aaa\nBBB\nccc\n"); + } + + #[test] + fn test_apply_marker_span_preserves_trailing_blank_line() { + let old = "/\nresult\n\n"; + let output = "<|marker_1|>\n//\nresult\n\n<|marker_2|>"; + let result = apply_marker_span(old, output).unwrap(); + assert_eq!(result, "//\nresult\n\n"); + } + + #[test] + fn test_encode_no_edits() { + let old = "aaa\nbbb\nccc\n"; + let result = encode_from_old_and_new( + old, + old, + None, + "<|user_cursor|>", + ">>>>>>> UPDATED\n", + "NO_EDITS\n", + ) + .unwrap(); + assert_eq!(result, "NO_EDITS\n>>>>>>> UPDATED\n"); + } + + #[test] + fn test_encode_with_change() { + let old = "aaa\nbbb\nccc\n"; + let new = "aaa\nBBB\nccc\n"; + let result = encode_from_old_and_new( + old, + new, + None, + "<|user_cursor|>", + ">>>>>>> UPDATED\n", + "NO_EDITS\n", + ) + .unwrap(); + assert!(result.contains("<|marker_1|>")); + assert!(result.contains("<|marker_2|>")); + assert!(result.contains("aaa\nBBB\nccc\n")); + assert!(result.ends_with(">>>>>>> UPDATED\n")); + } + + #[test] + fn test_roundtrip_encode_apply() { + let old = "line1\nline2\nline3\n\nline5\nline6\nline7\nline8\nline9\nline10\n"; + let new = "line1\nline2\nline3\n\nline5\nLINE6\nline7\nline8\nline9\nline10\n"; + let encoded = encode_from_old_and_new( + old, + new, + None, + "<|user_cursor|>", + ">>>>>>> UPDATED\n", + "NO_EDITS\n", + ) + .unwrap(); + let output = encoded + .strip_suffix(">>>>>>> UPDATED\n") + .expect("should have end marker"); + let reconstructed = apply_marker_span(old, output).unwrap(); + assert_eq!(reconstructed, new); + } + + #[test] + fn test_extract_editable_region_from_markers_multi() { + let text = "prefix\n<|marker_1|>\naaa\nbbb\n<|marker_2|>\nccc\nddd\n<|marker_3|>\nsuffix"; + let parsed = extract_editable_region_from_markers(text).unwrap(); + assert_eq!(parsed, "aaa\nbbb\nccc\nddd"); + } + + #[test] + fn test_extract_editable_region_two_markers() { + let text = "<|marker_1|>\none\ntwo three\n<|marker_2|>"; + let parsed = extract_editable_region_from_markers(text).unwrap(); + assert_eq!(parsed, "one\ntwo three"); + } + + #[test] + fn test_encode_with_cursor() { + let old = "aaa\nbbb\nccc\n"; + let new = "aaa\nBBB\nccc\n"; + let result = encode_from_old_and_new( + old, + new, + Some(5), + "<|user_cursor|>", + ">>>>>>> UPDATED\n", + "NO_EDITS\n", + ) + .unwrap(); + assert!(result.contains("<|user_cursor|>"), "result: {result}"); + assert!(result.contains("B<|user_cursor|>BB"), "result: {result}"); + } + + #[test] + fn test_extract_marker_span_strips_intermediate_markers() { + let text = "<|marker_2|>\nline1\n<|marker_3|>\nline2\n<|marker_4|>"; + let (start, end, content) = extract_marker_span(text).unwrap(); + assert_eq!(start, 2); + assert_eq!(end, 4); + assert_eq!(content, "line1\nline2\n"); + } + + #[test] + fn test_extract_marker_span_strips_multiple_intermediate_markers() { + let text = "<|marker_1|>\naaa\n<|marker_2|>\nbbb\n<|marker_3|>\nccc\n<|marker_4|>"; + let (start, end, content) = extract_marker_span(text).unwrap(); + assert_eq!(start, 1); + assert_eq!(end, 4); + assert_eq!(content, "aaa\nbbb\nccc\n"); + } + + #[test] + fn test_apply_marker_span_with_extra_intermediate_marker() { + let old = "aaa\nbbb\nccc\n"; + let output = "<|marker_1|>\naaa\n<|marker_1|>\nBBB\nccc\n<|marker_2|>"; + let result = apply_marker_span(old, output).unwrap(); + assert_eq!(result, "aaa\nBBB\nccc\n"); + } + + #[test] + fn test_strip_marker_tags_inline() { + assert_eq!(strip_marker_tags("no markers here"), "no markers here"); + assert_eq!(strip_marker_tags("before<|marker_5|>after"), "beforeafter"); + assert_eq!( + strip_marker_tags("line1\n<|marker_3|>\nline2"), + "line1\nline2" + ); + } +} diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 1dd675e8b39ccab8403682beb040a075381aaf1d..41d02478c33ce807bf1771cf25799c9a427e63ed 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -1,4 +1,5 @@ pub mod excerpt_ranges; +pub mod multi_region; use anyhow::{Result, anyhow}; use serde::{Deserialize, Serialize}; @@ -81,6 +82,7 @@ pub enum ZetaFormat { v0226Hashline, V0304VariableEdit, V0304SeedNoEdits, + V0306SeedMultiRegions, } impl std::fmt::Display for ZetaFormat { @@ -218,6 +220,20 @@ pub fn special_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] ZetaFormat::v0226Hashline => hashline::special_tokens(), ZetaFormat::V0304VariableEdit => v0304_variable_edit::special_tokens(), ZetaFormat::V0304SeedNoEdits => seed_coder::special_tokens(), + ZetaFormat::V0306SeedMultiRegions => { + static TOKENS: &[&str] = &[ + seed_coder::FIM_SUFFIX, + seed_coder::FIM_PREFIX, + seed_coder::FIM_MIDDLE, + seed_coder::FILE_MARKER, + seed_coder::START_MARKER, + seed_coder::SEPARATOR, + seed_coder::END_MARKER, + CURSOR_MARKER, + multi_region::MARKER_TAG_PREFIX, + ]; + TOKENS + } } } @@ -231,6 +247,7 @@ pub fn token_limits_for_format(format: ZetaFormat) -> (usize, usize) { | ZetaFormat::V0211Prefill | ZetaFormat::V0211SeedCoder | ZetaFormat::v0226Hashline + | ZetaFormat::V0306SeedMultiRegions | ZetaFormat::V0304SeedNoEdits => (350, 150), ZetaFormat::V0304VariableEdit => (1024, 0), } @@ -247,6 +264,7 @@ pub fn stop_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] { | ZetaFormat::V0211Prefill | ZetaFormat::V0211SeedCoder | ZetaFormat::V0304VariableEdit + | ZetaFormat::V0306SeedMultiRegions | ZetaFormat::V0304SeedNoEdits => &[], } } @@ -269,7 +287,8 @@ pub fn excerpt_ranges_for_format( | ZetaFormat::V0211Prefill | ZetaFormat::V0211SeedCoder | ZetaFormat::v0226Hashline - | ZetaFormat::V0304SeedNoEdits => ( + | ZetaFormat::V0304SeedNoEdits + | ZetaFormat::V0306SeedMultiRegions => ( ranges.editable_350.clone(), ranges.editable_350_context_150.clone(), ), @@ -344,9 +363,46 @@ pub fn write_cursor_excerpt_section_for_format( ZetaFormat::V0304VariableEdit => { v0304_variable_edit::write_cursor_excerpt_section(prompt, path, context, cursor_offset) } + ZetaFormat::V0306SeedMultiRegions => { + prompt.push_str(&build_v0306_cursor_prefix( + path, + context, + editable_range, + cursor_offset, + )); + } } } +fn build_v0306_cursor_prefix( + path: &Path, + context: &str, + editable_range: &Range, + cursor_offset: usize, +) -> String { + let mut section = String::new(); + let path_str = path.to_string_lossy(); + write!(section, "{}{}\n", seed_coder::FILE_MARKER, path_str).ok(); + + section.push_str(&context[..editable_range.start]); + section.push_str(seed_coder::START_MARKER); + + let editable_text = &context[editable_range.clone()]; + let cursor_in_editable = cursor_offset - editable_range.start; + multi_region::write_editable_with_markers( + &mut section, + editable_text, + cursor_in_editable, + CURSOR_MARKER, + ); + + if !section.ends_with('\n') { + section.push('\n'); + } + section.push_str(seed_coder::SEPARATOR); + section +} + fn offset_range_to_row_range(text: &str, range: Range) -> Range { let start_row = text[0..range.start].matches('\n').count() as u32; let mut end_row = start_row + text[range.clone()].matches('\n').count() as u32; @@ -392,6 +448,18 @@ pub fn format_prompt_with_budget_for_format( max_tokens, ) } + ZetaFormat::V0306SeedMultiRegions => { + let cursor_prefix = + build_v0306_cursor_prefix(path, context, &editable_range, cursor_offset); + seed_coder::assemble_fim_prompt( + context, + &editable_range, + &cursor_prefix, + &input.events, + related_files, + max_tokens, + ) + } _ => { let mut cursor_section = String::new(); write_cursor_excerpt_section_for_format( @@ -463,7 +531,7 @@ pub fn get_prefill_for_format( | ZetaFormat::V0211SeedCoder | ZetaFormat::v0226Hashline | ZetaFormat::V0304VariableEdit => String::new(), - ZetaFormat::V0304SeedNoEdits => String::new(), + ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions => String::new(), } } @@ -472,7 +540,9 @@ pub fn output_end_marker_for_format(format: ZetaFormat) -> Option<&'static str> ZetaFormat::V0120GitMergeMarkers => Some(v0120_git_merge_markers::END_MARKER), ZetaFormat::V0131GitMergeMarkersPrefix => Some(v0131_git_merge_markers_prefix::END_MARKER), ZetaFormat::V0211Prefill => Some(v0131_git_merge_markers_prefix::END_MARKER), - ZetaFormat::V0211SeedCoder | ZetaFormat::V0304SeedNoEdits => Some(seed_coder::END_MARKER), + ZetaFormat::V0211SeedCoder + | ZetaFormat::V0304SeedNoEdits + | ZetaFormat::V0306SeedMultiRegions => Some(seed_coder::END_MARKER), ZetaFormat::V0112MiddleAtEnd | ZetaFormat::V0113Ordered | ZetaFormat::V0114180EditableRegion @@ -497,7 +567,9 @@ pub fn encode_patch_as_output_for_format( cursor_offset, ) .map(Some), - ZetaFormat::V0304SeedNoEdits => Ok(seed_coder::no_edits(patch)), + ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions => { + Ok(seed_coder::no_edits(patch)) + } _ => Ok(None), } } @@ -543,6 +615,14 @@ pub fn parse_zeta2_model_output( output.to_string() }, ), + ZetaFormat::V0306SeedMultiRegions => ( + editable_range_in_context, + if output.starts_with(seed_coder::NO_EDITS) { + old_editable_region.to_string() + } else { + multi_region::apply_marker_span(old_editable_region, output)? + }, + ), _ => (editable_range_in_context, output.to_string()), }; @@ -2587,9 +2667,27 @@ pub mod seed_coder { related_files: &[RelatedFile], max_tokens: usize, ) -> String { - let suffix_section = build_suffix_section(context, editable_range); let cursor_prefix_section = build_cursor_prefix_section(path, context, editable_range, cursor_offset); + assemble_fim_prompt( + context, + editable_range, + &cursor_prefix_section, + events, + related_files, + max_tokens, + ) + } + + pub fn assemble_fim_prompt( + context: &str, + editable_range: &Range, + cursor_prefix_section: &str, + events: &[Arc], + related_files: &[RelatedFile], + max_tokens: usize, + ) -> String { + let suffix_section = build_suffix_section(context, editable_range); let suffix_tokens = estimate_tokens(suffix_section.len()); let cursor_prefix_tokens = estimate_tokens(cursor_prefix_section.len()); @@ -2622,7 +2720,7 @@ pub mod seed_coder { if !edit_history_section.is_empty() { prompt.push('\n'); } - prompt.push_str(&cursor_prefix_section); + prompt.push_str(cursor_prefix_section); prompt.push_str(FIM_MIDDLE); prompt } From 01e8df43d218fa1f55ba6ba6b28d824f54bd8892 Mon Sep 17 00:00:00 2001 From: Om Chillure Date: Tue, 10 Mar 2026 18:20:51 +0530 Subject: [PATCH 098/219] agent_ui: Fix button to copy the command from terminal calls not appearing (#51191) Fixes #51048 The "Copy Command" button uses `.visible_on_hover(group)` from GPUI to only appear when the user hovers over its parent container. In `render_collapsible_command` (used to render the code blocks for terminal tool calls like "Run Command"), the parent `v_flex()` container was missing the `.group()` assignment. This caused the copy button to never become visible, which became apparent when an agent session was restored from history. This commit adds `.group(group.clone())` to the root `v_flex()` container in `render_collapsible_command` to restore the hover visibility for the "Copy Command" button. Video : [Screencast from 2026-03-10 18-06-57.webm](https://github.com/user-attachments/assets/ae931ac3-c7f1-4044-a3d8-a91a93d9c3bb) [Screencast from 2026-03-10 18-06-01.webm](https://github.com/user-attachments/assets/5ddb8085-bafe-4e9a-bb02-74e3d860ae1a) Release Notes: - Agent: Fixed an issue where the "Copy Command" button would not appear on hover for terminal tool calls. --- crates/agent_ui/src/connection_view/thread_view.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 0519362ab1194a6e21ff9b3f213112f94f4cce55..f5fc681a82b636ec401f3a8c6168bcb368931930 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -4870,6 +4870,7 @@ impl ThreadView { cx: &Context, ) -> Div { v_flex() + .group(group.clone()) .p_1p5() .bg(self.tool_card_header_bg(cx)) .when(is_preview, |this| { From f18567c1f0a4d2db2dd121202d4a4d84a2d9d02d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:19:26 -0300 Subject: [PATCH 099/219] git: Add the ability to resolve merge conflicts with the agent (#49807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a "Resolve with Agent" button in each merge conflict block, as well as "Resolve Conflicts with Agents" button on a notification for resolving conflicts across all the files that have any. When clicking on either of these buttons, the agent panel opens up with a template prompt auto-submitted. For the first case, the specific content of the merge block is already attached as context for the agent to act quickly, given it's a local and small context. For the second case (all conflicts across the codebase), the prompt just indicates to the agent which files have conflicts and then it's up for the agent to see them. This felt like a simpler way to go as opposed to extracting the content for all merge conflicts across all damaged files. Here's how the UI looks like: Screenshot 2026-02-21 at 11  04@2x --- 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: - Git: Added the ability to quickly resolve merge conflicts with the agent. --------- Co-authored-by: Bennet Bo Fenner Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> --- assets/icons/git_merge_conflict.svg | 7 + crates/acp_thread/src/mention.rs | 19 + crates/agent/src/thread.rs | 21 + crates/agent_ui/src/agent_panel.rs | 442 +++++++++++++++++- .../src/connection_view/thread_view.rs | 1 + crates/agent_ui/src/mention_set.rs | 7 +- crates/agent_ui/src/ui/mention_crease.rs | 3 +- crates/git_ui/src/conflict_view.rs | 154 +++++- crates/git_ui/src/git_ui.rs | 1 + crates/icons/src/icons.rs | 1 + crates/zed_actions/src/lib.rs | 27 ++ 11 files changed, 675 insertions(+), 8 deletions(-) create mode 100644 assets/icons/git_merge_conflict.svg diff --git a/assets/icons/git_merge_conflict.svg b/assets/icons/git_merge_conflict.svg new file mode 100644 index 0000000000000000000000000000000000000000..10bc2c04fc9877112723273b0d60351c3a4c56bc --- /dev/null +++ b/assets/icons/git_merge_conflict.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index b63eec154a40de8909d13de2a4e1bd3e9d1e06f3..43dfe7610e34a0399a27a1d28858b938acfc2e0f 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -60,6 +60,9 @@ pub enum MentionUri { GitDiff { base_ref: String, }, + MergeConflict { + file_path: String, + }, } impl MentionUri { @@ -215,6 +218,9 @@ impl MentionUri { let base_ref = single_query_param(&url, "base")?.unwrap_or_else(|| "main".to_string()); Ok(Self::GitDiff { base_ref }) + } else if path.starts_with("/agent/merge-conflict") { + let file_path = single_query_param(&url, "path")?.unwrap_or_default(); + Ok(Self::MergeConflict { file_path }) } else { bail!("invalid zed url: {:?}", input); } @@ -245,6 +251,13 @@ impl MentionUri { } } MentionUri::GitDiff { base_ref } => format!("Branch Diff ({})", base_ref), + MentionUri::MergeConflict { file_path } => { + let name = Path::new(file_path) + .file_name() + .unwrap_or_default() + .to_string_lossy(); + format!("Merge Conflict ({name})") + } MentionUri::Selection { abs_path: path, line_range, @@ -306,6 +319,7 @@ impl MentionUri { MentionUri::Selection { .. } => IconName::Reader.path().into(), MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(), MentionUri::GitDiff { .. } => IconName::GitBranch.path().into(), + MentionUri::MergeConflict { .. } => IconName::GitMergeConflict.path().into(), } } @@ -409,6 +423,11 @@ impl MentionUri { url.query_pairs_mut().append_pair("base", base_ref); url } + MentionUri::MergeConflict { file_path } => { + let mut url = Url::parse("zed:///agent/merge-conflict").unwrap(); + url.query_pairs_mut().append_pair("path", file_path); + url + } } } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index e61a395e71f93d49d63d378355c89e44359db835..02ffac47f120ee3ec4694b3a3be085af053c5909 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -219,6 +219,7 @@ impl UserMessage { "\nThe user has specified the following rules that should be applied:\n"; const OPEN_DIAGNOSTICS_TAG: &str = ""; const OPEN_DIFFS_TAG: &str = ""; + const MERGE_CONFLICT_TAG: &str = ""; let mut file_context = OPEN_FILES_TAG.to_string(); let mut directory_context = OPEN_DIRECTORIES_TAG.to_string(); @@ -229,6 +230,7 @@ impl UserMessage { let mut rules_context = OPEN_RULES_TAG.to_string(); let mut diagnostics_context = OPEN_DIAGNOSTICS_TAG.to_string(); let mut diffs_context = OPEN_DIFFS_TAG.to_string(); + let mut merge_conflict_context = MERGE_CONFLICT_TAG.to_string(); for chunk in &self.content { let chunk = match chunk { @@ -336,6 +338,18 @@ impl UserMessage { ) .ok(); } + MentionUri::MergeConflict { file_path } => { + write!( + &mut merge_conflict_context, + "\nMerge conflict in {}:\n{}", + file_path, + MarkdownCodeBlock { + tag: "diff", + text: content + } + ) + .ok(); + } } language_model::MessageContent::Text(uri.as_link().to_string()) @@ -410,6 +424,13 @@ impl UserMessage { .push(language_model::MessageContent::Text(diagnostics_context)); } + if merge_conflict_context.len() > MERGE_CONFLICT_TAG.len() { + merge_conflict_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(merge_conflict_context)); + } + if message.content.len() > len_before_context { message.content.insert( len_before_context, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index c49b7f668ab12ad4d2b04e8ec48488f7afab3c1c..c63d41b6833db425fb28ac9b64b34aa27d6d2490 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -13,6 +13,7 @@ use acp_thread::{AcpThread, MentionUri, ThreadStatus}; use agent::{ContextServerRegistry, SharedThread, ThreadStore}; use agent_client_protocol as acp; use agent_servers::AgentServer; +use collections::HashSet; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use itertools::Itertools; use project::{ @@ -23,7 +24,10 @@ use serde::{Deserialize, Serialize}; use settings::{LanguageModelProviderSetting, LanguageModelSelection}; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; -use zed_actions::agent::{OpenClaudeAgentOnboardingModal, ReauthenticateAgent, ReviewBranchDiff}; +use zed_actions::agent::{ + ConflictContent, OpenClaudeAgentOnboardingModal, ReauthenticateAgent, + ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, ReviewBranchDiff, +}; use crate::ManageProfiles; use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; @@ -358,6 +362,55 @@ pub fn init(cx: &mut App) { ); }); }) + .register_action( + |workspace, action: &ResolveConflictsWithAgent, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + + let content_blocks = build_conflict_resolution_prompt(&action.conflicts); + + workspace.focus_panel::(window, cx); + + panel.update(cx, |panel, cx| { + panel.external_thread( + None, + None, + Some(AgentInitialContent::ContentBlock { + blocks: content_blocks, + auto_submit: true, + }), + window, + cx, + ); + }); + }, + ) + .register_action( + |workspace, action: &ResolveConflictedFilesWithAgent, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + + let content_blocks = + build_conflicted_files_resolution_prompt(&action.conflicted_file_paths); + + workspace.focus_panel::(window, cx); + + panel.update(cx, |panel, cx| { + panel.external_thread( + None, + None, + Some(AgentInitialContent::ContentBlock { + blocks: content_blocks, + auto_submit: true, + }), + window, + cx, + ); + }); + }, + ) .register_action(|workspace, action: &StartThreadIn, _window, cx| { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { @@ -370,6 +423,113 @@ pub fn init(cx: &mut App) { .detach(); } +fn conflict_resource_block(conflict: &ConflictContent) -> acp::ContentBlock { + let mention_uri = MentionUri::MergeConflict { + file_path: conflict.file_path.clone(), + }; + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents::new( + conflict.conflict_text.clone(), + mention_uri.to_uri().to_string(), + )), + )) +} + +fn build_conflict_resolution_prompt(conflicts: &[ConflictContent]) -> Vec { + if conflicts.is_empty() { + return Vec::new(); + } + + let mut blocks = Vec::new(); + + if conflicts.len() == 1 { + let conflict = &conflicts[0]; + + blocks.push(acp::ContentBlock::Text(acp::TextContent::new( + "Please resolve the following merge conflict in ", + ))); + let mention = MentionUri::File { + abs_path: PathBuf::from(conflict.file_path.clone()), + }; + blocks.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + mention.name(), + mention.to_uri(), + ))); + + blocks.push(acp::ContentBlock::Text(acp::TextContent::new( + indoc::formatdoc!( + "\nThe conflict is between branch `{ours}` (ours) and `{theirs}` (theirs). + + Analyze both versions carefully and resolve the conflict by editing \ + the file directly. Choose the resolution that best preserves the intent \ + of both changes, or combine them if appropriate. + + ", + ours = conflict.ours_branch_name, + theirs = conflict.theirs_branch_name, + ), + ))); + } else { + let n = conflicts.len(); + let unique_files: HashSet<&str> = conflicts.iter().map(|c| c.file_path.as_str()).collect(); + let ours = &conflicts[0].ours_branch_name; + let theirs = &conflicts[0].theirs_branch_name; + blocks.push(acp::ContentBlock::Text(acp::TextContent::new( + indoc::formatdoc!( + "Please resolve all {n} merge conflicts below. + + The conflicts are between branch `{ours}` (ours) and `{theirs}` (theirs). + + For each conflict, analyze both versions carefully and resolve them \ + by editing the file{suffix} directly. Choose resolutions that best preserve \ + the intent of both changes, or combine them if appropriate. + + ", + suffix = if unique_files.len() > 1 { "s" } else { "" }, + ), + ))); + } + + for conflict in conflicts { + blocks.push(conflict_resource_block(conflict)); + } + + blocks +} + +fn build_conflicted_files_resolution_prompt( + conflicted_file_paths: &[String], +) -> Vec { + if conflicted_file_paths.is_empty() { + return Vec::new(); + } + + let instruction = indoc::indoc!( + "The following files have unresolved merge conflicts. Please open each \ + file, find the conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`), \ + and resolve every conflict by editing the files directly. + + Choose resolutions that best preserve the intent of both changes, \ + or combine them if appropriate. + + Files with conflicts: + ", + ); + + let mut content = vec![acp::ContentBlock::Text(acp::TextContent::new(instruction))]; + for path in conflicted_file_paths { + let mention = MentionUri::File { + abs_path: PathBuf::from(path), + }; + content.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + mention.name(), + mention.to_uri(), + ))); + content.push(acp::ContentBlock::Text(acp::TextContent::new("\n"))); + } + content +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum HistoryKind { AgentThreads, @@ -4920,6 +5080,286 @@ mod tests { cx.run_until_parked(); } + /// Extracts the text from a Text content block, panicking if it's not Text. + fn expect_text_block(block: &acp::ContentBlock) -> &str { + match block { + acp::ContentBlock::Text(t) => t.text.as_str(), + other => panic!("expected Text block, got {:?}", other), + } + } + + /// Extracts the (text_content, uri) from a Resource content block, panicking + /// if it's not a TextResourceContents resource. + fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) { + match block { + acp::ContentBlock::Resource(r) => match &r.resource { + acp::EmbeddedResourceResource::TextResourceContents(t) => { + (t.text.as_str(), t.uri.as_str()) + } + other => panic!("expected TextResourceContents, got {:?}", other), + }, + other => panic!("expected Resource block, got {:?}", other), + } + } + + #[test] + fn test_build_conflict_resolution_prompt_single_conflict() { + let conflicts = vec![ConflictContent { + file_path: "src/main.rs".to_string(), + conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature" + .to_string(), + ours_branch_name: "HEAD".to_string(), + theirs_branch_name: "feature".to_string(), + }]; + + let blocks = build_conflict_resolution_prompt(&conflicts); + // 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict + assert_eq!( + blocks.len(), + 4, + "expected 2 text + 1 resource link + 1 resource block" + ); + + let intro_text = expect_text_block(&blocks[0]); + assert!( + intro_text.contains("Please resolve the following merge conflict in"), + "prompt should include single-conflict intro text" + ); + + match &blocks[1] { + acp::ContentBlock::ResourceLink(link) => { + assert!( + link.uri.contains("file://"), + "resource link URI should use file scheme" + ); + assert!( + link.uri.contains("main.rs"), + "resource link URI should reference file path" + ); + } + other => panic!("expected ResourceLink block, got {:?}", other), + } + + let body_text = expect_text_block(&blocks[2]); + assert!( + body_text.contains("`HEAD` (ours)"), + "prompt should mention ours branch" + ); + assert!( + body_text.contains("`feature` (theirs)"), + "prompt should mention theirs branch" + ); + assert!( + body_text.contains("editing the file directly"), + "prompt should instruct the agent to edit the file" + ); + + let (resource_text, resource_uri) = expect_resource_block(&blocks[3]); + assert!( + resource_text.contains("<<<<<<< HEAD"), + "resource should contain the conflict text" + ); + assert!( + resource_uri.contains("merge-conflict"), + "resource URI should use the merge-conflict scheme" + ); + assert!( + resource_uri.contains("main.rs"), + "resource URI should reference the file path" + ); + } + + #[test] + fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() { + let conflicts = vec![ + ConflictContent { + file_path: "src/lib.rs".to_string(), + conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev" + .to_string(), + ours_branch_name: "main".to_string(), + theirs_branch_name: "dev".to_string(), + }, + ConflictContent { + file_path: "src/lib.rs".to_string(), + conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev" + .to_string(), + ours_branch_name: "main".to_string(), + theirs_branch_name: "dev".to_string(), + }, + ]; + + let blocks = build_conflict_resolution_prompt(&conflicts); + // 1 Text instruction + 2 Resource blocks + assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks"); + + let text = expect_text_block(&blocks[0]); + assert!( + text.contains("all 2 merge conflicts"), + "prompt should mention the total count" + ); + assert!( + text.contains("`main` (ours)"), + "prompt should mention ours branch" + ); + assert!( + text.contains("`dev` (theirs)"), + "prompt should mention theirs branch" + ); + // Single file, so "file" not "files" + assert!( + text.contains("file directly"), + "single file should use singular 'file'" + ); + + let (resource_a, _) = expect_resource_block(&blocks[1]); + let (resource_b, _) = expect_resource_block(&blocks[2]); + assert!( + resource_a.contains("fn a()"), + "first resource should contain first conflict" + ); + assert!( + resource_b.contains("fn b()"), + "second resource should contain second conflict" + ); + } + + #[test] + fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() { + let conflicts = vec![ + ConflictContent { + file_path: "src/a.rs".to_string(), + conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(), + ours_branch_name: "main".to_string(), + theirs_branch_name: "dev".to_string(), + }, + ConflictContent { + file_path: "src/b.rs".to_string(), + conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(), + ours_branch_name: "main".to_string(), + theirs_branch_name: "dev".to_string(), + }, + ]; + + let blocks = build_conflict_resolution_prompt(&conflicts); + // 1 Text instruction + 2 Resource blocks + assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks"); + + let text = expect_text_block(&blocks[0]); + assert!( + text.contains("files directly"), + "multiple files should use plural 'files'" + ); + + let (_, uri_a) = expect_resource_block(&blocks[1]); + let (_, uri_b) = expect_resource_block(&blocks[2]); + assert!( + uri_a.contains("a.rs"), + "first resource URI should reference a.rs" + ); + assert!( + uri_b.contains("b.rs"), + "second resource URI should reference b.rs" + ); + } + + #[test] + fn test_build_conflicted_files_resolution_prompt_file_paths_only() { + let file_paths = vec![ + "src/main.rs".to_string(), + "src/lib.rs".to_string(), + "tests/integration.rs".to_string(), + ]; + + let blocks = build_conflicted_files_resolution_prompt(&file_paths); + // 1 instruction Text block + (ResourceLink + newline Text) per file + assert_eq!( + blocks.len(), + 1 + (file_paths.len() * 2), + "expected instruction text plus resource links and separators" + ); + + let text = expect_text_block(&blocks[0]); + assert!( + text.contains("unresolved merge conflicts"), + "prompt should describe the task" + ); + assert!( + text.contains("conflict markers"), + "prompt should mention conflict markers" + ); + + for (index, path) in file_paths.iter().enumerate() { + let link_index = 1 + (index * 2); + let newline_index = link_index + 1; + + match &blocks[link_index] { + acp::ContentBlock::ResourceLink(link) => { + assert!( + link.uri.contains("file://"), + "resource link URI should use file scheme" + ); + assert!( + link.uri.contains(path), + "resource link URI should reference file path: {path}" + ); + } + other => panic!( + "expected ResourceLink block at index {}, got {:?}", + link_index, other + ), + } + + let separator = expect_text_block(&blocks[newline_index]); + assert_eq!( + separator, "\n", + "expected newline separator after each file" + ); + } + } + + #[test] + fn test_build_conflict_resolution_prompt_empty_conflicts() { + let blocks = build_conflict_resolution_prompt(&[]); + assert!( + blocks.is_empty(), + "empty conflicts should produce no blocks, got {} blocks", + blocks.len() + ); + } + + #[test] + fn test_build_conflicted_files_resolution_prompt_empty_paths() { + let blocks = build_conflicted_files_resolution_prompt(&[]); + assert!( + blocks.is_empty(), + "empty paths should produce no blocks, got {} blocks", + blocks.len() + ); + } + + #[test] + fn test_conflict_resource_block_structure() { + let conflict = ConflictContent { + file_path: "src/utils.rs".to_string(), + conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(), + ours_branch_name: "HEAD".to_string(), + theirs_branch_name: "branch".to_string(), + }; + + let block = conflict_resource_block(&conflict); + let (text, uri) = expect_resource_block(&block); + + assert_eq!( + text, conflict.conflict_text, + "resource text should be the raw conflict" + ); + assert!( + uri.starts_with("zed:///agent/merge-conflict"), + "URI should use the zed merge-conflict scheme, got: {uri}" + ); + assert!(uri.contains("utils.rs"), "URI should encode the file path"); + } + async fn setup_panel(cx: &mut TestAppContext) -> (Entity, VisualTestContext) { init_test(cx); cx.update(|cx| { diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index f5fc681a82b636ec401f3a8c6168bcb368931930..806b2c9c397de1c729164b5f859ceae4b7f6231f 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -7996,6 +7996,7 @@ pub(crate) fn open_link( MentionUri::Diagnostics { .. } => {} MentionUri::TerminalSelection { .. } => {} MentionUri::GitDiff { .. } => {} + MentionUri::MergeConflict { .. } => {} }) } else { cx.open_url(&url); diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 792bfc11a63471e02b22835823fa8c59cdfc9bcf..5a76e2b355c3373ee278b0f0de95ddcfcdd13101 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -150,7 +150,8 @@ impl MentionSet { MentionUri::PastedImage | MentionUri::Selection { .. } | MentionUri::TerminalSelection { .. } - | MentionUri::GitDiff { .. } => { + | MentionUri::GitDiff { .. } + | MentionUri::MergeConflict { .. } => { Task::ready(Err(anyhow!("Unsupported mention URI type for paste"))) } } @@ -301,6 +302,10 @@ impl MentionSet { debug_panic!("unexpected git diff URI"); Task::ready(Err(anyhow!("unexpected git diff URI"))) } + MentionUri::MergeConflict { .. } => { + debug_panic!("unexpected merge conflict URI"); + Task::ready(Err(anyhow!("unexpected merge conflict URI"))) + } }; let task = cx .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 8b813ef7e40c2afe91b98600b9d1146d4751d48b..0f0b8ecc1d7d66a6025bcfed772c7ead7061fe20 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -187,7 +187,8 @@ fn open_mention_uri( | MentionUri::Selection { abs_path: None, .. } | MentionUri::Diagnostics { .. } | MentionUri::TerminalSelection { .. } - | MentionUri::GitDiff { .. } => {} + | MentionUri::GitDiff { .. } + | MentionUri::MergeConflict { .. } => {} }); } diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 67b39618eaaaa2f7704e100d98621f53b725ff43..6c2c0b6f58696147da069b0aebdf55d396f7a388 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -1,3 +1,4 @@ +use agent_settings::AgentSettings; use collections::{HashMap, HashSet}; use editor::{ ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker, @@ -5,14 +6,25 @@ use editor::{ display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, }; use gpui::{ - App, Context, Entity, InteractiveElement as _, ParentElement as _, Subscription, Task, - WeakEntity, + App, Context, DismissEvent, Entity, InteractiveElement as _, ParentElement as _, Subscription, + Task, WeakEntity, }; use language::{Anchor, Buffer, BufferId}; -use project::{ConflictRegion, ConflictSet, ConflictSetUpdate, ProjectItem as _}; +use project::{ + ConflictRegion, ConflictSet, ConflictSetUpdate, ProjectItem as _, + git_store::{GitStoreEvent, RepositoryEvent}, +}; +use settings::Settings; use std::{ops::Range, sync::Arc}; -use ui::{ActiveTheme, Element as _, Styled, Window, prelude::*}; +use ui::{ActiveTheme, Divider, Element as _, Styled, Window, prelude::*}; use util::{ResultExt as _, debug_panic, maybe}; +use workspace::{ + Workspace, + notifications::{NotificationId, simple_message_notification::MessageNotification}, +}; +use zed_actions::agent::{ + ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, +}; pub(crate) struct ConflictAddon { buffers: HashMap, @@ -368,11 +380,12 @@ fn render_conflict_buttons( editor: WeakEntity, cx: &mut BlockContext, ) -> AnyElement { + let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx); + h_flex() .id(cx.block_id) .h(cx.line_height) .ml(cx.margins.gutter.width) - .items_end() .gap_1() .bg(cx.theme().colors().editor_background) .child( @@ -419,6 +432,7 @@ fn render_conflict_buttons( Button::new("both", "Use Both") .label_size(LabelSize::Small) .on_click({ + let editor = editor.clone(); let conflict = conflict.clone(); let ours = conflict.ours.clone(); let theirs = conflict.theirs.clone(); @@ -435,9 +449,139 @@ fn render_conflict_buttons( } }), ) + .when(is_ai_enabled, |this| { + this.child(Divider::vertical()).child( + Button::new("resolve-with-agent", "Resolve with Agent") + .label_size(LabelSize::Small) + .icon(IconName::ZedAssistant) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .on_click({ + let conflict = conflict.clone(); + move |_, window, cx| { + let content = editor + .update(cx, |editor, cx| { + let multibuffer = editor.buffer().read(cx); + let buffer_id = conflict.ours.end.buffer_id?; + let buffer = multibuffer.buffer(buffer_id)?; + let buffer_read = buffer.read(cx); + let snapshot = buffer_read.snapshot(); + let conflict_text = snapshot + .text_for_range(conflict.range.clone()) + .collect::(); + let file_path = buffer_read + .file() + .and_then(|file| file.as_local()) + .map(|f| f.abs_path(cx).to_string_lossy().to_string()) + .unwrap_or_default(); + Some(ConflictContent { + file_path, + conflict_text, + ours_branch_name: conflict.ours_branch_name.to_string(), + theirs_branch_name: conflict.theirs_branch_name.to_string(), + }) + }) + .ok() + .flatten(); + if let Some(content) = content { + window.dispatch_action( + Box::new(ResolveConflictsWithAgent { + conflicts: vec![content], + }), + cx, + ); + } + } + }), + ) + }) .into_any() } +struct MergeConflictNotification; + +fn merge_conflict_notification_id() -> NotificationId { + NotificationId::unique::() +} + +fn collect_conflicted_file_paths(workspace: &Workspace, cx: &App) -> Vec { + let project = workspace.project().read(cx); + let git_store = project.git_store().read(cx); + let mut paths = Vec::new(); + + for repo in git_store.repositories().values() { + let snapshot = repo.read(cx).snapshot(); + for (repo_path, _) in snapshot.merge.merge_heads_by_conflicted_path.iter() { + if let Some(project_path) = repo.read(cx).repo_path_to_project_path(repo_path, cx) { + paths.push( + project_path + .path + .as_std_path() + .to_string_lossy() + .to_string(), + ); + } + } + } + + paths +} + +pub(crate) fn register_conflict_notification( + workspace: &mut Workspace, + cx: &mut Context, +) { + let git_store = workspace.project().read(cx).git_store().clone(); + + cx.subscribe(&git_store, |workspace, _git_store, event, cx| { + let conflicts_changed = matches!( + event, + GitStoreEvent::ConflictsUpdated + | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _) + ); + if !AgentSettings::get_global(cx).enabled || !conflicts_changed { + return; + } + + let paths = collect_conflicted_file_paths(workspace, cx); + let notification_id = merge_conflict_notification_id(); + + if paths.is_empty() { + workspace.dismiss_notification(¬ification_id, cx); + } else { + let file_count = paths.len(); + workspace.show_notification(notification_id, cx, |cx| { + cx.new(|cx| { + let message = if file_count == 1 { + "1 file has unresolved merge conflicts".to_string() + } else { + format!("{file_count} files have unresolved merge conflicts") + }; + + MessageNotification::new(message, cx) + .primary_message("Resolve Conflicts with Agent") + .primary_icon(IconName::ZedAssistant) + .primary_icon_color(Color::Muted) + .primary_on_click({ + let paths = paths.clone(); + move |window, cx| { + window.dispatch_action( + Box::new(ResolveConflictedFilesWithAgent { + conflicted_file_paths: paths.clone(), + }), + cx, + ); + cx.emit(DismissEvent); + } + }) + }) + }); + } + }) + .detach(); +} + pub(crate) fn resolve_conflict( editor: WeakEntity, excerpt_id: ExcerptId, diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index e19eb8c21f9bf8a3eb88e4804d2a977ffb97e31c..1a9866fcc6e7ef420742620dab3faa2f38bfa5f5 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -62,6 +62,7 @@ pub fn init(cx: &mut App) { git_panel::register(workspace); repository_selector::register(workspace); git_picker::register(workspace); + conflict_view::register_conflict_notification(workspace, cx); let project = workspace.project().read(cx); if project.is_read_only(cx) { diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 3536e73a9db6247a798145f186ae20d2efe29da5..7c06eaef92ece60e8b4a9ad78976b68aee854226 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -147,6 +147,7 @@ pub enum IconName { GitBranchPlus, GitCommit, GitGraph, + GitMergeConflict, Github, Hash, HistoryRerun, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index ae785bb4a0c792dd7f55d8850e8c05ce6327c108..854f71175e79c84f03261a3d58f89638b7259e54 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -469,6 +469,33 @@ pub mod agent { /// The base ref that the diff was computed against (e.g. "main"). pub base_ref: SharedString, } + + /// A single merge conflict region extracted from a file. + #[derive(Clone, Debug, PartialEq, Deserialize, JsonSchema)] + pub struct ConflictContent { + pub file_path: String, + pub conflict_text: String, + pub ours_branch_name: String, + pub theirs_branch_name: String, + } + + /// Opens a new agent thread to resolve specific merge conflicts. + #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] + #[action(namespace = agent)] + #[serde(deny_unknown_fields)] + pub struct ResolveConflictsWithAgent { + /// Individual conflicts with their full text. + pub conflicts: Vec, + } + + /// Opens a new agent thread to resolve merge conflicts in the given file paths. + #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] + #[action(namespace = agent)] + #[serde(deny_unknown_fields)] + pub struct ResolveConflictedFilesWithAgent { + /// File paths with unresolved conflicts (for project-wide resolution). + pub conflicted_file_paths: Vec, + } } pub mod assistant { From d18e4a75bc8892090912bc536c081e3e84df1d2a Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:57:06 +0100 Subject: [PATCH 100/219] git: Add SSH support for removing and renaming git worktrees (#50759) This should be the last step in implementing full git worktree support in the `GitStore`. We still need to add UI for that allows a user to rename a git worktree and, by extension git branches if we use the git picker to do so. Also, I added a helper function called `disallow_guest_request::` to the `Collab::rpc` that is used to specify a proto request isn't allowed to be sent by a guest. This enabled me to add a regression test that checks that a guest isn't allowed to delete a git worktree, without the test hanging forever because it's waiting for the proto server to respond. Since SSH connections send the proto message directly from client to remote host, this won't affect those requests. Before you mark this PR as ready for review, make sure that you have: - [x] Added solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - git: Add SSH support for removing git worktrees --- crates/agent_ui/src/agent_panel.rs | 6 + crates/collab/src/rpc.rs | 20 +++ crates/collab/tests/integration/git_tests.rs | 61 +++++++++ .../remote_editing_collaboration_tests.rs | 116 ++++++++++++++++++ crates/project/src/git_store.rs | 86 ++++++++++++- crates/proto/proto/git.proto | 14 +++ crates/proto/proto/zed.proto | 4 +- crates/proto/src/proto.rs | 6 + 8 files changed, 308 insertions(+), 5 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index c63d41b6833db425fb28ac9b64b34aa27d6d2490..2b9f2f5624072f7b9c9f01f1daecd7e1103c758b 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -374,12 +374,15 @@ pub fn init(cx: &mut App) { panel.update(cx, |panel, cx| { panel.external_thread( + None, + None, None, None, Some(AgentInitialContent::ContentBlock { blocks: content_blocks, auto_submit: true, }), + true, window, cx, ); @@ -399,12 +402,15 @@ pub fn init(cx: &mut App) { panel.update(cx, |panel, cx| { panel.external_thread( + None, + None, None, None, Some(AgentInitialContent::ContentBlock { blocks: content_blocks, auto_submit: true, }), + true, window, cx, ); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index b521f6b083ae311d98ec46c900ce821fd8042e4a..6c05bd4e535df0235f708af0272b2eae71581fa2 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -439,6 +439,8 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(disallow_guest_request::) + .add_request_handler(disallow_guest_request::) .add_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler(update_context) @@ -2250,6 +2252,24 @@ where Ok(()) } +async fn disallow_guest_request( + _request: T, + response: Response, + _session: MessageContext, +) -> Result<()> +where + T: RequestMessage, +{ + response.peer.respond_with_error( + response.receipt, + ErrorCode::Forbidden + .message("request is not allowed for guests".to_string()) + .to_proto(), + )?; + response.responded.store(true, SeqCst); + Ok(()) +} + async fn lsp_query( request: proto::LspQuery, response: Response, diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index dccc99a07769e66a3eb318a8201d8e14a29ef4f2..f8c461b91fc41cc5a0e20271a85e685af2801d24 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -302,6 +302,67 @@ async fn test_remote_git_worktrees( worktree_directory.join("bugfix-branch") ); assert_eq!(bugfix_worktree.sha.as_ref(), "fake-sha"); + + // Client B (guest) attempts to rename a worktree. This should fail + // because worktree renaming is not forwarded through collab + let rename_result = cx_b + .update(|cx| { + repo_b.update(cx, |repository, _| { + repository.rename_worktree( + worktree_directory.join("feature-branch"), + worktree_directory.join("renamed-branch"), + ) + }) + }) + .await + .unwrap(); + assert!( + rename_result.is_err(), + "Guest should not be able to rename worktrees via collab" + ); + + executor.run_until_parked(); + + // Verify worktrees are unchanged — still 3 + let worktrees = cx_b + .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees())) + .await + .unwrap() + .unwrap(); + assert_eq!( + worktrees.len(), + 3, + "Worktree count should be unchanged after failed rename" + ); + + // Client B (guest) attempts to remove a worktree. This should fail + // because worktree removal is not forwarded through collab + let remove_result = cx_b + .update(|cx| { + repo_b.update(cx, |repository, _| { + repository.remove_worktree(worktree_directory.join("feature-branch"), false) + }) + }) + .await + .unwrap(); + assert!( + remove_result.is_err(), + "Guest should not be able to remove worktrees via collab" + ); + + executor.run_until_parked(); + + // Verify worktrees are unchanged — still 3 + let worktrees = cx_b + .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees())) + .await + .unwrap() + .unwrap(); + assert_eq!( + worktrees.len(), + 3, + "Worktree count should be unchanged after failed removal" + ); } #[gpui::test] diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs index 6825c468e783ee8d3a2a6107a031accfc108abd0..ceb7db145970b52d23a6ef7ace82cd84acf1e840 100644 --- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs +++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs @@ -518,6 +518,122 @@ async fn test_ssh_collaboration_git_worktrees( server_worktrees[1].path, worktree_directory.join("feature-branch") ); + + // Host (client A) renames the worktree via SSH + let repo_a = cx_a.update(|cx| { + project_a + .read(cx) + .repositories(cx) + .values() + .next() + .unwrap() + .clone() + }); + cx_a.update(|cx| { + repo_a.update(cx, |repository, _| { + repository.rename_worktree( + PathBuf::from("/project/feature-branch"), + PathBuf::from("/project/renamed-branch"), + ) + }) + }) + .await + .unwrap() + .unwrap(); + + executor.run_until_parked(); + + let host_worktrees = cx_a + .update(|cx| repo_a.update(cx, |repository, _| repository.worktrees())) + .await + .unwrap() + .unwrap(); + assert_eq!( + host_worktrees.len(), + 2, + "Host should still have 2 worktrees after rename" + ); + assert_eq!( + host_worktrees[1].path, + PathBuf::from("/project/renamed-branch") + ); + + let server_worktrees = { + let server_repo = server_cx.update(|cx| { + headless_project.update(cx, |headless_project, cx| { + headless_project + .git_store + .read(cx) + .repositories() + .values() + .next() + .unwrap() + .clone() + }) + }); + server_cx + .update(|cx| server_repo.update(cx, |repo, _| repo.worktrees())) + .await + .unwrap() + .unwrap() + }; + assert_eq!( + server_worktrees.len(), + 2, + "Server should still have 2 worktrees after rename" + ); + assert_eq!( + server_worktrees[1].path, + PathBuf::from("/project/renamed-branch") + ); + + // Host (client A) removes the renamed worktree via SSH + cx_a.update(|cx| { + repo_a.update(cx, |repository, _| { + repository.remove_worktree(PathBuf::from("/project/renamed-branch"), false) + }) + }) + .await + .unwrap() + .unwrap(); + + executor.run_until_parked(); + + let host_worktrees = cx_a + .update(|cx| repo_a.update(cx, |repository, _| repository.worktrees())) + .await + .unwrap() + .unwrap(); + assert_eq!( + host_worktrees.len(), + 1, + "Host should only have the main worktree after removal" + ); + + let server_worktrees = { + let server_repo = server_cx.update(|cx| { + headless_project.update(cx, |headless_project, cx| { + headless_project + .git_store + .read(cx) + .repositories() + .values() + .next() + .unwrap() + .clone() + }) + }); + server_cx + .update(|cx| server_repo.update(cx, |repo, _| repo.worktrees())) + .await + .unwrap() + .unwrap() + }; + assert_eq!( + server_worktrees.len(), + 1, + "Server should only have the main worktree after removal" + ); } #[gpui::test] diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index eed16761974876247df2e5936f9db9fbdd8fafcc..0572fd1f4f19beebd3674e1b24c828daffb9973c 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -578,6 +578,8 @@ impl GitStore { client.add_entity_request_handler(Self::handle_git_clone); client.add_entity_request_handler(Self::handle_get_worktrees); client.add_entity_request_handler(Self::handle_create_worktree); + client.add_entity_request_handler(Self::handle_remove_worktree); + client.add_entity_request_handler(Self::handle_rename_worktree); } pub fn is_local(&self) -> bool { @@ -2384,6 +2386,44 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_remove_worktree( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let path = PathBuf::from(envelope.payload.path); + let force = envelope.payload.force; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.remove_worktree(path, force) + }) + .await??; + + Ok(proto::Ack {}) + } + + async fn handle_rename_worktree( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let old_path = PathBuf::from(envelope.payload.old_path); + let new_path = PathBuf::from(envelope.payload.new_path); + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.rename_worktree(old_path, new_path) + }) + .await??; + + Ok(proto::Ack {}) + } + async fn handle_get_branches( this: Entity, envelope: TypedEnvelope, @@ -5731,6 +5771,7 @@ impl Repository { } pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver> { + let id = self.id; self.send_job( Some(format!("git worktree remove: {}", path.display()).into()), move |repo, _cx| async move { @@ -5738,10 +5779,47 @@ impl Repository { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.remove_worktree(path, force).await } - RepositoryState::Remote(_) => { - anyhow::bail!( - "Removing worktrees on remote repositories is not yet supported" - ) + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitRemoveWorktree { + project_id: project_id.0, + repository_id: id.to_proto(), + path: path.to_string_lossy().to_string(), + force, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + + pub fn rename_worktree( + &mut self, + old_path: PathBuf, + new_path: PathBuf, + ) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + Some(format!("git worktree move: {}", old_path.display()).into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.rename_worktree(old_path, new_path).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitRenameWorktree { + project_id: project_id.0, + repository_id: id.to_proto(), + old_path: old_path.to_string_lossy().to_string(), + new_path: new_path.to_string_lossy().to_string(), + }) + .await?; + + Ok(()) } } }, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 736abcdaa49f62d72582750a8a28ea785baee282..87fdc058f95c045de5f1e8f7ef03c8e32c2fa518 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -583,6 +583,20 @@ message GitCreateWorktree { optional string commit = 5; } +message GitRemoveWorktree { + uint64 project_id = 1; + uint64 repository_id = 2; + string path = 3; + bool force = 4; +} + +message GitRenameWorktree { + uint64 project_id = 1; + uint64 repository_id = 2; + string old_path = 3; + string new_path = 4; +} + message RunGitHook { enum GitHook { PRE_COMMIT = 0; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index c129b6eff26404b66b38439c29f0b83289b37172..1fd7dfb89b01c16c6099a0e79a9d320a788fd7e4 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -474,7 +474,9 @@ message Envelope { SpawnKernel spawn_kernel = 426; SpawnKernelResponse spawn_kernel_response = 427; - KillKernel kill_kernel = 428; // current max + KillKernel kill_kernel = 428; + GitRemoveWorktree git_remove_worktree = 431; + GitRenameWorktree git_rename_worktree = 432; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index dd0a77beb29345021563b21bafd261d02b87e1ab..88607abf6decdd167cf3594e56ad1eb6b79d3ac6 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -354,6 +354,8 @@ messages!( (GitGetWorktrees, Background), (GitWorktreesResponse, Background), (GitCreateWorktree, Background), + (GitRemoveWorktree, Background), + (GitRenameWorktree, Background), (ShareAgentThread, Foreground), (GetSharedAgentThread, Foreground), (GetSharedAgentThreadResponse, Foreground), @@ -557,6 +559,8 @@ request_messages!( (RemoteStarted, Ack), (GitGetWorktrees, GitWorktreesResponse), (GitCreateWorktree, Ack), + (GitRemoveWorktree, Ack), + (GitRenameWorktree, Ack), (TrustWorktrees, Ack), (RestrictWorktrees, Ack), (FindSearchCandidatesChunk, Ack), @@ -747,6 +751,8 @@ entity_messages!( NewExternalAgentVersionAvailable, GitGetWorktrees, GitCreateWorktree, + GitRemoveWorktree, + GitRenameWorktree, TrustWorktrees, RestrictWorktrees, FindSearchCandidatesChunk, From eae21de630ca111ec7b73839b8d25a7916affc9f Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:22:01 -0300 Subject: [PATCH 101/219] sidebar: Sort threads by created time (#51193) Release Notes: - N/A --- crates/acp_thread/src/connection.rs | 2 ++ crates/agent/src/db.rs | 6 +++++- crates/agent_servers/src/acp.rs | 1 + crates/agent_ui/src/thread_history.rs | 7 +++++++ crates/sidebar/src/sidebar.rs | 23 +++++++++++++++++------ 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 644986bc15eccbe7d2be32ea5ad6e422db930541..1236058226eee840e1a36009df85291a774548dc 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -242,6 +242,7 @@ pub struct AgentSessionInfo { pub cwd: Option, pub title: Option, pub updated_at: Option>, + pub created_at: Option>, pub meta: Option, } @@ -252,6 +253,7 @@ impl AgentSessionInfo { cwd: None, title: None, updated_at: None, + created_at: None, meta: None, } } diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 2c9b33e4efc4f22059e2914589ca6c635b51c0e5..43ab9c3c1826ea7d81fed2c934b96f3bb05dd519 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -45,6 +45,7 @@ impl From<&DbThreadMetadata> for acp_thread::AgentSessionInfo { cwd: None, title: Some(meta.title.clone()), updated_at: Some(meta.updated_at), + created_at: meta.created_at, meta: None, } } @@ -482,7 +483,10 @@ impl ThreadsDatabase { let data_type = DataType::Zstd; let data = compressed; - let created_at = Utc::now().to_rfc3339(); + // Use the thread's updated_at as created_at for new threads. + // This ensures the creation time reflects when the thread was conceptually + // created, not when it was saved to the database. + let created_at = updated_at.clone(); let mut insert = connection.exec_bound::<(Arc, Option>, Option, Option, String, String, DataType, Vec, String)>(indoc! {" INSERT INTO threads (id, parent_id, folder_paths, folder_paths_order, summary, updated_at, data_type, data, created_at) diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index ceceb5b8ae02a0674b27e0fa18244a94f2b409de..b9e4eba497ef1e01016a17e34d634fea20cab499 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -131,6 +131,7 @@ impl AgentSessionList for AcpSessionList { .ok() .map(|dt| dt.with_timezone(&chrono::Utc)) }), + created_at: None, meta: s.meta, }) .collect(), diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs index 6601616e9f2ef447beb448f2753460fa7c380fa6..01536b00e98d13a699457377a6ebf8e9e87a59b4 100644 --- a/crates/agent_ui/src/thread_history.rs +++ b/crates/agent_ui/src/thread_history.rs @@ -1232,6 +1232,7 @@ mod tests { cwd: None, title: Some(title.to_string().into()), updated_at: None, + created_at: None, meta: None, } } @@ -1443,6 +1444,7 @@ mod tests { cwd: None, title: Some("Original Title".into()), updated_at: None, + created_at: None, meta: None, }]; let session_list = Rc::new(TestSessionList::new(sessions)); @@ -1479,6 +1481,7 @@ mod tests { cwd: None, title: Some("Original Title".into()), updated_at: None, + created_at: None, meta: None, }]; let session_list = Rc::new(TestSessionList::new(sessions)); @@ -1512,6 +1515,7 @@ mod tests { cwd: None, title: Some("Original Title".into()), updated_at: None, + created_at: None, meta: None, }]; let session_list = Rc::new(TestSessionList::new(sessions)); @@ -1548,6 +1552,7 @@ mod tests { cwd: None, title: None, updated_at: None, + created_at: None, meta: None, }]; let session_list = Rc::new(TestSessionList::new(sessions)); @@ -1588,6 +1593,7 @@ mod tests { cwd: None, title: Some("Server Title".into()), updated_at: None, + created_at: None, meta: None, }]; let session_list = Rc::new(TestSessionList::new(sessions)); @@ -1625,6 +1631,7 @@ mod tests { cwd: None, title: Some("Original".into()), updated_at: None, + created_at: None, meta: None, }]; let session_list = Rc::new(TestSessionList::new(sessions)); diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 4dbc2f811a62c266bc34708cd3b8bd1377938d4d..bef521c6a0fe8a09724410b4fb186ffce672d8c3 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -63,6 +63,7 @@ impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo { cwd: None, title: Some(info.title.clone()), updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), meta: None, } } @@ -512,7 +513,13 @@ impl Sidebar { } } - threads.sort_by(|a, b| b.session_info.updated_at.cmp(&a.session_info.updated_at)); + // Sort by created_at (newest first), falling back to updated_at + // for threads without a created_at (e.g., ACP sessions). + threads.sort_by(|a, b| { + let a_time = a.session_info.created_at.or(a.session_info.updated_at); + let b_time = b.session_info.created_at.or(b.session_info.updated_at); + b_time.cmp(&a_time) + }); } if !query.is_empty() { @@ -726,12 +733,9 @@ impl Sidebar { } => self.render_new_thread(ix, path_list, workspace, is_selected, cx), }; - // add the blue border here, not in the sub methods - if is_group_header_after_first { v_flex() .w_full() - .pt_2() .border_t_1() .border_color(cx.theme().colors().border_variant) .child(rendered) @@ -1472,9 +1476,9 @@ impl Render for Sidebar { .child( h_flex() .flex_none() - .p_2() + .px_2p5() .h(Tab::container_height(cx)) - .gap_1p5() + .gap_2() .border_b_1() .border_color(cx.theme().colors().border) .child( @@ -2017,6 +2021,7 @@ mod tests { cwd: None, title: Some("Completed thread".into()), updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), meta: None, }, icon: IconName::ZedAgent, @@ -2034,6 +2039,7 @@ mod tests { cwd: None, title: Some("Running thread".into()), updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), meta: None, }, icon: IconName::ZedAgent, @@ -2051,6 +2057,7 @@ mod tests { cwd: None, title: Some("Error thread".into()), updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), meta: None, }, icon: IconName::ZedAgent, @@ -2068,6 +2075,7 @@ mod tests { cwd: None, title: Some("Waiting thread".into()), updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), meta: None, }, icon: IconName::ZedAgent, @@ -2085,6 +2093,7 @@ mod tests { cwd: None, title: Some("Notified thread".into()), updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), meta: None, }, icon: IconName::ZedAgent, @@ -3456,6 +3465,7 @@ mod tests { cwd: None, title: Some("Test".into()), updated_at: None, + created_at: None, meta: None, }, &workspace_a, @@ -3511,6 +3521,7 @@ mod tests { cwd: None, title: Some("Thread B".into()), updated_at: None, + created_at: None, meta: None, }, &workspace_b, From b21f4a3debcc89343be0283af891fac9c3476e48 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Tue, 10 Mar 2026 16:23:49 +0100 Subject: [PATCH 102/219] Prevent remote edits from triggering edit predictions when collaborating (#51196) BufferEvent::Edited had no way to distinguish local edits from remote (collaboration) edits. This caused edit prediction behavior to fire on the guest's editor when the host made document changes. Release Notes: - Fixed edit predictions triggering on collaboration guests when the host edits the document. --------- Co-authored-by: Ben Kunkle --- crates/action_log/src/action_log.rs | 2 +- .../assistant_text_thread/src/text_thread.rs | 2 +- crates/channel/src/channel_buffer.rs | 2 +- crates/copilot/src/copilot.rs | 2 +- crates/edit_prediction/src/edit_prediction.rs | 2 +- crates/editor/src/editor.rs | 7 +++-- crates/git_ui/src/file_diff_view.rs | 2 +- crates/git_ui/src/text_diff_view.rs | 2 +- crates/language/src/buffer.rs | 30 +++++++++++-------- crates/language/src/buffer_tests.rs | 21 +++++++++---- crates/multi_buffer/src/multi_buffer.rs | 14 ++++++++- crates/multi_buffer/src/multi_buffer_tests.rs | 3 ++ crates/project/src/lsp_store.rs | 2 +- crates/project/src/project.rs | 4 +-- .../tests/integration/project_tests.rs | 12 ++++---- crates/svg_preview/src/svg_preview_view.rs | 2 +- crates/vim/src/state.rs | 2 +- 17 files changed, 72 insertions(+), 39 deletions(-) diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 5679f3c58fe52057f7a4a0faa24d5b5db2b5e497..28245944e39deca7fb2b3f86902f114420d31d20 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -209,7 +209,7 @@ impl ActionLog { cx: &mut Context, ) { match event { - BufferEvent::Edited => { + BufferEvent::Edited { .. } => { let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else { return; }; diff --git a/crates/assistant_text_thread/src/text_thread.rs b/crates/assistant_text_thread/src/text_thread.rs index 34007868f9f128fa80f09f884ccbaf57ffd103c1..7df6b32e59733086b70ce4dccaa40bbc9cbccf32 100644 --- a/crates/assistant_text_thread/src/text_thread.rs +++ b/crates/assistant_text_thread/src/text_thread.rs @@ -1219,7 +1219,7 @@ impl TextThread { } => cx.emit(TextThreadEvent::Operation( TextThreadOperation::BufferOperation(operation.clone()), )), - language::BufferEvent::Edited => { + language::BufferEvent::Edited { .. } => { self.count_remaining_tokens(cx); self.reparse(cx); cx.emit(TextThreadEvent::MessagesEdited); diff --git a/crates/channel/src/channel_buffer.rs b/crates/channel/src/channel_buffer.rs index 8b6f30a3cd3bf1d61f76a9b39c99a7b51a30ea4f..6145b1cf055fae543d68cd982a496d423d987e80 100644 --- a/crates/channel/src/channel_buffer.rs +++ b/crates/channel/src/channel_buffer.rs @@ -221,7 +221,7 @@ impl ChannelBuffer { }) .log_err(); } - language::BufferEvent::Edited => { + language::BufferEvent::Edited { .. } => { cx.emit(ChannelBufferEvent::BufferEdited); } _ => {} diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 179e217d207554bcf226ce905aa9226c1c334b72..3506672b2e79419a3a46cb0963af353a3a71730a 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -949,7 +949,7 @@ impl Copilot { && let Some(registered_buffer) = server.registered_buffers.get_mut(&buffer.entity_id()) { match event { - language::BufferEvent::Edited => { + language::BufferEvent::Edited { .. } => { drop(registered_buffer.report_changes(&buffer, cx)); } language::BufferEvent::Saved => { diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 1f692eff2c062cf703e72117c6fd39c7a4e1efbb..5e1c9f9a03ec0c4bff0bbd60a9aefc6a06fa5368 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -1217,7 +1217,7 @@ impl EditPredictionStore { cx.subscribe(buffer, { let project = project.downgrade(); move |this, buffer, event, cx| { - if let language::BufferEvent::Edited = event + if let language::BufferEvent::Edited { .. } = event && let Some(project) = project.upgrade() { this.report_changes_for_buffer(&buffer, &project, false, cx); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 40cfb8caf01a0343cb27104d7b23a24e999e9334..28c200c22ab01f6e691ea52d6463c9d8be530e8c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -24128,7 +24128,10 @@ impl Editor { cx: &mut Context, ) { match event { - multi_buffer::Event::Edited { edited_buffer } => { + multi_buffer::Event::Edited { + edited_buffer, + is_local, + } => { self.scrollbar_marker_state.dirty = true; self.active_indent_guides_state.dirty = true; self.refresh_active_diagnostics(cx); @@ -24138,7 +24141,7 @@ impl Editor { self.refresh_matching_bracket_highlights(&snapshot, cx); self.refresh_outline_symbols_at_cursor(cx); self.refresh_sticky_headers(&snapshot, cx); - if self.has_active_edit_prediction() { + if *is_local && self.has_active_edit_prediction() { self.update_visible_edit_prediction(window, cx); } diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index 115a53abbc240a37b7d4800c4c7905bed270be91..c684c230cf54cdbe89f13d9126c142e2dece3558 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -108,7 +108,7 @@ impl FileDiffView { for buffer in [&old_buffer, &new_buffer] { cx.subscribe(buffer, move |this, _, event, _| match event { - language::BufferEvent::Edited + language::BufferEvent::Edited { .. } | language::BufferEvent::LanguageChanged(_) | language::BufferEvent::Reparsed => { this.buffer_changes_tx.send(()).ok(); diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 1419fa049ee2aae1992dac517aad8371800ac532..9ae1b379471e4921b0ba3e77148ef198991e309b 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -165,7 +165,7 @@ impl TextDiffView { let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(()); cx.subscribe(&source_buffer, move |this, _, event, _| match event { - language::BufferEvent::Edited + language::BufferEvent::Edited { .. } | language::BufferEvent::LanguageChanged(_) | language::BufferEvent::Reparsed => { this.buffer_changes_tx.send(()).ok(); diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index d183615317ecaa481cda45d780c64b2ddf7ec833..a8bf8dd83ca76f8e9bd9892c1355ca8a7835867a 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -359,7 +359,7 @@ pub enum BufferEvent { is_local: bool, }, /// The buffer was edited. - Edited, + Edited { is_local: bool }, /// The buffer's `dirty` bit changed. DirtyChanged, /// The buffer was saved. @@ -2457,7 +2457,7 @@ impl Buffer { false }; if let Some((transaction_id, start_version)) = self.text.end_transaction_at(now) { - self.did_edit(&start_version, was_dirty, cx); + self.did_edit(&start_version, was_dirty, true, cx); Some(transaction_id) } else { None @@ -2844,7 +2844,13 @@ impl Buffer { Some(edit_id) } - fn did_edit(&mut self, old_version: &clock::Global, was_dirty: bool, cx: &mut Context) { + fn did_edit( + &mut self, + old_version: &clock::Global, + was_dirty: bool, + is_local: bool, + cx: &mut Context, + ) { self.was_changed(); if self.edits_since::(old_version).next().is_none() { @@ -2852,7 +2858,7 @@ impl Buffer { } self.reparse(cx, true); - cx.emit(BufferEvent::Edited); + cx.emit(BufferEvent::Edited { is_local }); if was_dirty != self.is_dirty() { cx.emit(BufferEvent::DirtyChanged); } @@ -2964,7 +2970,7 @@ impl Buffer { self.text.apply_ops(buffer_ops); self.deferred_ops.insert(deferred_ops); self.flush_deferred_ops(cx); - self.did_edit(&old_version, was_dirty, cx); + self.did_edit(&old_version, was_dirty, false, cx); // Notify independently of whether the buffer was edited as the operations could include a // selection update. cx.notify(); @@ -3119,7 +3125,7 @@ impl Buffer { if let Some((transaction_id, operation)) = self.text.undo() { self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, cx); + self.did_edit(&old_version, was_dirty, true, cx); self.restore_encoding_for_transaction(transaction_id, was_dirty); Some(transaction_id) } else { @@ -3137,7 +3143,7 @@ impl Buffer { let old_version = self.version.clone(); if let Some(operation) = self.text.undo_transaction(transaction_id) { self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, cx); + self.did_edit(&old_version, was_dirty, true, cx); true } else { false @@ -3159,7 +3165,7 @@ impl Buffer { self.send_operation(Operation::Buffer(operation), true, cx); } if undone { - self.did_edit(&old_version, was_dirty, cx) + self.did_edit(&old_version, was_dirty, true, cx) } undone } @@ -3169,7 +3175,7 @@ impl Buffer { let operation = self.text.undo_operations(counts); let old_version = self.version.clone(); self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, cx); + self.did_edit(&old_version, was_dirty, true, cx); } /// Manually redoes a specific transaction in the buffer's redo history. @@ -3179,7 +3185,7 @@ impl Buffer { if let Some((transaction_id, operation)) = self.text.redo() { self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, cx); + self.did_edit(&old_version, was_dirty, true, cx); self.restore_encoding_for_transaction(transaction_id, was_dirty); Some(transaction_id) } else { @@ -3220,7 +3226,7 @@ impl Buffer { self.send_operation(Operation::Buffer(operation), true, cx); } if redone { - self.did_edit(&old_version, was_dirty, cx) + self.did_edit(&old_version, was_dirty, true, cx) } redone } @@ -3330,7 +3336,7 @@ impl Buffer { if !ops.is_empty() { for op in ops { self.send_operation(Operation::Buffer(op), true, cx); - self.did_edit(&old_version, was_dirty, cx); + self.did_edit(&old_version, was_dirty, true, cx); } } } diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 49d871cc860bb6df892b80ac433fb70264788664..a47578faa2037e5f17a0e2be4ce5329e61d0fa84 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -458,15 +458,18 @@ fn test_edit_events(cx: &mut gpui::App) { assert_eq!( mem::take(&mut *buffer_1_events.lock()), vec![ - BufferEvent::Edited, + BufferEvent::Edited { is_local: true }, BufferEvent::DirtyChanged, - BufferEvent::Edited, - BufferEvent::Edited, + BufferEvent::Edited { is_local: true }, + BufferEvent::Edited { is_local: true }, ] ); assert_eq!( mem::take(&mut *buffer_2_events.lock()), - vec![BufferEvent::Edited, BufferEvent::DirtyChanged] + vec![ + BufferEvent::Edited { is_local: false }, + BufferEvent::DirtyChanged + ] ); buffer1.update(cx, |buffer, cx| { @@ -481,11 +484,17 @@ fn test_edit_events(cx: &mut gpui::App) { }); assert_eq!( mem::take(&mut *buffer_1_events.lock()), - vec![BufferEvent::Edited, BufferEvent::DirtyChanged,] + vec![ + BufferEvent::Edited { is_local: true }, + BufferEvent::DirtyChanged, + ] ); assert_eq!( mem::take(&mut *buffer_2_events.lock()), - vec![BufferEvent::Edited, BufferEvent::DirtyChanged] + vec![ + BufferEvent::Edited { is_local: false }, + BufferEvent::DirtyChanged + ] ); } diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 32898f1515a0c457260a7a9c89ce17c9dddf8cd9..2b4428b36a8c8f3b91f53425981bfe27480f7e64 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -119,6 +119,7 @@ pub enum Event { DiffHunksToggled, Edited { edited_buffer: Option>, + is_local: bool, }, TransactionUndone { transaction_id: TransactionId, @@ -1912,6 +1913,7 @@ impl MultiBuffer { cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); cx.emit(Event::ExcerptsAdded { buffer, @@ -1974,6 +1976,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); cx.emit(Event::ExcerptsRemoved { ids, @@ -2330,6 +2333,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); cx.emit(Event::ExcerptsRemoved { ids, @@ -2394,8 +2398,9 @@ impl MultiBuffer { use language::BufferEvent; let buffer_id = buffer.read(cx).remote_id(); cx.emit(match event { - BufferEvent::Edited => Event::Edited { + &BufferEvent::Edited { is_local } => Event::Edited { edited_buffer: Some(buffer), + is_local, }, BufferEvent::DirtyChanged => Event::DirtyChanged, BufferEvent::Saved => Event::Saved, @@ -2484,6 +2489,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); } @@ -2530,6 +2536,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); } @@ -2769,6 +2776,7 @@ impl MultiBuffer { cx.emit(Event::DiffHunksToggled); cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); } @@ -2885,6 +2893,7 @@ impl MultiBuffer { cx.emit(Event::DiffHunksToggled); cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); } @@ -2952,6 +2961,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); cx.emit(Event::ExcerptsExpanded { ids: vec![id] }); cx.notify(); @@ -3059,6 +3069,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); cx.emit(Event::ExcerptsExpanded { ids }); cx.notify(); @@ -3702,6 +3713,7 @@ impl MultiBuffer { cx.emit(Event::DiffHunksToggled); cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); } } diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 41e475a554b99485a86ffb0d7147414f8b9ef46a..c169297e2d5e170cc6cd7d85838c36c3e6bcf71e 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -171,12 +171,15 @@ fn test_excerpt_boundaries_and_clipping(cx: &mut App) { &[ Event::Edited { edited_buffer: None, + is_local: true, }, Event::Edited { edited_buffer: None, + is_local: true, }, Event::Edited { edited_buffer: None, + is_local: true, } ] ); diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 97aa03cec730c61acfb129579c77f6a5b560ee32..ff272cb10a662f7e69d1789d9afd719cb9e73005 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -4429,7 +4429,7 @@ impl LspStore { cx: &mut Context, ) { match event { - language::BufferEvent::Edited => { + language::BufferEvent::Edited { .. } => { self.on_buffer_edited(buffer, cx); } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 756f095511a9688678df013458710e69d720c52e..ed8884cd68c6df32375686dd5ceb41b21cbb5cdd 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -3636,11 +3636,11 @@ impl Project { event: &BufferEvent, cx: &mut Context, ) -> Option<()> { - if matches!(event, BufferEvent::Edited | BufferEvent::Reloaded) { + if matches!(event, BufferEvent::Edited { .. } | BufferEvent::Reloaded) { self.request_buffer_diff_recalculation(&buffer, cx); } - if matches!(event, BufferEvent::Edited) { + if matches!(event, BufferEvent::Edited { .. }) { cx.emit(Event::BufferEdited); } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index d86b969e61ed173ee314cde6f584f2dbab6859f9..2cecc5054df29b024530e39b6bf61f74c64fa850 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -5552,7 +5552,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited, + language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged ] ); @@ -5581,9 +5581,9 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited, + language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged, - language::BufferEvent::Edited, + language::BufferEvent::Edited { is_local: true }, ], ); events.lock().clear(); @@ -5598,7 +5598,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited, + language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged ] ); @@ -5638,7 +5638,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( mem::take(&mut *events.lock()), &[ - language::BufferEvent::Edited, + language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged ] ); @@ -5653,7 +5653,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited, + language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged ] ); diff --git a/crates/svg_preview/src/svg_preview_view.rs b/crates/svg_preview/src/svg_preview_view.rs index cc7e2052295f735f06e94f080a60ef25ec4da49d..1a001c6e18854428636626cc499e49433710a84d 100644 --- a/crates/svg_preview/src/svg_preview_view.rs +++ b/crates/svg_preview/src/svg_preview_view.rs @@ -182,7 +182,7 @@ impl SvgPreviewView { buffer, window, move |this, _buffer, event: &BufferEvent, window, cx| match event { - BufferEvent::Edited | BufferEvent::Saved => { + BufferEvent::Edited { .. } | BufferEvent::Saved => { this.render_image(window, cx); } _ => {} diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 0244a14c83b422a1fed803c761c7e873b42bd267..69b2816cc0bdc5aeed2af787b9a92166e2c93956 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -515,7 +515,7 @@ impl MarksState { cx: &mut Context, ) { let on_change = cx.subscribe(buffer_handle, move |this, buffer, event, cx| match event { - BufferEvent::Edited => { + BufferEvent::Edited { .. } => { if let Some(path) = this.path_for_buffer(&buffer, cx) { this.serialize_buffer_marks(path, &buffer, cx); } From 5712c87b766d7fca08eb4606597e98ff5d9308a8 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:31:23 -0300 Subject: [PATCH 103/219] sidebar: Fix project header active state (#51203) Release Notes: - N/A --- crates/sidebar/src/sidebar.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index bef521c6a0fe8a09724410b4fb186ffce672d8c3..ceb566f4c7b22acea44faa3b7f0bf3879d28b7ec 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -796,13 +796,15 @@ impl Sidebar { }; let color = cx.theme().colors(); - let gradient_overlay = GradientFade::new( - color.panel_background, - color.element_hover, - color.element_active, - ) - .width(px(48.0)) - .group_name(group_name.clone()); + let base_bg = if is_active_workspace { + color.ghost_element_selected + } else { + color.panel_background + }; + let gradient_overlay = + GradientFade::new(base_bg, color.element_hover, color.element_active) + .width(px(48.0)) + .group_name(group_name.clone()); ListItem::new(id) .group_name(group_name) From 3d36d1f2af7427c8b785470cae3599d49adabdd2 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 10 Mar 2026 21:19:05 +0530 Subject: [PATCH 104/219] recent_projects: Fix open project buttons hidden when there are no recent projects (#51207) Release Notes: - N/A --- crates/recent_projects/src/recent_projects.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 548e08eccb49c19551984e6acdd086d78927d614..b5ae7b048276f671da48beaa52b0db5fbcdda61a 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1241,8 +1241,8 @@ impl PickerDelegate for RecentProjectsDelegate { let focus_handle = self.focus_handle.clone(); let popover_style = matches!(self.style, ProjectPickerStyle::Popover); let open_folder_section = matches!( - self.filtered_entries.get(self.selected_index)?, - ProjectPickerEntry::OpenFolder { .. } + self.filtered_entries.get(self.selected_index), + Some(ProjectPickerEntry::OpenFolder { .. }) ); if popover_style { From f4b04af3dc63d23ab0fb70527d7e0938c81ef4d0 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 10 Mar 2026 17:04:48 +0100 Subject: [PATCH 105/219] agent: Allow `NativeAgent` to work with multiple projects (#51202) This removes the assumption that one project <-> one native agent. The native agent now maintains a project per session. We don't make use of this right now, but it will come in handy once we start sharing ACP connections globally. Release Notes: - N/A --- crates/agent/src/agent.rs | 456 ++++++++++++++-------- crates/agent/src/native_agent_server.rs | 8 +- crates/agent/src/tests/mod.rs | 103 ++--- crates/agent_servers/src/agent_servers.rs | 2 +- crates/agent_ui/src/mention_set.rs | 4 +- crates/eval_cli/src/main.rs | 24 +- 6 files changed, 325 insertions(+), 272 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index d9ad55c7127983516dbb5fe0392ef135186b79f7..a62e219b2d075e10e074b55859fc6c366c25523d 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -37,7 +37,8 @@ use futures::channel::{mpsc, oneshot}; use futures::future::Shared; use futures::{FutureExt as _, StreamExt as _, future}; use gpui::{ - App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, + App, AppContext, AsyncApp, Context, Entity, EntityId, SharedString, Subscription, Task, + WeakEntity, }; use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry}; use project::{Project, ProjectItem, ProjectPath, Worktree}; @@ -65,12 +66,22 @@ pub struct RulesLoadingError { pub message: SharedString, } +struct ProjectState { + project: Entity, + project_context: Entity, + project_context_needs_refresh: watch::Sender<()>, + _maintain_project_context: Task>, + context_server_registry: Entity, + _subscriptions: Vec, +} + /// Holds both the internal Thread and the AcpThread for a session struct Session { /// The internal thread that processes messages thread: Entity, /// The ACP thread that handles protocol communication acp_thread: Entity, + project_id: EntityId, pending_save: Task<()>, _subscriptions: Vec, } @@ -235,79 +246,47 @@ pub struct NativeAgent { /// Session ID -> Session mapping sessions: HashMap, thread_store: Entity, - /// Shared project context for all threads - project_context: Entity, - project_context_needs_refresh: watch::Sender<()>, - _maintain_project_context: Task>, - context_server_registry: Entity, + /// Project-specific state keyed by project EntityId + projects: HashMap, /// Shared templates for all threads templates: Arc, /// Cached model information models: LanguageModels, - project: Entity, prompt_store: Option>, fs: Arc, _subscriptions: Vec, } impl NativeAgent { - pub async fn new( - project: Entity, + pub fn new( thread_store: Entity, templates: Arc, prompt_store: Option>, fs: Arc, - cx: &mut AsyncApp, - ) -> Result> { + cx: &mut App, + ) -> Entity { log::debug!("Creating new NativeAgent"); - let project_context = cx - .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx)) - .await; - - Ok(cx.new(|cx| { - let context_server_store = project.read(cx).context_server_store(); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); - - let mut subscriptions = vec![ - cx.subscribe(&project, Self::handle_project_event), - cx.subscribe( - &LanguageModelRegistry::global(cx), - Self::handle_models_updated_event, - ), - cx.subscribe( - &context_server_store, - Self::handle_context_server_store_updated, - ), - cx.subscribe( - &context_server_registry, - Self::handle_context_server_registry_event, - ), - ]; + cx.new(|cx| { + let mut subscriptions = vec![cx.subscribe( + &LanguageModelRegistry::global(cx), + Self::handle_models_updated_event, + )]; if let Some(prompt_store) = prompt_store.as_ref() { subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) } - let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) = - watch::channel(()); Self { sessions: HashMap::default(), thread_store, - project_context: cx.new(|_| project_context), - project_context_needs_refresh: project_context_needs_refresh_tx, - _maintain_project_context: cx.spawn(async move |this, cx| { - Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await - }), - context_server_registry, + projects: HashMap::default(), templates, models: LanguageModels::new(cx), - project, prompt_store, fs, _subscriptions: subscriptions, } - })) + }) } fn new_session( @@ -315,10 +294,10 @@ impl NativeAgent { project: Entity, cx: &mut Context, ) -> Entity { - // Create Thread - // Fetch default model from registry settings + let project_id = self.get_or_create_project_state(&project, cx); + let project_state = &self.projects[&project_id]; + let registry = LanguageModelRegistry::read_global(cx); - // Log available models for debugging let available_count = registry.available_models(cx).count(); log::debug!("Total available models: {}", available_count); @@ -328,21 +307,22 @@ impl NativeAgent { }); let thread = cx.new(|cx| { Thread::new( - project.clone(), - self.project_context.clone(), - self.context_server_registry.clone(), + project, + project_state.project_context.clone(), + project_state.context_server_registry.clone(), self.templates.clone(), default_model, cx, ) }); - self.register_session(thread, cx) + self.register_session(thread, project_id, cx) } fn register_session( &mut self, thread_handle: Entity, + project_id: EntityId, cx: &mut Context, ) -> Entity { let connection = Rc::new(NativeAgentConnection(cx.entity())); @@ -405,12 +385,13 @@ impl NativeAgent { Session { thread: thread_handle, acp_thread: acp_thread.clone(), + project_id, _subscriptions: subscriptions, pending_save: Task::ready(()), }, ); - self.update_available_commands(cx); + self.update_available_commands_for_project(project_id, cx); acp_thread } @@ -419,19 +400,102 @@ impl NativeAgent { &self.models } + fn get_or_create_project_state( + &mut self, + project: &Entity, + cx: &mut Context, + ) -> EntityId { + let project_id = project.entity_id(); + if self.projects.contains_key(&project_id) { + return project_id; + } + + let project_context = cx.new(|_| ProjectContext::new(vec![], vec![])); + self.register_project_with_initial_context(project.clone(), project_context, cx); + if let Some(state) = self.projects.get_mut(&project_id) { + state.project_context_needs_refresh.send(()).ok(); + } + project_id + } + + fn register_project_with_initial_context( + &mut self, + project: Entity, + project_context: Entity, + cx: &mut Context, + ) { + let project_id = project.entity_id(); + + let context_server_store = project.read(cx).context_server_store(); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); + + let subscriptions = vec![ + cx.subscribe(&project, Self::handle_project_event), + cx.subscribe( + &context_server_store, + Self::handle_context_server_store_updated, + ), + cx.subscribe( + &context_server_registry, + Self::handle_context_server_registry_event, + ), + ]; + + let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) = + watch::channel(()); + + self.projects.insert( + project_id, + ProjectState { + project, + project_context, + project_context_needs_refresh: project_context_needs_refresh_tx, + _maintain_project_context: cx.spawn(async move |this, cx| { + Self::maintain_project_context( + this, + project_id, + project_context_needs_refresh_rx, + cx, + ) + .await + }), + context_server_registry, + _subscriptions: subscriptions, + }, + ); + } + + fn session_project_state(&self, session_id: &acp::SessionId) -> Option<&ProjectState> { + self.sessions + .get(session_id) + .and_then(|session| self.projects.get(&session.project_id)) + } + async fn maintain_project_context( this: WeakEntity, + project_id: EntityId, mut needs_refresh: watch::Receiver<()>, cx: &mut AsyncApp, ) -> Result<()> { while needs_refresh.changed().await.is_ok() { let project_context = this .update(cx, |this, cx| { - Self::build_project_context(&this.project, this.prompt_store.as_ref(), cx) - })? + let state = this + .projects + .get(&project_id) + .context("project state not found")?; + anyhow::Ok(Self::build_project_context( + &state.project, + this.prompt_store.as_ref(), + cx, + )) + })?? .await; this.update(cx, |this, cx| { - this.project_context = cx.new(|_| project_context); + if let Some(state) = this.projects.get_mut(&project_id) { + state.project_context = cx.new(|_| project_context); + } })?; } @@ -620,13 +684,17 @@ impl NativeAgent { fn handle_project_event( &mut self, - _project: Entity, + project: Entity, event: &project::Event, _cx: &mut Context, ) { + let project_id = project.entity_id(); + let Some(state) = self.projects.get_mut(&project_id) else { + return; + }; match event { project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => { - self.project_context_needs_refresh.send(()).ok(); + state.project_context_needs_refresh.send(()).ok(); } project::Event::WorktreeUpdatedEntries(_, items) => { if items.iter().any(|(path, _, _)| { @@ -634,7 +702,7 @@ impl NativeAgent { .iter() .any(|name| path.as_ref() == RelPath::unix(name).unwrap()) }) { - self.project_context_needs_refresh.send(()).ok(); + state.project_context_needs_refresh.send(()).ok(); } } _ => {} @@ -647,7 +715,9 @@ impl NativeAgent { _event: &prompt_store::PromptsUpdatedEvent, _cx: &mut Context, ) { - self.project_context_needs_refresh.send(()).ok(); + for state in self.projects.values_mut() { + state.project_context_needs_refresh.send(()).ok(); + } } fn handle_models_updated_event( @@ -677,30 +747,52 @@ impl NativeAgent { fn handle_context_server_store_updated( &mut self, - _store: Entity, + store: Entity, _event: &project::context_server_store::ServerStatusChangedEvent, cx: &mut Context, ) { - self.update_available_commands(cx); + let project_id = self.projects.iter().find_map(|(id, state)| { + if *state.context_server_registry.read(cx).server_store() == store { + Some(*id) + } else { + None + } + }); + if let Some(project_id) = project_id { + self.update_available_commands_for_project(project_id, cx); + } } fn handle_context_server_registry_event( &mut self, - _registry: Entity, + registry: Entity, event: &ContextServerRegistryEvent, cx: &mut Context, ) { match event { ContextServerRegistryEvent::ToolsChanged => {} ContextServerRegistryEvent::PromptsChanged => { - self.update_available_commands(cx); + let project_id = self.projects.iter().find_map(|(id, state)| { + if state.context_server_registry == registry { + Some(*id) + } else { + None + } + }); + if let Some(project_id) = project_id { + self.update_available_commands_for_project(project_id, cx); + } } } } - fn update_available_commands(&self, cx: &mut Context) { - let available_commands = self.build_available_commands(cx); + fn update_available_commands_for_project(&self, project_id: EntityId, cx: &mut Context) { + let available_commands = + Self::build_available_commands_for_project(self.projects.get(&project_id), cx); for session in self.sessions.values() { + if session.project_id != project_id { + continue; + } session.acp_thread.update(cx, |thread, cx| { thread .handle_session_update( @@ -714,8 +806,14 @@ impl NativeAgent { } } - fn build_available_commands(&self, cx: &App) -> Vec { - let registry = self.context_server_registry.read(cx); + fn build_available_commands_for_project( + project_state: Option<&ProjectState>, + cx: &App, + ) -> Vec { + let Some(state) = project_state else { + return vec![]; + }; + let registry = state.context_server_registry.read(cx); let mut prompt_name_counts: HashMap<&str, usize> = HashMap::default(); for context_server_prompt in registry.prompts() { @@ -769,8 +867,10 @@ impl NativeAgent { pub fn load_thread( &mut self, id: acp::SessionId, + project: Entity, cx: &mut Context, ) -> Task>> { + let project_id = self.get_or_create_project_state(&project, cx); let database_future = ThreadsDatabase::connect(cx); cx.spawn(async move |this, cx| { let database = database_future.await.map_err(|err| anyhow!(err))?; @@ -780,41 +880,48 @@ impl NativeAgent { .with_context(|| format!("no thread found with ID: {id:?}"))?; this.update(cx, |this, cx| { + let project_state = this + .projects + .get(&project_id) + .context("project state not found")?; let summarization_model = LanguageModelRegistry::read_global(cx) .thread_summary_model() .map(|c| c.model); - cx.new(|cx| { + Ok(cx.new(|cx| { let mut thread = Thread::from_db( id.clone(), db_thread, - this.project.clone(), - this.project_context.clone(), - this.context_server_registry.clone(), + project_state.project.clone(), + project_state.project_context.clone(), + project_state.context_server_registry.clone(), this.templates.clone(), cx, ); thread.set_summarization_model(summarization_model, cx); thread - }) - }) + })) + })? }) } pub fn open_thread( &mut self, id: acp::SessionId, + project: Entity, cx: &mut Context, ) -> Task>> { if let Some(session) = self.sessions.get(&id) { return Task::ready(Ok(session.acp_thread.clone())); } - let task = self.load_thread(id, cx); + let project_id = self.get_or_create_project_state(&project, cx); + let task = self.load_thread(id, project, cx); cx.spawn(async move |this, cx| { let thread = task.await?; - let acp_thread = - this.update(cx, |this, cx| this.register_session(thread.clone(), cx))?; + let acp_thread = this.update(cx, |this, cx| { + this.register_session(thread.clone(), project_id, cx) + })?; let events = thread.update(cx, |thread, cx| thread.replay(cx)); cx.update(|cx| { NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx) @@ -827,9 +934,10 @@ impl NativeAgent { pub fn thread_summary( &mut self, id: acp::SessionId, + project: Entity, cx: &mut Context, ) -> Task> { - let thread = self.open_thread(id.clone(), cx); + let thread = self.open_thread(id.clone(), project, cx); cx.spawn(async move |this, cx| { let acp_thread = thread.await?; let result = this @@ -857,8 +965,13 @@ impl NativeAgent { return; }; + let project_id = session.project_id; + let Some(state) = self.projects.get(&project_id) else { + return; + }; + let folder_paths = PathList::new( - &self + &state .project .read(cx) .visible_worktrees(cx) @@ -889,15 +1002,22 @@ impl NativeAgent { fn send_mcp_prompt( &self, message_id: UserMessageId, - session_id: agent_client_protocol::SessionId, + session_id: acp::SessionId, prompt_name: String, server_id: ContextServerId, arguments: HashMap, original_content: Vec, cx: &mut Context, ) -> Task> { - let server_store = self.context_server_registry.read(cx).server_store().clone(); - let path_style = self.project.read(cx).path_style(cx); + let Some(state) = self.session_project_state(&session_id) else { + return Task::ready(Err(anyhow!("Project state not found for session"))); + }; + let server_store = state + .context_server_registry + .read(cx) + .server_store() + .clone(); + let path_style = state.project.read(cx).path_style(cx); cx.spawn(async move |this, cx| { let prompt = @@ -996,8 +1116,14 @@ impl NativeAgentConnection { .map(|session| session.thread.clone()) } - pub fn load_thread(&self, id: acp::SessionId, cx: &mut App) -> Task>> { - self.0.update(cx, |this, cx| this.load_thread(id, cx)) + pub fn load_thread( + &self, + id: acp::SessionId, + project: Entity, + cx: &mut App, + ) -> Task>> { + self.0 + .update(cx, |this, cx| this.load_thread(id, project, cx)) } fn run_turn( @@ -1279,13 +1405,13 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn load_session( self: Rc, session_id: acp::SessionId, - _project: Entity, + project: Entity, _cwd: &Path, _title: Option, cx: &mut App, ) -> Task>> { self.0 - .update(cx, |agent, cx| agent.open_thread(session_id, cx)) + .update(cx, |agent, cx| agent.open_thread(session_id, project, cx)) } fn supports_close_session(&self) -> bool { @@ -1294,7 +1420,15 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn close_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task> { self.0.update(cx, |agent, _cx| { + let project_id = agent.sessions.get(session_id).map(|s| s.project_id); agent.sessions.remove(session_id); + + if let Some(project_id) = project_id { + let has_remaining = agent.sessions.values().any(|s| s.project_id == project_id); + if !has_remaining { + agent.projects.remove(&project_id); + } + } }); Task::ready(Ok(())) } @@ -1325,8 +1459,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection { log::info!("Received prompt request for session: {}", session_id); log::debug!("Prompt blocks count: {}", params.prompt.len()); + let Some(project_state) = self.0.read(cx).session_project_state(&session_id) else { + return Task::ready(Err(anyhow::anyhow!("Session not found"))); + }; + if let Some(parsed_command) = Command::parse(¶ms.prompt) { - let registry = self.0.read(cx).context_server_registry.read(cx); + let registry = project_state.context_server_registry.read(cx); let explicit_server_id = parsed_command .explicit_server_id @@ -1362,10 +1500,10 @@ impl acp_thread::AgentConnection for NativeAgentConnection { cx, ) }); - }; + } }; - let path_style = self.0.read(cx).project.read(cx).path_style(cx); + let path_style = project_state.project.read(cx).path_style(cx); self.run_turn(session_id, cx, move |thread, cx| { let content: Vec = params @@ -1406,7 +1544,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn truncate( &self, - session_id: &agent_client_protocol::SessionId, + session_id: &acp::SessionId, cx: &App, ) -> Option> { self.0.read_with(cx, |agent, _cx| { @@ -1611,6 +1749,7 @@ impl NativeThreadEnvironment { }; let parent_thread = parent_thread_entity.read(cx); let current_depth = parent_thread.depth(); + let parent_session_id = parent_thread.id().clone(); if current_depth >= MAX_SUBAGENT_DEPTH { return Err(anyhow!( @@ -1627,9 +1766,16 @@ impl NativeThreadEnvironment { let session_id = subagent_thread.read(cx).id().clone(); - let acp_thread = self.agent.update(cx, |agent, cx| { - agent.register_session(subagent_thread.clone(), cx) - })?; + let acp_thread = self + .agent + .update(cx, |agent, cx| -> Result> { + let project_id = agent + .sessions + .get(&parent_session_id) + .map(|s| s.project_id) + .context("parent session not found")?; + Ok(agent.register_session(subagent_thread.clone(), project_id, cx)) + })??; let depth = current_depth + 1; @@ -1955,18 +2101,21 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + + // Creating a session registers the project and triggers context building. + let connection = NativeAgentConnection(agent.clone()); + let _acp_thread = cx + .update(|cx| Rc::new(connection).new_session(project.clone(), Path::new("/"), cx)) + .await + .unwrap(); + cx.run_until_parked(); + agent.read_with(cx, |agent, cx| { - assert_eq!(agent.project_context.read(cx).worktrees, vec![]) + let project_id = project.entity_id(); + let state = agent.projects.get(&project_id).unwrap(); + assert_eq!(state.project_context.read(cx).worktrees, vec![]) }); let worktree = project @@ -1975,8 +2124,10 @@ mod internal_tests { .unwrap(); cx.run_until_parked(); agent.read_with(cx, |agent, cx| { + let project_id = project.entity_id(); + let state = agent.projects.get(&project_id).unwrap(); assert_eq!( - agent.project_context.read(cx).worktrees, + state.project_context.read(cx).worktrees, vec![WorktreeContext { root_name: "a".into(), abs_path: Path::new("/a").into(), @@ -1989,12 +2140,14 @@ mod internal_tests { fs.insert_file("/a/.rules", Vec::new()).await; cx.run_until_parked(); agent.read_with(cx, |agent, cx| { + let project_id = project.entity_id(); + let state = agent.projects.get(&project_id).unwrap(); let rules_entry = worktree .read(cx) .entry_for_path(rel_path(".rules")) .unwrap(); assert_eq!( - agent.project_context.read(cx).worktrees, + state.project_context.read(cx).worktrees, vec![WorktreeContext { root_name: "a".into(), abs_path: Path::new("/a").into(), @@ -2015,18 +2168,10 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let connection = NativeAgentConnection( - NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(), - ); + let connection = + NativeAgentConnection(cx.update(|cx| { + NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx) + })); // Create a thread/session let acp_thread = cx @@ -2095,16 +2240,8 @@ mod internal_tests { let thread_store = cx.new(|cx| ThreadStore::new(cx)); // Create the agent and connection - let agent = NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); // Create a thread/session @@ -2196,16 +2333,8 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); let acp_thread = cx @@ -2288,16 +2417,9 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); // Register a thinking model. @@ -2371,7 +2493,9 @@ mod internal_tests { // Reload the thread and verify thinking_enabled is still true. let reloaded_acp_thread = agent - .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx)) + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) .await .unwrap(); let reloaded_thread = agent.read_with(cx, |agent, _| { @@ -2394,16 +2518,9 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); // Register a model where id() != name(), like real Anthropic models @@ -2478,7 +2595,9 @@ mod internal_tests { // Reload the thread and verify the model was preserved. let reloaded_acp_thread = agent - .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx)) + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) .await .unwrap(); let reloaded_thread = agent.read_with(cx, |agent, _| { @@ -2513,16 +2632,9 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -2642,7 +2754,9 @@ mod internal_tests { )] ); let acp_thread = agent - .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx)) + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) .await .unwrap(); acp_thread.read_with(cx, |thread, cx| { diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index 18c41670ac4b4ba3146fb207992a7020a44fbd5f..ca5128fc80d49df0f165ab065a510585400f55d9 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -35,11 +35,10 @@ impl AgentServer for NativeAgentServer { fn connect( &self, - delegate: AgentServerDelegate, + _delegate: AgentServerDelegate, cx: &mut App, ) -> Task>> { log::debug!("NativeAgentServer::connect"); - let project = delegate.project().clone(); let fs = self.fs.clone(); let thread_store = self.thread_store.clone(); let prompt_store = PromptStore::global(cx); @@ -49,9 +48,8 @@ impl AgentServer for NativeAgentServer { let prompt_store = prompt_store.await?; log::debug!("Creating native agent entity"); - let agent = - NativeAgent::new(project, thread_store, templates, Some(prompt_store), fs, cx) - .await?; + let agent = cx + .update(|cx| NativeAgent::new(thread_store, templates, Some(prompt_store), fs, cx)); // Create the connection wrapper let connection = NativeAgentConnection(agent); diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index d33c80a435e84359976d4d8a9edb2bdebd66e0ff..db3fa7c56ebc8ba7a94850d9d38b07c65a7ef4ba 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -3181,16 +3181,8 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let thread_store = cx.new(|cx| ThreadStore::new(cx)); // Create agent and connection - let agent = NativeAgent::new( - project.clone(), - thread_store, - templates.clone(), - None, - fake_fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx + .update(|cx| NativeAgent::new(thread_store, templates.clone(), None, fake_fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); // Create a thread using new_thread @@ -4388,16 +4380,9 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -4530,16 +4515,9 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -4685,16 +4663,9 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -4822,16 +4793,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5201,16 +5165,9 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5334,16 +5291,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5515,16 +5465,9 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index a07226ca25095fdb7037114d32d5033364a4999f..adcbd923c2aecbd88e71037591687acda9f57fac 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -9,12 +9,12 @@ use collections::{HashMap, HashSet}; pub use custom::*; use fs::Fs; use http_client::read_no_proxy_from_env; +use project::Project; use project::agent_server_store::AgentServerStore; use acp_thread::AgentConnection; use anyhow::Result; use gpui::{App, AppContext, Entity, SharedString, Task}; -use project::Project; use settings::SettingsStore; use std::{any::Any, rc::Rc, sync::Arc}; diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 5a76e2b355c3373ee278b0f0de95ddcfcdd13101..dc9d793a5ca5012ca2fe719f1e39bb3fc4fa6d66 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -565,7 +565,9 @@ impl MentionSet { let agent = agent.downcast::().unwrap(); let summary = agent .0 - .update(cx, |agent, cx| agent.thread_summary(id, cx)) + .update(cx, |agent, cx| { + agent.thread_summary(id, project.clone(), cx) + }) .await?; Ok(Mention::Text { content: summary.to_string(), diff --git a/crates/eval_cli/src/main.rs b/crates/eval_cli/src/main.rs index 0f8dbed7ba12cee934e7631dc7068c83db1dc293..7b9f822a539c8d1e0a29bdef0bccee5d4a55721e 100644 --- a/crates/eval_cli/src/main.rs +++ b/crates/eval_cli/src/main.rs @@ -357,20 +357,16 @@ async fn run_agent( Err(e) => return (Err(e), None), }; - let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = match NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - app_state.fs.clone(), - cx, - ) - .await - { - Ok(a) => a, - Err(e) => return (Err(e).context("creating agent"), None), - }; + let agent = cx.update(|cx| { + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + NativeAgent::new( + thread_store, + Templates::new(), + None, + app_state.fs.clone(), + cx, + ) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = match cx From 50aef1f115493aab506df9d5b33da5435dc36bfc Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:22:03 -0600 Subject: [PATCH 106/219] buffer: Reload after undo when file changed while dirty (#51037) Closes #48697 Supersedes #48698 Related to #38109 ## Problem If you edit a file and an external tool writes to it while you have unsaved changes, Zed tracks the new file but skips the reload to preserve your edits. If you then undo everything, the buffer goes back to clean but still shows the old content. The disk has moved on, but nothing triggers a reload. ## Fix In `did_edit()`, when the buffer transitions from dirty to clean, check if the file's mtime changed while it was dirty. If so, emit `ReloadNeeded`. Only fires for files that still exist on disk (`DiskState::Present`). 7 lines in `crates/language/src/buffer.rs`. ### No double reload `file_updated()` suppresses `ReloadNeeded` when the buffer is dirty (that's the whole bug). So by the time `did_edit()` fires on dirty-to-clean, no prior reload was emitted for this file change. The two paths are mutually exclusive. ## Test plan - New: `test_dirty_buffer_reloads_after_undo` - No regression in `test_buffer_is_dirty` or other buffer tests - All project integration tests pass - clippy clean Release Notes: - Fixed an issue where buffer content could become stale after undoing edits when an external tool wrote to the file while the buffer was dirty. Co-authored-by: Claude Opus 4.6 Co-authored-by: Ben Kunkle --- crates/language/src/buffer.rs | 12 +++- .../tests/integration/project_tests.rs | 69 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index a8bf8dd83ca76f8e9bd9892c1355ca8a7835867a..f92ae2419edf61aaa20643c3f87dac2f4af8bf4e 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -2859,9 +2859,19 @@ impl Buffer { self.reparse(cx, true); cx.emit(BufferEvent::Edited { is_local }); - if was_dirty != self.is_dirty() { + let is_dirty = self.is_dirty(); + if was_dirty != is_dirty { cx.emit(BufferEvent::DirtyChanged); } + if was_dirty && !is_dirty { + if let Some(file) = self.file.as_ref() { + if matches!(file.disk_state(), DiskState::Present { .. }) + && file.disk_state().mtime() != self.saved_mtime + { + cx.emit(BufferEvent::ReloadNeeded); + } + } + } cx.notify(); } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 2cecc5054df29b024530e39b6bf61f74c64fa850..0080236758214b284b74abc2f1831b9f9978241e 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -5687,6 +5687,75 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { cx.update(|cx| assert!(buffer3.read(cx).is_dirty())); } +#[gpui::test] +async fn test_dirty_buffer_reloads_after_undo(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "file.txt": "version 1", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let buffer = project + .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file.txt"), cx)) + .await + .unwrap(); + + buffer.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "version 1"); + assert!(!buffer.is_dirty()); + }); + + // User makes an edit, making the buffer dirty. + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "user edit: ")], None, cx); + }); + + buffer.read_with(cx, |buffer, _| { + assert!(buffer.is_dirty()); + assert_eq!(buffer.text(), "user edit: version 1"); + }); + + // External tool writes new content while buffer is dirty. + // file_updated() updates the File but suppresses ReloadNeeded. + fs.save( + path!("/dir/file.txt").as_ref(), + &"version 2 from external tool".into(), + Default::default(), + ) + .await + .unwrap(); + cx.executor().run_until_parked(); + + buffer.read_with(cx, |buffer, _| { + assert!(buffer.has_conflict()); + assert_eq!(buffer.text(), "user edit: version 1"); + }); + + // User undoes their edit. Buffer becomes clean, but disk has different + // content. did_edit() detects the dirty->clean transition and checks if + // disk changed while dirty. Since mtime differs from saved_mtime, it + // emits ReloadNeeded. + buffer.update(cx, |buffer, cx| { + buffer.undo(cx); + }); + cx.executor().run_until_parked(); + + buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer.text(), + "version 2 from external tool", + "buffer should reload from disk after undo makes it clean" + ); + assert!(!buffer.is_dirty()); + }); +} + #[gpui::test] async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) { init_test(cx); From e4b3c0fa84c668def9db2c1827c834b4391012b6 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 10 Mar 2026 18:32:51 +0100 Subject: [PATCH 107/219] agent: Re-use ACP connections per project (#51209) Release Notes: - N/A --------- Co-authored-by: Ben Brandt --- crates/agent_servers/src/agent_servers.rs | 11 -- crates/agent_servers/src/custom.rs | 1 - crates/agent_servers/src/e2e_tests.rs | 2 +- crates/agent_ui/src/agent_connection_store.rs | 163 +++++++++++++++++ crates/agent_ui/src/agent_panel.rs | 9 +- crates/agent_ui/src/agent_ui.rs | 3 +- crates/agent_ui/src/connection_view.rs | 173 +++++++++++------- crates/agent_ui/src/mention_set.rs | 8 +- crates/project/src/agent_server_store.rs | 98 +++------- .../tests/integration/ext_agent_tests.rs | 1 - .../integration/extension_agent_tests.rs | 1 - crates/proto/proto/ai.proto | 2 +- .../remote_server/src/remote_editing_tests.rs | 1 - crates/sidebar/Cargo.toml | 3 +- crates/sidebar/src/sidebar.rs | 11 +- 15 files changed, 313 insertions(+), 174 deletions(-) create mode 100644 crates/agent_ui/src/agent_connection_store.rs diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index adcbd923c2aecbd88e71037591687acda9f57fac..a12b63164325cfc447e44b3a5899e79b774e141f 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -9,7 +9,6 @@ use collections::{HashMap, HashSet}; pub use custom::*; use fs::Fs; use http_client::read_no_proxy_from_env; -use project::Project; use project::agent_server_store::AgentServerStore; use acp_thread::AgentConnection; @@ -22,29 +21,19 @@ pub use acp::AcpConnection; pub struct AgentServerDelegate { store: Entity, - project: Entity, - status_tx: Option>, new_version_available: Option>>, } impl AgentServerDelegate { pub fn new( store: Entity, - project: Entity, - status_tx: Option>, new_version_tx: Option>>, ) -> Self { Self { store, - project, - status_tx, new_version_available: new_version_tx, } } - - pub fn project(&self) -> &Entity { - &self.project - } } pub trait AgentServer: Send { diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 0a1830717217872868e66a8222902c49eeaabf9c..d87b9dc4ece042d94da6e6e0ac99e1474c1ce018 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -364,7 +364,6 @@ impl AgentServer for CustomAgentServer { })?; anyhow::Ok(agent.get_command( extra_env, - delegate.status_tx, delegate.new_version_available, &mut cx.to_async(), )) diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index a0150d41726c94dc830be70e006f4370de919ead..5dcf416bb87ba4812e1a828c23d49819f2874a99 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -431,7 +431,7 @@ pub async fn new_test_thread( cx: &mut TestAppContext, ) -> Entity { let store = project.read_with(cx, |project, _| project.agent_server_store().clone()); - let delegate = AgentServerDelegate::new(store, project.clone(), None, None); + let delegate = AgentServerDelegate::new(store, None); let connection = cx.update(|cx| server.connect(delegate, cx)).await.unwrap(); diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..c0c4519bcc64d53690dd782a55e6b9da4f498fe0 --- /dev/null +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -0,0 +1,163 @@ +use std::rc::Rc; + +use acp_thread::{AgentConnection, LoadError}; +use agent_servers::{AgentServer, AgentServerDelegate}; +use anyhow::Result; +use collections::HashMap; +use futures::{FutureExt, future::Shared}; +use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task}; +use project::{AgentServerStore, AgentServersUpdated, Project}; +use watch::Receiver; + +use crate::ExternalAgent; +use project::ExternalAgentServerName; + +pub enum ConnectionEntry { + Connecting { + connect_task: Shared, LoadError>>>, + }, + Connected { + connection: Rc, + }, + Error { + error: LoadError, + }, +} + +impl ConnectionEntry { + pub fn wait_for_connection(&self) -> Shared, LoadError>>> { + match self { + ConnectionEntry::Connecting { connect_task } => connect_task.clone(), + ConnectionEntry::Connected { connection } => { + Task::ready(Ok(connection.clone())).shared() + } + ConnectionEntry::Error { error } => Task::ready(Err(error.clone())).shared(), + } + } +} + +pub enum ConnectionEntryEvent { + NewVersionAvailable(SharedString), +} + +impl EventEmitter for ConnectionEntry {} + +pub struct AgentConnectionStore { + project: Entity, + entries: HashMap>, + _subscriptions: Vec, +} + +impl AgentConnectionStore { + pub fn new(project: Entity, cx: &mut Context) -> Self { + let agent_server_store = project.read(cx).agent_server_store().clone(); + let subscription = cx.subscribe(&agent_server_store, Self::handle_agent_servers_updated); + Self { + project, + entries: HashMap::default(), + _subscriptions: vec![subscription], + } + } + + pub fn request_connection( + &mut self, + key: ExternalAgent, + server: Rc, + cx: &mut Context, + ) -> Entity { + self.entries.get(&key).cloned().unwrap_or_else(|| { + let (mut new_version_rx, connect_task) = self.start_connection(server.clone(), cx); + let connect_task = connect_task.shared(); + + let entry = cx.new(|_cx| ConnectionEntry::Connecting { + connect_task: connect_task.clone(), + }); + + self.entries.insert(key.clone(), entry.clone()); + + cx.spawn({ + let key = key.clone(); + let entry = entry.clone(); + async move |this, cx| match connect_task.await { + Ok(connection) => { + entry.update(cx, |entry, cx| { + if let ConnectionEntry::Connecting { .. } = entry { + *entry = ConnectionEntry::Connected { connection }; + cx.notify(); + } + }); + } + Err(error) => { + entry.update(cx, |entry, cx| { + if let ConnectionEntry::Connecting { .. } = entry { + *entry = ConnectionEntry::Error { error }; + cx.notify(); + } + }); + this.update(cx, |this, _cx| this.entries.remove(&key)).ok(); + } + } + }) + .detach(); + + cx.spawn({ + let entry = entry.clone(); + async move |this, cx| { + while let Ok(version) = new_version_rx.recv().await { + if let Some(version) = version { + entry.update(cx, |_entry, cx| { + cx.emit(ConnectionEntryEvent::NewVersionAvailable( + version.clone().into(), + )); + }); + this.update(cx, |this, _cx| this.entries.remove(&key)).ok(); + } + } + } + }) + .detach(); + + entry + }) + } + + fn handle_agent_servers_updated( + &mut self, + store: Entity, + _: &AgentServersUpdated, + cx: &mut Context, + ) { + let store = store.read(cx); + self.entries.retain(|key, _| match key { + ExternalAgent::NativeAgent => true, + ExternalAgent::Custom { name } => store + .external_agents + .contains_key(&ExternalAgentServerName(name.clone())), + }); + cx.notify(); + } + + fn start_connection( + &self, + server: Rc, + cx: &mut Context, + ) -> ( + Receiver>, + Task, LoadError>>, + ) { + let (new_version_tx, new_version_rx) = watch::channel::>(None); + + let agent_server_store = self.project.read(cx).agent_server_store().clone(); + let delegate = AgentServerDelegate::new(agent_server_store, Some(new_version_tx)); + + let connect_task = server.connect(delegate, cx); + let connect_task = cx.spawn(async move |_this, _cx| match connect_task.await { + Ok(connection) => Ok(connection), + Err(err) => match err.downcast::() { + Ok(load_error) => Err(load_error), + Err(err) => Err(LoadError::Other(SharedString::from(err.to_string()))), + }, + }); + (new_version_rx, connect_task) + } +} diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 2b9f2f5624072f7b9c9f01f1daecd7e1103c758b..80f8925ad05414b9839ac53953156ef35c43e08f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -30,6 +30,7 @@ use zed_actions::agent::{ }; use crate::ManageProfiles; +use crate::agent_connection_store::AgentConnectionStore; use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; use crate::{ AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow, @@ -790,6 +791,7 @@ pub struct AgentPanel { thread_store: Entity, text_thread_store: Entity, prompt_store: Option>, + connection_store: Entity, context_server_registry: Entity, configuration: Option>, configuration_subscription: Option, @@ -1116,6 +1118,7 @@ impl AgentPanel { language_registry, text_thread_store, prompt_store, + connection_store: cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)), configuration: None, configuration_subscription: None, focus_handle: cx.focus_handle(), @@ -2395,7 +2398,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - let selected_agent = AgentType::from(ext_agent); + let selected_agent = AgentType::from(ext_agent.clone()); if self.selected_agent != selected_agent { self.selected_agent = selected_agent; self.serialize(cx); @@ -2406,9 +2409,13 @@ impl AgentPanel { .is_some() .then(|| self.thread_store.clone()); + let connection_store = self.connection_store.clone(); + let server_view = cx.new(|cx| { crate::ConnectionView::new( server, + connection_store, + ext_agent, resume_session_id, cwd, title, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 8583e8977a719987b12770eec2d77408187a4e1f..d37dbdbbeb184cac31320b4bc9232354eb3dcc8d 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1,4 +1,5 @@ mod agent_configuration; +pub(crate) mod agent_connection_store; mod agent_diff; mod agent_model_selector; mod agent_panel; @@ -212,7 +213,7 @@ pub struct NewNativeAgentThreadFromSummary { } // TODO unify this with AgentType -#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ExternalAgent { NativeAgent, diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 07841c42215795ffcccf9f7e5ca684f42a59b498..3b07929813e5583164700905a1fa327f3ac9d964 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -8,7 +8,9 @@ use acp_thread::{AgentConnection, Plan}; use action_log::{ActionLog, ActionLogTelemetry}; use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore}; use agent_client_protocol::{self as acp, PromptCapabilities}; -use agent_servers::{AgentServer, AgentServerDelegate}; +use agent_servers::AgentServer; +#[cfg(test)] +use agent_servers::AgentServerDelegate; use agent_settings::{AgentProfileId, AgentSettings}; use anyhow::{Result, anyhow}; use arrayvec::ArrayVec; @@ -65,6 +67,7 @@ use super::entry_view_state::EntryViewState; use super::thread_history::ThreadHistory; use crate::ModeSelector; use crate::ModelSelectorPopover; +use crate::agent_connection_store::{AgentConnectionStore, ConnectionEntryEvent}; use crate::agent_diff::AgentDiff; use crate::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::message_editor::{MessageEditor, MessageEditorEvent}; @@ -73,10 +76,10 @@ use crate::ui::{AgentNotification, AgentNotificationEvent}; use crate::{ AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort, - EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAddContextMenu, - OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, RemoveFirstQueuedMessage, SendImmediately, - SendNextQueuedMessage, ToggleFastMode, ToggleProfileSelector, ToggleThinkingEffortMenu, - ToggleThinkingMode, UndoLastReject, + EditFirstQueuedMessage, ExpandMessageEditor, ExternalAgent, Follow, KeepAll, NewThread, + OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, + RemoveFirstQueuedMessage, SendImmediately, SendNextQueuedMessage, ToggleFastMode, + ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject, }; const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30); @@ -303,6 +306,8 @@ impl EventEmitter for ConnectionView {} pub struct ConnectionView { agent: Rc, + connection_store: Entity, + connection_key: ExternalAgent, agent_server_store: Entity, workspace: WeakEntity, project: Entity, @@ -414,6 +419,7 @@ pub struct ConnectedServerState { threads: HashMap>, connection: Rc, conversation: Entity, + _connection_entry_subscription: Subscription, } enum AuthState { @@ -434,9 +440,7 @@ impl AuthState { struct LoadingView { session_id: Option, - title: SharedString, _load_task: Task<()>, - _update_title_task: Task>, } impl ConnectedServerState { @@ -470,6 +474,8 @@ impl ConnectedServerState { impl ConnectionView { pub fn new( agent: Rc, + connection_store: Entity, + connection_key: ExternalAgent, resume_session_id: Option, cwd: Option, title: Option, @@ -509,6 +515,8 @@ impl ConnectionView { Self { agent: agent.clone(), + connection_store: connection_store.clone(), + connection_key: connection_key.clone(), agent_server_store, workspace, project: project.clone(), @@ -516,6 +524,8 @@ impl ConnectionView { prompt_store, server_state: Self::initial_state( agent.clone(), + connection_store, + connection_key, resume_session_id, cwd, title, @@ -558,6 +568,8 @@ impl ConnectionView { let state = Self::initial_state( self.agent.clone(), + self.connection_store.clone(), + self.connection_key.clone(), resume_session_id, cwd, title, @@ -584,6 +596,8 @@ impl ConnectionView { fn initial_state( agent: Rc, + connection_store: Entity, + connection_key: ExternalAgent, resume_session_id: Option, cwd: Option, title: Option, @@ -640,29 +654,31 @@ impl ConnectionView { .or_else(|| worktree_roots.first().cloned()) .unwrap_or_else(|| paths::home_dir().as_path().into()); - let (status_tx, mut status_rx) = watch::channel("Loading…".into()); - let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None); - let delegate = AgentServerDelegate::new( - project.read(cx).agent_server_store().clone(), - project.clone(), - Some(status_tx), - Some(new_version_available_tx), - ); + let connection_entry = connection_store.update(cx, |store, cx| { + store.request_connection(connection_key, agent.clone(), cx) + }); + + let connection_entry_subscription = + cx.subscribe(&connection_entry, |this, _entry, event, cx| match event { + ConnectionEntryEvent::NewVersionAvailable(version) => { + if let Some(thread) = this.active_thread() { + thread.update(cx, |thread, cx| { + thread.new_server_version_available = Some(version.clone()); + cx.notify(); + }); + } + } + }); + + let connect_result = connection_entry.read(cx).wait_for_connection(); - let connect_task = agent.connect(delegate, cx); let load_session_id = resume_session_id.clone(); let load_task = cx.spawn_in(window, async move |this, cx| { - let connection = match connect_task.await { + let connection = match connect_result.await { Ok(connection) => connection, Err(err) => { this.update_in(cx, |this, window, cx| { - if err.downcast_ref::().is_some() { - this.handle_load_error(load_session_id.clone(), err, window, cx); - } else if let Some(active) = this.active_thread() { - active.update(cx, |active, cx| active.handle_thread_error(err, cx)); - } else { - this.handle_load_error(load_session_id.clone(), err, window, cx); - } + this.handle_load_error(load_session_id.clone(), err, window, cx); cx.notify(); }) .log_err(); @@ -776,52 +792,27 @@ impl ConnectionView { active_id: Some(id.clone()), threads: HashMap::from_iter([(id, current)]), conversation, + _connection_entry_subscription: connection_entry_subscription, }), cx, ); } Err(err) => { - this.handle_load_error(load_session_id.clone(), err, window, cx); + this.handle_load_error( + load_session_id.clone(), + LoadError::Other(err.to_string().into()), + window, + cx, + ); } }; }) .log_err(); }); - cx.spawn(async move |this, cx| { - while let Ok(new_version) = new_version_available_rx.recv().await { - if let Some(new_version) = new_version { - this.update(cx, |this, cx| { - if let Some(thread) = this.active_thread() { - thread.update(cx, |thread, _cx| { - thread.new_server_version_available = Some(new_version.into()); - }); - } - cx.notify(); - }) - .ok(); - } - } - }) - .detach(); - - let loading_view = cx.new(|cx| { - let update_title_task = cx.spawn(async move |this, cx| { - loop { - let status = status_rx.recv().await?; - this.update(cx, |this: &mut LoadingView, cx| { - this.title = status; - cx.notify(); - })?; - } - }); - - LoadingView { - session_id: resume_session_id, - title: "Loading…".into(), - _load_task: load_task, - _update_title_task: update_title_task, - } + let loading_view = cx.new(|_cx| LoadingView { + session_id: resume_session_id, + _load_task: load_task, }); ServerState::Loading(loading_view) @@ -1099,6 +1090,7 @@ impl ConnectionView { threads: HashMap::default(), connection, conversation: cx.new(|_cx| Conversation::default()), + _connection_entry_subscription: Subscription::new(|| {}), }), cx, ); @@ -1111,7 +1103,7 @@ impl ConnectionView { fn handle_load_error( &mut self, session_id: Option, - err: anyhow::Error, + err: LoadError, window: &mut Window, cx: &mut Context, ) { @@ -1125,15 +1117,10 @@ impl ConnectionView { self.focus_handle.focus(window, cx) } } - let load_error = if let Some(load_err) = err.downcast_ref::() { - load_err.clone() - } else { - LoadError::Other(format!("{:#}", err).into()) - }; - self.emit_load_error_telemetry(&load_error); + self.emit_load_error_telemetry(&err); self.set_server_state( ServerState::LoadError { - error: load_error, + error: err, session_id, }, cx, @@ -1172,10 +1159,10 @@ impl ConnectionView { &self.workspace } - pub fn title(&self, cx: &App) -> SharedString { + pub fn title(&self, _cx: &App) -> SharedString { match &self.server_state { ServerState::Connected(_) => "New Thread".into(), - ServerState::Loading(loading_view) => loading_view.read(cx).title.clone(), + ServerState::Loading(_) => "Loading…".into(), ServerState::LoadError { error, .. } => match error { LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(), LoadError::FailedToInstall(_) => { @@ -2910,11 +2897,17 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); // Create history without an initial session list - it will be set after connection let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::default_response()), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, None, None, None, @@ -3010,11 +3003,17 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, Some(SessionId::new("resume-session")), None, None, @@ -3063,11 +3062,17 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let _thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, Some(SessionId::new("session-1")), Some(PathBuf::from("/project/subdir")), None, @@ -3114,11 +3119,17 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let _thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, Some(SessionId::new("session-1")), Some(PathBuf::from("/some/other/path")), None, @@ -3165,11 +3176,17 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let _thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, Some(SessionId::new("session-1")), Some(PathBuf::from("/project/../outside")), None, @@ -3477,12 +3494,18 @@ pub(crate) mod tests { // Set up thread view in workspace 1 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx))); let agent = StubAgentServer::default_response(); let thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(agent), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, None, None, None, @@ -3691,11 +3714,17 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(agent), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, None, None, None, @@ -4410,12 +4439,18 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let connection = Rc::new(StubAgentConnection::new()); let thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(StubAgentServer::new(connection.as_ref().clone())), + connection_store, + ExternalAgent::Custom { + name: "Test".into(), + }, None, None, None, diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index dc9d793a5ca5012ca2fe719f1e39bb3fc4fa6d66..e072037f1758e00e648dc46c7ee70599c4363eef 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -553,12 +553,8 @@ impl MentionSet { project.read(cx).fs().clone(), thread_store, )); - let delegate = AgentServerDelegate::new( - project.read(cx).agent_server_store().clone(), - project.clone(), - None, - None, - ); + let delegate = + AgentServerDelegate::new(project.read(cx).agent_server_store().clone(), None); let connection = server.connect(delegate, cx); cx.spawn(async move |_, cx| { let agent = connection.await?; diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index b1dbefa15a3dcaf64c36d027d68060d18f533def..4a7c2b03a4e03ddfa31bed24254ebe275a17c224 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -100,7 +100,6 @@ pub trait ExternalAgentServer { fn get_command( &mut self, extra_env: HashMap, - status_tx: Option>, new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task>; @@ -243,7 +242,6 @@ impl AgentServerStore { project_id: *project_id, upstream_client: upstream_client.clone(), name: agent_server_name.clone(), - status_tx: None, new_version_available_tx: None, }) as Box, @@ -347,7 +345,6 @@ impl AgentServerStore { pub fn init_remote(session: &AnyProtoClient) { session.add_entity_message_handler(Self::handle_external_agents_updated); - session.add_entity_message_handler(Self::handle_loading_status_updated); session.add_entity_message_handler(Self::handle_new_version_available); } @@ -695,57 +692,38 @@ impl AgentServerStore { .get_mut(&*envelope.payload.name) .map(|entry| entry.server.as_mut()) .with_context(|| format!("agent `{}` not found", envelope.payload.name))?; - let (status_tx, new_version_available_tx) = downstream_client - .clone() - .map(|(project_id, downstream_client)| { - let (status_tx, mut status_rx) = watch::channel(SharedString::from("")); - let (new_version_available_tx, mut new_version_available_rx) = - watch::channel(None); - cx.spawn({ - let downstream_client = downstream_client.clone(); - let name = envelope.payload.name.clone(); - async move |_, _| { - while let Some(status) = status_rx.recv().await.ok() { - downstream_client.send( - proto::ExternalAgentLoadingStatusUpdated { - project_id, - name: name.clone(), - status: status.to_string(), - }, - )?; + let new_version_available_tx = + downstream_client + .clone() + .map(|(project_id, downstream_client)| { + let (new_version_available_tx, mut new_version_available_rx) = + watch::channel(None); + cx.spawn({ + let name = envelope.payload.name.clone(); + async move |_, _| { + if let Some(version) = + new_version_available_rx.recv().await.ok().flatten() + { + downstream_client.send( + proto::NewExternalAgentVersionAvailable { + project_id, + name: name.clone(), + version, + }, + )?; + } + anyhow::Ok(()) } - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - cx.spawn({ - let name = envelope.payload.name.clone(); - async move |_, _| { - if let Some(version) = - new_version_available_rx.recv().await.ok().flatten() - { - downstream_client.send( - proto::NewExternalAgentVersionAvailable { - project_id, - name: name.clone(), - version, - }, - )?; - } - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - (status_tx, new_version_available_tx) - }) - .unzip(); + }) + .detach_and_log_err(cx); + new_version_available_tx + }); let mut extra_env = HashMap::default(); if no_browser { extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned()); } anyhow::Ok(agent.get_command( extra_env, - status_tx, new_version_available_tx, &mut cx.to_async(), )) @@ -782,13 +760,11 @@ impl AgentServerStore { }; let mut previous_entries = std::mem::take(&mut this.external_agents); - let mut status_txs = HashMap::default(); let mut new_version_available_txs = HashMap::default(); let mut metadata = HashMap::default(); for (name, mut entry) in previous_entries.drain() { if let Some(agent) = entry.server.downcast_mut::() { - status_txs.insert(name.clone(), agent.status_tx.take()); new_version_available_txs .insert(name.clone(), agent.new_version_available_tx.take()); } @@ -820,7 +796,6 @@ impl AgentServerStore { project_id: *project_id, upstream_client: upstream_client.clone(), name: agent_name.clone(), - status_tx: status_txs.remove(&agent_name).flatten(), new_version_available_tx: new_version_available_txs .remove(&agent_name) .flatten(), @@ -884,22 +859,6 @@ impl AgentServerStore { }) } - async fn handle_loading_status_updated( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result<()> { - this.update(&mut cx, |this, _| { - if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name) - && let Some(agent) = agent.server.downcast_mut::() - && let Some(status_tx) = &mut agent.status_tx - { - status_tx.send(envelope.payload.status.into()).ok(); - } - }); - Ok(()) - } - async fn handle_new_version_available( this: Entity, envelope: TypedEnvelope, @@ -936,7 +895,6 @@ struct RemoteExternalAgentServer { project_id: u64, upstream_client: Entity, name: ExternalAgentServerName, - status_tx: Option>, new_version_available_tx: Option>>, } @@ -944,14 +902,12 @@ impl ExternalAgentServer for RemoteExternalAgentServer { fn get_command( &mut self, extra_env: HashMap, - status_tx: Option>, new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { let project_id = self.project_id; let name = self.name.to_string(); let upstream_client = self.upstream_client.downgrade(); - self.status_tx = status_tx; self.new_version_available_tx = new_version_available_tx; cx.spawn(async move |cx| { let mut response = upstream_client @@ -1005,7 +961,6 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { fn get_command( &mut self, extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { @@ -1205,7 +1160,6 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { fn get_command( &mut self, extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { @@ -1386,7 +1340,6 @@ impl ExternalAgentServer for LocalRegistryNpxAgent { fn get_command( &mut self, extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { @@ -1453,7 +1406,6 @@ impl ExternalAgentServer for LocalCustomAgent { fn get_command( &mut self, extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { diff --git a/crates/project/tests/integration/ext_agent_tests.rs b/crates/project/tests/integration/ext_agent_tests.rs index f3c398a619a81ee81146de16f8e58b1093569e8a..40961cd0267db9effc897376de9531d5ceb6f463 100644 --- a/crates/project/tests/integration/ext_agent_tests.rs +++ b/crates/project/tests/integration/ext_agent_tests.rs @@ -10,7 +10,6 @@ impl ExternalAgentServer for NoopExternalAgent { fn get_command( &mut self, _extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, _cx: &mut AsyncApp, ) -> Task> { diff --git a/crates/project/tests/integration/extension_agent_tests.rs b/crates/project/tests/integration/extension_agent_tests.rs index eff41a99cab878336206f232450f3c1b490d1fc8..b45f76fbd6835f0cf94f8622df10c2eee3b3c9d3 100644 --- a/crates/project/tests/integration/extension_agent_tests.rs +++ b/crates/project/tests/integration/extension_agent_tests.rs @@ -26,7 +26,6 @@ impl ExternalAgentServer for NoopExternalAgent { fn get_command( &mut self, _extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, _cx: &mut AsyncApp, ) -> Task> { diff --git a/crates/proto/proto/ai.proto b/crates/proto/proto/ai.proto index 428d971c536f6e830e0c056372d311dc7ed7028f..8db36153b5ef75218f0c007e113f1c2c06ded7eb 100644 --- a/crates/proto/proto/ai.proto +++ b/crates/proto/proto/ai.proto @@ -222,7 +222,7 @@ message ExternalExtensionAgentsUpdated { message ExternalAgentLoadingStatusUpdated { uint64 project_id = 1; string name = 2; - string status = 3; + reserved 3; } message NewExternalAgentVersionAvailable { diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 7f9953c8a4e746d9586b663330badb38149cfb64..0f1d1e3769c405abce5ebf55818f19e64afadc82 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2028,7 +2028,6 @@ async fn test_remote_external_agent_server( .get_command( HashMap::from_iter([("OTHER_VAR".into(), "other-val".into())]), None, - None, &mut cx.to_async(), ) }) diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 36a8d1cf085e544d38d903fe63f514539287dcc5..e6b873704ffda9d241fec002eb0fdff0af979c48 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -47,4 +47,5 @@ fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } -workspace = { workspace = true, features = ["test-support"] } \ No newline at end of file +workspace = { workspace = true, features = ["test-support"] } +recent_projects = { workspace = true, features = ["test-support"] } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index ceb566f4c7b22acea44faa3b7f0bf3879d28b7ec..d5cf352665a8cd59bdd6a6b601248bce4a214e3b 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -2569,15 +2569,15 @@ mod tests { let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); // Open thread A and keep it generating. - let connection_a = StubAgentConnection::new(); - open_thread_with_connection(&panel, connection_a.clone(), cx); + 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_thread_to_store(&session_id_a, &path_list, cx).await; cx.update(|_, cx| { - connection_a.send_update( + connection.send_update( session_id_a.clone(), acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), cx, @@ -2586,11 +2586,10 @@ mod tests { cx.run_until_parked(); // Open thread B (idle, default response) — thread A goes to background. - let connection_b = StubAgentConnection::new(); - connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("Done".into()), )]); - open_thread_with_connection(&panel, connection_b, cx); + open_thread_with_connection(&panel, connection, cx); send_message(&panel, cx); let session_id_b = active_session_id(&panel, cx); From 074ca4cadf7390b77eb3ca6979632a6582d25e2b Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Tue, 10 Mar 2026 14:21:16 -0400 Subject: [PATCH 108/219] Enable diff stats in the git panel by default (#51215) Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [X] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Enabled `diff_stats` in the git panel by default. --- assets/settings/default.json | 4 ++-- crates/settings_content/src/settings_content.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 0a824bbe93a0d68a23d934a63eb1fdab1e2f1b02..d812673d9dac997df570625be3ea07cf1cb831dc 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -920,8 +920,8 @@ }, // Whether to show the addition/deletion change count next to each file in the Git panel. // - // Default: false - "diff_stats": false, + // Default: true + "diff_stats": true, }, "message_editor": { // Whether to automatically replace emoji shortcodes with emoji characters. diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 5a4e87c384d802f3de4c96c07f65cf163c3a6d1a..5b573a0f01dc7980abadeba5576b6e8e3553bfb4 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -622,7 +622,7 @@ pub struct GitPanelSettingsContent { /// Whether to show the addition/deletion change count next to each file in the Git panel. /// - /// Default: false + /// Default: true pub diff_stats: Option, } From e9e71431bb26ce554d4ed2024569745df6dd9047 Mon Sep 17 00:00:00 2001 From: Imamuzzaki Abu Salam Date: Wed, 11 Mar 2026 01:55:57 +0700 Subject: [PATCH 109/219] Add size to DiskState to detect file changes (#49436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This fix addresses the cross-platform root cause identified in issue #38109 where open buffers go stale or empty when external tools write files. ## The Problem The buffer's `file_updated()` method was only comparing `mtime` to determine if a buffer needed to be reloaded. This caused a race condition when external tools write files using `std::fs::write()`, which uses `O_TRUNC` and creates a brief window where the file is 0 bytes: 1. Scanner re-stats → sees 0 bytes, mtime T 2. `file_updated()` sees mtime changed → emits `ReloadNeeded` 3. Buffer reloads to empty, stamps `saved_mtime = T` 4. Tool finishes writing → file has content, but mtime is still T (or same-second granularity) 5. Scanner re-stats → mtime T matches `saved_mtime` → **no reload triggered** 6. Buffer permanently stuck empty ## The Fix Release Notes: - Add the file `size` to `DiskState::Present`, so that even when mtime stays the same, size changes (0 → N bytes) will trigger a reload. This is the same fix that was identified in the issue by @lex00. ## Changes - `crates/language/src/buffer.rs`: Add `size: u64` to `DiskState::Present`, add `size()` method - `crates/worktree/src/worktree.rs`: Pass size when constructing File and DiskState::Present - `crates/project/src/buffer_store.rs`: Pass size when constructing File - `crates/project/src/image_store.rs`: Pass size when constructing File - `crates/copilot/src/copilot.rs`: Update test mock ## Test plan - [ ] Open a file in Zed - [ ] Write to that file from an external tool (e.g., `echo "content" > file`) - [ ] Verify the buffer updates correctly without needing to reload Fixes #38109 --------- Co-authored-by: Claude Sonnet 4.5 Co-authored-by: Ben Kunkle Co-authored-by: Jakub Konka --- crates/copilot/src/copilot.rs | 1 + crates/language/src/buffer.rs | 16 +++++++++++++--- crates/project/src/buffer_store.rs | 5 ++++- crates/project/src/image_store.rs | 5 ++++- crates/worktree/src/worktree.rs | 10 ++++++++-- 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 3506672b2e79419a3a46cb0963af353a3a71730a..4a08cf2803aaa51a86d5dc7017c559bee1184c2e 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1779,6 +1779,7 @@ mod tests { fn disk_state(&self) -> language::DiskState { language::DiskState::Present { mtime: ::fs::MTime::from_seconds_and_nanos(100, 42), + size: 0, } } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index f92ae2419edf61aaa20643c3f87dac2f4af8bf4e..6724b5b1c2e6b666b7f0295685e40427279a0b30 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -435,7 +435,7 @@ pub enum DiskState { /// File created in Zed that has not been saved. New, /// File present on the filesystem. - Present { mtime: MTime }, + Present { mtime: MTime, size: u64 }, /// Deleted file that was previously present. Deleted, /// An old version of a file that was previously present @@ -448,7 +448,17 @@ impl DiskState { pub fn mtime(self) -> Option { match self { DiskState::New => None, - DiskState::Present { mtime } => Some(mtime), + DiskState::Present { mtime, .. } => Some(mtime), + DiskState::Deleted => None, + DiskState::Historic { .. } => None, + } + } + + /// Returns the file's size on disk in bytes. + pub fn size(self) -> Option { + match self { + DiskState::New => None, + DiskState::Present { size, .. } => Some(size), DiskState::Deleted => None, DiskState::Historic { .. } => None, } @@ -2377,7 +2387,7 @@ impl Buffer { }; match file.disk_state() { DiskState::New => false, - DiskState::Present { mtime } => match self.saved_mtime { + DiskState::Present { mtime, .. } => match self.saved_mtime { Some(saved_mtime) => { mtime.bad_is_greater_than(saved_mtime) && self.has_unsaved_edits() } diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index b9d1105ad02415699fa6a9bd1be8ec1f9c71271a..d2f05a119a1883a1ec744b40d4cdb467074d3c83 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -527,7 +527,10 @@ impl LocalBufferStore { let new_file = if let Some(entry) = snapshot_entry { File { disk_state: match entry.mtime { - Some(mtime) => DiskState::Present { mtime }, + Some(mtime) => DiskState::Present { + mtime, + size: entry.size, + }, None => old_file.disk_state, }, is_local: true, diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 654fb0344db4b7dc581234a5b446e8ac4d2b10ab..0ba9787d2e4144cb529756b15fc05ff72dab83c8 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -808,7 +808,10 @@ impl LocalImageStore { let new_file = if let Some(entry) = snapshot_entry { worktree::File { disk_state: match entry.mtime { - Some(mtime) => DiskState::Present { mtime }, + Some(mtime) => DiskState::Present { + mtime, + size: entry.size, + }, None => old_file.disk_state, }, is_local: true, diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 9e62beb3c375fb8d580be02382091cafe04d31e2..44ba4e752cff778b7918b9a29935d0f0e1ebb614 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -1322,6 +1322,7 @@ impl LocalWorktree { path, disk_state: DiskState::Present { mtime: metadata.mtime, + size: metadata.len, }, is_local: true, is_private, @@ -1378,6 +1379,7 @@ impl LocalWorktree { path, disk_state: DiskState::Present { mtime: metadata.mtime, + size: metadata.len, }, is_local: true, is_private, @@ -1575,6 +1577,7 @@ impl LocalWorktree { path, disk_state: DiskState::Present { mtime: metadata.mtime, + size: metadata.len, }, entry_id: None, is_local: true, @@ -3289,7 +3292,10 @@ impl File { worktree, path: entry.path.clone(), disk_state: if let Some(mtime) = entry.mtime { - DiskState::Present { mtime } + DiskState::Present { + mtime, + size: entry.size, + } } else { DiskState::New }, @@ -3318,7 +3324,7 @@ impl File { } else if proto.is_deleted { DiskState::Deleted } else if let Some(mtime) = proto.mtime.map(&Into::into) { - DiskState::Present { mtime } + DiskState::Present { mtime, size: 0 } } else { DiskState::New }; From 95aa4f274daf690c7c7048ba6c135c81af0d3347 Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Tue, 10 Mar 2026 20:34:42 +0100 Subject: [PATCH 110/219] git_graph: Add select first & last actions (#50956) This PR adds support for select first & last actions, as I was missing them badly :). **Example**: https://github.com/user-attachments/assets/709037e6-544c-4891-8f48-7808d556a5a2 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: - N/A --- crates/git_graph/src/git_graph.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 90ccf94f5f91720972a52d85bc506d12c1a528cb..12ed44cd7ec2de0e68d56642b756e1be824e19fe 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -15,7 +15,7 @@ use gpui::{ px, uniform_list, }; use language::line_diff; -use menu::{Cancel, SelectNext, SelectPrevious}; +use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::{ Project, git_store::{ @@ -1171,22 +1171,35 @@ impl GitGraph { cx.notify(); } - fn select_prev(&mut self, _: &SelectPrevious, _window: &mut Window, cx: &mut Context) { + fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { + self.select_entry(0, 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); } else { - self.select_entry(0, cx); + self.select_first(&SelectFirst, window, cx); } } fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { if let Some(selected_entry_idx) = &self.selected_entry_idx { - self.select_entry(selected_entry_idx.saturating_add(1), cx); + self.select_entry( + selected_entry_idx + .saturating_add(1) + .min(self.graph_data.commits.len().saturating_sub(1)), + cx, + ); } else { self.select_prev(&SelectPrevious, window, cx); } } + fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { + self.select_entry(self.graph_data.commits.len().saturating_sub(1), cx); + } + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { self.open_selected_commit_view(window, cx); } @@ -2260,8 +2273,10 @@ impl Render for GitGraph { this.open_selected_commit_view(window, cx); })) .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_prev)) .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) .child(content) .children(self.context_menu.as_ref().map(|(menu, position, _)| { From 7132b67962e2827f73f8486793ab5d5dcee53862 Mon Sep 17 00:00:00 2001 From: Justin Su Date: Tue, 10 Mar 2026 15:41:24 -0400 Subject: [PATCH 111/219] Normalize `line_comments` strings to have a trailing space (#51033) I did a search for `/^line_comments = .*[^\s\[]"/` to identify these 3 languages: - Git Commit - Go Mod - Go Work that don't add/remove a trailing space for inline comments. I couldn't find any indication that the absence of the trailing space is due to any peculiarity of these languages. --- For Git Commit, I should note that (strictly speaking) the comment character is a single `#` without a trailing space, as Git removes any line starting with the default comment character (`#`) (see https://git-scm.com/docs/git-config#Documentation/git-config.txt-corecommentChar). But I believe this change only affects whether `editor::ToggleComments` adds/removes the space, and not how the file is syntax highlighted. So for aesthetics and consistency, it should be better to add/remove the trailing space. --- Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Add/remove a space when toggling inline comments in Git Commit and Go Mod/Work languages --- crates/languages/src/gitcommit/config.toml | 2 +- crates/languages/src/gomod/config.toml | 2 +- crates/languages/src/gowork/config.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/languages/src/gitcommit/config.toml b/crates/languages/src/gitcommit/config.toml index c2421ce00613e5848aacab5d1230ab839c8b1388..83cd6f550e3f18c5d8cb61efa4d632ece6c1ad4d 100644 --- a/crates/languages/src/gitcommit/config.toml +++ b/crates/languages/src/gitcommit/config.toml @@ -7,7 +7,7 @@ path_suffixes = [ "NOTES_EDITMSG", "EDIT_DESCRIPTION", ] -line_comments = ["#"] +line_comments = ["# "] brackets = [ { start = "(", end = ")", close = true, newline = false }, { start = "`", end = "`", close = true, newline = false }, diff --git a/crates/languages/src/gomod/config.toml b/crates/languages/src/gomod/config.toml index e70c9358bfc6f467b69897fa6d20dd9ae0082f9a..d151db961106591c07850034f669304db7edb650 100644 --- a/crates/languages/src/gomod/config.toml +++ b/crates/languages/src/gomod/config.toml @@ -2,7 +2,7 @@ name = "Go Mod" code_fence_block_name = "go.mod" grammar = "gomod" path_suffixes = ["mod"] -line_comments = ["//"] +line_comments = ["// "] autoclose_before = ")" brackets = [ { start = "(", end = ")", close = true, newline = true} diff --git a/crates/languages/src/gowork/config.toml b/crates/languages/src/gowork/config.toml index 68beb073ab64df4761bf3f87a88f28a0608656f7..90e62f0cf102306b258e9efd56bb9ae9838f0f27 100644 --- a/crates/languages/src/gowork/config.toml +++ b/crates/languages/src/gowork/config.toml @@ -2,7 +2,7 @@ name = "Go Work" code_fence_block_name = "gowork" grammar = "gowork" path_suffixes = ["work"] -line_comments = ["//"] +line_comments = ["// "] autoclose_before = ")" brackets = [ { start = "(", end = ")", close = true, newline = true} From 9ddaee0ac2e54d27d543aa0e74001851da157944 Mon Sep 17 00:00:00 2001 From: Danny Milosavljevic Date: Tue, 10 Mar 2026 21:25:12 +0100 Subject: [PATCH 112/219] sqlez: Open named in-memory databases as SQLite URIs (#50967) Closes #51011 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: - N/A *or* Added/Fixed/Improved ... --- crates/sqlez/src/connection.rs | 103 +++++++++++++++++++-- crates/sqlez/src/thread_safe_connection.rs | 101 ++++++++++++-------- 2 files changed, 155 insertions(+), 49 deletions(-) diff --git a/crates/sqlez/src/connection.rs b/crates/sqlez/src/connection.rs index 53f0d4e2614f340cc0563d5cd9374bdc3626d9bb..fb3194aaf428f9848b858b104e94de60765d6f9a 100644 --- a/crates/sqlez/src/connection.rs +++ b/crates/sqlez/src/connection.rs @@ -18,7 +18,7 @@ pub struct Connection { unsafe impl Send for Connection {} impl Connection { - pub(crate) fn open(uri: &str, persistent: bool) -> Result { + fn open_with_flags(uri: &str, persistent: bool, flags: i32) -> Result { let mut connection = Self { sqlite3: ptr::null_mut(), persistent, @@ -26,7 +26,6 @@ impl Connection { _sqlite: PhantomData, }; - let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE; unsafe { sqlite3_open_v2( CString::new(uri)?.as_ptr(), @@ -44,6 +43,14 @@ impl Connection { Ok(connection) } + pub(crate) fn open(uri: &str, persistent: bool) -> Result { + Self::open_with_flags( + uri, + persistent, + SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE, + ) + } + /// Attempts to open the database at uri. If it fails, a shared memory db will be opened /// instead. pub fn open_file(uri: &str) -> Self { @@ -51,13 +58,17 @@ impl Connection { } pub fn open_memory(uri: Option<&str>) -> Self { - let in_memory_path = if let Some(uri) = uri { - format!("file:{}?mode=memory&cache=shared", uri) + if let Some(uri) = uri { + let in_memory_path = format!("file:{}?mode=memory&cache=shared", uri); + return Self::open_with_flags( + &in_memory_path, + false, + SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE | SQLITE_OPEN_URI, + ) + .expect("Could not create fallback in memory db"); } else { - ":memory:".to_string() - }; - - Self::open(&in_memory_path, false).expect("Could not create fallback in memory db") + Self::open(":memory:", false).expect("Could not create fallback in memory db") + } } pub fn persistent(&self) -> bool { @@ -265,9 +276,50 @@ impl Drop for Connection { mod test { use anyhow::Result; use indoc::indoc; + use std::{ + fs, + sync::atomic::{AtomicUsize, Ordering}, + }; use crate::connection::Connection; + static NEXT_NAMED_MEMORY_DB_ID: AtomicUsize = AtomicUsize::new(0); + + fn unique_named_memory_db(prefix: &str) -> String { + format!( + "{prefix}_{}_{}", + std::process::id(), + NEXT_NAMED_MEMORY_DB_ID.fetch_add(1, Ordering::Relaxed) + ) + } + + fn literal_named_memory_paths(name: &str) -> [String; 3] { + let main = format!("file:{name}?mode=memory&cache=shared"); + [main.clone(), format!("{main}-wal"), format!("{main}-shm")] + } + + struct NamedMemoryPathGuard { + paths: [String; 3], + } + + impl NamedMemoryPathGuard { + fn new(name: &str) -> Self { + let paths = literal_named_memory_paths(name); + for path in &paths { + let _ = fs::remove_file(path); + } + Self { paths } + } + } + + impl Drop for NamedMemoryPathGuard { + fn drop(&mut self) { + for path in &self.paths { + let _ = fs::remove_file(path); + } + } + } + #[test] fn string_round_trips() -> Result<()> { let connection = Connection::open_memory(Some("string_round_trips")); @@ -382,6 +434,41 @@ mod test { assert_eq!(read_blobs, vec![blob]); } + #[test] + fn named_memory_connections_do_not_create_literal_backing_files() { + let name = unique_named_memory_db("named_memory_connections_do_not_create_backing_files"); + let guard = NamedMemoryPathGuard::new(&name); + + let connection1 = Connection::open_memory(Some(&name)); + connection1 + .exec(indoc! {" + CREATE TABLE shared ( + value INTEGER + )"}) + .unwrap()() + .unwrap(); + connection1 + .exec("INSERT INTO shared (value) VALUES (7)") + .unwrap()() + .unwrap(); + + let connection2 = Connection::open_memory(Some(&name)); + assert_eq!( + connection2 + .select_row::("SELECT value FROM shared") + .unwrap()() + .unwrap(), + Some(7) + ); + + for path in &guard.paths { + assert!( + fs::metadata(path).is_err(), + "named in-memory database unexpectedly created backing file {path}" + ); + } + } + #[test] fn multi_step_statement_works() { let connection = Connection::open_memory(Some("multi_step_statement_works")); diff --git a/crates/sqlez/src/thread_safe_connection.rs b/crates/sqlez/src/thread_safe_connection.rs index 966f14a9c2f244780da7190aebac88e95c7ac068..7b3630cdf65f900469e3d7544f3bd75b33250625 100644 --- a/crates/sqlez/src/thread_safe_connection.rs +++ b/crates/sqlez/src/thread_safe_connection.rs @@ -7,12 +7,15 @@ use std::{ ops::Deref, sync::{Arc, LazyLock}, thread, + time::Duration, }; use thread_local::ThreadLocal; use crate::{connection::Connection, domain::Migrator, util::UnboundedSyncSender}; const MIGRATION_RETRIES: usize = 10; +const CONNECTION_INITIALIZE_RETRIES: usize = 50; +const CONNECTION_INITIALIZE_RETRY_DELAY: Duration = Duration::from_millis(1); type QueuedWrite = Box; type WriteQueue = Box; @@ -197,21 +200,54 @@ impl ThreadSafeConnection { Self::open_shared_memory(uri) }; + if let Some(initialize_query) = connection_initialize_query { + let mut last_error = None; + let initialized = (0..CONNECTION_INITIALIZE_RETRIES).any(|attempt| { + match connection + .exec(initialize_query) + .and_then(|mut statement| statement()) + { + Ok(()) => true, + Err(err) + if is_schema_lock_error(&err) + && attempt + 1 < CONNECTION_INITIALIZE_RETRIES => + { + last_error = Some(err); + thread::sleep(CONNECTION_INITIALIZE_RETRY_DELAY); + false + } + Err(err) => { + panic!( + "Initialize query failed to execute: {}\n\nCaused by:\n{err:#}", + initialize_query + ) + } + } + }); + + if !initialized { + let err = last_error + .expect("connection initialization retries should record the last error"); + panic!( + "Initialize query failed to execute after retries: {}\n\nCaused by:\n{err:#}", + initialize_query + ); + } + } + // Disallow writes on the connection. The only writes allowed for thread safe connections // are from the background thread that can serialize them. *connection.write.get_mut() = false; - if let Some(initialize_query) = connection_initialize_query { - connection.exec(initialize_query).unwrap_or_else(|_| { - panic!("Initialize query failed to execute: {}", initialize_query) - })() - .unwrap() - } - connection } } +fn is_schema_lock_error(err: &anyhow::Error) -> bool { + let message = format!("{err:#}"); + message.contains("database schema is locked") || message.contains("database is locked") +} + impl ThreadSafeConnection { /// Special constructor for ThreadSafeConnection which disallows db initialization and migrations. /// This allows construction to be infallible and not write to the db. @@ -282,7 +318,7 @@ mod test { use indoc::indoc; use std::ops::Deref; - use std::thread; + use std::{thread, time::Duration}; use crate::{domain::Domain, thread_safe_connection::ThreadSafeConnection}; @@ -318,38 +354,21 @@ mod test { } #[test] - #[should_panic] - fn wild_zed_lost_failure() { - enum TestWorkspace {} - impl Domain for TestWorkspace { - const NAME: &str = "workspace"; - - const MIGRATIONS: &[&str] = &[" - CREATE TABLE workspaces( - workspace_id INTEGER PRIMARY KEY, - dock_visible INTEGER, -- Boolean - dock_anchor TEXT, -- Enum: 'Bottom' / 'Right' / 'Expanded' - dock_pane INTEGER, -- NULL indicates that we don't have a dock pane yet - timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, - FOREIGN KEY(dock_pane) REFERENCES panes(pane_id), - FOREIGN KEY(active_pane) REFERENCES panes(pane_id) - ) STRICT; - - CREATE TABLE panes( - pane_id INTEGER PRIMARY KEY, - workspace_id INTEGER NOT NULL, - active INTEGER NOT NULL, -- Boolean - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) - ON DELETE CASCADE - ON UPDATE CASCADE - ) STRICT; - "]; - } - - let builder = - ThreadSafeConnection::builder::("wild_zed_lost_failure", false) - .with_connection_initialize_query("PRAGMA FOREIGN_KEYS=true"); - - smol::block_on(builder.build()).unwrap(); + fn connection_initialize_query_retries_transient_schema_lock() { + let name = "connection_initialize_query_retries_transient_schema_lock"; + let locking_connection = crate::connection::Connection::open_memory(Some(name)); + locking_connection.exec("BEGIN IMMEDIATE").unwrap()().unwrap(); + locking_connection + .exec("CREATE TABLE test(col TEXT)") + .unwrap()() + .unwrap(); + + let releaser = thread::spawn(move || { + thread::sleep(Duration::from_millis(10)); + locking_connection.exec("ROLLBACK").unwrap()().unwrap(); + }); + + ThreadSafeConnection::create_connection(false, name, Some("PRAGMA FOREIGN_KEYS=true")); + releaser.join().unwrap(); } } From 38fa78cec7afbe24259ba30b5b499a90b348d378 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Tue, 10 Mar 2026 22:41:43 +0100 Subject: [PATCH 113/219] ci: Update workflows/scripts for deploying collab to use clang (#51224) Release Notes: - N/A --- .github/workflows/deploy_collab.yml | 6 ++++++ Dockerfile-collab | 6 +++++- tooling/xtask/src/tasks/workflows/deploy_collab.rs | 10 +++++----- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 89fb6980b65f2d09a6571f140ab016a710be230f..0d98438c9e3029f85cc37cb4e57f6c9e24df43b0 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -12,6 +12,9 @@ jobs: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') name: Check formatting and Clippy lints runs-on: namespace-profile-16x32-ubuntu-2204 + env: + CC: clang + CXX: clang++ steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 @@ -42,6 +45,9 @@ jobs: - style name: Run tests runs-on: namespace-profile-16x32-ubuntu-2204 + env: + CC: clang + CXX: clang++ steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 diff --git a/Dockerfile-collab b/Dockerfile-collab index 63359334906b58c560c0ed6acc6378259ccbd5c5..50af874200a6ef3bc3c882b7d08257ec41f944de 100644 --- a/Dockerfile-collab +++ b/Dockerfile-collab @@ -14,8 +14,12 @@ ARG GITHUB_SHA ENV GITHUB_SHA=$GITHUB_SHA # Also add `cmake`, since we need it to build `wasmtime`. +# clang is needed because `webrtc-sys` uses Clang-specific compiler flags. RUN apt-get update; \ - apt-get install -y --no-install-recommends cmake + apt-get install -y --no-install-recommends cmake clang + +ENV CC=clang +ENV CXX=clang++ RUN --mount=type=cache,target=./script/node_modules \ --mount=type=cache,target=/usr/local/cargo/registry \ diff --git a/tooling/xtask/src/tasks/workflows/deploy_collab.rs b/tooling/xtask/src/tasks/workflows/deploy_collab.rs index 300680f95b880e9adb14dffd2572d80cb08fd63c..a13e5684f615e1c219e131f7308f6e021e89ac9f 100644 --- a/tooling/xtask/src/tasks/workflows/deploy_collab.rs +++ b/tooling/xtask/src/tasks/workflows/deploy_collab.rs @@ -3,7 +3,7 @@ use indoc::indoc; use crate::tasks::workflows::runners::{self, Platform}; use crate::tasks::workflows::steps::{ - self, CommonJobConditions, FluentBuilder as _, NamedJob, dependant_job, named, + self, CommonJobConditions, FluentBuilder as _, NamedJob, dependant_job, named, use_clang, }; use crate::tasks::workflows::vars; @@ -23,7 +23,7 @@ pub(crate) fn deploy_collab() -> Workflow { } fn style() -> NamedJob { - named::job( + named::job(use_clang( dependant_job(&[]) .name("Check formatting and Clippy lints") .with_repository_owner_guard() @@ -34,7 +34,7 @@ fn style() -> NamedJob { .map(steps::install_linux_dependencies) .add_step(steps::cargo_fmt()) .add_step(steps::clippy(Platform::Linux)), - ) + )) } fn tests(deps: &[&NamedJob]) -> NamedJob { @@ -42,7 +42,7 @@ fn tests(deps: &[&NamedJob]) -> NamedJob { named::bash("cargo nextest run --package collab --no-fail-fast") } - named::job( + named::job(use_clang( dependant_job(deps) .name("Run tests") .runs_on(runners::LINUX_XL) @@ -65,7 +65,7 @@ fn tests(deps: &[&NamedJob]) -> NamedJob { .add_step(steps::cargo_install_nextest()) .add_step(steps::clear_target_dir_if_large(Platform::Linux)) .add_step(run_collab_tests()), - ) + )) } fn publish(deps: &[&NamedJob]) -> NamedJob { From 51ba321b4ac95ab4ff3c58c99eb4e849da622f90 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 10 Mar 2026 18:36:49 -0400 Subject: [PATCH 114/219] collab: Update test database schema (#51233) This PR updates the database schema for Collab tests, along with a warning to not modify the file by hand. Release Notes: - N/A --- .../migrations/20251208000000_test_schema.sql | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/collab/migrations/20251208000000_test_schema.sql b/crates/collab/migrations/20251208000000_test_schema.sql index 0f4e4f2d2e3925ea1e4d2b964c5e4f159f393b4f..53543a23f710e49084a7b1127e7b743df6ef97c8 100644 --- a/crates/collab/migrations/20251208000000_test_schema.sql +++ b/crates/collab/migrations/20251208000000_test_schema.sql @@ -1,3 +1,6 @@ +-- This file is auto-generated. Do not modify it by hand. +-- To regenerate, run `cargo xtask db dump-schema app --collab` from the Cloud repository. + CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public; CREATE TABLE public.breakpoints ( @@ -315,10 +318,10 @@ CREATE TABLE public.project_repository_statuses ( status_kind integer NOT NULL, first_status integer, second_status integer, - lines_added integer, - lines_deleted integer, scan_id bigint NOT NULL, - is_deleted boolean NOT NULL + is_deleted boolean NOT NULL, + lines_added integer, + lines_deleted integer ); CREATE TABLE public.projects ( @@ -706,6 +709,8 @@ CREATE INDEX trigram_index_extensions_name ON public.extensions USING gin (name CREATE INDEX trigram_index_users_on_github_login ON public.users USING gin (github_login public.gin_trgm_ops); +CREATE INDEX trigram_index_users_on_name ON public.users USING gin (name public.gin_trgm_ops); + CREATE UNIQUE INDEX uix_channels_parent_path_name ON public.channels USING btree (parent_path, name) WHERE ((parent_path IS NOT NULL) AND (parent_path <> ''::text)); CREATE UNIQUE INDEX uix_users_on_github_user_id ON public.users USING btree (github_user_id); @@ -753,7 +758,7 @@ ALTER TABLE ONLY public.contacts ADD CONSTRAINT contacts_user_id_b_fkey FOREIGN KEY (user_id_b) REFERENCES public.users(id) ON DELETE CASCADE; ALTER TABLE ONLY public.contributors - ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; ALTER TABLE ONLY public.extension_versions ADD CONSTRAINT extension_versions_extension_id_fkey FOREIGN KEY (extension_id) REFERENCES public.extensions(id); From 7bf73098a7d90f2cc3ec0293bc052a81888875e9 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 10 Mar 2026 18:53:38 -0400 Subject: [PATCH 115/219] danger: Add a check for changing Collab database schemas (#51234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a Danger check to remind contributors that any database changes for Collab need to be done via a migration in the Cloud repo: Screenshot 2026-03-10 at 6 39 21 PM Release Notes: - N/A --- script/danger/dangerfile.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/script/danger/dangerfile.ts b/script/danger/dangerfile.ts index b604a42e45ac7d276a1f278bd2e9727daa98c375..c1ca883f3e910f434f686985d2c94df22986a029 100644 --- a/script/danger/dangerfile.ts +++ b/script/danger/dangerfile.ts @@ -61,6 +61,25 @@ if (includesIssueUrl) { ); } +const MIGRATION_SCHEMA_FILES = [ + "crates/collab/migrations/20251208000000_test_schema.sql", + "crates/collab/migrations.sqlite/20221109000000_test_schema.sql", +]; + +const modifiedSchemaFiles = danger.git.modified_files.filter((file) => + MIGRATION_SCHEMA_FILES.some((schemaFilePath) => file.endsWith(schemaFilePath)), +); + +if (modifiedSchemaFiles.length > 0) { + warn( + [ + "This PR modifies database schema files.", + "", + "If you are making database changes, a migration needs to be added in the Cloud repository.", + ].join("\n"), + ); +} + const FIXTURE_CHANGE_ATTESTATION = "Changes to test fixtures are intentional and necessary."; const FIXTURES_PATHS = ["crates/assistant_tools/src/edit_agent/evals/fixtures"]; From b13a8e8fe1899ad27e03a5a4ebdd5bed50a50128 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:05:32 -0300 Subject: [PATCH 116/219] agent_ui: Enable mentioning branch diff with main (#51235) As a follow up to the possibility of sending the branch diff to an agent review, this PR enables directly @-mentioning the content of your diff with main to the agent. Here's a quick video of it: https://github.com/user-attachments/assets/f27b7287-c9b9-4ccf-875e-4ac6ce4cd8ad Release Notes: - Agent: Enabled mentioning the branch diff with main. --- crates/agent_ui/src/completion_provider.rs | 134 ++++++++++++++++++++- crates/agent_ui/src/mention_set.rs | 45 ++++++- crates/agent_ui/src/message_editor.rs | 1 + 3 files changed, 175 insertions(+), 5 deletions(-) diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 40ad7bc729269d5dae3364ecf3e0de6e5ee5b0ec..d8c45755413ffb14433e3eeb4309e869de195a75 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -64,6 +64,7 @@ pub(crate) enum PromptContextType { Thread, Rules, Diagnostics, + BranchDiff, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -102,6 +103,7 @@ impl TryFrom<&str> for PromptContextType { "thread" => Ok(Self::Thread), "rule" => Ok(Self::Rules), "diagnostics" => Ok(Self::Diagnostics), + "diff" => Ok(Self::BranchDiff), _ => Err(format!("Invalid context picker mode: {}", value)), } } @@ -116,6 +118,7 @@ impl PromptContextType { Self::Thread => "thread", Self::Rules => "rule", Self::Diagnostics => "diagnostics", + Self::BranchDiff => "branch diff", } } @@ -127,6 +130,7 @@ impl PromptContextType { Self::Thread => "Threads", Self::Rules => "Rules", Self::Diagnostics => "Diagnostics", + Self::BranchDiff => "Branch Diff", } } @@ -138,6 +142,7 @@ impl PromptContextType { Self::Thread => IconName::Thread, Self::Rules => IconName::Reader, Self::Diagnostics => IconName::Warning, + Self::BranchDiff => IconName::GitBranch, } } } @@ -150,6 +155,12 @@ pub(crate) enum Match { Fetch(SharedString), Rules(RulesContextEntry), Entry(EntryMatch), + BranchDiff(BranchDiffMatch), +} + +#[derive(Debug, Clone)] +pub struct BranchDiffMatch { + pub base_ref: SharedString, } impl Match { @@ -162,6 +173,7 @@ impl Match { Match::Symbol(_) => 1., Match::Rules(_) => 1., Match::Fetch(_) => 1., + Match::BranchDiff(_) => 1., } } } @@ -781,6 +793,47 @@ impl PromptCompletionProvider { } } + fn build_branch_diff_completion( + base_ref: SharedString, + source_range: Range, + source: Arc, + editor: WeakEntity, + mention_set: WeakEntity, + workspace: Entity, + cx: &mut App, + ) -> Completion { + let uri = MentionUri::GitDiff { + base_ref: base_ref.to_string(), + }; + let crease_text: SharedString = format!("Branch Diff (vs {})", base_ref).into(); + let display_text = format!("@{}", crease_text); + let new_text = format!("[{}]({}) ", display_text, uri.to_uri()); + let new_text_len = new_text.len(); + let icon_path = uri.icon_path(cx); + + Completion { + replace_range: source_range.clone(), + new_text, + label: CodeLabel::plain(crease_text.to_string(), None), + documentation: None, + source: project::CompletionSource::Custom, + icon_path: Some(icon_path), + match_start: None, + snippet_deduplication_key: None, + insert_text_mode: None, + confirm: Some(confirm_completion_callback( + crease_text, + source_range.start, + new_text_len - 1, + uri, + source, + editor, + mention_set, + workspace, + )), + } + } + fn search_slash_commands(&self, query: String, cx: &mut App) -> Task> { let commands = self.source.available_commands(cx); if commands.is_empty() { @@ -812,6 +865,27 @@ impl PromptCompletionProvider { }) } + fn fetch_branch_diff_match( + &self, + workspace: &Entity, + cx: &mut App, + ) -> Option>> { + let project = workspace.read(cx).project().clone(); + let repo = project.read(cx).active_repository(cx)?; + + let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false)); + + Some(cx.spawn(async move |_cx| { + let base_ref = default_branch_receiver + .await + .ok() + .and_then(|r| r.ok()) + .flatten()?; + + Some(BranchDiffMatch { base_ref }) + })) + } + fn search_mentions( &self, mode: Option, @@ -892,6 +966,8 @@ impl PromptCompletionProvider { Some(PromptContextType::Diagnostics) => Task::ready(Vec::new()), + Some(PromptContextType::BranchDiff) => Task::ready(Vec::new()), + None if query.is_empty() => { let recent_task = self.recent_context_picker_entries(&workspace, cx); let entries = self @@ -905,9 +981,25 @@ impl PromptCompletionProvider { }) .collect::>(); + let branch_diff_task = if self + .source + .supports_context(PromptContextType::BranchDiff, cx) + { + self.fetch_branch_diff_match(&workspace, cx) + } else { + None + }; + cx.spawn(async move |_cx| { let mut matches = recent_task.await; matches.extend(entries); + + if let Some(branch_diff_task) = branch_diff_task { + if let Some(branch_diff_match) = branch_diff_task.await { + matches.push(Match::BranchDiff(branch_diff_match)); + } + } + matches }) } @@ -924,7 +1016,16 @@ impl PromptCompletionProvider { .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword())) .collect::>(); - cx.background_spawn(async move { + let branch_diff_task = if self + .source + .supports_context(PromptContextType::BranchDiff, cx) + { + self.fetch_branch_diff_match(&workspace, cx) + } else { + None + }; + + cx.spawn(async move |cx| { let mut matches = search_files_task .await .into_iter() @@ -949,6 +1050,26 @@ impl PromptCompletionProvider { }) })); + if let Some(branch_diff_task) = branch_diff_task { + let branch_diff_keyword = PromptContextType::BranchDiff.keyword(); + let branch_diff_matches = fuzzy::match_strings( + &[StringMatchCandidate::new(0, branch_diff_keyword)], + &query, + false, + true, + 1, + &Arc::new(AtomicBool::default()), + cx.background_executor().clone(), + ) + .await; + + if !branch_diff_matches.is_empty() { + if let Some(branch_diff_match) = branch_diff_task.await { + matches.push(Match::BranchDiff(branch_diff_match)); + } + } + } + matches.sort_by(|a, b| { b.score() .partial_cmp(&a.score()) @@ -1364,6 +1485,17 @@ impl CompletionProvider for PromptCompletio cx, ) } + Match::BranchDiff(branch_diff) => { + Some(Self::build_branch_diff_completion( + branch_diff.base_ref, + source_range.clone(), + source.clone(), + editor.clone(), + mention_set.clone(), + workspace.clone(), + cx, + )) + } }) .collect::>() }); diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index e072037f1758e00e648dc46c7ee70599c4363eef..1cb22af6a3fd15df5eeedc5018deaeff77a1dbff 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -147,10 +147,12 @@ impl MentionSet { include_errors, include_warnings, } => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx), + MentionUri::GitDiff { base_ref } => { + self.confirm_mention_for_git_diff(base_ref.into(), cx) + } MentionUri::PastedImage | MentionUri::Selection { .. } | MentionUri::TerminalSelection { .. } - | MentionUri::GitDiff { .. } | MentionUri::MergeConflict { .. } => { Task::ready(Err(anyhow!("Unsupported mention URI type for paste"))) } @@ -298,9 +300,8 @@ impl MentionSet { debug_panic!("unexpected terminal URI"); Task::ready(Err(anyhow!("unexpected terminal URI"))) } - MentionUri::GitDiff { .. } => { - debug_panic!("unexpected git diff URI"); - Task::ready(Err(anyhow!("unexpected git diff URI"))) + MentionUri::GitDiff { base_ref } => { + self.confirm_mention_for_git_diff(base_ref.into(), cx) } MentionUri::MergeConflict { .. } => { debug_panic!("unexpected merge conflict URI"); @@ -602,6 +603,42 @@ impl MentionSet { }) }) } + + fn confirm_mention_for_git_diff( + &self, + base_ref: SharedString, + cx: &mut Context, + ) -> Task> { + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow!("project not found"))); + }; + + let Some(repo) = project.read(cx).active_repository(cx) else { + return Task::ready(Err(anyhow!("no active repository"))); + }; + + let diff_receiver = repo.update(cx, |repo, cx| { + repo.diff( + git::repository::DiffType::MergeBase { base_ref: base_ref }, + cx, + ) + }); + + cx.spawn(async move |_, _| { + let diff_text = diff_receiver.await??; + if diff_text.is_empty() { + Ok(Mention::Text { + content: "No changes found in branch diff.".into(), + tracked_buffers: Vec::new(), + }) + } else { + Ok(Mention::Text { + content: diff_text, + tracked_buffers: Vec::new(), + }) + } + }) + } } #[cfg(test)] diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 933e24e83c0450dcbdde27d49abebb7fda2fa119..6c2628f9d37efd0531d5663ac4b1d27d9ae5ae0f 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -80,6 +80,7 @@ impl PromptCompletionProviderDelegate for Entity { PromptContextType::Diagnostics, PromptContextType::Fetch, PromptContextType::Rules, + PromptContextType::BranchDiff, ]); } supported From e7a659964e1fb5cb386e7321e299fe0d2ce7e806 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:12:26 -0300 Subject: [PATCH 117/219] ui: Fix end_hover gradient overlay in `ListItem` (#51237) This PR adds a bool method to the `ListItem` that allows to turn on the gradient overlay in the `end_hover_slot`. Places that are not the sidebar, at least at the moment, don't need it. And with the previous code, they were getting it, which felt wrong. Release Notes: - N/A --- crates/sidebar/src/sidebar.rs | 20 ++++---------------- crates/ui/src/components/list/list_item.rs | 13 ++++++++++++- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index d5cf352665a8cd59bdd6a6b601248bce4a214e3b..dd1dcab9ee7b5c6de25630b9f0b8fcebcdad7cb2 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -19,9 +19,8 @@ use std::mem; use theme::{ActiveTheme, ThemeSettings}; use ui::utils::TRAFFIC_LIGHT_PADDING; use ui::{ - AgentThreadStatus, ButtonStyle, GradientFade, HighlightedLabel, IconButtonShape, KeyBinding, - ListItem, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, - prelude::*, + AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, + PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::path_list::PathList; use workspace::{ @@ -795,17 +794,6 @@ impl Sidebar { .into_any_element() }; - let color = cx.theme().colors(); - let base_bg = if is_active_workspace { - color.ghost_element_selected - } else { - color.panel_background - }; - let gradient_overlay = - GradientFade::new(base_bg, color.element_hover, color.element_active) - .width(px(48.0)) - .group_name(group_name.clone()); - ListItem::new(id) .group_name(group_name) .toggle_state(is_active_workspace) @@ -822,9 +810,9 @@ impl Sidebar { .size(IconSize::Small) .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))), ) - .child(label) - .child(gradient_overlay), + .child(label), ) + .end_hover_gradient_overlay(true) .end_hover_slot( h_flex() .when(workspace_count > 1, |this| { diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index dc2fc76a06c29c72457d385effd06ea71e5f9625..01e88e1fe666fa2038b05af055a0e02b195e9bac 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -31,6 +31,9 @@ pub struct ListItem { /// A slot for content that appears on hover after the children /// It will obscure the `end_slot` when visible. end_hover_slot: Option, + /// When true, renders a gradient fade overlay before the `end_hover_slot` + /// to smoothly truncate overflowing content. + end_hover_gradient_overlay: bool, toggle: Option, inset: bool, on_click: Option>, @@ -60,6 +63,7 @@ impl ListItem { start_slot: None, end_slot: None, end_hover_slot: None, + end_hover_gradient_overlay: false, toggle: None, inset: false, on_click: None, @@ -166,6 +170,11 @@ impl ListItem { self } + pub fn end_hover_gradient_overlay(mut self, show: bool) -> Self { + self.end_hover_gradient_overlay = show; + self + } + pub fn outlined(mut self) -> Self { self.outlined = true; self @@ -362,7 +371,9 @@ impl RenderOnce for ListItem { .right(DynamicSpacing::Base06.rems(cx)) .top_0() .visible_on_hover("list_item") - .child(end_hover_gradient_overlay) + .when(self.end_hover_gradient_overlay, |this| { + this.child(end_hover_gradient_overlay) + }) .child(end_hover_slot), ) }), From 86a757237ec4f455911738428d36e462ca9fdabf Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:30:57 +0100 Subject: [PATCH 118/219] ui: Add close and confirm button to breakpoint edit prompt block (#51239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes a problem with our breakpoint prompt edit component where there was no way through the mouse to close/confirm the edit a user was making. ### Before image ### After Screenshot 2026-03-11 at 12 16 38 AM Before you mark this PR as ready for review, make sure that you have: - [ ] 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: - N/A --- crates/editor/src/editor.rs | 58 +++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 28c200c22ab01f6e691ea52d6463c9d8be530e8c..aabf16d2b64846388b6b1c0903e280e9f465a41d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -209,6 +209,7 @@ use theme::{ use ui::{ Avatar, ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName, IconSize, Indicator, Key, Tooltip, h_flex, prelude::*, scrollbars::ScrollbarAutoHide, + utils::WithRemSize, }; use ui_input::ErasedEditor; use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc}; @@ -29064,12 +29065,41 @@ impl BreakpointPromptEditor { }, ) } + + fn render_close_button(&self, cx: &mut Context) -> impl IntoElement { + let focus_handle = self.prompt.focus_handle(cx); + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .shape(IconButtonShape::Square) + .tooltip(move |_window, cx| { + Tooltip::for_action_in("Cancel", &menu::Cancel, &focus_handle, cx) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel(&menu::Cancel, window, cx); + })) + } + + fn render_confirm_button(&self, cx: &mut Context) -> impl IntoElement { + let focus_handle = self.prompt.focus_handle(cx); + IconButton::new("confirm", IconName::Return) + .icon_color(Color::Muted) + .shape(IconButtonShape::Square) + .tooltip(move |_window, cx| { + Tooltip::for_action_in("Confirm", &menu::Confirm, &focus_handle, cx) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.confirm(&menu::Confirm, window, cx); + })) + } } impl Render for BreakpointPromptEditor { 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 editor_margins = *self.editor_margins.lock(); let gutter_dimensions = editor_margins.gutter; + let left_gutter_width = gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0); + let right_padding = editor_margins.right + px(9.); h_flex() .key_context("Editor") .bg(cx.theme().colors().editor_background) @@ -29077,10 +29107,34 @@ impl Render for BreakpointPromptEditor { .border_color(cx.theme().status().info_border) .size_full() .py(window.line_height() / 2.5) + .pr(right_padding) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::cancel)) - .child(h_flex().w(gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0))) - .child(div().flex_1().child(self.render_prompt_editor(cx))) + .child( + WithRemSize::new(ui_font_size) + .h_full() + .w(left_gutter_width) + .flex() + .flex_row() + .flex_shrink_0() + .items_center() + .justify_center() + .gap_1() + .child(self.render_close_button(cx)), + ) + .child( + h_flex() + .w_full() + .justify_between() + .child(div().flex_1().child(self.render_prompt_editor(cx))) + .child( + WithRemSize::new(ui_font_size) + .flex() + .flex_row() + .items_center() + .child(self.render_confirm_button(cx)), + ), + ) } } From f0e301cea0b86bbb057f526bf12d672b8b3e958f Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 10 Mar 2026 21:08:39 -0600 Subject: [PATCH 119/219] Redact string panics (#51248) String panics are a non-trivial percentage of the crashes we see at Zed, and doubly unfortunately they may incidentally include the contents of a user's buffer. Although this hasn't happened yet (to my knowledge), I don't want to be in the position of having received sensitive information this way. See also https://github.com/rust-lang/rust/pull/153677 Release Notes: - N/A --- Cargo.lock | 1 - crates/crashes/src/crashes.rs | 28 ++++++++++- crates/feature_flags/Cargo.toml | 1 - crates/feature_flags/src/feature_flags.rs | 61 +---------------------- crates/zed/src/zed.rs | 33 ++++++------ 5 files changed, 46 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b9b048468cbc4f52b86b1cd0f1b0a9d3d0f4d9e0..dfd8a74acba5056e468a72d8cd105c0f2cfd156a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6212,7 +6212,6 @@ dependencies = [ name = "feature_flags" version = "0.1.0" dependencies = [ - "futures 0.3.31", "gpui", ] diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index 60af963ee5520addedcfe9abdf41941e77922867..9f18088b0ec2e709ff420b8e107e61dd7424e643 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -350,8 +350,34 @@ impl minidumper::ServerHandler for CrashServer { } } +/// Rust's string-slicing panics embed the user's string content in the message, +/// e.g. "byte index 4 is out of bounds of `a`". Strip that suffix so we +/// don't upload arbitrary user text in crash reports. +fn strip_user_string_from_panic(message: &str) -> String { + const STRING_PANIC_PREFIXES: &[&str] = &[ + // Older rustc (pre-1.95): + "byte index ", + "begin <= end (", + // Newer rustc (1.95+): + // https://github.com/rust-lang/rust/pull/145024 + "start byte index ", + "end byte index ", + "begin > end (", + ]; + + if (message.ends_with('`') || message.ends_with("`[...]")) + && STRING_PANIC_PREFIXES + .iter() + .any(|prefix| message.starts_with(prefix)) + && let Some(open) = message.find('`') + { + return format!("{} ``", &message[..open]); + } + message.to_owned() +} + pub fn panic_hook(info: &PanicHookInfo) { - let message = info.payload_as_str().unwrap_or("Box").to_owned(); + let message = strip_user_string_from_panic(info.payload_as_str().unwrap_or("Box")); let span = info .location() diff --git a/crates/feature_flags/Cargo.toml b/crates/feature_flags/Cargo.toml index a25ca1629a539a87a7356f0419ef074e9546bc52..960834211ff18980675b236cd0cc2893d563d668 100644 --- a/crates/feature_flags/Cargo.toml +++ b/crates/feature_flags/Cargo.toml @@ -12,5 +12,4 @@ workspace = true path = "src/feature_flags.rs" [dependencies] -futures.workspace = true gpui.workspace = true diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 1d1929ed4cf89abfc5304fa111dfc7ee523d5dd8..5b8af1180aae812ed1475810acc1920a8ec708f1 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -3,12 +3,8 @@ mod flags; use std::cell::RefCell; use std::rc::Rc; use std::sync::LazyLock; -use std::time::Duration; -use std::{future::Future, pin::Pin, task::Poll}; -use futures::channel::oneshot; -use futures::{FutureExt, select_biased}; -use gpui::{App, Context, Global, Subscription, Task, Window}; +use gpui::{App, Context, Global, Subscription, Window}; pub use flags::*; @@ -122,11 +118,6 @@ pub struct OnFlagsReady { } pub trait FeatureFlagAppExt { - fn wait_for_flag(&mut self) -> WaitForFlag; - - /// Waits for the specified feature flag to resolve, up to the given timeout. - fn wait_for_flag_or_timeout(&mut self, timeout: Duration) -> Task; - fn update_flags(&mut self, staff: bool, flags: Vec); fn set_staff(&mut self, staff: bool); fn has_flag(&self) -> bool; @@ -192,54 +183,4 @@ impl FeatureFlagAppExt for App { callback(feature_flags.has_flag::(), cx); }) } - - fn wait_for_flag(&mut self) -> WaitForFlag { - let (tx, rx) = oneshot::channel::(); - let mut tx = Some(tx); - let subscription: Option; - - match self.try_global::() { - Some(feature_flags) => { - subscription = None; - tx.take().unwrap().send(feature_flags.has_flag::()).ok(); - } - None => { - subscription = Some(self.observe_global::(move |cx| { - let feature_flags = cx.global::(); - if let Some(tx) = tx.take() { - tx.send(feature_flags.has_flag::()).ok(); - } - })); - } - } - - WaitForFlag(rx, subscription) - } - - fn wait_for_flag_or_timeout(&mut self, timeout: Duration) -> Task { - let wait_for_flag = self.wait_for_flag::(); - - self.spawn(async move |cx| { - let mut wait_for_flag = wait_for_flag.fuse(); - let mut timeout = FutureExt::fuse(cx.background_executor().timer(timeout)); - - select_biased! { - is_enabled = wait_for_flag => is_enabled, - _ = timeout => false, - } - }) - } -} - -pub struct WaitForFlag(oneshot::Receiver, Option); - -impl Future for WaitForFlag { - type Output = bool; - - fn poll(mut self: Pin<&mut Self>, cx: &mut core::task::Context<'_>) -> Poll { - self.0.poll_unpin(cx).map(|result| { - self.1.take(); - result.unwrap_or(false) - }) - } } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 079a78225c248e341121f1980a368b37f85eea84..6eee25e6faddae5fdaae7ac2704a10a979b30ce7 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -163,21 +163,24 @@ pub fn init(cx: &mut App) { cx.on_action(quit); cx.on_action(|_: &RestoreBanner, cx| title_bar::restore_banner(cx)); - let flag = cx.wait_for_flag::(); - cx.spawn(async |cx| { - if cx.update(|cx| ReleaseChannel::global(cx) == ReleaseChannel::Dev) || flag.await { - cx.update(|cx| { - cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")) - .on_action(|_: &TestCrash, _| { - unsafe extern "C" { - fn puts(s: *const i8); - } - unsafe { - puts(0xabad1d3a as *const i8); - } - }); - }); - }; + + cx.observe_flag::({ + let mut added = false; + move |enabled, cx| { + if added || !enabled { + return; + } + added = true; + cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")) + .on_action(|_: &TestCrash, _| { + unsafe extern "C" { + fn puts(s: *const i8); + } + unsafe { + puts(0xabad1d3a as *const i8); + } + }); + } }) .detach(); cx.on_action(|_: &OpenLog, cx| { From b5666319b4409ca882ace3ad8baaab513e5f3a8c Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 10 Mar 2026 23:45:55 -0700 Subject: [PATCH 120/219] Move threads sidebar into agent panel (#51241) * [x] Put back persistence of sidebar open state * [x] when agent panel is docked right, put sidebar on the right side * [x] remove stale entries from `SidebarsByWindow` Release Notes: - N/A --------- Co-authored-by: Eric Holk Co-authored-by: Mikayla Maki Co-authored-by: Anthony Eid --- Cargo.lock | 29 - Cargo.toml | 3 - crates/agent_ui/Cargo.toml | 1 - crates/agent_ui/src/agent_panel.rs | 249 ++++++++- crates/agent_ui/src/agent_ui.rs | 1 + crates/agent_ui/src/connection_view.rs | 2 +- crates/{sidebar => agent_ui}/src/sidebar.rs | 497 ++++++++---------- .../debugger_ui/src/tests/stack_frame_list.rs | 4 +- .../src/platform_title_bar.rs | 40 +- crates/sidebar/Cargo.toml | 51 -- crates/sidebar/LICENSE-GPL | 1 - crates/title_bar/Cargo.toml | 1 - crates/title_bar/src/title_bar.rs | 125 +---- crates/workspace/src/multi_workspace.rs | 405 +------------- crates/workspace/src/persistence.rs | 10 +- crates/workspace/src/persistence/model.rs | 9 +- crates/workspace/src/status_bar.rs | 14 +- crates/workspace/src/workspace.rs | 29 +- crates/zed/Cargo.toml | 1 - crates/zed/src/visual_test_runner.rs | 42 +- crates/zed/src/zed.rs | 15 - 21 files changed, 517 insertions(+), 1012 deletions(-) rename crates/{sidebar => agent_ui}/src/sidebar.rs (91%) delete mode 100644 crates/sidebar/Cargo.toml delete mode 120000 crates/sidebar/LICENSE-GPL diff --git a/Cargo.lock b/Cargo.lock index dfd8a74acba5056e468a72d8cd105c0f2cfd156a..f11d2023b319501778768fdea39fb8dbb242a9e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15807,33 +15807,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "sidebar" -version = "0.1.0" -dependencies = [ - "acp_thread", - "agent", - "agent-client-protocol", - "agent_ui", - "assistant_text_thread", - "chrono", - "editor", - "feature_flags", - "fs", - "gpui", - "language_model", - "menu", - "project", - "recent_projects", - "serde_json", - "settings", - "theme", - "ui", - "util", - "workspace", - "zed_actions", -] - [[package]] name = "signal-hook" version = "0.3.18" @@ -17660,7 +17633,6 @@ dependencies = [ "client", "cloud_api_types", "db", - "feature_flags", "git_ui", "gpui", "notifications", @@ -21887,7 +21859,6 @@ dependencies = [ "settings_profile_selector", "settings_ui", "shellexpand 2.1.2", - "sidebar", "smol", "snippet_provider", "snippets_ui", diff --git a/Cargo.toml b/Cargo.toml index b6760fa917da7e051fd60a1375be49d516fcf113..c184837bfd6a67490169b7a6908b17b4d61e121f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -173,7 +173,6 @@ members = [ "crates/settings_profile_selector", "crates/settings_ui", "crates/shell_command_parser", - "crates/sidebar", "crates/snippet", "crates/snippet_provider", "crates/snippets_ui", @@ -412,7 +411,6 @@ rules_library = { path = "crates/rules_library" } scheduler = { path = "crates/scheduler" } search = { path = "crates/search" } session = { path = "crates/session" } -sidebar = { path = "crates/sidebar" } settings = { path = "crates/settings" } settings_content = { path = "crates/settings_content" } settings_json = { path = "crates/settings_json" } @@ -907,7 +905,6 @@ refineable = { codegen-units = 1 } release_channel = { codegen-units = 1 } reqwest_client = { codegen-units = 1 } session = { codegen-units = 1 } -sidebar = { codegen-units = 1 } snippet = { codegen-units = 1 } snippets_ui = { codegen-units = 1 } story = { codegen-units = 1 } diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 8b06417d2f5812ef2e0fb265e6afa4cfeb26eb3f..7a0910726e03221dc0a105d69c4852e7515e0c35 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -132,7 +132,6 @@ languages = { workspace = true, features = ["test-support"] } language_model = { workspace = true, "features" = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } - semver.workspace = true reqwest_client.workspace = true diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 80f8925ad05414b9839ac53953156ef35c43e08f..630411c2400ee925f980b5d3a410cb3574e81cd6 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -65,9 +65,10 @@ use extension_host::ExtensionStore; use fs::Fs; use git::repository::validate_worktree_directory; use gpui::{ - Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner, - DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, - Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, + Action, Animation, AnimationExt, AnyElement, AnyView, App, AsyncWindowContext, ClipboardItem, + Corner, DismissEvent, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, + Focusable, KeyContext, MouseButton, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, + deferred, prelude::*, pulsating_between, }; use language::LanguageRegistry; use language_model::{ConfigurationError, LanguageModelRegistry}; @@ -79,15 +80,17 @@ use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; use theme::ThemeSettings; use ui::{ - Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, - PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*, + Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, Indicator, + KeyBinding, PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*, utils::WithRemSize, }; use util::ResultExt as _; use workspace::{ - CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace, - WorkspaceId, + CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar, + MultiWorkspace, SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, ToggleZoom, + ToolbarItemView, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, + multi_workspace_enabled, }; use zed_actions::{ DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, @@ -99,6 +102,55 @@ const AGENT_PANEL_KEY: &str = "agent_panel"; const RECENTLY_UPDATED_MENU_LIMIT: usize = 6; const DEFAULT_THREAD_TITLE: &str = "New Thread"; +#[derive(Default)] +struct SidebarsByWindow( + collections::HashMap>, +); + +impl gpui::Global for SidebarsByWindow {} + +pub(crate) fn sidebar_is_open(window: &Window, cx: &App) -> bool { + if !multi_workspace_enabled(cx) { + return false; + } + let window_id = window.window_handle().window_id(); + cx.try_global::() + .and_then(|sidebars| sidebars.0.get(&window_id)?.upgrade()) + .is_some_and(|sidebar| sidebar.read(cx).is_open()) +} + +fn find_or_create_sidebar_for_window( + window: &mut Window, + cx: &mut App, +) -> Option> { + let window_id = window.window_handle().window_id(); + let multi_workspace = window.root::().flatten()?; + + if !cx.has_global::() { + cx.set_global(SidebarsByWindow::default()); + } + + cx.global_mut::() + .0 + .retain(|_, weak| weak.upgrade().is_some()); + + let existing = cx + .global::() + .0 + .get(&window_id) + .and_then(|weak| weak.upgrade()); + + if let Some(sidebar) = existing { + return Some(sidebar); + } + + let sidebar = cx.new(|cx| crate::sidebar::Sidebar::new(multi_workspace, window, cx)); + cx.global_mut::() + .0 + .insert(window_id, sidebar.downgrade()); + Some(sidebar) +} + fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option { let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY); let key = i64::from(workspace_id).to_string(); @@ -424,6 +476,30 @@ pub fn init(cx: &mut App) { panel.set_start_thread_in(action, cx); }); } + }) + .register_action(|workspace, _: &ToggleWorkspaceSidebar, window, cx| { + if !multi_workspace_enabled(cx) { + return; + } + if let Some(panel) = workspace.panel::(cx) { + if let Some(sidebar) = panel.read(cx).sidebar.clone() { + sidebar.update(cx, |sidebar, cx| { + sidebar.toggle(window, cx); + }); + } + } + }) + .register_action(|workspace, _: &FocusWorkspaceSidebar, window, cx| { + if !multi_workspace_enabled(cx) { + return; + } + if let Some(panel) = workspace.panel::(cx) { + if let Some(sidebar) = panel.read(cx).sidebar.clone() { + sidebar.update(cx, |sidebar, cx| { + sidebar.focus_or_unfocus(workspace, window, cx); + }); + } + } }); }, ) @@ -820,6 +896,7 @@ pub struct AgentPanel { last_configuration_error_telemetry: Option, on_boarding_upsell_dismissed: AtomicBool, _active_view_observation: Option, + pub(crate) sidebar: Option>, } impl AgentPanel { @@ -991,7 +1068,6 @@ impl AgentPanel { let client = workspace.client().clone(); let workspace_id = workspace.database_id(); let workspace = workspace.weak_handle(); - let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); @@ -1149,10 +1225,17 @@ impl AgentPanel { last_configuration_error_telemetry: None, on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()), _active_view_observation: None, + sidebar: None, }; // Initial sync of agent servers from extensions panel.sync_agent_servers_from_extensions(cx); + + cx.defer_in(window, move |this, window, cx| { + this.sidebar = find_or_create_sidebar_for_window(window, cx); + cx.notify(); + }); + panel } @@ -3526,9 +3609,109 @@ impl AgentPanel { }) } + fn sidebar_info(&self, cx: &App) -> Option<(AnyView, Pixels, bool)> { + if !multi_workspace_enabled(cx) { + return None; + } + let sidebar = self.sidebar.as_ref()?; + let is_open = sidebar.read(cx).is_open(); + let width = sidebar.read(cx).width(cx); + let view: AnyView = sidebar.clone().into(); + Some((view, width, is_open)) + } + + fn render_sidebar_toggle(&self, cx: &Context) -> Option { + if !multi_workspace_enabled(cx) { + return None; + } + let sidebar = self.sidebar.as_ref()?; + let sidebar_read = sidebar.read(cx); + if sidebar_read.is_open() { + return None; + } + let has_notifications = sidebar_read.has_notifications(cx); + + Some( + IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed) + .icon_size(IconSize::Small) + .when(has_notifications, |button| { + button + .indicator(Indicator::dot().color(Color::Accent)) + .indicator_border_color(Some(cx.theme().colors().tab_bar_background)) + }) + .tooltip(move |_, cx| { + Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + }) + .into_any_element(), + ) + } + + fn render_sidebar(&self, cx: &Context) -> Option { + let (sidebar_view, sidebar_width, is_open) = self.sidebar_info(cx)?; + if !is_open { + return None; + } + + let docked_right = agent_panel_dock_position(cx) == DockPosition::Right; + let sidebar = self.sidebar.as_ref()?.downgrade(); + + let resize_handle = deferred( + div() + .id("sidebar-resize-handle") + .absolute() + .when(docked_right, |this| { + this.left(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + }) + .when(!docked_right, |this| { + this.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + }) + .top(px(0.)) + .h_full() + .w(SIDEBAR_RESIZE_HANDLE_SIZE) + .cursor_col_resize() + .on_drag(DraggedSidebar, |dragged, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| dragged.clone()) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up(MouseButton::Left, move |event, _, cx| { + if event.click_count == 2 { + sidebar + .update(cx, |sidebar, cx| { + sidebar.set_width(None, cx); + }) + .ok(); + cx.stop_propagation(); + } + }) + .occlude(), + ); + + Some( + div() + .id("sidebar-container") + .relative() + .h_full() + .w(sidebar_width) + .flex_shrink_0() + .when(docked_right, |this| this.border_l_1()) + .when(!docked_right, |this| this.border_r_1()) + .border_color(cx.theme().colors().border) + .child(sidebar_view) + .child(resize_handle) + .into_any_element(), + ) + } + fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); + let docked_right = agent_panel_dock_position(cx) == DockPosition::Right; let (selected_agent_custom_icon, selected_agent_label) = if let AgentType::Custom { name, .. } = &self.selected_agent { @@ -3991,6 +4174,9 @@ impl AgentPanel { .size_full() .gap(DynamicSpacing::Base04.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) + .when(!docked_right, |this| { + this.children(self.render_sidebar_toggle(cx)) + }) .child(agent_selector_menu) .child(self.render_start_thread_in_selector(cx)), ) @@ -4007,7 +4193,10 @@ impl AgentPanel { cx, )) }) - .child(self.render_panel_options_menu(window, cx)), + .child(self.render_panel_options_menu(window, cx)) + .when(docked_right, |this| { + this.children(self.render_sidebar_toggle(cx)) + }), ) .into_any_element() } else { @@ -4045,6 +4234,9 @@ impl AgentPanel { .size_full() .gap(DynamicSpacing::Base04.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) + .when(!docked_right, |this| { + this.children(self.render_sidebar_toggle(cx)) + }) .child(match &self.active_view { ActiveView::History { .. } | ActiveView::Configuration => { self.render_toolbar_back_button(cx).into_any_element() @@ -4067,7 +4259,10 @@ impl AgentPanel { cx, )) }) - .child(self.render_panel_options_menu(window, cx)), + .child(self.render_panel_options_menu(window, cx)) + .when(docked_right, |this| { + this.children(self.render_sidebar_toggle(cx)) + }), ) .into_any_element() } @@ -4607,14 +4802,44 @@ impl Render for AgentPanel { }) .children(self.render_trial_end_upsell(window, cx)); + let sidebar = self.render_sidebar(cx); + let has_sidebar = sidebar.is_some(); + let docked_right = agent_panel_dock_position(cx) == DockPosition::Right; + + let panel = h_flex() + .size_full() + .when(has_sidebar, |this| { + this.on_drag_move(cx.listener( + move |this, e: &DragMoveEvent, _window, cx| { + if let Some(sidebar) = &this.sidebar { + let width = if docked_right { + e.bounds.right() - e.event.position.x + } else { + e.event.position.x + }; + sidebar.update(cx, |sidebar, cx| { + sidebar.set_width(Some(width), cx); + }); + } + }, + )) + }) + .map(|this| { + if docked_right { + this.child(content).children(sidebar) + } else { + this.children(sidebar).child(content) + } + }); + match self.active_view.which_font_size_used() { WhichFontSize::AgentFont => { WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx)) .size_full() - .child(content) + .child(panel) .into_any() } - _ => content.into_any(), + _ => panel.into_any(), } } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index d37dbdbbeb184cac31320b4bc9232354eb3dcc8d..292db8fc7c0398fdd8c8800b8acc2b3c6df22740 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -23,6 +23,7 @@ mod mode_selector; mod model_selector; mod model_selector_popover; mod profile_selector; +pub mod sidebar; mod slash_command; mod slash_command_picker; mod terminal_codegen; diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 3b07929813e5583164700905a1fa327f3ac9d964..fd4ac66c05e380ddd3e1c3e2c196c5a397754c9d 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -2340,7 +2340,7 @@ impl ConnectionView { } if let Some(multi_workspace) = window.root::().flatten() { - multi_workspace.read(cx).is_sidebar_open() + crate::agent_panel::sidebar_is_open(window, cx) || self.agent_panel_visible(&multi_workspace, cx) } else { self.workspace diff --git a/crates/sidebar/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs similarity index 91% rename from crates/sidebar/src/sidebar.rs rename to crates/agent_ui/src/sidebar.rs index dd1dcab9ee7b5c6de25630b9f0b8fcebcdad7cb2..2679807388eb6261f9bc32be10c10ed500078b22 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1,33 +1,32 @@ +use crate::{AgentPanel, AgentPanelEvent, NewThread}; use acp_thread::ThreadStatus; use agent::ThreadStore; use agent_client_protocol as acp; -use agent_ui::{AgentPanel, AgentPanelEvent, NewThread}; +use agent_settings::AgentSettings; use chrono::Utc; +use db::kvp::KEY_VALUE_STORE; use editor::{Editor, EditorElement, EditorStyle}; use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState, + Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, FontStyle, ListState, Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px, relative, rems, }; use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::Event as ProjectEvent; -use recent_projects::RecentProjects; use settings::Settings; use std::collections::{HashMap, HashSet}; use std::mem; use theme::{ActiveTheme, ThemeSettings}; -use ui::utils::TRAFFIC_LIGHT_PADDING; use ui::{ - AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, - PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*, + AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem, + Tooltip, WithScrollbar, prelude::*, }; +use util::ResultExt as _; use util::path_list::PathList; use workspace::{ - FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar, - SidebarEvent, ToggleWorkspaceSidebar, Workspace, + MultiWorkspace, MultiWorkspaceEvent, ToggleWorkspaceSidebar, Workspace, multi_workspace_enabled, }; -use zed_actions::OpenRecent; use zed_actions::editor::{MoveDown, MoveUp}; actions!( @@ -44,6 +43,27 @@ const DEFAULT_WIDTH: Pixels = px(320.0); const MIN_WIDTH: Pixels = px(200.0); const MAX_WIDTH: Pixels = px(800.0); const DEFAULT_THREADS_SHOWN: usize = 5; +const SIDEBAR_STATE_KEY: &str = "sidebar_state"; + +fn read_sidebar_open_state(multi_workspace_id: u64) -> bool { + KEY_VALUE_STORE + .scoped(SIDEBAR_STATE_KEY) + .read(&multi_workspace_id.to_string()) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).ok()) + .unwrap_or(false) +} + +async fn save_sidebar_open_state(multi_workspace_id: u64, is_open: bool) { + if let Ok(json) = serde_json::to_string(&is_open) { + KEY_VALUE_STORE + .scoped(SIDEBAR_STATE_KEY) + .write(multi_workspace_id.to_string(), json) + .await + .log_err(); + } +} #[derive(Clone, Debug)] struct ActiveThreadInfo { @@ -173,6 +193,8 @@ fn workspace_path_list_and_label( pub struct Sidebar { multi_workspace: WeakEntity, + persistence_key: Option, + is_open: bool, width: Pixels, focus_handle: FocusHandle, filter_editor: Entity, @@ -186,11 +208,8 @@ pub struct Sidebar { active_entry_index: Option, collapsed_groups: HashSet, expanded_groups: HashMap, - recent_projects_popover_handle: PopoverMenuHandle, } -impl EventEmitter for Sidebar {} - impl Sidebar { pub fn new( multi_workspace: Entity, @@ -212,7 +231,6 @@ impl Sidebar { window, |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event { MultiWorkspaceEvent::ActiveWorkspaceChanged => { - this.focused_thread = None; this.update_entries(cx); } MultiWorkspaceEvent::WorkspaceAdded(workspace) => { @@ -270,8 +288,15 @@ impl Sidebar { this.update_entries(cx); }); + let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0); + let is_open = persistence_key + .map(read_sidebar_open_state) + .unwrap_or(false); + Self { multi_workspace: multi_workspace.downgrade(), + persistence_key, + is_open, width: DEFAULT_WIDTH, focus_handle, filter_editor, @@ -282,7 +307,6 @@ impl Sidebar { active_entry_index: None, collapsed_groups: HashSet::new(), expanded_groups: HashMap::new(), - recent_projects_popover_handle: PopoverMenuHandle::default(), } } @@ -334,31 +358,10 @@ impl Sidebar { cx.subscribe_in( agent_panel, window, - |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event { - AgentPanelEvent::ActiveViewChanged => { - match agent_panel.read(cx).active_connection_view() { - Some(thread) => { - if let Some(session_id) = thread.read(cx).parent_id(cx) { - this.focused_thread = Some(session_id); - } - } - None => { - this.focused_thread = None; - } - } - this.update_entries(cx); - } - AgentPanelEvent::ThreadFocused => { - let new_focused = agent_panel - .read(cx) - .active_connection_view() - .and_then(|thread| thread.read(cx).parent_id(cx)); - if new_focused.is_some() && new_focused != this.focused_thread { - this.focused_thread = new_focused; - this.update_entries(cx); - } - } - AgentPanelEvent::BackgroundThreadChanged => { + |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event { + AgentPanelEvent::ActiveViewChanged + | AgentPanelEvent::ThreadFocused + | AgentPanelEvent::BackgroundThreadChanged => { this.update_entries(cx); } }, @@ -419,6 +422,12 @@ impl Sidebar { let workspaces = mw.workspaces().to_vec(); let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); + self.focused_thread = active_workspace + .as_ref() + .and_then(|ws| ws.read(cx).panel::(cx)) + .and_then(|panel| panel.read(cx).active_connection_view().cloned()) + .and_then(|cv| cv.read(cx).parent_id(cx)); + let thread_store = ThreadStore::try_global(cx); let query = self.filter_editor.read(cx).text(cx); @@ -657,7 +666,7 @@ impl Sidebar { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; - if !multi_workspace.read(cx).multi_workspace_enabled(cx) { + if !multi_workspace_enabled(cx) { return; } @@ -885,8 +894,6 @@ impl Sidebar { return; }; - self.focused_thread = None; - multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), cx); }); @@ -1173,48 +1180,6 @@ impl Sidebar { .into_any_element() } - fn render_recent_projects_button(&self, cx: &mut Context) -> impl IntoElement { - let workspace = self - .multi_workspace - .upgrade() - .map(|mw| mw.read(cx).workspace().downgrade()); - - let focus_handle = workspace - .as_ref() - .and_then(|ws| ws.upgrade()) - .map(|w| w.read(cx).focus_handle(cx)) - .unwrap_or_else(|| cx.focus_handle()); - - let popover_handle = self.recent_projects_popover_handle.clone(); - - PopoverMenu::new("sidebar-recent-projects-menu") - .with_handle(popover_handle) - .menu(move |window, cx| { - workspace.as_ref().map(|ws| { - RecentProjects::popover(ws.clone(), false, focus_handle.clone(), window, cx) - }) - }) - .trigger_with_tooltip( - IconButton::new("open-project", IconName::OpenFolder) - .icon_size(IconSize::Small) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)), - |_window, cx| { - Tooltip::for_action( - "Recent Projects", - &OpenRecent { - create_new_window: false, - }, - cx, - ) - }, - ) - .anchor(gpui::Corner::TopLeft) - .offset(gpui::Point { - x: px(0.0), - y: px(2.0), - }) - } - fn render_filter_input(&self, cx: &mut Context) -> impl IntoElement { let settings = ThemeSettings::get_global(cx); let text_style = TextStyle { @@ -1343,26 +1308,66 @@ impl Sidebar { } } -impl WorkspaceSidebar for Sidebar { - fn width(&self, _cx: &App) -> Pixels { - self.width +impl Sidebar { + pub fn is_open(&self) -> bool { + self.is_open } - fn set_width(&mut self, width: Option, cx: &mut Context) { - self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); + pub fn set_open(&mut self, open: bool, cx: &mut Context) { + if self.is_open == open { + return; + } + self.is_open = open; cx.notify(); + if let Some(key) = self.persistence_key { + let is_open = self.is_open; + cx.background_spawn(async move { + save_sidebar_open_state(key, is_open).await; + }) + .detach(); + } } - fn has_notifications(&self, _cx: &App) -> bool { - !self.contents.notified_threads.is_empty() + pub fn toggle(&mut self, window: &mut Window, cx: &mut Context) { + let new_state = !self.is_open; + self.set_open(new_state, cx); + if new_state { + cx.focus_self(window); + } + } + + pub fn focus_or_unfocus( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + if self.is_open { + let sidebar_is_focused = self.focus_handle(cx).contains_focused(window, cx); + if sidebar_is_focused { + let active_pane = workspace.active_pane().clone(); + let pane_focus = active_pane.read(cx).focus_handle(cx); + window.focus(&pane_focus, cx); + } else { + cx.focus_self(window); + } + } else { + self.set_open(true, cx); + cx.focus_self(window); + } } - fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) { - self.recent_projects_popover_handle.toggle(window, cx); + pub fn width(&self, _cx: &App) -> Pixels { + self.width + } + + pub fn set_width(&mut self, width: Option, cx: &mut Context) { + self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); + cx.notify(); } - fn is_recent_projects_popover_deployed(&self) -> bool { - self.recent_projects_popover_handle.is_deployed() + pub fn has_notifications(&self, _cx: &App) -> bool { + !self.contents.notified_threads.is_empty() } } @@ -1374,18 +1379,9 @@ 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 is_focused = self.focus_handle.is_focused(window) - || self.filter_editor.focus_handle(cx).is_focused(window); let has_query = self.has_filter_query(cx); - let focus_tooltip_label = if is_focused { - "Focus Workspace" - } else { - "Focus Sidebar" - }; - v_flex() .id("workspace-sidebar") .key_context("WorkspaceSidebar") @@ -1401,69 +1397,26 @@ impl Render for Sidebar { .on_action(cx.listener(Self::collapse_selected_entry)) .on_action(cx.listener(Self::cancel)) .font(ui_font) - .h_full() - .w(self.width) + .size_full() .bg(cx.theme().colors().surface_background) - .border_r_1() - .border_color(cx.theme().colors().border) - .child( - h_flex() - .flex_none() - .h(titlebar_height) - .w_full() - .mt_px() - .pb_px() - .pr_1() - .when_else( - cfg!(target_os = "macos") && !window.is_fullscreen(), - |this| this.pl(px(TRAFFIC_LIGHT_PADDING)), - |this| this.pl_2(), - ) - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child({ - let focus_handle_toggle = self.focus_handle.clone(); - let focus_handle_focus = self.focus_handle.clone(); - IconButton::new("close-sidebar", IconName::WorkspaceNavOpen) - .icon_size(IconSize::Small) - .tooltip(Tooltip::element(move |_, cx| { - v_flex() - .gap_1() - .child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new("Close Sidebar")) - .child(KeyBinding::for_action_in( - &ToggleWorkspaceSidebar, - &focus_handle_toggle, - cx, - )), - ) - .child( - h_flex() - .pt_1() - .gap_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .justify_between() - .child(Label::new(focus_tooltip_label)) - .child(KeyBinding::for_action_in( - &FocusWorkspaceSidebar, - &focus_handle_focus, - cx, - )), - ) - .into_any_element() - })) - .on_click(cx.listener(|_this, _, _window, cx| { - cx.emit(SidebarEvent::Close); - })) - }) - .child(self.render_recent_projects_button(cx)), - ) - .child( + .child({ + let docked_right = + AgentSettings::get_global(cx).dock == settings::DockPosition::Right; + let render_close_button = || { + IconButton::new("sidebar-close-toggle", IconName::WorkspaceNavOpen) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action( + "Close Threads Sidebar", + &ToggleWorkspaceSidebar, + cx, + ) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + }) + }; + h_flex() .flex_none() .px_2p5() @@ -1471,6 +1424,7 @@ impl Render for Sidebar { .gap_2() .border_b_1() .border_color(cx.theme().colors().border) + .when(!docked_right, |this| this.child(render_close_button())) .child( Icon::new(IconName::MagnifyingGlass) .size(IconSize::Small) @@ -1487,8 +1441,9 @@ impl Render for Sidebar { this.update_entries(cx); })), ) - }), - ) + }) + .when(docked_right, |this| this.child(render_close_button())) + }) .child( v_flex() .flex_1() @@ -1509,26 +1464,24 @@ impl Render for Sidebar { #[cfg(test)] mod tests { use super::*; + use crate::test_support::{active_session_id, open_thread_with_connection, send_message}; 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 settings::SettingsStore; use std::sync::Arc; use util::path_list::PathList; fn init_test(cx: &mut TestAppContext) { + crate::test_support::init_test(cx); cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); - editor::init(cx); cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); }); } @@ -1569,14 +1522,33 @@ mod tests { 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_in(cx, |mw, window, cx| { - mw.register_sidebar(sidebar.clone(), window, cx); + let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx); + sidebar + } + + fn setup_sidebar_with_agent_panel( + multi_workspace: &Entity, + cx: &mut gpui::VisualTestContext, + ) -> (Entity, Entity) { + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + let project = workspace.read_with(cx, |ws, _cx| ws.project().clone()); + let panel = add_agent_panel(&workspace, &project, cx); + workspace.update_in(cx, |workspace, window, cx| { + workspace.right_dock().update(cx, |dock, cx| { + if let Some(panel_ix) = dock.panel_index_for_type::() { + dock.activate_panel(panel_ix, window, cx); + } + dock.set_open(true, window, cx); + }); }); cx.run_until_parked(); - sidebar + let sidebar = panel.read_with(cx, |panel, _cx| { + panel + .sidebar + .clone() + .expect("AgentPanel should have created a sidebar") + }); + (sidebar, panel) } async fn save_n_test_threads( @@ -1623,16 +1595,10 @@ mod tests { cx.run_until_parked(); } - fn open_and_focus_sidebar( - sidebar: &Entity, - multi_workspace: &Entity, - cx: &mut gpui::VisualTestContext, - ) { - multi_workspace.update_in(cx, |mw, window, cx| { - mw.toggle_sidebar(window, cx); - }); + fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { cx.run_until_parked(); - sidebar.update_in(cx, |_, window, cx| { + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.set_open(true, cx); cx.focus_self(window); }); cx.run_until_parked(); @@ -1886,7 +1852,7 @@ mod tests { assert!(entries.iter().any(|e| e.contains("View More (12)"))); // Focus and navigate to View More, then confirm to expand by one batch - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); for _ in 0..7 { cx.dispatch_action(SelectNext); } @@ -2169,7 +2135,7 @@ mod tests { // 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, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); // First SelectNext from None starts at index 0 @@ -2218,7 +2184,7 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); // SelectLast jumps to the end cx.dispatch_action(SelectLast); @@ -2241,7 +2207,7 @@ mod tests { // 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, &multi_workspace, cx); + 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 @@ -2273,6 +2239,9 @@ mod tests { }); cx.run_until_parked(); + // Add an agent panel to workspace 1 so the sidebar renders when it's active. + setup_sidebar_with_agent_panel(&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()); @@ -2299,7 +2268,7 @@ mod tests { ); // Focus the sidebar and manually select the header (index 0) - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); }); @@ -2342,7 +2311,7 @@ mod tests { assert!(entries.iter().any(|e| e.contains("View More (3)"))); // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); for _ in 0..7 { cx.dispatch_action(SelectNext); } @@ -2377,7 +2346,7 @@ mod tests { ); // Focus sidebar and manually select the header (index 0). Press left to collapse. - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); }); @@ -2417,7 +2386,7 @@ mod tests { cx.run_until_parked(); // Focus sidebar (selection starts at None), then navigate down to the thread (child) - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + 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)); @@ -2452,7 +2421,7 @@ mod tests { ); // Focus sidebar — focus_in does not set a selection - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + 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) @@ -2485,7 +2454,7 @@ mod tests { cx.run_until_parked(); // Focus sidebar (selection starts at None), navigate down to the thread (index 1) - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + 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)); @@ -2505,24 +2474,6 @@ mod tests { ); } - 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); - language_model::LanguageModelRegistry::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 add_agent_panel( workspace: &Entity, project: &Entity, @@ -2536,23 +2487,12 @@ mod tests { }) } - 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 project = init_test_project("/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 (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); @@ -2595,10 +2535,10 @@ mod tests { #[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 project_a = init_test_project("/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 (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx); let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); @@ -2802,7 +2742,7 @@ mod tests { ); // User types a search query to filter down. - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); type_in_search(&sidebar, "alpha", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), @@ -3125,7 +3065,7 @@ mod tests { // User focuses the sidebar and collapses the group using keyboard: // manually select the header, then press CollapseSelectedEntry to collapse. - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); }); @@ -3175,7 +3115,7 @@ mod tests { } cx.run_until_parked(); - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + open_and_focus_sidebar(&sidebar, cx); // User types "fix" — two threads match. type_in_search(&sidebar, "fix", cx); @@ -3352,10 +3292,10 @@ mod tests { #[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 project = init_test_project("/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 (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); @@ -3400,10 +3340,10 @@ mod tests { #[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 project_a = init_test_project("/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 (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx); let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); @@ -3432,7 +3372,8 @@ mod tests { let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); // ── 1. Initial state: no focused thread ────────────────────────────── - // Workspace B is active (just added), so its header is the active entry. + // Workspace B is active (just added) and has no thread, so its header + // is the active entry. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread, None, @@ -3447,6 +3388,7 @@ mod tests { ); }); + // ── 2. Click thread in workspace A via sidebar ─────────────────────── sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_thread( acp_thread::AgentSessionInfo { @@ -3490,6 +3432,7 @@ mod tests { ); }); + // ── 3. Open thread in workspace B, then click it via sidebar ───────── let connection_b = StubAgentConnection::new(); connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("Thread B".into()), @@ -3501,6 +3444,16 @@ mod tests { save_thread_to_store(&session_id_b, &path_list_b, cx).await; cx.run_until_parked(); + // Opening a thread in a non-active workspace should NOT change + // focused_thread — it's derived from the active workspace. + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Opening a thread in a non-active workspace should not affect focused_thread" + ); + }); + // Workspace A is currently active. Click a thread in workspace B, // which also triggers a workspace switch. sidebar.update_in(cx, |sidebar, window, cx| { @@ -3535,25 +3488,30 @@ mod tests { ); }); + // ── 4. Switch workspace → focused_thread reflects new workspace ────── multi_workspace.update_in(cx, |mw, window, cx| { mw.activate_next_workspace(window, cx); }); cx.run_until_parked(); + // Workspace A is now active. Its agent panel still has session_id_a + // loaded, so focused_thread should reflect that. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( - sidebar.focused_thread, None, - "External workspace switch should clear focused_thread" + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Switching workspaces should derive focused_thread from the new active workspace" ); let active_entry = sidebar .active_entry_index .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( - matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), - "Active entry should be the workspace header after external switch" + matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a), + "Active entry should be workspace_a's active thread" ); }); + // ── 5. Opening a thread in a non-active workspace is ignored ────────── let connection_b2 = StubAgentConnection::new(); connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("New thread".into()), @@ -3564,69 +3522,48 @@ mod tests { save_thread_to_store(&session_id_b2, &path_list_b, cx).await; cx.run_until_parked(); + // Workspace A is still active, so focused_thread stays on session_id_a. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Opening a thread externally should set 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_b2), - "Defocusing the sidebar should not clear focused_thread" + Some(&session_id_a), + "Opening a thread in a non-active workspace should not affect focused_thread" ); }); + // ── 6. Activating workspace B shows its active thread ──────────────── sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_workspace(&workspace_b, window, cx); }); cx.run_until_parked(); + // Workspace B is now active with session_id_b2 loaded. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( - sidebar.focused_thread, None, - "Clicking a workspace header should clear focused_thread" + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Activating workspace_b should show workspace_b's active thread" ); let active_entry = sidebar .active_entry_index .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( - matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), - "Active entry should be the workspace header" + matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2), + "Active entry should be workspace_b's active thread" ); }); - // ── 8. Focusing the agent panel thread restores focused_thread ──── - // Workspace B still has session_id_b2 loaded in the agent panel. - // Clicking into the thread (simulated by focusing its view) should - // set focused_thread via the ThreadFocused event. - panel_b.update_in(cx, |panel, window, cx| { - if let Some(thread_view) = panel.active_connection_view() { - thread_view.read(cx).focus_handle(cx).focus(window, cx); - } + // ── 7. Switching back to workspace A reflects its thread ───────────── + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_next_workspace(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" - ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); - assert!( - matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2), - "Active entry should be the focused thread" + Some(&session_id_a), + "Switching back to workspace_a should show its active thread" ); }); } diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs index 1f5ac5dea4a19af338feceaa2ee51fd9322fa9a5..9a9a9316fb09def438f78734831c5e560c838fba 100644 --- a/crates/debugger_ui/src/tests/stack_frame_list.rs +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -1211,7 +1211,9 @@ async fn test_stack_frame_filter_persistence( cx.run_until_parked(); let workspace_id = workspace - .update(cx, |workspace, _window, cx| workspace.database_id(cx)) + .update(cx, |workspace, _window, cx| { + workspace.active_workspace_database_id(cx) + }) .ok() .flatten() .expect("workspace id has to be some for this test to work properly"); diff --git a/crates/platform_title_bar/src/platform_title_bar.rs b/crates/platform_title_bar/src/platform_title_bar.rs index 7053fe89e7fdc6ece9ad50fdd8facaf31dba3086..1db29b0f53d9e7b185e6c3cd3029ed2e6077753e 100644 --- a/crates/platform_title_bar/src/platform_title_bar.rs +++ b/crates/platform_title_bar/src/platform_title_bar.rs @@ -31,8 +31,6 @@ pub struct PlatformTitleBar { children: SmallVec<[AnyElement; 2]>, should_move: bool, system_window_tabs: Entity, - workspace_sidebar_open: bool, - sidebar_has_notifications: bool, } impl PlatformTitleBar { @@ -46,8 +44,6 @@ impl PlatformTitleBar { children: SmallVec::new(), should_move: false, system_window_tabs, - workspace_sidebar_open: false, - sidebar_has_notifications: false, } } @@ -74,28 +70,6 @@ impl PlatformTitleBar { SystemWindowTabs::init(cx); } - pub fn is_workspace_sidebar_open(&self) -> bool { - self.workspace_sidebar_open - } - - pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { - self.workspace_sidebar_open = open; - cx.notify(); - } - - pub fn sidebar_has_notifications(&self) -> bool { - self.sidebar_has_notifications - } - - pub fn set_sidebar_has_notifications( - &mut self, - has_notifications: bool, - cx: &mut Context, - ) { - self.sidebar_has_notifications = has_notifications; - cx.notify(); - } - pub fn is_multi_workspace_enabled(cx: &App) -> bool { cx.has_flag::() && !DisableAiSettings::get_global(cx).disable_ai } @@ -110,9 +84,6 @@ impl Render for PlatformTitleBar { let close_action = Box::new(workspace::CloseWindow); let children = mem::take(&mut self.children); - let is_multiworkspace_sidebar_open = - PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open(); - let title_bar = h_flex() .window_control_area(WindowControlArea::Drag) .w_full() @@ -161,9 +132,7 @@ impl Render for PlatformTitleBar { .map(|this| { if window.is_fullscreen() { this.pl_2() - } else if self.platform_style == PlatformStyle::Mac - && !is_multiworkspace_sidebar_open - { + } else if self.platform_style == PlatformStyle::Mac { this.pl(px(TRAFFIC_LIGHT_PADDING)) } else { this.pl_2() @@ -175,10 +144,9 @@ impl Render for PlatformTitleBar { .when(!(tiling.top || tiling.right), |el| { el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING) }) - .when( - !(tiling.top || tiling.left) && !is_multiworkspace_sidebar_open, - |el| el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING), - ) + .when(!(tiling.top || tiling.left), |el| { + el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING) + }) // this border is to avoid a transparent gap in the rounded corners .mt(px(-1.)) .mb(px(-1.)) diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml deleted file mode 100644 index e6b873704ffda9d241fec002eb0fdff0af979c48..0000000000000000000000000000000000000000 --- a/crates/sidebar/Cargo.toml +++ /dev/null @@ -1,51 +0,0 @@ -[package] -name = "sidebar" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/sidebar.rs" - -[features] -default = [] - -[dependencies] -acp_thread.workspace = true -agent.workspace = true -agent-client-protocol.workspace = true -agent_ui.workspace = true -chrono.workspace = true -editor.workspace = true -feature_flags.workspace = true -fs.workspace = true -gpui.workspace = true -menu.workspace = true -project.workspace = true -recent_projects.workspace = true -settings.workspace = true -theme.workspace = true -ui.workspace = true -util.workspace = true -workspace.workspace = true -zed_actions.workspace = true - -[dev-dependencies] -acp_thread = { workspace = true, features = ["test-support"] } -agent = { workspace = true, features = ["test-support"] } -agent_ui = { workspace = true, features = ["test-support"] } -assistant_text_thread = { workspace = true, features = ["test-support"] } -editor.workspace = true -language_model = { workspace = true, features = ["test-support"] } -serde_json.workspace = true -feature_flags.workspace = true -fs = { workspace = true, features = ["test-support"] } -gpui = { workspace = true, features = ["test-support"] } -project = { workspace = true, features = ["test-support"] } -settings = { workspace = true, features = ["test-support"] } -workspace = { workspace = true, features = ["test-support"] } -recent_projects = { workspace = true, features = ["test-support"] } diff --git a/crates/sidebar/LICENSE-GPL b/crates/sidebar/LICENSE-GPL deleted file mode 120000 index 89e542f750cd3860a0598eff0dc34b56d7336dc4..0000000000000000000000000000000000000000 --- a/crates/sidebar/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index b5c10835c6bf85ea24db1ff9bad5abbbf3b517ee..f6483d1d70d4017edf8ab8b188d67ecf85e19aef 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -38,7 +38,6 @@ chrono.workspace = true client.workspace = true cloud_api_types.workspace = true db.workspace = true -feature_flags.workspace = true git_ui.workspace = true gpui = { workspace = true, features = ["screen-capture"] } notifications.workspace = true diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 96cc929c06039c14a9ce4eaa05fd067fbd95b7d0..916d58426b76f020bce8a9bf69971f34bc3803a4 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -24,16 +24,13 @@ use auto_update::AutoUpdateStatus; use call::ActiveCall; use client::{Client, UserStore, zed_urls}; use cloud_api_types::Plan; -use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ Action, AnyElement, App, Context, Corner, Element, Empty, Entity, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div, }; use onboarding_banner::OnboardingBanner; -use project::{ - DisableAiSettings, Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees, -}; +use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees}; use remote::RemoteConnectionOptions; use settings::Settings; use settings::WorktreeId; @@ -47,8 +44,7 @@ use ui::{ use update_version::UpdateVersion; use util::ResultExt; use workspace::{ - MultiWorkspace, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace, - notifications::NotifyResultExt, + MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt, }; use zed_actions::OpenRemote; @@ -174,7 +170,6 @@ impl Render for TitleBar { let mut render_project_items = title_bar_settings.show_branch_name || title_bar_settings.show_project_items; title_bar - .children(self.render_workspace_sidebar_toggle(window, cx)) .when_some( self.application_menu.clone().filter(|_| !show_menus), |title_bar, menu| { @@ -357,7 +352,6 @@ impl TitleBar { // Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar. { - let platform_titlebar = platform_titlebar.clone(); let window_handle = window.window_handle(); cx.spawn(async move |this: WeakEntity, cx| { let Some(multi_workspace_handle) = window_handle.downcast::() @@ -370,26 +364,8 @@ impl TitleBar { return; }; - let is_open = multi_workspace.read(cx).is_sidebar_open(); - let has_notifications = multi_workspace.read(cx).sidebar_has_notifications(cx); - platform_titlebar.update(cx, |titlebar, cx| { - titlebar.set_workspace_sidebar_open(is_open, cx); - titlebar.set_sidebar_has_notifications(has_notifications, cx); - }); - - let platform_titlebar = platform_titlebar.clone(); - let subscription = cx.observe(&multi_workspace, move |mw, cx| { - let is_open = mw.read(cx).is_sidebar_open(); - let has_notifications = mw.read(cx).sidebar_has_notifications(cx); - platform_titlebar.update(cx, |titlebar, cx| { - titlebar.set_workspace_sidebar_open(is_open, cx); - titlebar.set_sidebar_has_notifications(has_notifications, cx); - }); - }); - if let Some(this) = this.upgrade() { this.update(cx, |this, _| { - this._subscriptions.push(subscription); this.multi_workspace = Some(multi_workspace.downgrade()); }); } @@ -686,46 +662,7 @@ impl TitleBar { ) } - fn render_workspace_sidebar_toggle( - &self, - _window: &mut Window, - cx: &mut Context, - ) -> Option { - if !cx.has_flag::() || DisableAiSettings::get_global(cx).disable_ai { - return None; - } - - let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open(); - - if is_sidebar_open { - return None; - } - - let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications(); - - Some( - IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed) - .icon_size(IconSize::Small) - .when(has_notifications, |button| { - button - .indicator(Indicator::dot().color(Color::Accent)) - .indicator_border_color(Some(cx.theme().colors().title_bar_background)) - }) - .tooltip(move |_, cx| { - Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) - }) - .on_click(|_, window, cx| { - window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); - }) - .into_any_element(), - ) - } - - pub fn render_project_name( - &self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { + pub fn render_project_name(&self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let workspace = self.workspace.clone(); let name = self.effective_active_worktree(cx).map(|worktree| { @@ -741,19 +678,6 @@ impl TitleBar { "Open Recent Project".to_string() }; - let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open(); - - if is_sidebar_open { - return self - .render_project_name_with_sidebar_popover( - window, - display_name, - is_project_selected, - cx, - ) - .into_any_element(); - } - let focus_handle = workspace .upgrade() .map(|w| w.read(cx).focus_handle(cx)) @@ -793,49 +717,6 @@ impl TitleBar { .into_any_element() } - fn render_project_name_with_sidebar_popover( - &self, - _window: &Window, - display_name: String, - is_project_selected: bool, - cx: &mut Context, - ) -> impl IntoElement { - let multi_workspace = self.multi_workspace.clone(); - - let is_popover_deployed = multi_workspace - .as_ref() - .and_then(|mw| mw.upgrade()) - .map(|mw| mw.read(cx).is_recent_projects_popover_deployed(cx)) - .unwrap_or(false); - - Button::new("project_name_trigger", display_name) - .label_size(LabelSize::Small) - .when(self.worktree_count(cx) > 1, |this| { - this.icon(IconName::ChevronDown) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - }) - .toggle_state(is_popover_deployed) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .when(!is_project_selected, |s| s.color(Color::Muted)) - .tooltip(move |_window, cx| { - Tooltip::for_action( - "Recent Projects", - &zed_actions::OpenRecent { - create_new_window: false, - }, - cx, - ) - }) - .on_click(move |_, window, cx| { - if let Some(mw) = multi_workspace.as_ref().and_then(|mw| mw.upgrade()) { - mw.update(cx, |mw, cx| { - mw.toggle_recent_projects_popover(window, cx); - }); - } - }) - } - pub fn render_project_branch(&self, cx: &mut Context) -> Option { let effective_worktree = self.effective_active_worktree(cx)?; let repository = self.get_repository_for_worktree(&effective_worktree, cx)?; diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 26af1ce27ecc28b7b541625a16731d0d721a7fc9..adfc62a2bd210b4da24202d734ba9f9eedd17aef 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -1,9 +1,8 @@ use anyhow::Result; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ - AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId, - actions, deferred, px, + App, Context, Entity, EntityId, EventEmitter, Focusable, ManagedView, Pixels, Render, + Subscription, Task, Tiling, Window, WindowId, actions, px, }; use project::{DisableAiSettings, Project}; use settings::Settings; @@ -12,11 +11,12 @@ use std::path::PathBuf; use ui::prelude::*; use util::ResultExt; -const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); +pub const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); use crate::{ CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, Panel, Toast, Workspace, WorkspaceId, client_side_decorations, notifications::NotificationId, + persistence::model::MultiWorkspaceId, }; actions!( @@ -41,31 +41,6 @@ pub enum MultiWorkspaceEvent { WorkspaceRemoved(EntityId), } -pub enum SidebarEvent { - Open, - Close, -} - -pub trait Sidebar: EventEmitter + Focusable + Render + Sized { - fn width(&self, cx: &App) -> Pixels; - fn set_width(&mut self, width: Option, cx: &mut Context); - fn has_notifications(&self, cx: &App) -> bool; - fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App); - fn is_recent_projects_popover_deployed(&self) -> bool; -} - -pub trait SidebarHandle: 'static + Send + Sync { - fn width(&self, cx: &App) -> Pixels; - fn set_width(&self, width: Option, cx: &mut App); - fn focus_handle(&self, cx: &App) -> FocusHandle; - fn focus(&self, window: &mut Window, cx: &mut App); - fn has_notifications(&self, cx: &App) -> bool; - fn to_any(&self) -> AnyView; - fn entity_id(&self) -> EntityId; - fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App); - fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool; -} - #[derive(Clone)] pub struct DraggedSidebar; @@ -75,54 +50,11 @@ impl Render for DraggedSidebar { } } -impl SidebarHandle for Entity { - fn width(&self, cx: &App) -> Pixels { - self.read(cx).width(cx) - } - - fn set_width(&self, width: Option, cx: &mut App) { - self.update(cx, |this, cx| this.set_width(width, cx)) - } - - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.read(cx).focus_handle(cx) - } - - fn focus(&self, window: &mut Window, cx: &mut App) { - let handle = self.read(cx).focus_handle(cx); - window.focus(&handle, cx); - } - - fn has_notifications(&self, cx: &App) -> bool { - self.read(cx).has_notifications(cx) - } - - fn to_any(&self) -> AnyView { - self.clone().into() - } - - fn entity_id(&self) -> EntityId { - Entity::entity_id(self) - } - - fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) { - self.update(cx, |this, cx| { - this.toggle_recent_projects_popover(window, cx); - }); - } - - fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool { - self.read(cx).is_recent_projects_popover_deployed() - } -} - pub struct MultiWorkspace { window_id: WindowId, workspaces: Vec>, + database_id: Option, active_workspace_index: usize, - sidebar: Option>, - sidebar_open: bool, - _sidebar_subscription: Option, pending_removal_tasks: Vec>, _serialize_task: Option>, _create_task: Option>, @@ -131,6 +63,10 @@ pub struct MultiWorkspace { impl EventEmitter for MultiWorkspace {} +pub fn multi_workspace_enabled(cx: &App) -> bool { + cx.has_flag::() && !DisableAiSettings::get_global(cx).disable_ai +} + impl MultiWorkspace { pub fn new(workspace: Entity, window: &mut Window, cx: &mut Context) -> Self { let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| { @@ -145,142 +81,17 @@ impl MultiWorkspace { } }); let quit_subscription = cx.on_app_quit(Self::app_will_quit); - let settings_subscription = - cx.observe_global_in::(window, |this, window, cx| { - if DisableAiSettings::get_global(cx).disable_ai && this.sidebar_open { - this.close_sidebar(window, cx); - } - }); Self::subscribe_to_workspace(&workspace, cx); Self { window_id: window.window_handle().window_id(), + database_id: None, workspaces: vec![workspace], active_workspace_index: 0, - sidebar: None, - sidebar_open: false, - _sidebar_subscription: None, pending_removal_tasks: Vec::new(), _serialize_task: None, _create_task: None, - _subscriptions: vec![ - release_subscription, - quit_subscription, - settings_subscription, - ], - } - } - - pub fn register_sidebar( - &mut self, - sidebar: Entity, - window: &mut Window, - cx: &mut Context, - ) { - let subscription = - cx.subscribe_in(&sidebar, window, |this, _, event, window, cx| match event { - SidebarEvent::Open => this.toggle_sidebar(window, cx), - SidebarEvent::Close => { - this.close_sidebar(window, cx); - } - }); - self.sidebar = Some(Box::new(sidebar)); - self._sidebar_subscription = Some(subscription); - } - - pub fn sidebar(&self) -> Option<&dyn SidebarHandle> { - self.sidebar.as_deref() - } - - pub fn sidebar_open(&self) -> bool { - self.sidebar_open && self.sidebar.is_some() - } - - pub fn sidebar_has_notifications(&self, cx: &App) -> bool { - self.sidebar - .as_ref() - .map_or(false, |s| s.has_notifications(cx)) - } - - pub fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) { - if let Some(sidebar) = &self.sidebar { - sidebar.toggle_recent_projects_popover(window, cx); - } - } - - pub fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool { - self.sidebar - .as_ref() - .map_or(false, |s| s.is_recent_projects_popover_deployed(cx)) - } - - pub fn multi_workspace_enabled(&self, cx: &App) -> bool { - cx.has_flag::() && !DisableAiSettings::get_global(cx).disable_ai - } - - pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context) { - if !self.multi_workspace_enabled(cx) { - return; - } - - if self.sidebar_open { - self.close_sidebar(window, cx); - } else { - self.open_sidebar(cx); - if let Some(sidebar) = &self.sidebar { - sidebar.focus(window, cx); - } - } - } - - pub fn focus_sidebar(&mut self, window: &mut Window, cx: &mut Context) { - if !self.multi_workspace_enabled(cx) { - return; - } - - if self.sidebar_open { - let sidebar_is_focused = self - .sidebar - .as_ref() - .is_some_and(|s| s.focus_handle(cx).contains_focused(window, cx)); - - if sidebar_is_focused { - let pane = self.workspace().read(cx).active_pane().clone(); - let pane_focus = pane.read(cx).focus_handle(cx); - window.focus(&pane_focus, cx); - } else if let Some(sidebar) = &self.sidebar { - sidebar.focus(window, cx); - } - } else { - self.open_sidebar(cx); - if let Some(sidebar) = &self.sidebar { - sidebar.focus(window, cx); - } - } - } - - pub fn open_sidebar(&mut self, cx: &mut Context) { - self.sidebar_open = true; - for workspace in &self.workspaces { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(true, cx); - }); - } - self.serialize(cx); - cx.notify(); - } - - fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context) { - self.sidebar_open = false; - for workspace in &self.workspaces { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(false, cx); - }); + _subscriptions: vec![release_subscription, quit_subscription], } - let pane = self.workspace().read(cx).active_pane().clone(); - let pane_focus = pane.read(cx).focus_handle(cx); - window.focus(&pane_focus, cx); - self.serialize(cx); - cx.notify(); } pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context) { @@ -318,10 +129,6 @@ impl MultiWorkspace { .detach(); } - pub fn is_sidebar_open(&self) -> bool { - self.sidebar_open - } - pub fn workspace(&self) -> &Entity { &self.workspaces[self.active_workspace_index] } @@ -335,7 +142,7 @@ impl MultiWorkspace { } pub fn activate(&mut self, workspace: Entity, cx: &mut Context) { - if !self.multi_workspace_enabled(cx) { + if !multi_workspace_enabled(cx) { self.workspaces[0] = workspace; self.active_workspace_index = 0; cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); @@ -371,11 +178,6 @@ impl MultiWorkspace { if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) { index } else { - if self.sidebar_open { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(true, cx); - }); - } Self::subscribe_to_workspace(&workspace, cx); self.workspaces.push(workspace.clone()); cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); @@ -384,6 +186,14 @@ impl MultiWorkspace { } } + pub fn database_id(&self) -> Option { + self.database_id + } + + pub fn set_database_id(&mut self, id: Option) { + self.database_id = id; + } + pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context) { debug_assert!( index < self.workspaces.len(), @@ -421,7 +231,6 @@ impl MultiWorkspace { let window_id = self.window_id; let state = crate::persistence::model::MultiWorkspaceState { active_workspace_id: self.workspace().read(cx).database_id(), - sidebar_open: self.sidebar_open, }; self._serialize_task = Some(cx.background_spawn(async move { crate::persistence::write_multi_workspace_state(window_id, state).await; @@ -540,7 +349,7 @@ impl MultiWorkspace { self.workspace().read(cx).items_of_type::(cx) } - pub fn database_id(&self, cx: &App) -> Option { + pub fn active_workspace_database_id(&self, cx: &App) -> Option { self.workspace().read(cx).database_id() } @@ -583,7 +392,7 @@ impl MultiWorkspace { } pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context) { - if !self.multi_workspace_enabled(cx) { + if !multi_workspace_enabled(cx) { return; } let app_state = self.workspace().read(cx).app_state().clone(); @@ -692,7 +501,7 @@ impl MultiWorkspace { ) -> Task> { let workspace = self.workspace().clone(); - if self.multi_workspace_enabled(cx) { + if multi_workspace_enabled(cx) { workspace.update(cx, |workspace, cx| { workspace.open_workspace_for_paths(true, paths, window, cx) }) @@ -719,57 +528,6 @@ impl MultiWorkspace { impl Render for MultiWorkspace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let multi_workspace_enabled = self.multi_workspace_enabled(cx); - - let sidebar: Option = if multi_workspace_enabled && self.sidebar_open { - self.sidebar.as_ref().map(|sidebar_handle| { - let weak = cx.weak_entity(); - - let sidebar_width = sidebar_handle.width(cx); - let resize_handle = deferred( - div() - .id("sidebar-resize-handle") - .absolute() - .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) - .top(px(0.)) - .h_full() - .w(SIDEBAR_RESIZE_HANDLE_SIZE) - .cursor_col_resize() - .on_drag(DraggedSidebar, |dragged, _, _, cx| { - cx.stop_propagation(); - cx.new(|_| dragged.clone()) - }) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up(MouseButton::Left, move |event, _, cx| { - if event.click_count == 2 { - weak.update(cx, |this, cx| { - if let Some(sidebar) = this.sidebar.as_mut() { - sidebar.set_width(None, cx); - } - }) - .ok(); - cx.stop_propagation(); - } - }) - .occlude(), - ); - - div() - .id("sidebar-container") - .relative() - .h_full() - .w(sidebar_width) - .flex_shrink_0() - .child(sidebar_handle.to_any()) - .child(resize_handle) - .into_any_element() - }) - } else { - None - }; - let ui_font = theme::setup_ui_font(window, cx); let text_color = cx.theme().colors().text; @@ -799,32 +557,6 @@ impl Render for MultiWorkspace { this.activate_previous_workspace(window, cx); }, )) - .when(self.multi_workspace_enabled(cx), |this| { - this.on_action(cx.listener( - |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| { - this.toggle_sidebar(window, cx); - }, - )) - .on_action(cx.listener( - |this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| { - this.focus_sidebar(window, cx); - }, - )) - }) - .when( - self.sidebar_open() && self.multi_workspace_enabled(cx), - |this| { - this.on_drag_move(cx.listener( - |this: &mut Self, e: &DragMoveEvent, _window, cx| { - if let Some(sidebar) = &this.sidebar { - let new_width = e.event.position.x; - sidebar.set_width(Some(new_width), cx); - } - }, - )) - .children(sidebar) - }, - ) .child( div() .flex() @@ -837,98 +569,9 @@ impl Render for MultiWorkspace { window, cx, Tiling { - left: multi_workspace_enabled && self.sidebar_open, + left: false, ..Tiling::default() }, ) } } - -#[cfg(test)] -mod tests { - 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); - DisableAiSettings::register(cx); - cx.update_flags(false, vec!["agent-v2".into()]); - }); - } - - #[gpui::test] - async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, [], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - - multi_workspace.read_with(cx, |mw, cx| { - assert!(mw.multi_workspace_enabled(cx)); - }); - - multi_workspace.update_in(cx, |mw, _window, cx| { - mw.open_sidebar(cx); - assert!(mw.is_sidebar_open()); - }); - - cx.update(|_window, cx| { - DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx); - }); - cx.run_until_parked(); - - multi_workspace.read_with(cx, |mw, cx| { - assert!( - !mw.is_sidebar_open(), - "Sidebar should be closed when disable_ai is true" - ); - assert!( - !mw.multi_workspace_enabled(cx), - "Multi-workspace should be disabled when disable_ai is true" - ); - }); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.toggle_sidebar(window, cx); - }); - multi_workspace.read_with(cx, |mw, _cx| { - assert!( - !mw.is_sidebar_open(), - "Sidebar should remain closed when toggled with disable_ai true" - ); - }); - - cx.update(|_window, cx| { - DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx); - }); - cx.run_until_parked(); - - multi_workspace.read_with(cx, |mw, cx| { - assert!( - mw.multi_workspace_enabled(cx), - "Multi-workspace should be enabled after re-enabling AI" - ); - assert!( - !mw.is_sidebar_open(), - "Sidebar should still be closed after re-enabling AI (not auto-opened)" - ); - }); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.toggle_sidebar(window, cx); - }); - multi_workspace.read_with(cx, |mw, _cx| { - assert!( - mw.is_sidebar_open(), - "Sidebar should open when toggled after re-enabling AI" - ); - }); - } -} diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 492b7a8f385730feaa06dfe3b5e8b4cc0a20bb59..9f0b035049ebb5bfbeef7211acee9ced5288bb47 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -341,6 +341,7 @@ pub fn read_serialized_multi_workspaces( .map(read_multi_workspace_state) .unwrap_or_default(); model::SerializedMultiWorkspace { + id: window_id.map(|id| model::MultiWorkspaceId(id.as_u64())), workspaces: group, state, } @@ -3877,7 +3878,6 @@ mod tests { window_10, MultiWorkspaceState { active_workspace_id: Some(WorkspaceId(2)), - sidebar_open: true, }, ) .await; @@ -3886,7 +3886,6 @@ mod tests { window_20, MultiWorkspaceState { active_workspace_id: Some(WorkspaceId(3)), - sidebar_open: false, }, ) .await; @@ -3924,23 +3923,20 @@ mod tests { // Should produce 3 groups: window 10, window 20, and the orphan. assert_eq!(results.len(), 3); - // Window 10 group: 2 workspaces, active_workspace_id = 2, sidebar open. + // Window 10 group: 2 workspaces, active_workspace_id = 2. let group_10 = &results[0]; assert_eq!(group_10.workspaces.len(), 2); assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2))); - assert_eq!(group_10.state.sidebar_open, true); - // Window 20 group: 1 workspace, active_workspace_id = 3, sidebar closed. + // Window 20 group: 1 workspace, active_workspace_id = 3. let group_20 = &results[1]; assert_eq!(group_20.workspaces.len(), 1); assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3))); - assert_eq!(group_20.state.sidebar_open, false); // Orphan group: no window_id, so state is default. let group_none = &results[2]; assert_eq!(group_none.workspaces.len(), 1); assert_eq!(group_none.state.active_workspace_id, None); - assert_eq!(group_none.state.sidebar_open, false); } #[gpui::test] diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 0971ebd0ddc9265ccf9ea10da7745ba59914db30..c5251f20be9313a50f2256c54823d8839bdfe7fd 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -63,18 +63,19 @@ pub struct SessionWorkspace { #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct MultiWorkspaceState { pub active_workspace_id: Option, - pub sidebar_open: bool, } -/// The serialized state of a single MultiWorkspace window from a previous session: -/// all workspaces that shared the window, which one was active, and whether the -/// sidebar was open. +/// The serialized state of a single MultiWorkspace window from a previous session. #[derive(Debug, Clone)] pub struct SerializedMultiWorkspace { + pub id: Option, pub workspaces: Vec, pub state: MultiWorkspaceState, } +#[derive(Debug, Clone, Copy)] +pub struct MultiWorkspaceId(pub u64); + #[derive(Debug, PartialEq, Clone)] pub(crate) struct SerializedWorkspace { pub(crate) id: WorkspaceId, diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 5e0b8a7f6eabbd652f1f429342a837aa0b43e6d2..9087cbba42b054c1b247bdf3d9402688de4b7add 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -34,7 +34,6 @@ pub struct StatusBar { right_items: Vec>, active_pane: Entity, _observe_active_pane: Subscription, - workspace_sidebar_open: bool, } impl Render for StatusBar { @@ -52,10 +51,9 @@ impl Render for StatusBar { .when(!(tiling.bottom || tiling.right), |el| { el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) }) - .when( - !(tiling.bottom || tiling.left) && !self.workspace_sidebar_open, - |el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING), - ) + .when(!(tiling.bottom || tiling.left), |el| { + el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING) + }) // This border is to avoid a transparent gap in the rounded corners .mb(px(-1.)) .border_b(px(1.0)) @@ -91,17 +89,11 @@ impl StatusBar { _observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| { this.update_active_pane_item(window, cx) }), - workspace_sidebar_open: false, }; this.update_active_pane_item(window, cx); this } - pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { - self.workspace_sidebar_open = open; - cx.notify(); - } - pub fn add_left_item(&mut self, item: Entity, window: &mut Window, cx: &mut Context) where T: 'static + StatusItemView, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 90f05d07a3a87a53ca25a1dc15da7663a95984a8..b57b5028a4e5558b1f90c715463165ba68d914e3 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -28,8 +28,8 @@ pub use crate::notifications::NotificationFrame; pub use dock::Panel; pub use multi_workspace::{ DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, - NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent, - SidebarHandle, ToggleWorkspaceSidebar, + NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow, + SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, multi_workspace_enabled, }; pub use path_list::{PathList, SerializedPathList}; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; @@ -80,8 +80,8 @@ use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace}; pub use persistence::{ DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items, model::{ - DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation, - SessionWorkspace, + DockStructure, ItemId, MultiWorkspaceId, SerializedMultiWorkspace, + SerializedWorkspaceLocation, SessionWorkspace, }, read_serialized_multi_workspaces, }; @@ -2154,12 +2154,6 @@ impl Workspace { &self.status_bar } - pub fn set_workspace_sidebar_open(&self, open: bool, cx: &mut App) { - self.status_bar.update(cx, |status_bar, cx| { - status_bar.set_workspace_sidebar_open(open, cx); - }); - } - pub fn status_bar_visible(&self, cx: &App) -> bool { StatusBarSettings::get_global(cx).show } @@ -8184,7 +8178,11 @@ pub async fn restore_multiworkspace( app_state: Arc, cx: &mut AsyncApp, ) -> anyhow::Result { - let SerializedMultiWorkspace { workspaces, state } = multi_workspace; + let SerializedMultiWorkspace { + workspaces, + state, + id: window_id, + } = multi_workspace; let mut group_iter = workspaces.into_iter(); let first = group_iter .next() @@ -8248,6 +8246,7 @@ pub async fn restore_multiworkspace( if let Some(target_id) = state.active_workspace_id { window_handle .update(cx, |multi_workspace, window, cx| { + multi_workspace.set_database_id(window_id); let target_index = multi_workspace .workspaces() .iter() @@ -8269,14 +8268,6 @@ pub async fn restore_multiworkspace( .ok(); } - if state.sidebar_open { - window_handle - .update(cx, |multi_workspace, _, cx| { - multi_workspace.open_sidebar(cx); - }) - .ok(); - } - window_handle .update(cx, |_, window, _cx| { window.activate_window(); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 9c0c892ad7105cc5be9b3dd548659aa1f12a7966..2f61121d9c0aeb80a77d36bc4836b33c63936584 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -182,7 +182,6 @@ settings.workspace = true settings_profile_selector.workspace = true settings_ui.workspace = true shellexpand.workspace = true -sidebar.workspace = true smol.workspace = true snippet_provider.workspace = true snippets_ui.workspace = true diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index ead16b911e3ccf9ebd1b9f54113cb01dca849e9d..37642b012edcd133dfe770a4c57c5404658582b5 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -103,8 +103,8 @@ use { feature_flags::FeatureFlagAppExt as _, git_ui::project_diff::ProjectDiff, gpui::{ - App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString, VisualTestAppContext, - WindowBounds, WindowHandle, WindowOptions, point, px, size, + Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString, + VisualTestAppContext, WindowBounds, WindowHandle, WindowOptions, point, px, size, }, image::RgbaImage, project_panel::ProjectPanel, @@ -2649,22 +2649,6 @@ fn run_multi_workspace_sidebar_visual_tests( cx.run_until_parked(); - // Create the sidebar and register it on the MultiWorkspace - let sidebar = multi_workspace_window - .update(cx, |_multi_workspace, window, cx| { - let multi_workspace_handle = cx.entity(); - cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx)) - }) - .context("Failed to create sidebar")?; - - multi_workspace_window - .update(cx, |multi_workspace, window, cx| { - multi_workspace.register_sidebar(sidebar.clone(), window, cx); - }) - .context("Failed to register sidebar")?; - - cx.run_until_parked(); - // Save test threads to the ThreadStore for each workspace let save_tasks = multi_workspace_window .update(cx, |multi_workspace, _window, cx| { @@ -2742,8 +2726,8 @@ fn run_multi_workspace_sidebar_visual_tests( // Open the sidebar multi_workspace_window - .update(cx, |multi_workspace, window, cx| { - multi_workspace.toggle_sidebar(window, cx); + .update(cx, |_multi_workspace, window, cx| { + window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx); }) .context("Failed to toggle sidebar")?; @@ -3181,24 +3165,10 @@ edition = "2021" cx.run_until_parked(); - // Create and register the workspace sidebar - let sidebar = workspace_window - .update(cx, |_multi_workspace, window, cx| { - let multi_workspace_handle = cx.entity(); - cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx)) - }) - .context("Failed to create sidebar")?; - - workspace_window - .update(cx, |multi_workspace, window, cx| { - multi_workspace.register_sidebar(sidebar.clone(), window, cx); - }) - .context("Failed to register sidebar")?; - // Open the sidebar workspace_window - .update(cx, |multi_workspace, window, cx| { - multi_workspace.toggle_sidebar(window, cx); + .update(cx, |_multi_workspace, window, cx| { + window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx); }) .context("Failed to toggle sidebar")?; diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 6eee25e6faddae5fdaae7ac2704a10a979b30ce7..b64bcbf3ab9ab5e29fdd473a200c2367e3f6f777 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -68,7 +68,6 @@ use settings::{ initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content, update_settings_file, }; -use sidebar::Sidebar; use std::time::Duration; use std::{ borrow::Cow, @@ -389,20 +388,6 @@ pub fn initialize_workspace( }) .unwrap_or(true) }); - - let window_handle = window.window_handle(); - let multi_workspace_handle = cx.entity(); - cx.defer(move |cx| { - window_handle - .update(cx, |_, window, cx| { - let sidebar = - cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx)); - multi_workspace_handle.update(cx, |multi_workspace, cx| { - multi_workspace.register_sidebar(sidebar, window, cx); - }); - }) - .ok(); - }); }) .detach(); From c08fd438ecce6c75834bf884ae49cd3574f7e62a Mon Sep 17 00:00:00 2001 From: Kyle Kelley Date: Wed, 11 Mar 2026 01:12:18 -0700 Subject: [PATCH 121/219] languages: Validate pylsp binary before returning from check_if_user_installed (#51034) Run `pylsp --version` via `delegate.try_exec()` in both branches of `PyLspAdapter::check_if_user_installed` before returning the binary. If execution fails (broken shebang, missing interpreter, etc.), log a warning and return None so the system falls through gracefully instead of surfacing an error dialog. This matches the existing validation pattern used by TyLspAdapter and RuffLspAdapter in their `fetch_server_binary` implementations. No idea if this closes an issue but it sure was annoying on a system where I deleted the pylsp environment I had. It surprised me too since I had pylsp disabled in settings. Release Notes: - Fixed detection of when `pylsp` is not installed properly on a user's system so that it doesn't get launched as an LSP when it doesn't exist. --- crates/languages/src/python.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 95bfc798414f5d3629e1ea46f54d14a7ed58a8d4..078db5ba027c4d089b7c2f62cbd7e8468e526171 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -1846,6 +1846,17 @@ impl LspInstaller for PyLspAdapter { ) -> Option { if let Some(pylsp_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await { let env = delegate.shell_env().await; + delegate + .try_exec(LanguageServerBinary { + path: pylsp_bin.clone(), + arguments: vec!["--version".into()], + env: Some(env.clone()), + }) + .await + .inspect_err(|err| { + log::warn!("failed to validate user-installed pylsp at {pylsp_bin:?}: {err:#}") + }) + .ok()?; Some(LanguageServerBinary { path: pylsp_bin, env: Some(env), @@ -1854,7 +1865,21 @@ impl LspInstaller for PyLspAdapter { } else { let toolchain = toolchain?; let pylsp_path = Path::new(toolchain.path.as_ref()).parent()?.join("pylsp"); - pylsp_path.exists().then(|| LanguageServerBinary { + if !pylsp_path.exists() { + return None; + } + delegate + .try_exec(LanguageServerBinary { + path: toolchain.path.to_string().into(), + arguments: vec![pylsp_path.clone().into(), "--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!("failed to validate toolchain pylsp at {pylsp_path:?}: {err:#}") + }) + .ok()?; + Some(LanguageServerBinary { path: toolchain.path.to_string().into(), arguments: vec![pylsp_path.into()], env: None, From deccb78ff1e9c37460da2ffd8708c5c49bb2db02 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 11 Mar 2026 09:49:04 +0100 Subject: [PATCH 122/219] agent_ui: Fix thread summarization not working (#51259) Release Notes: - N/A --- crates/agent_ui/src/connection_view.rs | 22 ++++++++++++++----- .../src/connection_view/thread_view.rs | 7 ++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index fd4ac66c05e380ddd3e1c3e2c196c5a397754c9d..3f1f1fb164693e0bb9e0b6d8883b97ab5539ba4f 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -3782,8 +3782,16 @@ pub(crate) mod tests { } impl Render for ThreadViewItem { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.0.clone().into_any_element() + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Render the title editor in the element tree too. In the real app + // it is part of the agent panel + let title_editor = self + .0 + .read(cx) + .active_thread() + .map(|t| t.read(cx).title_editor.clone()); + + v_flex().children(title_editor).child(self.0.clone()) } } @@ -6060,6 +6068,7 @@ pub(crate) mod tests { init_test(cx); let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + add_to_workspace(thread_view.clone(), cx); let active = active_thread(&thread_view, cx); let title_editor = cx.read(|cx| active.read(cx).title_editor.clone()); @@ -6069,9 +6078,12 @@ pub(crate) mod tests { assert!(!editor.read_only(cx)); }); - title_editor.update_in(cx, |editor, window, cx| { - editor.set_text("My Custom Title", window, cx); - }); + cx.focus(&thread_view); + cx.focus(&title_editor); + + cx.dispatch_action(editor::actions::DeleteLine); + cx.simulate_input("My Custom Title"); + cx.run_until_parked(); title_editor.read_with(cx, |editor, cx| { diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 806b2c9c397de1c729164b5f859ceae4b7f6231f..771d80f08306838e756a2ea3dd8aa4b378cfd402 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -1464,6 +1464,13 @@ impl ThreadView { match event { EditorEvent::BufferEdited => { + // We only want to set the title if the user has actively edited + // it. If the title editor is not focused, we programmatically + // changed the text, so we don't want to set the title again. + if !title_editor.read(cx).is_focused(window) { + return; + } + let new_title = title_editor.read(cx).text(cx); thread.update(cx, |thread, cx| { thread From 2c59990135f299a976b5d9cd4664b787f04bc451 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 11 Mar 2026 12:51:54 +0200 Subject: [PATCH 123/219] Do not load runnables and diagnostics in git panel (#51270) Release Notes: - N/A --------- Co-authored-by: Jakub Konka --- crates/editor/src/document_colors.rs | 2 +- crates/editor/src/document_symbols.rs | 2 +- crates/editor/src/editor.rs | 56 ++++++++++++---------- crates/editor/src/folding_ranges.rs | 2 +- crates/editor/src/inlays/inlay_hints.rs | 2 +- crates/editor/src/linked_editing_ranges.rs | 2 +- crates/editor/src/semantic_tokens.rs | 2 +- crates/editor/src/split.rs | 3 ++ 8 files changed, 40 insertions(+), 31 deletions(-) diff --git a/crates/editor/src/document_colors.rs b/crates/editor/src/document_colors.rs index 579414c7f91c6b2770951a2439599abc4000b27c..a38a0527f0641ef2d622b2f33fa1e932080ad7b5 100644 --- a/crates/editor/src/document_colors.rs +++ b/crates/editor/src/document_colors.rs @@ -145,7 +145,7 @@ impl Editor { _: &Window, cx: &mut Context, ) { - if !self.mode().is_full() { + if !self.lsp_data_enabled() { return; } let Some(project) = self.project.as_ref() else { diff --git a/crates/editor/src/document_symbols.rs b/crates/editor/src/document_symbols.rs index b73c1abbfb9bfec86093eed72082232275388faf..0228bbd917ad96b94778b2fc01d3a66e81224296 100644 --- a/crates/editor/src/document_symbols.rs +++ b/crates/editor/src/document_symbols.rs @@ -147,7 +147,7 @@ impl Editor { for_buffer: Option, cx: &mut Context, ) { - if !self.mode().is_full() { + if !self.lsp_data_enabled() { return; } let Some(project) = self.project.clone() else { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index aabf16d2b64846388b6b1c0903e280e9f465a41d..a08ac3bbc466d159ce81a7aa3bebf82599914a0b 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -7733,7 +7733,7 @@ impl Editor { #[ztracing::instrument(skip_all)] fn refresh_outline_symbols_at_cursor(&mut self, cx: &mut Context) { - if !self.mode.is_full() { + if !self.lsp_data_enabled() { return; } let cursor = self.selections.newest_anchor().head(); @@ -17154,13 +17154,17 @@ impl Editor { } fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context) -> Task<()> { - if !EditorSettings::get_global(cx).gutter.runnables || !self.enable_runnables { + if !self.mode().is_full() + || !EditorSettings::get_global(cx).gutter.runnables + || !self.enable_runnables + { self.clear_tasks(); return Task::ready(()); } let project = self.project().map(Entity::downgrade); let task_sources = self.lsp_task_sources(cx); let multi_buffer = self.buffer.downgrade(); + let lsp_data_enabled = self.lsp_data_enabled(); cx.spawn_in(window, async move |editor, cx| { cx.background_executor().timer(UPDATE_DEBOUNCE).await; let Some(project) = project.and_then(|p| p.upgrade()) else { @@ -17176,20 +17180,27 @@ impl Editor { if hide_runnables { return; } - let new_rows = - cx.background_spawn({ + let new_rows = cx + .background_spawn({ let snapshot = display_snapshot.clone(); async move { - Self::fetch_runnable_ranges(&snapshot, Anchor::min()..Anchor::max()) + snapshot + .buffer_snapshot() + .runnable_ranges(Anchor::min()..Anchor::max()) + .collect() } }) - .await; - let Ok(lsp_tasks) = - cx.update(|_, cx| crate::lsp_tasks(project.clone(), &task_sources, None, cx)) - else { - return; + .await; + let lsp_tasks = if lsp_data_enabled { + let Ok(lsp_tasks) = + cx.update(|_, cx| crate::lsp_tasks(project.clone(), &task_sources, None, cx)) + else { + return; + }; + lsp_tasks.await + } else { + Vec::new() }; - let lsp_tasks = lsp_tasks.await; let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| { lsp_tasks @@ -17270,12 +17281,6 @@ impl Editor { .ok(); }) } - fn fetch_runnable_ranges( - snapshot: &DisplaySnapshot, - range: Range, - ) -> Vec<(Range, language::RunnableRange)> { - snapshot.buffer_snapshot().runnable_ranges(range).collect() - } fn runnable_rows( project: Entity, @@ -19607,7 +19612,7 @@ impl Editor { } pub fn diagnostics_enabled(&self) -> bool { - self.diagnostics_enabled && self.mode.is_full() + self.diagnostics_enabled && self.lsp_data_enabled() } pub fn inline_diagnostics_enabled(&self) -> bool { @@ -19771,10 +19776,7 @@ impl Editor { // `ActiveDiagnostic::All` is a special mode where editor's diagnostics are managed by the external view, // skip any LSP updates for it. - if self.active_diagnostics == ActiveDiagnostic::All - || !self.mode().is_full() - || !self.diagnostics_enabled() - { + if self.active_diagnostics == ActiveDiagnostic::All || !self.diagnostics_enabled() { return None; } let pull_diagnostics_settings = ProjectSettings::get_global(cx) @@ -25628,13 +25630,17 @@ impl Editor { } } + fn lsp_data_enabled(&self) -> bool { + self.enable_lsp_data && self.mode().is_full() + } + fn update_lsp_data( &mut self, for_buffer: Option, window: &mut Window, cx: &mut Context<'_, Self>, ) { - if !self.enable_lsp_data { + if !self.lsp_data_enabled() { return; } @@ -25648,7 +25654,7 @@ impl Editor { } fn register_visible_buffers(&mut self, cx: &mut Context) { - if !self.mode().is_full() { + if !self.lsp_data_enabled() { return; } for (_, (visible_buffer, _, _)) in self.visible_excerpts(true, cx) { @@ -25657,7 +25663,7 @@ impl Editor { } fn register_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { - if !self.mode().is_full() { + if !self.lsp_data_enabled() { return; } diff --git a/crates/editor/src/folding_ranges.rs b/crates/editor/src/folding_ranges.rs index 593095b004792be2055b0dc2614d086f114acd5e..745fdcbe30a0aede4f364afd5c58958c74b3da79 100644 --- a/crates/editor/src/folding_ranges.rs +++ b/crates/editor/src/folding_ranges.rs @@ -13,7 +13,7 @@ impl Editor { _window: &Window, cx: &mut Context, ) { - if !self.mode().is_full() || !self.use_document_folding_ranges { + if !self.lsp_data_enabled() || !self.use_document_folding_ranges { return; } let Some(project) = self.project.clone() else { diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs index 0b3f6bda09c2cf86b994682e2ed89c2614d72737..62eb35f1ac85227c9b52737660da0d1834e1bbfa 100644 --- a/crates/editor/src/inlays/inlay_hints.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -292,7 +292,7 @@ impl Editor { reason: InlayHintRefreshReason, cx: &mut Context, ) { - if !self.mode().is_full() || self.inlay_hints.is_none() { + if !self.lsp_data_enabled() || self.inlay_hints.is_none() { return; } let Some(semantics_provider) = self.semantics_provider() else { diff --git a/crates/editor/src/linked_editing_ranges.rs b/crates/editor/src/linked_editing_ranges.rs index 34fc1e97df2b01cb3e35b95ec90d0c8d31f5790a..ccd0e64bd850f6ce84e225fe77f1c0a0d5385dc1 100644 --- a/crates/editor/src/linked_editing_ranges.rs +++ b/crates/editor/src/linked_editing_ranges.rs @@ -50,7 +50,7 @@ pub(super) fn refresh_linked_ranges( window: &mut Window, cx: &mut Context, ) -> Option<()> { - if !editor.mode().is_full() || editor.pending_rename.is_some() { + if !editor.lsp_data_enabled() || editor.pending_rename.is_some() { return None; } let project = editor.project()?.downgrade(); diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index 31a573f04787e3759a6a21ec15f36ec148a80f30..e95b20aed5a6655d6ae4ccd2c6658cfcfecc2ea4 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -119,7 +119,7 @@ impl Editor { for_server: Option, cx: &mut Context, ) { - if !self.mode().is_full() || !self.semantic_token_state.enabled() { + if !self.lsp_data_enabled() || !self.semantic_token_state.enabled() { self.invalidate_semantic_tokens(None); self.display_map.update(cx, |display_map, _| { match Arc::get_mut(&mut display_map.semantic_token_highlights) { diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index 4e5f8ebf2793f6807e0a9108e12c276a7ab45427..877f388fc3b783202cb29f8ca063446635e4277a 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -446,6 +446,9 @@ impl SplittableEditor { let mut editor = Editor::for_multibuffer(rhs_multibuffer.clone(), Some(project.clone()), window, cx); editor.set_expand_all_diff_hunks(cx); + editor.disable_runnables(); + editor.disable_diagnostics(cx); + editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx); editor }); // TODO(split-diff) we might want to tag editor events with whether they came from rhs/lhs From e5fb57c8afc76731df15be3ae5510fb1d5bce965 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 11 Mar 2026 11:53:22 +0100 Subject: [PATCH 124/219] extension_rollout: Add incremental rollout (#51264) This will allow us to test changes against just a subset of extensions. Another advantage is that extension workflows will be pinned, which allows for easier debugging and better predictability. Release Notes: - N/A --- .github/workflows/bump_patch_version.yml | 2 +- .../workflows/extension_workflow_rollout.yml | 145 ++++++---- tooling/xtask/src/tasks/workflows.rs | 83 +++++- .../workflows/extension_workflow_rollout.rs | 263 ++++++++++++------ .../workflows/extensions/bump_version.rs | 8 +- .../tasks/workflows/extensions/run_tests.rs | 9 +- tooling/xtask/src/tasks/workflows/steps.rs | 20 +- 7 files changed, 356 insertions(+), 174 deletions(-) diff --git a/.github/workflows/bump_patch_version.yml b/.github/workflows/bump_patch_version.yml index 480d8b0ada98e859d2e72b49a39805ffe8f72b25..62540321ed755f2fd3879a7ddfc3a37237d8e7de 100644 --- a/.github/workflows/bump_patch_version.yml +++ b/.github/workflows/bump_patch_version.yml @@ -23,8 +23,8 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: clean: false - token: ${{ steps.get-app-token.outputs.token }} ref: ${{ inputs.branch }} + token: ${{ steps.get-app-token.outputs.token }} - name: bump_patch_version::run_bump_patch_version::bump_patch_version run: | channel="$(cat crates/zed/RELEASE_CHANNEL)" diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index 9bfac06d4527985553ba3d04e64c656ee5bf85e4..cbb813d91749bf3843b64372f12e50f6a3c3e785 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -4,12 +4,57 @@ name: extension_workflow_rollout env: CARGO_TERM_COLOR: always on: - workflow_dispatch: {} + workflow_dispatch: + inputs: + filter-repos: + description: Comma-separated list of repository names to rollout to. Leave empty for all repos. + type: string + default: '' + change-description: + description: Description for the changes to be expected with this rollout + type: string + default: '' jobs: fetch_extension_repos: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' runs-on: namespace-profile-2x4-ubuntu-2404 steps: + - name: checkout_zed_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: 0 + - id: prev-tag + name: extension_workflow_rollout::fetch_extension_repos::get_previous_tag_commit + run: | + PREV_COMMIT=$(git rev-parse "extension-workflows^{commit}" 2>/dev/null || echo "") + if [ -z "$PREV_COMMIT" ]; then + echo "::error::No previous rollout tag 'extension-workflows' found. Cannot determine file changes." + exit 1 + fi + echo "Found previous rollout at commit: $PREV_COMMIT" + echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT" + - id: calc-changes + name: extension_workflow_rollout::fetch_extension_repos::get_removed_files + run: | + for workflow_type in "ci" "shared"; do + if [ "$workflow_type" = "ci" ]; then + WORKFLOW_DIR="extensions/workflows" + else + WORKFLOW_DIR="extensions/workflows/shared" + fi + + REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \ + awk '/^D/ { print $2 } /^R/ { print $2 }' | \ + xargs -I{} basename {} 2>/dev/null | \ + tr '\n' ' ' || echo "") + REMOVED=$(echo "$REMOVED" | xargs) + + echo "Removed files for $workflow_type: $REMOVED" + echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT" + done + env: + 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 @@ -21,16 +66,42 @@ jobs: per_page: 100, }); - const filteredRepos = repos + let filteredRepos = repos .filter(repo => !repo.archived) .map(repo => repo.name); + const filterInput = `${{ inputs.filter-repos }}`.trim(); + if (filterInput.length > 0) { + const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0); + filteredRepos = filteredRepos.filter(name => allowedNames.includes(name)); + console.log(`Filter applied. Matched ${filteredRepos.length} repos from ${allowedNames.length} requested.`); + } + console.log(`Found ${filteredRepos.length} extension repos`); return filteredRepos; result-encoding: json + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + path: ~/.rustup + - name: extension_workflow_rollout::fetch_extension_repos::generate_workflow_files + run: | + cargo xtask workflows "$COMMIT_SHA" + env: + COMMIT_SHA: ${{ github.sha }} + - name: extension_workflow_rollout::fetch_extension_repos::upload_workflow_files + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: extension-workflow-files + path: extensions/workflows/**/*.yml + if-no-files-found: error outputs: repos: ${{ steps.list-repos.outputs.result }} - timeout-minutes: 5 + prev_commit: ${{ steps.prev-tag.outputs.prev_commit }} + removed_ci: ${{ steps.calc-changes.outputs.removed_ci }} + removed_shared: ${{ steps.calc-changes.outputs.removed_shared }} + timeout-minutes: 10 rollout_workflows_to_extension: needs: - fetch_extension_repos @@ -53,59 +124,28 @@ jobs: permission-pull-requests: write permission-contents: write permission-workflows: write - - name: checkout_zed_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - with: - clean: false - fetch-depth: 0 - path: zed - name: checkout_extension_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: clean: false - token: ${{ steps.generate-token.outputs.token }} path: extension repository: zed-extensions/${{ matrix.repo }} - - id: prev-tag - name: extension_workflow_rollout::rollout_workflows_to_extension::get_previous_tag_commit - run: | - PREV_COMMIT=$(git rev-parse "extension-workflows^{commit}" 2>/dev/null || echo "") - if [ -z "$PREV_COMMIT" ]; then - echo "::error::No previous rollout tag 'extension-workflows' found. Cannot determine file changes." - exit 1 - fi - echo "Found previous rollout at commit: $PREV_COMMIT" - echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT" - working-directory: zed - - id: calc-changes - name: extension_workflow_rollout::rollout_workflows_to_extension::get_removed_files + token: ${{ steps.generate-token.outputs.token }} + - name: extension_workflow_rollout::rollout_workflows_to_extension::download_workflow_files + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 + with: + name: extension-workflow-files + path: workflow-files + - name: extension_workflow_rollout::rollout_workflows_to_extension::sync_workflow_files run: | + mkdir -p extension/.github/workflows + if [ "$MATRIX_REPO" = "workflows" ]; then - WORKFLOW_DIR="extensions/workflows" + REMOVED_FILES="$REMOVED_CI" else - WORKFLOW_DIR="extensions/workflows/shared" + REMOVED_FILES="$REMOVED_SHARED" fi - echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR" - - # Get deleted files (status D) and renamed files (status R - old name needs removal) - # Using -M to detect renames, then extracting files that are gone from their original location - REMOVED_FILES=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \ - awk '/^D/ { print $2 } /^R/ { print $2 }' | \ - xargs -I{} basename {} 2>/dev/null | \ - tr '\n' ' ' || echo "") - - REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs) - - echo "Files to remove: $REMOVED_FILES" - echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT" - env: - PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }} - MATRIX_REPO: ${{ matrix.repo }} - working-directory: zed - - name: extension_workflow_rollout::rollout_workflows_to_extension::sync_workflow_files - run: | - mkdir -p extension/.github/workflows cd extension/.github/workflows if [ -n "$REMOVED_FILES" ]; then @@ -119,18 +159,18 @@ jobs: cd - > /dev/null if [ "$MATRIX_REPO" = "workflows" ]; then - cp zed/extensions/workflows/*.yml extension/.github/workflows/ + cp workflow-files/extensions/workflows/*.yml extension/.github/workflows/ else - cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/ + cp workflow-files/extensions/workflows/shared/*.yml extension/.github/workflows/ fi env: - REMOVED_FILES: ${{ steps.calc-changes.outputs.removed_files }} + REMOVED_CI: ${{ needs.fetch_extension_repos.outputs.removed_ci }} + REMOVED_SHARED: ${{ needs.fetch_extension_repos.outputs.removed_shared }} MATRIX_REPO: ${{ matrix.repo }} - id: short-sha name: extension_workflow_rollout::rollout_workflows_to_extension::get_short_sha run: | - echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT" - working-directory: zed + echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" - id: create-pr name: extension_workflow_rollout::rollout_workflows_to_extension::create_pull_request uses: peter-evans/create-pull-request@v7 @@ -140,6 +180,8 @@ jobs: body: | This PR updates the CI workflow files from the main Zed repository based on the commit zed-industries/zed@${{ github.sha }} + + ${{ inputs.change-description }} commit-message: Update CI workflows to `${{ steps.short-sha.outputs.sha_short }}` branch: update-workflows committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> @@ -151,16 +193,17 @@ jobs: - name: extension_workflow_rollout::rollout_workflows_to_extension::enable_auto_merge run: | if [ -n "$PR_NUMBER" ]; then - cd extension gh pr merge "$PR_NUMBER" --auto --squash fi env: GH_TOKEN: ${{ steps.generate-token.outputs.token }} PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }} + working-directory: extension timeout-minutes: 10 create_rollout_tag: needs: - rollout_workflows_to_extension + if: inputs.filter-repos == '' runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 9151b9c671ef42e3dc54661f80438a4e31aff1e9..26596c9401c1d3c500a8c1cb18083d525c934e20 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -29,38 +29,99 @@ mod runners; mod steps; mod vars; +#[derive(Clone)] +pub(crate) struct GitSha(String); + +impl AsRef for GitSha { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[allow( + clippy::disallowed_methods, + reason = "This runs only in a CLI environment" +)] +fn parse_ref(value: &str) -> Result { + const GIT_SHA_LENGTH: usize = 40; + (value.len() == GIT_SHA_LENGTH) + .then_some(value) + .ok_or_else(|| { + format!( + "Git SHA has wrong length! \ + Only SHAs with a full length of {GIT_SHA_LENGTH} are supported, found {len} characters.", + len = value.len() + ) + }) + .and_then(|value| { + let mut tmp = [0; 4]; + value + .chars() + .all(|char| u16::from_str_radix(char.encode_utf8(&mut tmp), 16).is_ok()).then_some(value) + .ok_or_else(|| "Not a valid Git SHA".to_owned()) + }) + .and_then(|sha| { + std::process::Command::new("git") + .args([ + "rev-parse", + "--quiet", + "--verify", + &format!("{sha}^{{commit}}") + ]) + .output() + .map_err(|_| "Failed to spawn Git command to verify SHA".to_owned()) + .and_then(|output| + output + .status.success() + .then_some(sha) + .ok_or_else(|| format!("SHA {sha} is not a valid Git SHA within this repository!"))) + }).map(|sha| GitSha(sha.to_owned())) +} + #[derive(Parser)] -pub struct GenerateWorkflowArgs {} +pub(crate) struct GenerateWorkflowArgs { + #[arg(value_parser = parse_ref)] + /// The Git SHA to use when invoking this + pub(crate) sha: Option, +} + +enum WorkflowSource { + Contextless(fn() -> Workflow), + WithContext(fn(&GenerateWorkflowArgs) -> Workflow), +} struct WorkflowFile { - source: fn() -> Workflow, + source: WorkflowSource, r#type: WorkflowType, } impl WorkflowFile { fn zed(f: fn() -> Workflow) -> WorkflowFile { WorkflowFile { - source: f, + source: WorkflowSource::Contextless(f), r#type: WorkflowType::Zed, } } - fn extension(f: fn() -> Workflow) -> WorkflowFile { + fn extension(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile { WorkflowFile { - source: f, + source: WorkflowSource::WithContext(f), r#type: WorkflowType::ExtensionCi, } } - fn extension_shared(f: fn() -> Workflow) -> WorkflowFile { + fn extension_shared(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile { WorkflowFile { - source: f, + source: WorkflowSource::WithContext(f), r#type: WorkflowType::ExtensionsShared, } } - fn generate_file(&self) -> Result<()> { - let workflow = (self.source)(); + fn generate_file(&self, workflow_args: &GenerateWorkflowArgs) -> Result<()> { + let workflow = match &self.source { + WorkflowSource::Contextless(f) => f(), + WorkflowSource::WithContext(f) => f(workflow_args), + }; let workflow_folder = self.r#type.folder_path(); fs::create_dir_all(&workflow_folder).with_context(|| { @@ -124,7 +185,7 @@ impl WorkflowType { } } -pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> { +pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> { if !Path::new("crates/zed/").is_dir() { anyhow::bail!("xtask workflows must be ran from the project root"); } @@ -154,7 +215,7 @@ pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> { ]; for workflow_file in workflows { - workflow_file.generate_file()?; + workflow_file.generate_file(&args)?; } workflow_checks::validate(Default::default()) diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs index 6f03ad1521850fb24c5bad7265ebf913228c5077..91154c91061fd2e8a51e60704eca0f9b0b94c900 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -6,46 +6,72 @@ use indoc::indoc; use serde_json::json; use crate::tasks::workflows::steps::CheckoutStep; +use crate::tasks::workflows::steps::cache_rust_dependencies_namespace; +use crate::tasks::workflows::vars::JobOutput; use crate::tasks::workflows::{ extension_bump::{RepositoryTarget, generate_token}, runners, steps::{self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named}, - vars::{self, StepOutput}, + vars::{self, StepOutput, WorkflowInput}, }; const ROLLOUT_TAG_NAME: &str = "extension-workflows"; +const WORKFLOW_ARTIFACT_NAME: &str = "extension-workflow-files"; pub(crate) fn extension_workflow_rollout() -> Workflow { - let fetch_repos = fetch_extension_repos(); - let rollout_workflows = rollout_workflows_to_extension(&fetch_repos); - let create_tag = create_rollout_tag(&rollout_workflows); + let filter_repos_input = WorkflowInput::string("filter-repos", Some(String::new())) + .description( + "Comma-separated list of repository names to rollout to. Leave empty for all repos.", + ); + let extra_context_input = WorkflowInput::string("change-description", Some(String::new())) + .description("Description for the changes to be expected with this rollout"); + + let (fetch_repos, removed_ci, removed_shared) = fetch_extension_repos(&filter_repos_input); + let rollout_workflows = rollout_workflows_to_extension( + &fetch_repos, + removed_ci, + removed_shared, + &extra_context_input, + ); + let create_tag = create_rollout_tag(&rollout_workflows, &filter_repos_input); named::workflow() - .on(Event::default().workflow_dispatch(WorkflowDispatch::default())) + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default() + .add_input(filter_repos_input.name, filter_repos_input.input()) + .add_input(extra_context_input.name, extra_context_input.input()), + )) .add_env(("CARGO_TERM_COLOR", "always")) .add_job(fetch_repos.name, fetch_repos.job) .add_job(rollout_workflows.name, rollout_workflows.job) .add_job(create_tag.name, create_tag.job) } -fn fetch_extension_repos() -> NamedJob { - fn get_repositories() -> (Step, StepOutput) { +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") .id("list-repos") .add_with(( "script", - indoc::indoc! {r#" - const repos = await github.paginate(github.rest.repos.listForOrg, { + formatdoc! {r#" + const repos = await github.paginate(github.rest.repos.listForOrg, {{ org: 'zed-extensions', type: 'public', per_page: 100, - }); + }}); - const filteredRepos = repos + let filteredRepos = repos .filter(repo => !repo.archived) .map(repo => repo.name); - console.log(`Found ${filteredRepos.length} extension repos`); + const filterInput = `{filter_repos_input}`.trim(); + if (filterInput.length > 0) {{ + const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0); + filteredRepos = filteredRepos.filter(name => allowedNames.includes(name)); + console.log(`Filter applied. Matched ${{filteredRepos.length}} repos from ${{allowedNames.length}} requested.`); + }} + + console.log(`Found ${{filteredRepos.length}} extension repos`); return filteredRepos; "#}, )) @@ -56,36 +82,12 @@ fn fetch_extension_repos() -> NamedJob { (step, filtered_repos) } - let (get_org_repositories, list_repos_output) = get_repositories(); - - let job = Job::default() - .cond(Expression::new(format!( - "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'" - ))) - .runs_on(runners::LINUX_SMALL) - .timeout_minutes(5u32) - .outputs([("repos".to_owned(), list_repos_output.to_string())]) - .add_step(get_org_repositories); - - named::job(job) -} - -fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { fn checkout_zed_repo() -> CheckoutStep { steps::checkout_repo() .with_full_history() - .with_path("zed") .with_custom_name("checkout_zed_repo") } - fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep { - steps::checkout_repo() - .with_custom_name("checkout_extension_repo") - .with_token(token) - .with_repository("zed-extensions/${{ matrix.repo }}") - .with_path("extension") - } - fn get_previous_tag_commit() -> (Step, StepOutput) { let step = named::bash(formatdoc! {r#" PREV_COMMIT=$(git rev-parse "{ROLLOUT_TAG_NAME}^{{commit}}" 2>/dev/null || echo "") @@ -96,49 +98,126 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { echo "Found previous rollout at commit: $PREV_COMMIT" echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT" "#}) - .id("prev-tag") - .working_directory("zed"); + .id("prev-tag"); let step_output = StepOutput::new(&step, "prev_commit"); (step, step_output) } - fn get_removed_files(prev_commit: &StepOutput) -> (Step, StepOutput) { - let step = named::bash(indoc::indoc! {r#" - if [ "$MATRIX_REPO" = "workflows" ]; then - WORKFLOW_DIR="extensions/workflows" - else - WORKFLOW_DIR="extensions/workflows/shared" - fi - - echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR" + fn get_removed_files(prev_commit: &StepOutput) -> (Step, StepOutput, StepOutput) { + let step = named::bash(indoc! {r#" + for workflow_type in "ci" "shared"; do + if [ "$workflow_type" = "ci" ]; then + WORKFLOW_DIR="extensions/workflows" + else + WORKFLOW_DIR="extensions/workflows/shared" + fi + + REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \ + awk '/^D/ { print $2 } /^R/ { print $2 }' | \ + xargs -I{} basename {} 2>/dev/null | \ + tr '\n' ' ' || echo "") + REMOVED=$(echo "$REMOVED" | xargs) + + echo "Removed files for $workflow_type: $REMOVED" + echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT" + done + "#}) + .id("calc-changes") + .add_env(("PREV_COMMIT", prev_commit.to_string())); - # Get deleted files (status D) and renamed files (status R - old name needs removal) - # Using -M to detect renames, then extracting files that are gone from their original location - REMOVED_FILES=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \ - awk '/^D/ { print $2 } /^R/ { print $2 }' | \ - xargs -I{} basename {} 2>/dev/null | \ - tr '\n' ' ' || echo "") + let removed_ci = StepOutput::new(&step, "removed_ci"); + let removed_shared = StepOutput::new(&step, "removed_shared"); - REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs) + (step, removed_ci, removed_shared) + } - echo "Files to remove: $REMOVED_FILES" - echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT" + fn generate_workflow_files() -> Step { + named::bash(indoc! {r#" + cargo xtask workflows "$COMMIT_SHA" "#}) - .id("calc-changes") - .working_directory("zed") - .add_env(("PREV_COMMIT", prev_commit.to_string())) - .add_env(("MATRIX_REPO", "${{ matrix.repo }}")); + .add_env(("COMMIT_SHA", "${{ github.sha }}")) + } - let removed_files = StepOutput::new(&step, "removed_files"); + fn upload_workflow_files() -> Step { + named::uses( + "actions", + "upload-artifact", + "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5 + ) + .add_with(("name", WORKFLOW_ARTIFACT_NAME)) + .add_with(("path", "extensions/workflows/**/*.yml")) + .add_with(("if-no-files-found", "error")) + } - (step, removed_files) + let (get_org_repositories, list_repos_output) = get_repositories(filter_repos_input); + let (get_prev_tag, prev_commit) = get_previous_tag_commit(); + let (calc_changes, removed_ci, removed_shared) = get_removed_files(&prev_commit); + + let job = Job::default() + .cond(Expression::new(format!( + "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'" + ))) + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(10u32) + .outputs([ + ("repos".to_owned(), list_repos_output.to_string()), + ("prev_commit".to_owned(), prev_commit.to_string()), + ("removed_ci".to_owned(), removed_ci.to_string()), + ("removed_shared".to_owned(), removed_shared.to_string()), + ]) + .add_step(checkout_zed_repo()) + .add_step(get_prev_tag) + .add_step(calc_changes) + .add_step(get_org_repositories) + .add_step(cache_rust_dependencies_namespace()) + .add_step(generate_workflow_files()) + .add_step(upload_workflow_files()); + + let job = named::job(job); + let (removed_ci, removed_shared) = ( + removed_ci.as_job_output(&job), + removed_shared.as_job_output(&job), + ); + + (job, removed_ci, removed_shared) +} + +fn rollout_workflows_to_extension( + fetch_repos_job: &NamedJob, + removed_ci: JobOutput, + removed_shared: JobOutput, + extra_context_input: &WorkflowInput, +) -> NamedJob { + fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep { + steps::checkout_repo() + .with_custom_name("checkout_extension_repo") + .with_token(token) + .with_repository("zed-extensions/${{ matrix.repo }}") + .with_path("extension") + } + + fn download_workflow_files() -> Step { + named::uses( + "actions", + "download-artifact", + "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0 + ) + .add_with(("name", WORKFLOW_ARTIFACT_NAME)) + .add_with(("path", "workflow-files")) } - fn sync_workflow_files(removed_files: &StepOutput) -> Step { - named::bash(indoc::indoc! {r#" + fn sync_workflow_files(removed_ci: JobOutput, removed_shared: JobOutput) -> Step { + named::bash(indoc! {r#" mkdir -p extension/.github/workflows + + if [ "$MATRIX_REPO" = "workflows" ]; then + REMOVED_FILES="$REMOVED_CI" + else + REMOVED_FILES="$REMOVED_SHARED" + fi + cd extension/.github/workflows if [ -n "$REMOVED_FILES" ]; then @@ -152,40 +231,46 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { cd - > /dev/null if [ "$MATRIX_REPO" = "workflows" ]; then - cp zed/extensions/workflows/*.yml extension/.github/workflows/ + cp workflow-files/extensions/workflows/*.yml extension/.github/workflows/ else - cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/ + cp workflow-files/extensions/workflows/shared/*.yml extension/.github/workflows/ fi "#}) - .add_env(("REMOVED_FILES", removed_files.to_string())) + .add_env(("REMOVED_CI", removed_ci)) + .add_env(("REMOVED_SHARED", removed_shared)) .add_env(("MATRIX_REPO", "${{ matrix.repo }}")) } fn get_short_sha() -> (Step, StepOutput) { - let step = named::bash(indoc::indoc! {r#" - echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT" + let step = named::bash(indoc! {r#" + echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" "#}) - .id("short-sha") - .working_directory("zed"); + .id("short-sha"); let step_output = StepOutput::new(&step, "sha_short"); (step, step_output) } - fn create_pull_request(token: &StepOutput, short_sha: &StepOutput) -> Step { + fn create_pull_request( + token: &StepOutput, + short_sha: &StepOutput, + context_input: &WorkflowInput, + ) -> Step { let title = format!("Update CI workflows to `{short_sha}`"); + let body = formatdoc! {r#" + This PR updates the CI workflow files from the main Zed repository + based on the commit zed-industries/zed@${{{{ github.sha }}}} + + {context_input} + "#, + }; + named::uses("peter-evans", "create-pull-request", "v7") .add_with(("path", "extension")) .add_with(("title", title.clone())) - .add_with(( - "body", - indoc::indoc! {r#" - This PR updates the CI workflow files from the main Zed repository - based on the commit zed-industries/zed@${{ github.sha }} - "#}, - )) + .add_with(("body", body)) .add_with(("commit-message", title)) .add_with(("branch", "update-workflows")) .add_with(( @@ -204,12 +289,12 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { } fn enable_auto_merge(token: &StepOutput) -> Step { - named::bash(indoc::indoc! {r#" + named::bash(indoc! {r#" if [ -n "$PR_NUMBER" ]; then - cd extension gh pr merge "$PR_NUMBER" --auto --squash fi "#}) + .working_directory("extension") .add_env(("GH_TOKEN", token.to_string())) .add_env(( "PR_NUMBER", @@ -228,8 +313,6 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { ]), ), ); - let (get_prev_tag, prev_commit) = get_previous_tag_commit(); - let (calc_changes, removed_files) = get_removed_files(&prev_commit); let (calculate_short_sha, short_sha) = get_short_sha(); let job = Job::default() @@ -249,19 +332,17 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { })), ) .add_step(authenticate) - .add_step(checkout_zed_repo()) .add_step(checkout_extension_repo(&token)) - .add_step(get_prev_tag) - .add_step(calc_changes) - .add_step(sync_workflow_files(&removed_files)) + .add_step(download_workflow_files()) + .add_step(sync_workflow_files(removed_ci, removed_shared)) .add_step(calculate_short_sha) - .add_step(create_pull_request(&token, &short_sha)) + .add_step(create_pull_request(&token, &short_sha, extra_context_input)) .add_step(enable_auto_merge(&token)); named::job(job) } -fn create_rollout_tag(rollout_job: &NamedJob) -> NamedJob { +fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput) -> NamedJob { fn checkout_zed_repo(token: &StepOutput) -> CheckoutStep { steps::checkout_repo().with_full_history().with_token(token) } @@ -297,6 +378,10 @@ fn create_rollout_tag(rollout_job: &NamedJob) -> NamedJob { let job = Job::default() .needs([rollout_job.name.clone()]) + .cond(Expression::new(format!( + "{filter_repos} == ''", + filter_repos = filter_repos_input.expr(), + ))) .runs_on(runners::LINUX_SMALL) .timeout_minutes(1u32) .add_step(authenticate) diff --git a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs index 2d82f1351f21645a77b1d13e158bd4142dbec069..4dc2560e2bea489566fb8eb5ad5d04701835de29 100644 --- a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs +++ b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs @@ -5,17 +5,18 @@ use gh_workflow::{ use indoc::indoc; use crate::tasks::workflows::{ + GenerateWorkflowArgs, GitSha, extensions::WithAppSecrets, runners, steps::{CommonJobConditions, NamedJob, named}, vars::{JobOutput, StepOutput, one_workflow_per_non_main_branch_and_token}, }; -pub(crate) fn bump_version() -> Workflow { +pub(crate) fn bump_version(args: &GenerateWorkflowArgs) -> Workflow { let (determine_bump_type, bump_type) = determine_bump_type(); let bump_type = bump_type.as_job_output(&determine_bump_type); - let call_bump_version = call_bump_version(&determine_bump_type, bump_type); + let call_bump_version = call_bump_version(args.sha.as_ref(), &determine_bump_type, bump_type); named::workflow() .on(Event::default() @@ -32,6 +33,7 @@ pub(crate) fn bump_version() -> Workflow { } pub(crate) fn call_bump_version( + target_ref: Option<&GitSha>, depending_job: &NamedJob, bump_type: JobOutput, ) -> NamedJob { @@ -51,7 +53,7 @@ pub(crate) fn call_bump_version( "zed-industries", "zed", ".github/workflows/extension_bump.yml", - "main", + target_ref.map_or("main", AsRef::as_ref), ) .add_need(depending_job.name.clone()) .with( diff --git a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs index 0c0ca696612fa57903f35c0ea69404f5dc7d1fe0..ae8000c15cad3a206b9c02f8bc389a369f4df096 100644 --- a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs @@ -1,12 +1,13 @@ use gh_workflow::{Event, Job, Level, Permissions, PullRequest, Push, UsesJob, Workflow}; use crate::tasks::workflows::{ + GenerateWorkflowArgs, GitSha, steps::{NamedJob, named}, vars::one_workflow_per_non_main_branch_and_token, }; -pub(crate) fn run_tests() -> Workflow { - let call_extension_tests = call_extension_tests(); +pub(crate) fn run_tests(args: &GenerateWorkflowArgs) -> Workflow { + let call_extension_tests = call_extension_tests(args.sha.as_ref()); named::workflow() .on(Event::default() .pull_request(PullRequest::default().add_branch("**")) @@ -15,14 +16,14 @@ pub(crate) fn run_tests() -> Workflow { .add_job(call_extension_tests.name, call_extension_tests.job) } -pub(crate) fn call_extension_tests() -> NamedJob { +pub(crate) fn call_extension_tests(target_ref: Option<&GitSha>) -> NamedJob { let job = Job::default() .permissions(Permissions::default().contents(Level::Read)) .uses( "zed-industries", "zed", ".github/workflows/extension_tests.yml", - "main", + target_ref.map_or("main", AsRef::as_ref), ); named::job(job) diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 4d17be81322277d0093de5d547bf4f0849e38dc3..6bede217b74a1172db712b92ed3d50cd2af603b2 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -131,22 +131,12 @@ impl From for Step { FetchDepth::Full => step.add_with(("fetch-depth", 0)), FetchDepth::Custom(depth) => step.add_with(("fetch-depth", depth)), }) - .map(|step| match value.token { - Some(token) => step.add_with(("token", token)), - None => step, - }) - .map(|step| match value.path { - Some(path) => step.add_with(("path", path)), - None => step, - }) - .map(|step| match value.repository { - Some(repository) => step.add_with(("repository", repository)), - None => step, - }) - .map(|step| match value.ref_ { - Some(ref_) => step.add_with(("ref", ref_)), - None => step, + .when_some(value.path, |step, path| step.add_with(("path", path))) + .when_some(value.repository, |step, repository| { + step.add_with(("repository", repository)) }) + .when_some(value.ref_, |step, ref_| step.add_with(("ref", ref_))) + .when_some(value.token, |step, token| step.add_with(("token", token))) } } From 0c49aaae3743e349dc18452c90877dbdee59bee1 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 11 Mar 2026 12:15:44 +0100 Subject: [PATCH 125/219] extension_rollout: Fix workflow file path (#51273) Release Notes: - N/A --- .github/workflows/extension_workflow_rollout.yml | 4 ++-- .../xtask/src/tasks/workflows/extension_workflow_rollout.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index cbb813d91749bf3843b64372f12e50f6a3c3e785..f695b43ecac47a221bbc795d03e6ddd6259d7014 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -159,9 +159,9 @@ jobs: cd - > /dev/null if [ "$MATRIX_REPO" = "workflows" ]; then - cp workflow-files/extensions/workflows/*.yml extension/.github/workflows/ + cp workflow-files/*.yml extension/.github/workflows/ else - cp workflow-files/extensions/workflows/shared/*.yml extension/.github/workflows/ + cp workflow-files/shared/*.yml extension/.github/workflows/ fi env: REMOVED_CI: ${{ needs.fetch_extension_repos.outputs.removed_ci }} diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs index 91154c91061fd2e8a51e60704eca0f9b0b94c900..4e247fe16ca7b97638488c218684889c39cfcfa8 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -231,9 +231,9 @@ fn rollout_workflows_to_extension( cd - > /dev/null if [ "$MATRIX_REPO" = "workflows" ]; then - cp workflow-files/extensions/workflows/*.yml extension/.github/workflows/ + cp workflow-files/*.yml extension/.github/workflows/ else - cp workflow-files/extensions/workflows/shared/*.yml extension/.github/workflows/ + cp workflow-files/shared/*.yml extension/.github/workflows/ fi "#}) .add_env(("REMOVED_CI", removed_ci)) From a540b7c22761a2fe8e2db0854a912c67c15819d4 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Wed, 11 Mar 2026 13:09:08 +0100 Subject: [PATCH 126/219] livekit_client: Always use output device's reported channels num (#51276) Fixes a panic in livekit's process_reverse_stream for non-matching channel counts, e.g., upmixing 2->4, or downmixing 2->1. Release Notes: - Fixed a panic in livekit when joining a channel using legacy audio using a device with less or more than 2 channels. --- crates/livekit_client/src/livekit_client/playback.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index 4933b05fc51592535c1f729ae8038a62103511ba..f62de78b4f9fb702f03943b06270abb41aa68e34 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -258,7 +258,7 @@ impl AudioStack { apm: Arc>, mixer: Arc>, sample_rate: u32, - num_channels: u32, + _num_channels: u32, output_audio_device: Option, ) -> Result<()> { // Prevent App Nap from throttling audio playback on macOS. @@ -270,6 +270,7 @@ impl AudioStack { let mut device_change_listener = DeviceChangeListener::new(false)?; let (output_device, output_config) = crate::default_device(false, output_audio_device.as_ref())?; + info!("Output config: {output_config:?}"); let (end_on_drop_tx, end_on_drop_rx) = std::sync::mpsc::channel::<()>(); let mixer = mixer.clone(); let apm = apm.clone(); @@ -300,7 +301,12 @@ impl AudioStack { let sampled = resampler.remix_and_resample( mixed, sample_rate / 100, - num_channels, + // We need to assume output number of channels as otherwise we will + // crash in process_reverse_stream otherwise as livekit's audio resampler + // does not seem to support non-matching channel counts. + // NOTE: you can verify this by debug printing buf.len() after this stage. + // For 2->4 channel upmix, we should see buf.len=1920, buf we get only 960. + output_config.channels() as u32, sample_rate, output_config.channels() as u32, output_config.sample_rate(), From cbb7982989cbe57370f5529495044710f0b2ed2b Mon Sep 17 00:00:00 2001 From: Abinash Date: Wed, 11 Mar 2026 18:13:56 +0530 Subject: [PATCH 127/219] docs: Allow navigating search results with arrow keys (#50901) Closes #50604 https://github.com/user-attachments/assets/8a85b39e-e463-4fee-bc1f-2a03fe193ce3 https://github.com/user-attachments/assets/8290bb06-1eaf-4852-9568-97654e30211e ### Results: Now, you can scroll the search results with the arrow keys. ### Suggestion: While this is working, I like to propose some better UX. When you scroll with the arrow keys, the whole search results are scrolling along with the search bar. But, I think it would be better if we keep the search bar fixed on top and only the results scroll. Here is an example: https://github.com/user-attachments/assets/af9dce73-27c6-407b-8180-2f771a85303b If you think this will be better, please let me know, and I will update this PR. Thank you. Release Notes: - Fixed docs search results scrolling with arrow keys --------- Co-authored-by: Gaauwe Rombouts --- docs/theme/css/chrome.css | 19 ++++++++++++------- docs/theme/index.hbs | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/theme/css/chrome.css b/docs/theme/css/chrome.css index 3f4fa40bc41a9c034c50c94c10fd8d0222d6b720..8f5b40cc19ecfd6cbedd0e5f76b5121afa5e5273 100644 --- a/docs/theme/css/chrome.css +++ b/docs/theme/css/chrome.css @@ -368,7 +368,10 @@ mark.fade-out { .searchbar-outer { margin-inline-start: auto; margin-inline-end: auto; + width: 100%; max-width: var(--content-max-width); + box-sizing: border-box; + padding: 16px; } #searchbar { @@ -394,21 +397,21 @@ mark.fade-out { .searchresults-header { font-weight: bold; font-size: 1em; - padding-block-start: 18px; + padding-block-start: 0; padding-block-end: 0; - padding-inline-start: 5px; - padding-inline-end: 0; color: var(--searchresults-header-fg); } ul#searchresults { list-style: none; padding-inline-start: 0; + margin-block-end: 0; } ul#searchresults li { margin: 10px 0px; padding: 2px; border-radius: 2px; + scroll-margin-block-end: 10px; } ul#searchresults li.focus { background-color: var(--searchresults-li-bg); @@ -794,8 +797,7 @@ ul#searchresults span.teaser em { max-height: 600px; display: flex; flex-direction: column; - padding: 16px; - overflow-y: auto; + overflow-y: hidden; border-radius: 8px; background: var(--popover-bg); @@ -803,8 +805,11 @@ ul#searchresults span.teaser em { box-shadow: var(--popover-shadow); } -.searchbar-outer { - width: 100%; +.searchresults-outer { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0px 22px 22px 22px; } #searchbar { diff --git a/docs/theme/index.hbs b/docs/theme/index.hbs index 1c833ee94d428a1578b35c7944c4d300a04a21db..24378bcca6909b2e3e894c6c32db5f32d77921de 100644 --- a/docs/theme/index.hbs +++ b/docs/theme/index.hbs @@ -424,6 +424,31 @@ + + {{/if}} From 8d4913168c4ea3ac6a4d6cc1b70d3e7d006d8639 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 11 Mar 2026 13:52:03 +0100 Subject: [PATCH 128/219] acp: Update to `0.10.2` (#51280) Updates to `0.10.2`, most notable change is implementing `session/close`. Release Notes: - N/A --- Cargo.lock | 35 ++++++++++++++++++----- Cargo.toml | 2 +- crates/acp_thread/src/acp_thread.rs | 2 +- crates/acp_thread/src/connection.rs | 6 +++- crates/agent/src/agent.rs | 6 +++- crates/agent_servers/src/acp.rs | 39 +++++++++++++++++++++----- crates/agent_ui/src/connection_view.rs | 26 +++++++++++------ 7 files changed, 89 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f11d2023b319501778768fdea39fb8dbb242a9e9..6fbffbcaff377bdf49d02afae172a19e72a2d188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -227,9 +227,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2659b1089101b15db31137710159421cb44785ecdb5ba784be3b4a6f8cb8a475" +checksum = "9c56a59cf6315e99f874d2c1f96c69d2da5ffe0087d211297fc4a41f849770a2" dependencies = [ "agent-client-protocol-schema", "anyhow", @@ -244,16 +244,16 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "0.10.8" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44bc1fef9c32f03bce2ab44af35b6f483bfd169bf55cc59beeb2e3b1a00ae4d1" +checksum = "e0497b9a95a404e35799904835c57c6f8c69b9d08ccfd3cb5b7d746425cd6789" dependencies = [ "anyhow", "derive_more", "schemars", "serde", "serde_json", - "strum 0.27.2", + "strum 0.28.0", ] [[package]] @@ -7151,7 +7151,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "strum_macros", + "strum_macros 0.27.2", ] [[package]] @@ -16544,7 +16544,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -16559,6 +16568,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index c184837bfd6a67490169b7a6908b17b4d61e121f..f650dace84b1b2e6491acf2806077f72000605f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -473,7 +473,7 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # -agent-client-protocol = { version = "=0.9.4", features = ["unstable"] } +agent-client-protocol = { version = "=0.10.2", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } any_vec = "0.14" diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 58252eaddca553eb1da4c960a829a88afb9eb497..95030443f642b019b27758f53fd413c5146857b1 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -4027,7 +4027,7 @@ mod tests { } fn authenticate(&self, method: acp::AuthMethodId, _cx: &mut App) -> Task> { - if self.auth_methods().iter().any(|m| m.id == method) { + if self.auth_methods().iter().any(|m| m.id() == &method) { Task::ready(Ok(())) } else { Task::ready(Err(anyhow!("Invalid Auth Method"))) diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 1236058226eee840e1a36009df85291a774548dc..4f6aaf86bad68f919c2c5de30214b21ff851c3dd 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -60,7 +60,11 @@ pub trait AgentConnection { } /// Close an existing session. Allows the agent to free the session from memory. - fn close_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task> { + fn close_session( + self: Rc, + _session_id: &acp::SessionId, + _cx: &mut App, + ) -> Task> { Task::ready(Err(anyhow::Error::msg("Closing sessions is not supported"))) } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index a62e219b2d075e10e074b55859fc6c366c25523d..95346d665732b40599b096d480178264601ce6d6 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -1418,7 +1418,11 @@ impl acp_thread::AgentConnection for NativeAgentConnection { true } - fn close_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task> { + fn close_session( + self: Rc, + session_id: &acp::SessionId, + cx: &mut App, + ) -> Task> { self.0.update(cx, |agent, _cx| { let project_id = agent.sessions.get(session_id).map(|s| s.project_id); agent.sessions.remove(session_id); diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index b9e4eba497ef1e01016a17e34d634fea20cab499..a661289f6221818c6f63c799b0593907bb665eb9 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -279,7 +279,7 @@ impl AcpConnection { acp::InitializeRequest::new(acp::ProtocolVersion::V1) .client_capabilities( acp::ClientCapabilities::new() - .fs(acp::FileSystemCapability::new() + .fs(acp::FileSystemCapabilities::new() .read_text_file(true) .write_text_file(true)) .terminal(true) @@ -331,11 +331,11 @@ impl AcpConnection { "env": command.env.clone().unwrap_or_default(), }); let meta = acp::Meta::from_iter([("terminal-auth".to_string(), value)]); - vec![ - acp::AuthMethod::new("spawn-gemini-cli", "Login") + vec![acp::AuthMethod::Agent( + acp::AuthMethodAgent::new("spawn-gemini-cli", "Login") .description("Login with your Google or Vertex AI account") .meta(meta), - ] + )] } else { response.auth_methods }; @@ -744,6 +744,31 @@ impl AgentConnection for AcpConnection { }) } + fn supports_close_session(&self) -> bool { + self.agent_capabilities.session_capabilities.close.is_some() + } + + fn close_session( + self: Rc, + session_id: &acp::SessionId, + cx: &mut App, + ) -> Task> { + if !self.agent_capabilities.session_capabilities.close.is_none() { + return Task::ready(Err(anyhow!(LoadError::Other( + "Closing sessions is not supported by this agent.".into() + )))); + } + + let conn = self.connection.clone(); + let session_id = session_id.clone(); + cx.foreground_executor().spawn(async move { + conn.close_session(acp::CloseSessionRequest::new(session_id.clone())) + .await?; + self.sessions.borrow_mut().remove(&session_id); + Ok(()) + }) + } + fn auth_methods(&self) -> &[acp::AuthMethod] { &self.auth_methods } @@ -1373,10 +1398,10 @@ impl acp::Client for ClientDelegate { Ok(acp::CreateTerminalResponse::new(terminal_id)) } - async fn kill_terminal_command( + async fn kill_terminal( &self, - args: acp::KillTerminalCommandRequest, - ) -> Result { + args: acp::KillTerminalRequest, + ) -> Result { self.session_thread(&args.session_id)? .update(&mut self.cx.clone(), |thread, cx| { thread.kill_terminal(args.terminal_id, cx) diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 3f1f1fb164693e0bb9e0b6d8883b97ab5539ba4f..2fd86f9c9d91abb7d5b08bd7a779b93592f2011c 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -463,7 +463,7 @@ impl ConnectedServerState { let tasks = self .threads .keys() - .map(|id| self.connection.close_session(id, cx)); + .map(|id| self.connection.clone().close_session(id, cx)); let task = futures::future::join_all(tasks); cx.background_spawn(async move { task.await; @@ -1431,7 +1431,7 @@ impl ConnectionView { .connection() .auth_methods() .iter() - .any(|method| method.id.0.as_ref() == "claude-login") + .any(|method| method.id().0.as_ref() == "claude-login") { available_commands.push(acp::AvailableCommand::new("login", "Authenticate")); available_commands.push(acp::AvailableCommand::new("logout", "Authenticate")); @@ -1495,10 +1495,15 @@ impl ConnectionView { let agent_telemetry_id = connection.telemetry_id(); // Check for the experimental "terminal-auth" _meta field - let auth_method = connection.auth_methods().iter().find(|m| m.id == method); + let auth_method = connection.auth_methods().iter().find(|m| m.id() == &method); if let Some(terminal_auth) = auth_method - .and_then(|a| a.meta.as_ref()) + .and_then(|a| match a { + acp::AuthMethod::EnvVar(env_var) => env_var.meta.as_ref(), + acp::AuthMethod::Terminal(terminal) => terminal.meta.as_ref(), + acp::AuthMethod::Agent(agent) => agent.meta.as_ref(), + _ => None, + }) .and_then(|m| m.get("terminal-auth")) { // Extract terminal auth details from meta @@ -1882,7 +1887,7 @@ impl ConnectionView { .enumerate() .rev() .map(|(ix, method)| { - let (method_id, name) = (method.id.0.clone(), method.name.clone()); + let (method_id, name) = (method.id().0.clone(), method.name().to_string()); let agent_telemetry_id = connection.telemetry_id(); Button::new(method_id.clone(), name) @@ -1894,8 +1899,8 @@ impl ConnectionView { this.style(ButtonStyle::Outlined) } }) - .when_some(method.description.clone(), |this, description| { - this.tooltip(Tooltip::text(description)) + .when_some(method.description(), |this, description| { + this.tooltip(Tooltip::text(description.to_string())) }) .on_click({ cx.listener(move |this, _, window, cx| { @@ -4074,7 +4079,10 @@ pub(crate) mod tests { fn new() -> Self { Self { authenticated: Arc::new(Mutex::new(false)), - auth_method: acp::AuthMethod::new(Self::AUTH_METHOD_ID, "Test Login"), + auth_method: acp::AuthMethod::Agent(acp::AuthMethodAgent::new( + Self::AUTH_METHOD_ID, + "Test Login", + )), } } } @@ -4127,7 +4135,7 @@ pub(crate) mod tests { method_id: acp::AuthMethodId, _cx: &mut App, ) -> Task> { - if method_id == self.auth_method.id { + if &method_id == self.auth_method.id() { *self.authenticated.lock() = true; Task::ready(Ok(())) } else { From db9d9752c738158e3ded77aaf280ca3901d1ed52 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 11 Mar 2026 14:56:53 +0100 Subject: [PATCH 129/219] agent: Support providers streaming edits out of order (#51286) Release Notes: - N/A --- .../src/tools/streaming_edit_file_tool.rs | 127 +++++++++++++++++- 1 file changed, 124 insertions(+), 3 deletions(-) diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index c326ed3c10170d1c45517103ba02e178bec32c36..574fe078063b0b8e66ceb6cf0503ad139c23cdc4 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -118,7 +118,7 @@ pub struct Edit { pub new_text: String, } -#[derive(Default, Debug, Deserialize)] +#[derive(Clone, Default, Debug, Deserialize)] struct StreamingEditFileToolPartialInput { #[serde(default)] display_description: Option, @@ -132,7 +132,7 @@ struct StreamingEditFileToolPartialInput { edits: Option>, } -#[derive(Default, Debug, Deserialize)] +#[derive(Clone, Default, Debug, Deserialize)] pub struct PartialEdit { #[serde(default)] pub old_text: Option, @@ -314,12 +314,19 @@ impl AgentTool for StreamingEditFileTool { ) -> Task> { cx.spawn(async move |cx: &mut AsyncApp| { let mut state: Option = None; + let mut last_partial: Option = None; loop { futures::select! { partial = input.recv_partial().fuse() => { let Some(partial_value) = partial else { break }; if let Ok(parsed) = serde_json::from_value::(partial_value) { + let path_complete = parsed.path.is_some() + && parsed.path.as_ref() == last_partial.as_ref().and_then(|p| p.path.as_ref()); + + last_partial = Some(parsed.clone()); + if state.is_none() + && path_complete && let StreamingEditFileToolPartialInput { path: Some(path), display_description: Some(display_description), @@ -1907,6 +1914,13 @@ mod tests { let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); // Setup + single edit that stays in-progress (no second edit to prove completion) + sender.send_partial(json!({ + "display_description": "Single edit", + "path": "root/file.txt", + "mode": "edit", + })); + cx.run_until_parked(); + sender.send_partial(json!({ "display_description": "Single edit", "path": "root/file.txt", @@ -3475,6 +3489,12 @@ mod tests { let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); // Transition to BufferResolved + sender.send_partial(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + })); + cx.run_until_parked(); + sender.send_partial(json!({ "display_description": "Overwrite file", "path": "root/file.txt", @@ -3550,8 +3570,9 @@ mod tests { // Verify buffer still has old content (no content partial yet) let buffer = project.update(cx, |project, cx| { let path = project.find_project_path("root/file.txt", cx).unwrap(); - project.get_open_buffer(&path, cx).unwrap() + project.open_buffer(path, cx) }); + let buffer = buffer.await.unwrap(); assert_eq!( buffer.read_with(cx, |b, _| b.text()), "old line 1\nold line 2\nold line 3\n" @@ -3735,6 +3756,106 @@ mod tests { ); } + #[gpui::test] + async fn test_streaming_edit_file_tool_fields_out_of_order_in_write_mode( + cx: &mut TestAppContext, + ) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "write" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "write", + "content": "new_content" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "write", + "content": "new_content", + "path": "root" + })); + cx.run_until_parked(); + + // Send final. + sender.send_final(json!({ + "display_description": "Overwrite file", + "mode": "write", + "content": "new_content", + "path": "root/file.txt" + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new_content"); + } + + #[gpui::test] + async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode( + cx: &mut TestAppContext, + ) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content", "new_text": "new_content"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content", "new_text": "new_content"}], + "path": "root" + })); + cx.run_until_parked(); + + // Send final. + sender.send_final(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content", "new_text": "new_content"}], + "path": "root/file.txt" + })); + cx.run_until_parked(); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new_content"); + } + async fn setup_test_with_fs( cx: &mut TestAppContext, fs: Arc, From 3c82ddf261cd31d6150cd4aebf4ccbfc6518ea2e Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:35:59 -0300 Subject: [PATCH 130/219] git_ui: Fix "resolve with agent" merge conflict notification (#51290) Follow up to https://github.com/zed-industries/zed/pull/49807 This PR fixes the merge conflict notification by making it appear only once per a given set of conflicted paths, as opposed to showing every time the `ConflictsUpdated` or `StatusesChanged` even would fire. Release Notes: - N/A --- crates/git_ui/src/conflict_view.rs | 15 +++++++++++---- crates/workspace/src/notifications.rs | 14 ++++++++------ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 6c2c0b6f58696147da069b0aebdf55d396f7a388..7bb880abe6d1209aaf6b15d78979cc388bf37a36 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -15,7 +15,7 @@ use project::{ git_store::{GitStoreEvent, RepositoryEvent}, }; use settings::Settings; -use std::{ops::Range, sync::Arc}; +use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; use ui::{ActiveTheme, Divider, Element as _, Styled, Window, prelude::*}; use util::{ResultExt as _, debug_panic, maybe}; use workspace::{ @@ -534,7 +534,9 @@ pub(crate) fn register_conflict_notification( ) { let git_store = workspace.project().read(cx).git_store().clone(); - cx.subscribe(&git_store, |workspace, _git_store, event, cx| { + let last_shown_paths: Rc>> = Rc::new(RefCell::new(HashSet::default())); + + cx.subscribe(&git_store, move |workspace, _git_store, event, cx| { let conflicts_changed = matches!( event, GitStoreEvent::ConflictsUpdated @@ -546,10 +548,15 @@ pub(crate) fn register_conflict_notification( let paths = collect_conflicted_file_paths(workspace, cx); let notification_id = merge_conflict_notification_id(); + let current_paths_set: HashSet = paths.iter().cloned().collect(); if paths.is_empty() { + last_shown_paths.borrow_mut().clear(); workspace.dismiss_notification(¬ification_id, cx); - } else { + } else if *last_shown_paths.borrow() != current_paths_set { + // Only show the notification if the set of conflicted paths has changed. + // This prevents re-showing after the user dismisses it while working on the same conflicts. + *last_shown_paths.borrow_mut() = current_paths_set; let file_count = paths.len(); workspace.show_notification(notification_id, cx, |cx| { cx.new(|cx| { @@ -560,7 +567,7 @@ pub(crate) fn register_conflict_notification( }; MessageNotification::new(message, cx) - .primary_message("Resolve Conflicts with Agent") + .primary_message("Resolve with Agent") .primary_icon(IconName::ZedAssistant) .primary_icon_color(Color::Muted) .primary_on_click({ diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 84f479b77e4f0274e0775353d3a7cd5579768f1c..9f4b5538ed67bde3f32969467828296485b7810f 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -657,15 +657,17 @@ impl RenderOnce for NotificationFrame { IconButton::new(close_id, close_icon) .tooltip(move |_window, cx| { if suppress { - Tooltip::for_action( - "Suppress.\nClose with click.", - &SuppressNotification, + Tooltip::with_meta( + "Suppress", + Some(&SuppressNotification), + "Click to Close", cx, ) } else if show_suppress_button { - Tooltip::for_action( - "Close.\nSuppress with shift-click.", - &menu::Cancel, + Tooltip::with_meta( + "Close", + Some(&menu::Cancel), + "Shift-click to Suppress", cx, ) } else { From bee94e73923267d83c11d0cbad66293388e3c380 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 11 Mar 2026 10:41:31 -0400 Subject: [PATCH 131/219] Bump Zed to v0.229 (#51292) Release Notes: - N/A --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6fbffbcaff377bdf49d02afae172a19e72a2d188..6570398f5b22f2248a9cd59f84d2cf70080c3591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21756,7 +21756,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.228.0" +version = "0.229.0" dependencies = [ "acp_thread", "acp_tools", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 2f61121d9c0aeb80a77d36bc4836b33c63936584..b38e5a774d7efe6e46642ed226515d7dff7275d3 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.228.0" +version = "0.229.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From a8def21f53c18720d9c846434db9caae486890da Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 11 Mar 2026 14:52:57 +0000 Subject: [PATCH 132/219] agent: Add thread git stats to sidebar (#51287) --- crates/action_log/src/action_log.rs | 45 +++++++++++++++++++ crates/agent_ui/src/connection_view.rs | 4 +- .../src/connection_view/thread_view.rs | 37 --------------- crates/agent_ui/src/sidebar.rs | 19 ++++++++ crates/ui/src/components/ai/thread_item.rs | 45 +++++++++---------- 5 files changed, 86 insertions(+), 64 deletions(-) diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 28245944e39deca7fb2b3f86902f114420d31d20..3faf767c7020763eadc7db6c93af42f650a07434 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -1028,6 +1028,11 @@ impl ActionLog { .collect() } + /// Returns the total number of lines added and removed across all unreviewed buffers. + pub fn diff_stats(&self, cx: &App) -> DiffStats { + DiffStats::all_files(&self.changed_buffers(cx), cx) + } + /// Iterate over buffers changed since last read or edited by the model pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator> { self.tracked_buffers @@ -1044,6 +1049,46 @@ impl ActionLog { } } +#[derive(Default, Debug, Clone, Copy)] +pub struct DiffStats { + pub lines_added: u32, + pub lines_removed: u32, +} + +impl DiffStats { + pub fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self { + let mut stats = DiffStats::default(); + let diff_snapshot = diff.snapshot(cx); + let buffer_snapshot = buffer.snapshot(); + let base_text = diff_snapshot.base_text(); + + for hunk in diff_snapshot.hunks(&buffer_snapshot) { + let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); + stats.lines_added += added_rows; + + let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row; + let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row; + let removed_rows = base_end.saturating_sub(base_start); + stats.lines_removed += removed_rows; + } + + stats + } + + pub fn all_files( + changed_buffers: &BTreeMap, Entity>, + cx: &App, + ) -> Self { + let mut total = DiffStats::default(); + for (buffer, diff) in changed_buffers { + let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); + total.lines_added += stats.lines_added; + total.lines_removed += stats.lines_removed; + } + total + } +} + #[derive(Clone)] pub struct ActionLogTelemetry { pub agent_telemetry_id: SharedString, diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 2fd86f9c9d91abb7d5b08bd7a779b93592f2011c..b896741cee26e14ed372480f80d6cf8302db180b 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -5,7 +5,7 @@ use acp_thread::{ UserMessageId, }; use acp_thread::{AgentConnection, Plan}; -use action_log::{ActionLog, ActionLogTelemetry}; +use action_log::{ActionLog, ActionLogTelemetry, DiffStats}; use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore}; use agent_client_protocol::{self as acp, PromptCapabilities}; use agent_servers::AgentServer; @@ -46,7 +46,7 @@ use std::sync::Arc; use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; use terminal_view::terminal_panel::TerminalPanel; -use text::{Anchor, ToPoint as _}; +use text::Anchor; use theme::AgentFontSize; use ui::{ Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 771d80f08306838e756a2ea3dd8aa4b378cfd402..d4d23f5a0a0722afc5c588a355a6a9de1b59d194 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -156,43 +156,6 @@ impl ThreadFeedbackState { } } -#[derive(Default, Clone, Copy)] -struct DiffStats { - lines_added: u32, - lines_removed: u32, -} - -impl DiffStats { - fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self { - let mut stats = DiffStats::default(); - let diff_snapshot = diff.snapshot(cx); - let buffer_snapshot = buffer.snapshot(); - let base_text = diff_snapshot.base_text(); - - for hunk in diff_snapshot.hunks(&buffer_snapshot) { - let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); - stats.lines_added += added_rows; - - let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row; - let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row; - let removed_rows = base_end.saturating_sub(base_start); - stats.lines_removed += removed_rows; - } - - stats - } - - fn all_files(changed_buffers: &BTreeMap, Entity>, cx: &App) -> Self { - let mut total = DiffStats::default(); - for (buffer, diff) in changed_buffers { - let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); - total.lines_added += stats.lines_added; - total.lines_removed += stats.lines_removed; - } - total - } -} - pub enum AcpThreadViewEvent { FirstSendRequested { content: Vec }, } diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 2679807388eb6261f9bc32be10c10ed500078b22..ae3a4f0ccb9df6073ae24a9c482b6c56de0ea968 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1,5 +1,6 @@ use crate::{AgentPanel, AgentPanelEvent, NewThread}; use acp_thread::ThreadStatus; +use action_log::DiffStats; use agent::ThreadStore; use agent_client_protocol as acp; use agent_settings::AgentSettings; @@ -73,6 +74,7 @@ struct ActiveThreadInfo { icon: IconName, icon_from_external_svg: Option, is_background: bool, + diff_stats: DiffStats, } impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo { @@ -98,6 +100,7 @@ struct ThreadEntry { is_live: bool, is_background: bool, highlight_positions: Vec, + diff_stats: DiffStats, } #[derive(Clone)] @@ -402,6 +405,8 @@ impl Sidebar { } }; + let diff_stats = thread.action_log().read(cx).diff_stats(cx); + ActiveThreadInfo { session_id, title, @@ -409,6 +414,7 @@ impl Sidebar { icon, icon_from_external_svg, is_background, + diff_stats, } }) .collect() @@ -472,6 +478,7 @@ impl Sidebar { is_live: false, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }); } } @@ -497,6 +504,7 @@ impl Sidebar { thread.icon_from_external_svg = info.icon_from_external_svg.clone(); thread.is_live = true; thread.is_background = info.is_background; + thread.diff_stats = info.diff_stats; } } @@ -1171,6 +1179,12 @@ impl Sidebar { .highlight_positions(thread.highlight_positions.to_vec()) .status(thread.status) .notified(has_notification) + .when(thread.diff_stats.lines_added > 0, |this| { + this.added(thread.diff_stats.lines_added as usize) + }) + .when(thread.diff_stats.lines_removed > 0, |this| { + this.removed(thread.diff_stats.lines_removed as usize) + }) .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) .focused(is_selected) .on_click(cx.listener(move |this, _, window, cx| { @@ -1987,6 +2001,7 @@ mod tests { is_live: false, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // Active thread with Running status ListEntry::Thread(ThreadEntry { @@ -2005,6 +2020,7 @@ mod tests { is_live: true, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // Active thread with Error status ListEntry::Thread(ThreadEntry { @@ -2023,6 +2039,7 @@ mod tests { is_live: true, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // Thread with WaitingForConfirmation status, not active ListEntry::Thread(ThreadEntry { @@ -2041,6 +2058,7 @@ mod tests { is_live: false, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // Background thread that completed (should show notification) ListEntry::Thread(ThreadEntry { @@ -2059,6 +2077,7 @@ mod tests { is_live: true, is_background: true, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // View More entry ListEntry::ViewMore { diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 3c08bd946710f76ccf49f933b82091a3bcb06e08..edc685159f5c9edc5fa872e9d453d0b81fa9cb16 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -227,6 +227,12 @@ impl RenderOnce for ThreadItem { .gradient_stop(0.8) .group_name("thread-item"); + let has_diff_stats = self.added.is_some() || self.removed.is_some(); + let added_count = self.added.unwrap_or(0); + let removed_count = self.removed.unwrap_or(0); + let diff_stat_id = self.id.clone(); + let has_worktree = self.worktree.is_some(); + v_flex() .id(self.id.clone()) .group("thread-item") @@ -235,7 +241,7 @@ impl RenderOnce for ThreadItem { .cursor_pointer() .w_full() .map(|this| { - if self.worktree.is_some() { + if has_worktree || has_diff_stats { this.p_2() } else { this.px_2().py_1() @@ -300,35 +306,24 @@ impl RenderOnce for ThreadItem { .gap_1p5() .child(icon_container()) // Icon Spacing .child(worktree_label) - // TODO: Uncomment the elements below when we're ready to expose this data - // .child(dot_separator()) - // .child( - // Label::new(self.timestamp) - // .size(LabelSize::Small) - // .color(Color::Muted), - // ) - // .child( - // Label::new("•") - // .size(LabelSize::Small) - // .color(Color::Muted) - // .alpha(0.5), - // ) - // .when(has_no_changes, |this| { - // this.child( - // Label::new("No Changes") - // .size(LabelSize::Small) - // .color(Color::Muted), - // ) - // }) - .when(self.added.is_some() || self.removed.is_some(), |this| { + .when(has_diff_stats, |this| { this.child(DiffStat::new( - self.id, - self.added.unwrap_or(0), - self.removed.unwrap_or(0), + diff_stat_id.clone(), + added_count, + removed_count, )) }), ) }) + .when(!has_worktree && has_diff_stats, |this| { + this.child( + h_flex() + .min_w_0() + .gap_1p5() + .child(icon_container()) // Icon Spacing + .child(DiffStat::new(diff_stat_id, added_count, removed_count)), + ) + }) .when_some(self.on_click, |this, on_click| this.on_click(on_click)) } } From 2b425aa102142932a88275341913e9e3d99bbbec Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 11 Mar 2026 17:05:22 +0200 Subject: [PATCH 133/219] Limit editors' refresh runnables (#51299) Before, both rust-analyzer's LSPext tasks and tree-sitter tasks were queried on the entire multi buffer range. The PR moves all runnable-related logic into a submodule, and reworks the logic to consider only the visible buffers. Singleton buffers have their tasks resolved for the entire range still (same as LSPext tasks work), multi buffers have their buffers' data cached and reused. Release Notes: - Improved multi buffer's runnables resolution performance --- crates/editor/src/editor.rs | 526 ++--------------- crates/editor/src/editor_tests.rs | 17 +- crates/editor/src/element.rs | 6 +- crates/editor/src/runnables.rs | 915 ++++++++++++++++++++++++++++++ crates/editor/src/tasks.rs | 110 ---- crates/tasks_ui/src/tasks_ui.rs | 4 +- 6 files changed, 979 insertions(+), 599 deletions(-) create mode 100644 crates/editor/src/runnables.rs delete mode 100644 crates/editor/src/tasks.rs diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a08ac3bbc466d159ce81a7aa3bebf82599914a0b..ca3dd81ab072d0e20389318515049793a8c827ef 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -35,13 +35,13 @@ mod lsp_ext; mod mouse_context_menu; pub mod movement; mod persistence; +mod runnables; mod rust_analyzer_ext; pub mod scroll; mod selections_collection; pub mod semantic_tokens; mod split; pub mod split_editor_view; -pub mod tasks; #[cfg(test)] mod code_completion_tests; @@ -133,8 +133,8 @@ use language::{ BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape, DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind, IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, LocalFile, OffsetRangeExt, - OutlineItem, Point, Runnable, Selection, SelectionGoal, TextObject, TransactionId, - TreeSitterOptions, WordsQuery, + OutlineItem, Point, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, + WordsQuery, language_settings::{ self, LanguageSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode, all_language_settings, language_settings, @@ -158,7 +158,7 @@ use project::{ BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, InlayId, InvalidationStrategy, Location, LocationLink, LspAction, PrepareRenameResponse, Project, - ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind, + ProjectItem, ProjectPath, ProjectTransaction, debugger::{ breakpoint_store::{ Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState, @@ -200,7 +200,7 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables}; +use task::TaskVariables; use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _, ToPoint as _}; use theme::{ AccentColors, ActiveTheme, GlobalTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, @@ -231,6 +231,7 @@ use crate::{ InlineValueCache, inlay_hints::{LspInlayHintData, inlay_hint_settings}, }, + runnables::{ResolvedTasks, RunnableData, RunnableTasks}, scroll::{ScrollOffset, ScrollPixelOffset}, selections_collection::resolve_selections_wrapping_blocks, semantic_tokens::SemanticTokenState, @@ -857,37 +858,6 @@ impl BufferSerialization { } } -#[derive(Clone, Debug)] -struct RunnableTasks { - templates: Vec<(TaskSourceKind, TaskTemplate)>, - offset: multi_buffer::Anchor, - // We need the column at which the task context evaluation should take place (when we're spawning it via gutter). - column: u32, - // Values of all named captures, including those starting with '_' - extra_variables: HashMap, - // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal. - context_range: Range, -} - -impl RunnableTasks { - fn resolve<'a>( - &'a self, - cx: &'a task::TaskContext, - ) -> impl Iterator + 'a { - self.templates.iter().filter_map(|(kind, template)| { - template - .resolve_task(&kind.to_id_base(), cx) - .map(|task| (kind.clone(), task)) - }) - } -} - -#[derive(Clone)] -pub struct ResolvedTasks { - templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>, - position: Anchor, -} - /// Addons allow storing per-editor state in other crates (e.g. Vim) pub trait Addon: 'static { fn extend_key_context(&self, _: &mut KeyContext, _: &App) {} @@ -1295,8 +1265,7 @@ pub struct Editor { last_bounds: Option>, last_position_map: Option>, expect_bounds_change: Option>, - tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>, - tasks_update_task: Option>, + runnables: RunnableData, breakpoint_store: Option>, gutter_breakpoint_indicator: (Option, Option>), pub(crate) gutter_diff_review_indicator: (Option, Option>), @@ -2173,16 +2142,9 @@ impl Editor { editor.registered_buffers.clear(); editor.register_visible_buffers(cx); editor.invalidate_semantic_tokens(None); + editor.refresh_runnables(window, cx); editor.update_lsp_data(None, window, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx); - if editor.tasks_update_task.is_none() { - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); - } - } - project::Event::LanguageServerAdded(..) => { - if editor.tasks_update_task.is_none() { - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); - } } project::Event::SnippetEdit(id, snippet_edits) => { // todo(lw): Non singletons @@ -2210,6 +2172,7 @@ impl Editor { let buffer_id = *buffer_id; if editor.buffer().read(cx).buffer(buffer_id).is_some() { editor.register_buffer(buffer_id, cx); + editor.refresh_runnables(window, cx); editor.update_lsp_data(Some(buffer_id), window, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); refresh_linked_ranges(editor, window, cx); @@ -2288,7 +2251,7 @@ impl Editor { &task_inventory, window, |editor, _, window, cx| { - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); + editor.refresh_runnables(window, cx); }, )); }; @@ -2529,7 +2492,6 @@ impl Editor { }), blame: None, blame_subscription: None, - tasks: BTreeMap::default(), breakpoint_store, gutter_breakpoint_indicator: (None, None), @@ -2565,7 +2527,7 @@ impl Editor { ] }) .unwrap_or_default(), - tasks_update_task: None, + runnables: RunnableData::new(), pull_diagnostics_task: Task::ready(()), colors: None, refresh_colors_task: Task::ready(()), @@ -2632,7 +2594,6 @@ impl Editor { cx.notify(); })); } - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); editor._subscriptions.extend(project_subscriptions); editor._subscriptions.push(cx.subscribe_in( @@ -2668,6 +2629,7 @@ impl Editor { ); if !editor.buffer().read(cx).is_singleton() { editor.update_lsp_data(None, window, cx); + editor.refresh_runnables(window, cx); } }) .ok(); @@ -5791,18 +5753,11 @@ impl Editor { let display_snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let multi_buffer = self.buffer().read(cx); let multi_buffer_snapshot = multi_buffer.snapshot(cx); - let multi_buffer_visible_start = self - .scroll_manager - .native_anchor(&display_snapshot, cx) - .anchor - .to_point(&multi_buffer_snapshot); - let multi_buffer_visible_end = multi_buffer_snapshot.clip_point( - multi_buffer_visible_start - + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0), - Bias::Left, - ); multi_buffer_snapshot - .range_to_buffer_ranges(multi_buffer_visible_start..=multi_buffer_visible_end) + .range_to_buffer_ranges( + self.multi_buffer_visible_range(&display_snapshot, cx) + .to_inclusive(), + ) .into_iter() .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) .filter_map(|(buffer, excerpt_visible_range, excerpt_id)| { @@ -6737,8 +6692,8 @@ impl Editor { }; let buffer_id = buffer.read(cx).remote_id(); let tasks = self - .tasks - .get(&(buffer_id, buffer_row)) + .runnables + .runnables((buffer_id, buffer_row)) .map(|t| Arc::new(t.to_owned())); if !self.focus_handle.is_focused(window) { @@ -7789,24 +7744,13 @@ impl Editor { self.debounced_selection_highlight_complete = false; } if on_buffer_edit || query_changed { - let multi_buffer_visible_start = self - .scroll_manager - .native_anchor(&display_snapshot, cx) - .anchor - .to_point(&multi_buffer_snapshot); - let multi_buffer_visible_end = multi_buffer_snapshot.clip_point( - multi_buffer_visible_start - + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0), - Bias::Left, - ); - let multi_buffer_visible_range = multi_buffer_visible_start..multi_buffer_visible_end; self.quick_selection_highlight_task = Some(( query_range.clone(), self.update_selection_occurrence_highlights( snapshot.buffer.clone(), query_text.clone(), query_range.clone(), - multi_buffer_visible_range, + self.multi_buffer_visible_range(&display_snapshot, cx), false, window, cx, @@ -7841,6 +7785,27 @@ impl Editor { } } + pub fn multi_buffer_visible_range( + &self, + display_snapshot: &DisplaySnapshot, + cx: &App, + ) -> Range { + let visible_start = self + .scroll_manager + .native_anchor(display_snapshot, cx) + .anchor + .to_point(display_snapshot.buffer_snapshot()) + .to_display_point(display_snapshot); + + let mut target_end = visible_start; + *target_end.row_mut() += self.visible_line_count().unwrap_or(0.).ceil() as u32; + + visible_start.to_point(display_snapshot) + ..display_snapshot + .clip_point(target_end, Bias::Right) + .to_point(display_snapshot) + } + pub fn refresh_edit_prediction( &mut self, debounce: bool, @@ -8809,19 +8774,6 @@ impl Editor { Some(self.edit_prediction_provider.as_ref()?.provider.clone()) } - fn clear_tasks(&mut self) { - self.tasks.clear() - } - - fn insert_tasks(&mut self, key: (BufferId, BufferRow), value: RunnableTasks) { - if self.tasks.insert(key, value).is_some() { - // This case should hopefully be rare, but just in case... - log::error!( - "multiple different run targets found on a single line, only the last target will be rendered" - ) - } - } - /// Get all display points of breakpoints that will be rendered within editor /// /// This function is used to handle overlaps between breakpoints and Code action/runner symbol. @@ -9199,156 +9151,6 @@ impl Editor { }) } - pub fn spawn_nearest_task( - &mut self, - action: &SpawnNearestTask, - window: &mut Window, - cx: &mut Context, - ) { - let Some((workspace, _)) = self.workspace.clone() else { - return; - }; - let Some(project) = self.project.clone() else { - return; - }; - - // Try to find a closest, enclosing node using tree-sitter that has a task - let Some((buffer, buffer_row, tasks)) = self - .find_enclosing_node_task(cx) - // Or find the task that's closest in row-distance. - .or_else(|| self.find_closest_task(cx)) - else { - return; - }; - - let reveal_strategy = action.reveal; - let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx); - cx.spawn_in(window, async move |_, cx| { - let context = task_context.await?; - let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?; - - let resolved = &mut resolved_task.resolved; - resolved.reveal = reveal_strategy; - - workspace - .update_in(cx, |workspace, window, cx| { - workspace.schedule_resolved_task( - task_source_kind, - resolved_task, - false, - window, - cx, - ); - }) - .ok() - }) - .detach(); - } - - fn find_closest_task( - &mut self, - cx: &mut Context, - ) -> Option<(Entity, u32, Arc)> { - let cursor_row = self - .selections - .newest_adjusted(&self.display_snapshot(cx)) - .head() - .row; - - let ((buffer_id, row), tasks) = self - .tasks - .iter() - .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?; - - let buffer = self.buffer.read(cx).buffer(*buffer_id)?; - let tasks = Arc::new(tasks.to_owned()); - Some((buffer, *row, tasks)) - } - - fn find_enclosing_node_task( - &mut self, - cx: &mut Context, - ) -> Option<(Entity, u32, Arc)> { - let snapshot = self.buffer.read(cx).snapshot(cx); - let offset = self - .selections - .newest::(&self.display_snapshot(cx)) - .head(); - let mut excerpt = snapshot.excerpt_containing(offset..offset)?; - let offset = excerpt.map_offset_to_buffer(offset); - let buffer_id = excerpt.buffer().remote_id(); - - let layer = excerpt.buffer().syntax_layer_at(offset)?; - let mut cursor = layer.node().walk(); - - while cursor.goto_first_child_for_byte(offset.0).is_some() { - if cursor.node().end_byte() == offset.0 { - cursor.goto_next_sibling(); - } - } - - // Ascend to the smallest ancestor that contains the range and has a task. - loop { - let node = cursor.node(); - let node_range = node.byte_range(); - let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row; - - // Check if this node contains our offset - if node_range.start <= offset.0 && node_range.end >= offset.0 { - // If it contains offset, check for task - if let Some(tasks) = self.tasks.get(&(buffer_id, symbol_start_row)) { - let buffer = self.buffer.read(cx).buffer(buffer_id)?; - return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned()))); - } - } - - if !cursor.goto_parent() { - break; - } - } - None - } - - fn render_run_indicator( - &self, - _style: &EditorStyle, - is_active: bool, - row: DisplayRow, - breakpoint: Option<(Anchor, Breakpoint, Option)>, - cx: &mut Context, - ) -> IconButton { - let color = Color::Muted; - let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor); - - IconButton::new( - ("run_indicator", row.0 as usize), - ui::IconName::PlayOutlined, - ) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(color) - .toggle_state(is_active) - .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| { - let quick_launch = match e { - ClickEvent::Keyboard(_) => true, - ClickEvent::Mouse(e) => e.down.button == MouseButton::Left, - }; - - window.focus(&editor.focus_handle(cx), cx); - editor.toggle_code_actions( - &ToggleCodeActions { - deployed_from: Some(CodeActionSource::RunMenu(row)), - quick_launch, - }, - window, - cx, - ); - })) - .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { - editor.set_breakpoint_context_menu(row, position, event.position(), window, cx); - })) - } - pub fn context_menu_visible(&self) -> bool { !self.edit_prediction_preview_is_active() && self @@ -17153,241 +16955,6 @@ impl Editor { }); } - fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context) -> Task<()> { - if !self.mode().is_full() - || !EditorSettings::get_global(cx).gutter.runnables - || !self.enable_runnables - { - self.clear_tasks(); - return Task::ready(()); - } - let project = self.project().map(Entity::downgrade); - let task_sources = self.lsp_task_sources(cx); - let multi_buffer = self.buffer.downgrade(); - let lsp_data_enabled = self.lsp_data_enabled(); - cx.spawn_in(window, async move |editor, cx| { - cx.background_executor().timer(UPDATE_DEBOUNCE).await; - let Some(project) = project.and_then(|p| p.upgrade()) else { - return; - }; - let Ok(display_snapshot) = editor.update(cx, |this, cx| { - this.display_map.update(cx, |map, cx| map.snapshot(cx)) - }) else { - return; - }; - - let hide_runnables = project.update(cx, |project, _| project.is_via_collab()); - if hide_runnables { - return; - } - let new_rows = cx - .background_spawn({ - let snapshot = display_snapshot.clone(); - async move { - snapshot - .buffer_snapshot() - .runnable_ranges(Anchor::min()..Anchor::max()) - .collect() - } - }) - .await; - let lsp_tasks = if lsp_data_enabled { - let Ok(lsp_tasks) = - cx.update(|_, cx| crate::lsp_tasks(project.clone(), &task_sources, None, cx)) - else { - return; - }; - lsp_tasks.await - } else { - Vec::new() - }; - - let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| { - lsp_tasks - .into_iter() - .flat_map(|(kind, tasks)| { - tasks.into_iter().filter_map(move |(location, task)| { - Some((kind.clone(), location?, task)) - }) - }) - .fold(HashMap::default(), |mut acc, (kind, location, task)| { - let buffer = location.target.buffer; - let buffer_snapshot = buffer.read(cx).snapshot(); - let offset = display_snapshot.buffer_snapshot().excerpts().find_map( - |(excerpt_id, snapshot, _)| { - if snapshot.remote_id() == buffer_snapshot.remote_id() { - display_snapshot - .buffer_snapshot() - .anchor_in_excerpt(excerpt_id, location.target.range.start) - } else { - None - } - }, - ); - if let Some(offset) = offset { - let task_buffer_range = - location.target.range.to_point(&buffer_snapshot); - let context_buffer_range = - task_buffer_range.to_offset(&buffer_snapshot); - let context_range = BufferOffset(context_buffer_range.start) - ..BufferOffset(context_buffer_range.end); - - acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row)) - .or_insert_with(|| RunnableTasks { - templates: Vec::new(), - offset, - column: task_buffer_range.start.column, - extra_variables: HashMap::default(), - context_range, - }) - .templates - .push((kind, task.original_task().clone())); - } - - acc - }) - }) else { - return; - }; - - let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| { - buffer.language_settings(cx).tasks.prefer_lsp - }) else { - return; - }; - - let rows = Self::runnable_rows( - project, - display_snapshot, - prefer_lsp && !lsp_tasks_by_rows.is_empty(), - new_rows, - cx.clone(), - ) - .await; - editor - .update(cx, |editor, _| { - editor.clear_tasks(); - for (key, mut value) in rows { - if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&key) { - value.templates.extend(lsp_tasks.templates); - } - - editor.insert_tasks(key, value); - } - for (key, value) in lsp_tasks_by_rows { - editor.insert_tasks(key, value); - } - }) - .ok(); - }) - } - - fn runnable_rows( - project: Entity, - snapshot: DisplaySnapshot, - prefer_lsp: bool, - runnable_ranges: Vec<(Range, language::RunnableRange)>, - cx: AsyncWindowContext, - ) -> Task> { - cx.spawn(async move |cx| { - let mut runnable_rows = Vec::with_capacity(runnable_ranges.len()); - for (run_range, mut runnable) in runnable_ranges { - let Some(tasks) = cx - .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx)) - .ok() - else { - continue; - }; - let mut tasks = tasks.await; - - if prefer_lsp { - tasks.retain(|(task_kind, _)| { - !matches!(task_kind, TaskSourceKind::Language { .. }) - }); - } - if tasks.is_empty() { - continue; - } - - let point = run_range.start.to_point(&snapshot.buffer_snapshot()); - let Some(row) = snapshot - .buffer_snapshot() - .buffer_line_for_row(MultiBufferRow(point.row)) - .map(|(_, range)| range.start.row) - else { - continue; - }; - - let context_range = - BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end); - runnable_rows.push(( - (runnable.buffer_id, row), - RunnableTasks { - templates: tasks, - offset: snapshot.buffer_snapshot().anchor_before(run_range.start), - context_range, - column: point.column, - extra_variables: runnable.extra_captures, - }, - )); - } - runnable_rows - }) - } - - fn templates_with_tags( - project: &Entity, - runnable: &mut Runnable, - cx: &mut App, - ) -> Task> { - let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| { - let (worktree_id, file) = project - .buffer_for_id(runnable.buffer, cx) - .and_then(|buffer| buffer.read(cx).file()) - .map(|file| (file.worktree_id(cx), file.clone())) - .unzip(); - - ( - project.task_store().read(cx).task_inventory().cloned(), - worktree_id, - file, - ) - }); - - let tags = mem::take(&mut runnable.tags); - let language = runnable.language.clone(); - cx.spawn(async move |cx| { - let mut templates_with_tags = Vec::new(); - if let Some(inventory) = inventory { - for RunnableTag(tag) in tags { - let new_tasks = inventory.update(cx, |inventory, cx| { - inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx) - }); - templates_with_tags.extend(new_tasks.await.into_iter().filter( - move |(_, template)| { - template.tags.iter().any(|source_tag| source_tag == &tag) - }, - )); - } - } - templates_with_tags.sort_by_key(|(kind, _)| kind.to_owned()); - - if let Some((leading_tag_source, _)) = templates_with_tags.first() { - // Strongest source wins; if we have worktree tag binding, prefer that to - // global and language bindings; - // if we have a global binding, prefer that to language binding. - let first_mismatch = templates_with_tags - .iter() - .position(|(tag_source, _)| tag_source != leading_tag_source); - if let Some(index) = first_mismatch { - templates_with_tags.truncate(index); - } - } - - templates_with_tags - }) - } - pub fn move_to_enclosing_bracket( &mut self, _: &MoveToEnclosingBracket, @@ -24184,7 +23751,6 @@ impl Editor { predecessor, excerpts, } => { - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); let buffer_id = buffer.read(cx).remote_id(); if self.buffer.read(cx).diff_for(buffer_id).is_none() && let Some(project) = &self.project @@ -24202,6 +23768,7 @@ impl Editor { .invalidate_buffer(&buffer.read(cx).remote_id()); self.update_lsp_data(Some(buffer_id), window, cx); self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + self.refresh_runnables(window, cx); self.colorize_brackets(false, cx); self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx); cx.emit(EditorEvent::ExcerptsAdded { @@ -24220,8 +23787,7 @@ impl Editor { self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx); for buffer_id in removed_buffer_ids { self.registered_buffers.remove(buffer_id); - self.tasks - .retain(|(task_buffer_id, _), _| task_buffer_id != buffer_id); + self.clear_runnables(Some(*buffer_id)); self.semantic_token_state.invalidate_buffer(buffer_id); self.display_map.update(cx, |display_map, cx| { display_map.invalidate_semantic_highlights(*buffer_id); @@ -24263,10 +23829,12 @@ impl Editor { } self.colorize_brackets(false, cx); self.update_lsp_data(None, window, cx); + self.refresh_runnables(window, cx); cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() }) } multi_buffer::Event::Reparsed(buffer_id) => { - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); + self.clear_runnables(Some(*buffer_id)); + self.refresh_runnables(window, cx); self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx); self.colorize_brackets(true, cx); jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); @@ -24274,7 +23842,7 @@ impl Editor { cx.emit(EditorEvent::Reparsed(*buffer_id)); } multi_buffer::Event::DiffHunksToggled => { - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); + self.refresh_runnables(window, cx); } multi_buffer::Event::LanguageChanged(buffer_id, is_fresh_language) => { if !is_fresh_language { @@ -24410,7 +23978,7 @@ impl Editor { .unwrap_or(DiagnosticSeverity::Hint); self.set_max_diagnostics_severity(new_severity, cx); } - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); + self.refresh_runnables(window, cx); self.update_edit_prediction_settings(cx); self.refresh_edit_prediction(true, false, window, cx); self.refresh_inline_values(cx); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index d3da58733dd0a24622a6dcde87f638069e206cf4..fe71cb76f0f16dc7a928ccff725585c0e857c62e 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -5,6 +5,7 @@ use crate::{ edit_prediction_tests::FakeEditPredictionDelegate, element::StickyHeader, linked_editing_ranges::LinkedEditingRanges, + runnables::RunnableTasks, scroll::scroll_amount::ScrollAmount, test::{ assert_text_with_selections, build_editor, editor_content_with_blocks, @@ -24403,20 +24404,24 @@ async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) { editor.update_in(cx, |editor, window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); - editor.tasks.insert( - (buffer.read(cx).remote_id(), 3), + editor.runnables.insert( + buffer.read(cx).remote_id(), + 3, + buffer.read(cx).version(), RunnableTasks { - templates: vec![], + templates: Vec::new(), offset: snapshot.anchor_before(MultiBufferOffset(43)), column: 0, extra_variables: HashMap::default(), context_range: BufferOffset(43)..BufferOffset(85), }, ); - editor.tasks.insert( - (buffer.read(cx).remote_id(), 8), + editor.runnables.insert( + buffer.read(cx).remote_id(), + 8, + buffer.read(cx).version(), RunnableTasks { - templates: vec![], + templates: Vec::new(), offset: snapshot.anchor_before(MultiBufferOffset(86)), column: 0, extra_variables: HashMap::default(), diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index b7207fce71bc71c5bdd5962ca3328030935238ca..3b1356525960654ea88c6cfa84115f1e67ac2e5b 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -3275,9 +3275,9 @@ impl EditorElement { snapshot.display_point_to_point(DisplayPoint::new(range.end, 0), Bias::Right); editor - .tasks - .iter() - .filter_map(|(_, tasks)| { + .runnables + .all_runnables() + .filter_map(|tasks| { let multibuffer_point = tasks.offset.to_point(&snapshot.buffer_snapshot()); if multibuffer_point < offset_range_start || multibuffer_point > offset_range_end diff --git a/crates/editor/src/runnables.rs b/crates/editor/src/runnables.rs new file mode 100644 index 0000000000000000000000000000000000000000..9fa6b89ec130e74f388c5e82b9b346197bb13abb --- /dev/null +++ b/crates/editor/src/runnables.rs @@ -0,0 +1,915 @@ +use std::{collections::BTreeMap, mem, ops::Range, sync::Arc}; + +use clock::Global; +use collections::HashMap; +use gpui::{ + App, AppContext as _, AsyncWindowContext, ClickEvent, Context, Entity, Focusable as _, + MouseButton, Task, Window, +}; +use language::{Buffer, BufferRow, Runnable}; +use lsp::LanguageServerName; +use multi_buffer::{ + Anchor, BufferOffset, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot, ToPoint as _, +}; +use project::{ + Location, Project, TaskSourceKind, + debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, + project_settings::ProjectSettings, +}; +use settings::Settings as _; +use smallvec::SmallVec; +use task::{ResolvedTask, RunnableTag, TaskContext, TaskTemplate, TaskVariables, VariableName}; +use text::{BufferId, OffsetRangeExt as _, ToOffset as _, ToPoint as _}; +use ui::{Clickable as _, Color, IconButton, IconSize, Toggleable as _}; + +use crate::{ + CodeActionSource, Editor, EditorSettings, EditorStyle, RangeToAnchorExt, SpawnNearestTask, + ToggleCodeActions, UPDATE_DEBOUNCE, display_map::DisplayRow, +}; + +#[derive(Debug)] +pub(super) struct RunnableData { + runnables: HashMap)>, + runnables_update_task: Task<()>, +} + +impl RunnableData { + pub fn new() -> Self { + Self { + runnables: HashMap::default(), + runnables_update_task: Task::ready(()), + } + } + + pub fn runnables( + &self, + (buffer_id, buffer_row): (BufferId, BufferRow), + ) -> Option<&RunnableTasks> { + self.runnables.get(&buffer_id)?.1.get(&buffer_row) + } + + pub fn all_runnables(&self) -> impl Iterator { + self.runnables + .values() + .flat_map(|(_, tasks)| tasks.values()) + } + + pub fn has_cached(&self, buffer_id: BufferId, version: &Global) -> bool { + self.runnables + .get(&buffer_id) + .is_some_and(|(cached_version, _)| !version.changed_since(cached_version)) + } + + #[cfg(test)] + pub fn insert( + &mut self, + buffer_id: BufferId, + buffer_row: BufferRow, + version: Global, + tasks: RunnableTasks, + ) { + self.runnables + .entry(buffer_id) + .or_insert_with(|| (version, BTreeMap::default())) + .1 + .insert(buffer_row, tasks); + } +} + +#[derive(Clone, Debug)] +pub struct RunnableTasks { + pub templates: Vec<(TaskSourceKind, TaskTemplate)>, + pub offset: multi_buffer::Anchor, + // We need the column at which the task context evaluation should take place (when we're spawning it via gutter). + pub column: u32, + // Values of all named captures, including those starting with '_' + pub extra_variables: HashMap, + // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal. + pub context_range: Range, +} + +impl RunnableTasks { + pub fn resolve<'a>( + &'a self, + cx: &'a task::TaskContext, + ) -> impl Iterator + 'a { + self.templates.iter().filter_map(|(kind, template)| { + template + .resolve_task(&kind.to_id_base(), cx) + .map(|task| (kind.clone(), task)) + }) + } +} + +#[derive(Clone)] +pub struct ResolvedTasks { + pub templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>, + pub position: Anchor, +} + +impl Editor { + pub fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context) { + if !self.mode().is_full() + || !EditorSettings::get_global(cx).gutter.runnables + || !self.enable_runnables + { + self.clear_runnables(None); + return; + } + if let Some(buffer) = self.buffer().read(cx).as_singleton() { + if self + .runnables + .has_cached(buffer.read(cx).remote_id(), &buffer.read(cx).version()) + { + return; + } + } + + let project = self.project().map(Entity::downgrade); + let lsp_task_sources = self.lsp_task_sources(true, true, cx); + let multi_buffer = self.buffer.downgrade(); + self.runnables.runnables_update_task = cx.spawn_in(window, async move |editor, cx| { + cx.background_executor().timer(UPDATE_DEBOUNCE).await; + let Some(project) = project.and_then(|p| p.upgrade()) else { + return; + }; + + let hide_runnables = project.update(cx, |project, _| project.is_via_collab()); + if hide_runnables { + return; + } + let lsp_tasks = if lsp_task_sources.is_empty() { + Vec::new() + } else { + let Ok(lsp_tasks) = cx + .update(|_, cx| crate::lsp_tasks(project.clone(), &lsp_task_sources, None, cx)) + else { + return; + }; + lsp_tasks.await + }; + let new_rows = { + let Some((multi_buffer_snapshot, multi_buffer_query_range)) = editor + .update(cx, |editor, cx| { + let multi_buffer = editor.buffer().read(cx); + if multi_buffer.is_singleton() { + Some((multi_buffer.snapshot(cx), Anchor::min()..Anchor::max())) + } else { + let display_snapshot = + editor.display_map.update(cx, |map, cx| map.snapshot(cx)); + let multi_buffer_query_range = + editor.multi_buffer_visible_range(&display_snapshot, cx); + let multi_buffer_snapshot = display_snapshot.buffer(); + Some(( + multi_buffer_snapshot.clone(), + multi_buffer_query_range.to_anchors(&multi_buffer_snapshot), + )) + } + }) + .ok() + .flatten() + else { + return; + }; + cx.background_spawn({ + async move { + multi_buffer_snapshot + .runnable_ranges(multi_buffer_query_range) + .collect() + } + }) + .await + }; + + let Ok(multi_buffer_snapshot) = + editor.update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx)) + else { + return; + }; + let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| { + lsp_tasks + .into_iter() + .flat_map(|(kind, tasks)| { + tasks.into_iter().filter_map(move |(location, task)| { + Some((kind.clone(), location?, task)) + }) + }) + .fold(HashMap::default(), |mut acc, (kind, location, task)| { + let buffer = location.target.buffer; + let buffer_snapshot = buffer.read(cx).snapshot(); + let offset = multi_buffer_snapshot.excerpts().find_map( + |(excerpt_id, snapshot, _)| { + if snapshot.remote_id() == buffer_snapshot.remote_id() { + multi_buffer_snapshot + .anchor_in_excerpt(excerpt_id, location.target.range.start) + } else { + None + } + }, + ); + if let Some(offset) = offset { + let task_buffer_range = + location.target.range.to_point(&buffer_snapshot); + let context_buffer_range = + task_buffer_range.to_offset(&buffer_snapshot); + let context_range = BufferOffset(context_buffer_range.start) + ..BufferOffset(context_buffer_range.end); + + acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row)) + .or_insert_with(|| RunnableTasks { + templates: Vec::new(), + offset, + column: task_buffer_range.start.column, + extra_variables: HashMap::default(), + context_range, + }) + .templates + .push((kind, task.original_task().clone())); + } + + acc + }) + }) else { + return; + }; + + let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| { + buffer.language_settings(cx).tasks.prefer_lsp + }) else { + return; + }; + + let rows = Self::runnable_rows( + project, + multi_buffer_snapshot, + prefer_lsp && !lsp_tasks_by_rows.is_empty(), + new_rows, + cx.clone(), + ) + .await; + editor + .update(cx, |editor, cx| { + for ((buffer_id, row), mut new_tasks) in rows { + let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else { + continue; + }; + + if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&(buffer_id, row)) { + new_tasks.templates.extend(lsp_tasks.templates); + } + editor.insert_runnables( + buffer_id, + buffer.read(cx).version(), + row, + new_tasks, + ); + } + for ((buffer_id, row), new_tasks) in lsp_tasks_by_rows { + let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else { + continue; + }; + editor.insert_runnables( + buffer_id, + buffer.read(cx).version(), + row, + new_tasks, + ); + } + }) + .ok(); + }); + } + + pub fn spawn_nearest_task( + &mut self, + action: &SpawnNearestTask, + window: &mut Window, + cx: &mut Context, + ) { + let Some((workspace, _)) = self.workspace.clone() else { + return; + }; + let Some(project) = self.project.clone() else { + return; + }; + + // Try to find a closest, enclosing node using tree-sitter that has a task + let Some((buffer, buffer_row, tasks)) = self + .find_enclosing_node_task(cx) + // Or find the task that's closest in row-distance. + .or_else(|| self.find_closest_task(cx)) + else { + return; + }; + + let reveal_strategy = action.reveal; + let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx); + cx.spawn_in(window, async move |_, cx| { + let context = task_context.await?; + let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?; + + let resolved = &mut resolved_task.resolved; + resolved.reveal = reveal_strategy; + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.schedule_resolved_task( + task_source_kind, + resolved_task, + false, + window, + cx, + ); + }) + .ok() + }) + .detach(); + } + + pub fn clear_runnables(&mut self, for_buffer: Option) { + if let Some(buffer_id) = for_buffer { + self.runnables.runnables.remove(&buffer_id); + } else { + self.runnables.runnables.clear(); + } + self.runnables.runnables_update_task = Task::ready(()); + } + + pub fn task_context(&self, window: &mut Window, cx: &mut App) -> Task> { + let Some(project) = self.project.clone() else { + return Task::ready(None); + }; + let (selection, buffer, editor_snapshot) = { + let selection = self.selections.newest_adjusted(&self.display_snapshot(cx)); + let Some((buffer, _)) = self + .buffer() + .read(cx) + .point_to_buffer_offset(selection.start, cx) + else { + return Task::ready(None); + }; + let snapshot = self.snapshot(window, cx); + (selection, buffer, snapshot) + }; + let selection_range = selection.range(); + let start = editor_snapshot + .display_snapshot + .buffer_snapshot() + .anchor_after(selection_range.start) + .text_anchor; + let end = editor_snapshot + .display_snapshot + .buffer_snapshot() + .anchor_after(selection_range.end) + .text_anchor; + let location = Location { + buffer, + range: start..end, + }; + let captured_variables = { + let mut variables = TaskVariables::default(); + let buffer = location.buffer.read(cx); + let buffer_id = buffer.remote_id(); + let snapshot = buffer.snapshot(); + let starting_point = location.range.start.to_point(&snapshot); + let starting_offset = starting_point.to_offset(&snapshot); + for (_, tasks) in self + .runnables + .runnables + .get(&buffer_id) + .into_iter() + .flat_map(|(_, tasks)| tasks.range(0..starting_point.row + 1)) + { + if !tasks + .context_range + .contains(&crate::BufferOffset(starting_offset)) + { + continue; + } + for (capture_name, value) in tasks.extra_variables.iter() { + variables.insert( + VariableName::Custom(capture_name.to_owned().into()), + value.clone(), + ); + } + } + variables + }; + + project.update(cx, |project, cx| { + project.task_store().update(cx, |task_store, cx| { + task_store.task_context_for_location(captured_variables, location, cx) + }) + }) + } + + pub fn lsp_task_sources( + &self, + visible_only: bool, + skip_cached: bool, + cx: &mut Context, + ) -> HashMap> { + if !self.lsp_data_enabled() { + return HashMap::default(); + } + let buffers = if visible_only { + self.visible_excerpts(true, cx) + .into_values() + .map(|(buffer, _, _)| buffer) + .collect() + } else { + self.buffer().read(cx).all_buffers() + }; + + let lsp_settings = &ProjectSettings::get_global(cx).lsp; + + buffers + .into_iter() + .filter_map(|buffer| { + let lsp_tasks_source = buffer + .read(cx) + .language()? + .context_provider()? + .lsp_task_source()?; + if lsp_settings + .get(&lsp_tasks_source) + .is_none_or(|s| s.enable_lsp_tasks) + { + let buffer_id = buffer.read(cx).remote_id(); + if skip_cached + && self + .runnables + .has_cached(buffer_id, &buffer.read(cx).version()) + { + None + } else { + Some((lsp_tasks_source, buffer_id)) + } + } else { + None + } + }) + .fold( + HashMap::default(), + |mut acc, (lsp_task_source, buffer_id)| { + acc.entry(lsp_task_source) + .or_insert_with(Vec::new) + .push(buffer_id); + acc + }, + ) + } + + pub fn find_enclosing_node_task( + &mut self, + cx: &mut Context, + ) -> Option<(Entity, u32, Arc)> { + let snapshot = self.buffer.read(cx).snapshot(cx); + let offset = self + .selections + .newest::(&self.display_snapshot(cx)) + .head(); + let mut excerpt = snapshot.excerpt_containing(offset..offset)?; + let offset = excerpt.map_offset_to_buffer(offset); + let buffer_id = excerpt.buffer().remote_id(); + + let layer = excerpt.buffer().syntax_layer_at(offset)?; + let mut cursor = layer.node().walk(); + + while cursor.goto_first_child_for_byte(offset.0).is_some() { + if cursor.node().end_byte() == offset.0 { + cursor.goto_next_sibling(); + } + } + + // Ascend to the smallest ancestor that contains the range and has a task. + loop { + let node = cursor.node(); + let node_range = node.byte_range(); + let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row; + + // Check if this node contains our offset + if node_range.start <= offset.0 && node_range.end >= offset.0 { + // If it contains offset, check for task + if let Some(tasks) = self + .runnables + .runnables + .get(&buffer_id) + .and_then(|(_, tasks)| tasks.get(&symbol_start_row)) + { + let buffer = self.buffer.read(cx).buffer(buffer_id)?; + return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned()))); + } + } + + if !cursor.goto_parent() { + break; + } + } + None + } + + pub fn render_run_indicator( + &self, + _style: &EditorStyle, + is_active: bool, + row: DisplayRow, + breakpoint: Option<(Anchor, Breakpoint, Option)>, + cx: &mut Context, + ) -> IconButton { + let color = Color::Muted; + let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor); + + IconButton::new( + ("run_indicator", row.0 as usize), + ui::IconName::PlayOutlined, + ) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(color) + .toggle_state(is_active) + .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| { + let quick_launch = match e { + ClickEvent::Keyboard(_) => true, + ClickEvent::Mouse(e) => e.down.button == MouseButton::Left, + }; + + window.focus(&editor.focus_handle(cx), cx); + editor.toggle_code_actions( + &ToggleCodeActions { + deployed_from: Some(CodeActionSource::RunMenu(row)), + quick_launch, + }, + window, + cx, + ); + })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_breakpoint_context_menu(row, position, event.position(), window, cx); + })) + } + + fn insert_runnables( + &mut self, + buffer: BufferId, + version: Global, + row: BufferRow, + new_tasks: RunnableTasks, + ) { + let (old_version, tasks) = self.runnables.runnables.entry(buffer).or_default(); + if !old_version.changed_since(&version) { + *old_version = version; + tasks.insert(row, new_tasks); + } + } + + fn runnable_rows( + project: Entity, + snapshot: MultiBufferSnapshot, + prefer_lsp: bool, + runnable_ranges: Vec<(Range, language::RunnableRange)>, + cx: AsyncWindowContext, + ) -> Task> { + cx.spawn(async move |cx| { + let mut runnable_rows = Vec::with_capacity(runnable_ranges.len()); + for (run_range, mut runnable) in runnable_ranges { + let Some(tasks) = cx + .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx)) + .ok() + else { + continue; + }; + let mut tasks = tasks.await; + + if prefer_lsp { + tasks.retain(|(task_kind, _)| { + !matches!(task_kind, TaskSourceKind::Language { .. }) + }); + } + if tasks.is_empty() { + continue; + } + + let point = run_range.start.to_point(&snapshot); + let Some(row) = snapshot + .buffer_line_for_row(MultiBufferRow(point.row)) + .map(|(_, range)| range.start.row) + else { + continue; + }; + + let context_range = + BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end); + runnable_rows.push(( + (runnable.buffer_id, row), + RunnableTasks { + templates: tasks, + offset: snapshot.anchor_before(run_range.start), + context_range, + column: point.column, + extra_variables: runnable.extra_captures, + }, + )); + } + runnable_rows + }) + } + + fn templates_with_tags( + project: &Entity, + runnable: &mut Runnable, + cx: &mut App, + ) -> Task> { + let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| { + let (worktree_id, file) = project + .buffer_for_id(runnable.buffer, cx) + .and_then(|buffer| buffer.read(cx).file()) + .map(|file| (file.worktree_id(cx), file.clone())) + .unzip(); + + ( + project.task_store().read(cx).task_inventory().cloned(), + worktree_id, + file, + ) + }); + + let tags = mem::take(&mut runnable.tags); + let language = runnable.language.clone(); + cx.spawn(async move |cx| { + let mut templates_with_tags = Vec::new(); + if let Some(inventory) = inventory { + for RunnableTag(tag) in tags { + let new_tasks = inventory.update(cx, |inventory, cx| { + inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx) + }); + templates_with_tags.extend(new_tasks.await.into_iter().filter( + move |(_, template)| { + template.tags.iter().any(|source_tag| source_tag == &tag) + }, + )); + } + } + templates_with_tags.sort_by_key(|(kind, _)| kind.to_owned()); + + if let Some((leading_tag_source, _)) = templates_with_tags.first() { + // Strongest source wins; if we have worktree tag binding, prefer that to + // global and language bindings; + // if we have a global binding, prefer that to language binding. + let first_mismatch = templates_with_tags + .iter() + .position(|(tag_source, _)| tag_source != leading_tag_source); + if let Some(index) = first_mismatch { + templates_with_tags.truncate(index); + } + } + + templates_with_tags + }) + } + + fn find_closest_task( + &mut self, + cx: &mut Context, + ) -> Option<(Entity, u32, Arc)> { + let cursor_row = self + .selections + .newest_adjusted(&self.display_snapshot(cx)) + .head() + .row; + + let ((buffer_id, row), tasks) = self + .runnables + .runnables + .iter() + .flat_map(|(buffer_id, (_, tasks))| { + tasks.iter().map(|(row, tasks)| ((*buffer_id, *row), tasks)) + }) + .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?; + + let buffer = self.buffer.read(cx).buffer(buffer_id)?; + let tasks = Arc::new(tasks.to_owned()); + Some((buffer, row, tasks)) + } +} + +#[cfg(test)] +mod tests { + use std::{sync::Arc, time::Duration}; + + use gpui::{AppContext as _, Task, TestAppContext}; + use indoc::indoc; + use language::ContextProvider; + use languages::rust_lang; + use multi_buffer::{MultiBuffer, PathKey}; + use project::{FakeFs, Project}; + use serde_json::json; + use task::{TaskTemplate, TaskTemplates}; + use text::Point; + use util::path; + + use crate::{ + Editor, UPDATE_DEBOUNCE, editor_tests::init_test, scroll::scroll_amount::ScrollAmount, + }; + + struct TestRustContextProvider; + + impl ContextProvider for TestRustContextProvider { + fn associated_tasks( + &self, + _: Option>, + _: &gpui::App, + ) -> Task> { + Task::ready(Some(TaskTemplates(vec![ + TaskTemplate { + label: "Run main".into(), + command: "cargo".into(), + args: vec!["run".into()], + tags: vec!["rust-main".into()], + ..TaskTemplate::default() + }, + TaskTemplate { + label: "Run test".into(), + command: "cargo".into(), + args: vec!["test".into()], + tags: vec!["rust-test".into()], + ..TaskTemplate::default() + }, + ]))) + } + } + + fn rust_lang_with_task_context() -> Arc { + Arc::new( + Arc::try_unwrap(rust_lang()) + .unwrap() + .with_context_provider(Some(Arc::new(TestRustContextProvider))), + ) + } + + fn collect_runnable_labels( + editor: &Editor, + ) -> Vec<(text::BufferId, language::BufferRow, Vec)> { + let mut result = editor + .runnables + .runnables + .iter() + .flat_map(|(buffer_id, (_, tasks))| { + tasks.iter().map(move |(row, runnable_tasks)| { + let mut labels: Vec = runnable_tasks + .templates + .iter() + .map(|(_, template)| template.label.clone()) + .collect(); + labels.sort(); + (*buffer_id, *row, labels) + }) + }) + .collect::>(); + result.sort_by_key(|(id, row, _)| (*id, *row)); + result + } + + #[gpui::test] + async fn test_multi_buffer_runnables_on_scroll(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let padding_lines = 50; + let mut first_rs = String::from("fn main() {\n println!(\"hello\");\n}\n"); + for _ in 0..padding_lines { + first_rs.push_str("//\n"); + } + let test_one_row = 3 + padding_lines as u32 + 1; + first_rs.push_str("#[test]\nfn test_one() {\n assert!(true);\n}\n"); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "first.rs": first_rs, + "second.rs": indoc! {" + #[test] + fn test_two() { + assert!(true); + } + + #[test] + fn test_three() { + assert!(true); + } + "}, + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang_with_task_context()); + + let buffer_1 = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/first.rs"), cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/second.rs"), cx) + }) + .await + .unwrap(); + + let buffer_1_id = buffer_1.read_with(cx, |buffer, _| buffer.remote_id()); + let buffer_2_id = buffer_2.read_with(cx, |buffer, _| buffer.remote_id()); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(language::Capability::ReadWrite); + let end = buffer_1.read(cx).max_point(); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..end], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(8, 1)], + 0, + cx, + ); + multi_buffer + }); + + let editor = cx.add_window(|window, cx| { + Editor::for_multibuffer(multi_buffer, Some(project.clone()), window, cx) + }); + cx.executor().advance_clock(Duration::from_millis(500)); + cx.executor().run_until_parked(); + + // Clear stale data from startup events, then refresh. + // first.rs is long enough that second.rs is below the ~47-line viewport. + editor + .update(cx, |editor, window, cx| { + editor.clear_runnables(None); + editor.refresh_runnables(window, cx); + }) + .unwrap(); + cx.executor().advance_clock(UPDATE_DEBOUNCE); + cx.executor().run_until_parked(); + assert_eq!( + editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .unwrap(), + vec![(buffer_1_id, 0, vec!["Run main".to_string()])], + "Only fn main from first.rs should be visible before scrolling" + ); + + // Scroll down to bring second.rs excerpts into view. + editor + .update(cx, |editor, window, cx| { + editor.scroll_screen(&ScrollAmount::Page(1.0), window, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.executor().run_until_parked(); + + let after_scroll = editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .unwrap(); + assert_eq!( + after_scroll, + vec![ + (buffer_1_id, 0, vec!["Run main".to_string()]), + (buffer_1_id, test_one_row, vec!["Run test".to_string()]), + (buffer_2_id, 1, vec!["Run test".to_string()]), + (buffer_2_id, 6, vec!["Run test".to_string()]), + ], + "Tree-sitter should detect both #[test] fns in second.rs after scroll" + ); + + // Edit second.rs to invalidate its cache; first.rs data should persist. + buffer_2.update(cx, |buffer, cx| { + buffer.edit([(0..0, "// added comment\n")], None, cx); + }); + editor + .update(cx, |editor, window, cx| { + editor.scroll_screen(&ScrollAmount::Page(-1.0), window, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.executor().run_until_parked(); + + assert_eq!( + editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .unwrap(), + vec![ + (buffer_1_id, 0, vec!["Run main".to_string()]), + (buffer_1_id, test_one_row, vec!["Run test".to_string()]), + ], + "first.rs runnables should survive an edit to second.rs" + ); + } +} diff --git a/crates/editor/src/tasks.rs b/crates/editor/src/tasks.rs deleted file mode 100644 index e39880ddc1f575a7b12f40c5496c75c1f473c6e9..0000000000000000000000000000000000000000 --- a/crates/editor/src/tasks.rs +++ /dev/null @@ -1,110 +0,0 @@ -use crate::Editor; - -use collections::HashMap; -use gpui::{App, Task, Window}; -use lsp::LanguageServerName; -use project::{Location, project_settings::ProjectSettings}; -use settings::Settings as _; -use task::{TaskContext, TaskVariables, VariableName}; -use text::{BufferId, ToOffset, ToPoint}; - -impl Editor { - pub fn task_context(&self, window: &mut Window, cx: &mut App) -> Task> { - let Some(project) = self.project.clone() else { - return Task::ready(None); - }; - let (selection, buffer, editor_snapshot) = { - let selection = self.selections.newest_adjusted(&self.display_snapshot(cx)); - let Some((buffer, _)) = self - .buffer() - .read(cx) - .point_to_buffer_offset(selection.start, cx) - else { - return Task::ready(None); - }; - let snapshot = self.snapshot(window, cx); - (selection, buffer, snapshot) - }; - let selection_range = selection.range(); - let start = editor_snapshot - .display_snapshot - .buffer_snapshot() - .anchor_after(selection_range.start) - .text_anchor; - let end = editor_snapshot - .display_snapshot - .buffer_snapshot() - .anchor_after(selection_range.end) - .text_anchor; - let location = Location { - buffer, - range: start..end, - }; - let captured_variables = { - let mut variables = TaskVariables::default(); - let buffer = location.buffer.read(cx); - let buffer_id = buffer.remote_id(); - let snapshot = buffer.snapshot(); - let starting_point = location.range.start.to_point(&snapshot); - let starting_offset = starting_point.to_offset(&snapshot); - for (_, tasks) in self - .tasks - .range((buffer_id, 0)..(buffer_id, starting_point.row + 1)) - { - if !tasks - .context_range - .contains(&crate::BufferOffset(starting_offset)) - { - continue; - } - for (capture_name, value) in tasks.extra_variables.iter() { - variables.insert( - VariableName::Custom(capture_name.to_owned().into()), - value.clone(), - ); - } - } - variables - }; - - project.update(cx, |project, cx| { - project.task_store().update(cx, |task_store, cx| { - task_store.task_context_for_location(captured_variables, location, cx) - }) - }) - } - - pub fn lsp_task_sources(&self, cx: &App) -> HashMap> { - let lsp_settings = &ProjectSettings::get_global(cx).lsp; - - self.buffer() - .read(cx) - .all_buffers() - .into_iter() - .filter_map(|buffer| { - let lsp_tasks_source = buffer - .read(cx) - .language()? - .context_provider()? - .lsp_task_source()?; - if lsp_settings - .get(&lsp_tasks_source) - .is_none_or(|s| s.enable_lsp_tasks) - { - let buffer_id = buffer.read(cx).remote_id(); - Some((lsp_tasks_source, buffer_id)) - } else { - None - } - }) - .fold( - HashMap::default(), - |mut acc, (lsp_task_source, buffer_id)| { - acc.entry(lsp_task_source) - .or_insert_with(Vec::new) - .push(buffer_id); - acc - }, - ) - } -} diff --git a/crates/tasks_ui/src/tasks_ui.rs b/crates/tasks_ui/src/tasks_ui.rs index 29e6a9de7fab9b5421fe38fee0fd24fd43b12ccc..fdacef3b193beb8a656916edb61fbff1a200385b 100644 --- a/crates/tasks_ui/src/tasks_ui.rs +++ b/crates/tasks_ui/src/tasks_ui.rs @@ -316,7 +316,9 @@ pub fn task_contexts( let lsp_task_sources = active_editor .as_ref() - .map(|active_editor| active_editor.update(cx, |editor, cx| editor.lsp_task_sources(cx))) + .map(|active_editor| { + active_editor.update(cx, |editor, cx| editor.lsp_task_sources(false, false, cx)) + }) .unwrap_or_default(); let latest_selection = active_editor.as_ref().map(|active_editor| { From dfc3a7c6e87cdc0465e152f216f2d9c561116af1 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 11 Mar 2026 16:57:22 +0100 Subject: [PATCH 134/219] agent_ui: Move UI logic from `ThreadHistory` to separate component (#51301) This is just a re-factor. We'll make use of this once we make thread history non-global (storing one history per ACP connection). Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 15 +- crates/agent_ui/src/agent_ui.rs | 4 +- crates/agent_ui/src/connection_view.rs | 16 +- .../src/connection_view/thread_view.rs | 2 +- crates/agent_ui/src/entry_view_state.rs | 3 +- crates/agent_ui/src/inline_assistant.rs | 2 +- crates/agent_ui/src/message_editor.rs | 48 +- crates/agent_ui/src/thread_history.rs | 959 +----------------- crates/agent_ui/src/thread_history_view.rs | 878 ++++++++++++++++ 9 files changed, 944 insertions(+), 983 deletions(-) create mode 100644 crates/agent_ui/src/thread_history_view.rs diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 630411c2400ee925f980b5d3a410cb3574e81cd6..1537c05096ec81f1b3f354cac236bfdda52c9f6f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -48,7 +48,7 @@ use crate::{ NewNativeAgentThreadFromSummary, }; use crate::{ - ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent, + ExpandMessageEditor, ThreadHistory, ThreadHistoryView, ThreadHistoryViewEvent, text_thread_history::{TextThreadHistory, TextThreadHistoryEvent}, }; use agent_settings::AgentSettings; @@ -863,6 +863,7 @@ pub struct AgentPanel { fs: Arc, language_registry: Arc, acp_history: Entity, + acp_history_view: Entity, text_thread_history: Entity, thread_store: Entity, text_thread_store: Entity, @@ -1072,14 +1073,15 @@ impl AgentPanel { cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let thread_store = ThreadStore::global(cx); - let acp_history = cx.new(|cx| ThreadHistory::new(None, window, cx)); + let acp_history = cx.new(|cx| ThreadHistory::new(None, cx)); + let acp_history_view = cx.new(|cx| ThreadHistoryView::new(acp_history.clone(), window, cx)); let text_thread_history = cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx)); cx.subscribe_in( - &acp_history, + &acp_history_view, window, |this, _, event, window, cx| match event { - ThreadHistoryEvent::Open(thread) => { + ThreadHistoryViewEvent::Open(thread) => { this.load_agent_thread( thread.session_id.clone(), thread.cwd.clone(), @@ -1213,6 +1215,7 @@ impl AgentPanel { pending_serialization: None, onboarding, acp_history, + acp_history_view, text_thread_history, thread_store, selected_agent: AgentType::default(), @@ -3046,7 +3049,7 @@ impl Focusable for AgentPanel { ActiveView::Uninitialized => self.focus_handle.clone(), ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx), ActiveView::History { kind } => match kind { - HistoryKind::AgentThreads => self.acp_history.focus_handle(cx), + HistoryKind::AgentThreads => self.acp_history_view.focus_handle(cx), HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx), }, ActiveView::TextThread { @@ -4763,7 +4766,7 @@ impl Render for AgentPanel { .child(server_view.clone()) .child(self.render_drag_target(cx)), ActiveView::History { kind } => match kind { - HistoryKind::AgentThreads => parent.child(self.acp_history.clone()), + HistoryKind::AgentThreads => parent.child(self.acp_history_view.clone()), HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()), }, ActiveView::TextThread { diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 292db8fc7c0398fdd8c8800b8acc2b3c6df22740..52ce6f0bd7a312966b6602fb43be4074d7f3e620 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -33,6 +33,7 @@ pub mod test_support; mod text_thread_editor; mod text_thread_history; mod thread_history; +mod thread_history_view; mod ui; use std::rc::Rc; @@ -74,7 +75,8 @@ pub(crate) use mode_selector::ModeSelector; pub(crate) use model_selector::ModelSelector; pub(crate) use model_selector_popover::ModelSelectorPopover; pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor}; -pub(crate) use thread_history::*; +pub(crate) use thread_history::ThreadHistory; +pub(crate) use thread_history_view::*; use zed_actions; actions!( diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index b896741cee26e14ed372480f80d6cf8302db180b..b562688a83b75b75a1b95c065b14d0484daef055 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -2901,7 +2901,7 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); // Create history without an initial session list - it will be set after connection - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3007,7 +3007,7 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3066,7 +3066,7 @@ pub(crate) mod tests { let captured_cwd = connection.captured_cwd.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3123,7 +3123,7 @@ pub(crate) mod tests { let captured_cwd = connection.captured_cwd.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3180,7 +3180,7 @@ pub(crate) mod tests { let captured_cwd = connection.captured_cwd.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3498,7 +3498,7 @@ pub(crate) mod tests { // Set up thread view in workspace 1 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx))); @@ -3718,7 +3718,7 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -4454,7 +4454,7 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index d4d23f5a0a0722afc5c588a355a6a9de1b59d194..44f9e78a2bb47af6cb171194fbd5a34de7383f1b 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -7409,7 +7409,7 @@ impl ThreadView { // TODO: Add keyboard navigation. let is_hovered = self.hovered_recent_history_item == Some(index); - crate::thread_history::HistoryEntryElement::new( + crate::thread_history_view::HistoryEntryElement::new( entry, self.server_view.clone(), ) diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index aef7f1f335eff7d092f924b9883ab0d64bbf65a8..17769335a1cc7e514bad15862d20d4048a089b7b 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -508,8 +508,7 @@ mod tests { }); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let view_state = cx.new(|_cx| { EntryViewState::new( diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 4e7eecfe07aac84269cb1d325cc5a95943578863..2aee2b4601e126b25a977cf92d314970049026da 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -2155,7 +2155,7 @@ pub mod test { }); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = cx.new(|cx| crate::ThreadHistory::new(None, window, cx)); + let history = cx.new(|cx| crate::ThreadHistory::new(None, cx)); // Add editor to workspace workspace.update(cx, |workspace, cx| { diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 6c2628f9d37efd0531d5663ac4b1d27d9ae5ae0f..c9067d4ec261261e66c7718b36ebcb96b2099fed 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -1708,8 +1708,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -1822,8 +1821,7 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let workspace_handle = workspace.downgrade(); let message_editor = workspace.update_in(cx, |_, window, cx| { cx.new(|cx| { @@ -1978,8 +1976,7 @@ mod tests { let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![ acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"), @@ -2213,8 +2210,7 @@ mod tests { } let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { @@ -2709,8 +2705,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2810,8 +2805,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let session_id = acp::SessionId::new("thread-123"); let title = Some("Previous Conversation".into()); @@ -2886,8 +2880,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2943,8 +2936,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2998,8 +2990,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -3054,8 +3045,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -3119,8 +3109,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -3279,8 +3268,7 @@ mod tests { }); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); // Create a new `MessageEditor`. The `EditorMode::full()` has to be used // to ensure we have a fixed viewport, so we can eventually actually @@ -3400,8 +3388,7 @@ mod tests { let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -3483,8 +3470,7 @@ mod tests { let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -3568,8 +3554,7 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -3721,8 +3706,7 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs index 01536b00e98d13a699457377a6ebf8e9e87a59b4..5e66d4468767e7002b8b5f6c79ffe8aaecf77127 100644 --- a/crates/agent_ui/src/thread_history.rs +++ b/crates/agent_ui/src/thread_history.rs @@ -1,118 +1,21 @@ -use crate::ConnectionView; -use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread}; use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, SessionListUpdate}; use agent_client_protocol as acp; -use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; -use editor::{Editor, EditorEvent}; -use fuzzy::StringMatchCandidate; -use gpui::{ - App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, - UniformListScrollHandle, WeakEntity, Window, uniform_list, -}; -use std::{fmt::Display, ops::Range, rc::Rc}; -use text::Bias; -use time::{OffsetDateTime, UtcOffset}; -use ui::{ - ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, - WithScrollbar, prelude::*, -}; - -const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); - -fn thread_title(entry: &AgentSessionInfo) -> &SharedString { - entry - .title - .as_ref() - .filter(|title| !title.is_empty()) - .unwrap_or(DEFAULT_TITLE) -} +use gpui::{App, Task}; +use std::rc::Rc; +use ui::prelude::*; pub struct ThreadHistory { session_list: Option>, sessions: Vec, - scroll_handle: UniformListScrollHandle, - selected_index: usize, - hovered_index: Option, - search_editor: Entity, - search_query: SharedString, - visible_items: Vec, - local_timezone: UtcOffset, - confirming_delete_history: bool, - _visible_items_task: Task<()>, _refresh_task: Task<()>, _watch_task: Option>, - _subscriptions: Vec, -} - -enum ListItemType { - BucketSeparator(TimeBucket), - Entry { - entry: AgentSessionInfo, - format: EntryTimeFormat, - }, - SearchResult { - entry: AgentSessionInfo, - positions: Vec, - }, -} - -impl ListItemType { - fn history_entry(&self) -> Option<&AgentSessionInfo> { - match self { - ListItemType::Entry { entry, .. } => Some(entry), - ListItemType::SearchResult { entry, .. } => Some(entry), - _ => None, - } - } } -pub enum ThreadHistoryEvent { - Open(AgentSessionInfo), -} - -impl EventEmitter for ThreadHistory {} - impl ThreadHistory { - pub fn new( - session_list: Option>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let search_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Search threads...", window, cx); - editor - }); - - let search_editor_subscription = - cx.subscribe(&search_editor, |this, search_editor, event, cx| { - if let EditorEvent::BufferEdited = event { - let query = search_editor.read(cx).text(cx); - if this.search_query != query { - this.search_query = query.into(); - this.update_visible_items(false, cx); - } - } - }); - - let scroll_handle = UniformListScrollHandle::default(); - + pub fn new(session_list: Option>, cx: &mut Context) -> Self { let mut this = Self { session_list: None, sessions: Vec::new(), - scroll_handle, - selected_index: 0, - hovered_index: None, - visible_items: Default::default(), - search_editor, - local_timezone: UtcOffset::from_whole_seconds( - chrono::Local::now().offset().local_minus_utc(), - ) - .unwrap(), - search_query: SharedString::default(), - confirming_delete_history: false, - _subscriptions: vec![search_editor_subscription], - _visible_items_task: Task::ready(()), _refresh_task: Task::ready(()), _watch_task: None, }; @@ -120,43 +23,6 @@ impl ThreadHistory { this } - fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { - let entries = self.sessions.clone(); - let new_list_items = if self.search_query.is_empty() { - self.add_list_separators(entries, cx) - } else { - self.filter_search_results(entries, cx) - }; - let selected_history_entry = if preserve_selected_item { - self.selected_history_entry().cloned() - } else { - None - }; - - self._visible_items_task = cx.spawn(async move |this, cx| { - let new_visible_items = new_list_items.await; - this.update(cx, |this, cx| { - let new_selected_index = if let Some(history_entry) = selected_history_entry { - new_visible_items - .iter() - .position(|visible_entry| { - visible_entry - .history_entry() - .is_some_and(|entry| entry.session_id == history_entry.session_id) - }) - .unwrap_or(0) - } else { - 0 - }; - - this.visible_items = new_visible_items; - this.set_selected_index(new_selected_index, Bias::Right, cx); - cx.notify(); - }) - .ok(); - }); - } - pub fn set_session_list( &mut self, session_list: Option>, @@ -170,9 +36,6 @@ impl ThreadHistory { self.session_list = session_list; self.sessions.clear(); - self.visible_items.clear(); - self.selected_index = 0; - self._visible_items_task = Task::ready(()); self._refresh_task = Task::ready(()); let Some(session_list) = self.session_list.as_ref() else { @@ -181,9 +44,8 @@ impl ThreadHistory { return; }; let Some(rx) = session_list.watch(cx) else { - // No watch support - do a one-time refresh self._watch_task = None; - self.refresh_sessions(false, false, cx); + self.refresh_sessions(false, cx); return; }; session_list.notify_refresh(); @@ -191,7 +53,6 @@ impl ThreadHistory { self._watch_task = Some(cx.spawn(async move |this, cx| { while let Ok(first_update) = rx.recv().await { let mut updates = vec![first_update]; - // Collect any additional updates that are already in the channel while let Ok(update) = rx.try_recv() { updates.push(update); } @@ -202,7 +63,7 @@ impl ThreadHistory { .any(|u| matches!(u, SessionListUpdate::Refresh)); if needs_refresh { - this.refresh_sessions(true, false, cx); + this.refresh_sessions(false, cx); } else { for update in updates { if let SessionListUpdate::SessionInfo { session_id, update } = update { @@ -217,7 +78,7 @@ impl ThreadHistory { } pub(crate) fn refresh_full_history(&mut self, cx: &mut Context) { - self.refresh_sessions(true, true, cx); + self.refresh_sessions(true, cx); } fn apply_info_update( @@ -258,23 +119,15 @@ impl ThreadHistory { session.meta = Some(meta); } - self.update_visible_items(true, cx); + cx.notify(); } - fn refresh_sessions( - &mut self, - preserve_selected_item: bool, - load_all_pages: bool, - cx: &mut Context, - ) { + fn refresh_sessions(&mut self, load_all_pages: bool, cx: &mut Context) { let Some(session_list) = self.session_list.clone() else { - self.update_visible_items(preserve_selected_item, cx); + cx.notify(); return; }; - // If a new refresh arrives while pagination is in progress, the previous - // `_refresh_task` is cancelled. This is intentional (latest refresh wins), - // but means sessions may be in a partial state until the new refresh completes. self._refresh_task = cx.spawn(async move |this, cx| { let mut cursor: Option = None; let mut is_first_page = true; @@ -305,7 +158,7 @@ impl ThreadHistory { } else { this.sessions.extend(page_sessions); } - this.update_visible_items(preserve_selected_item, cx); + cx.notify(); }) .ok(); @@ -378,693 +231,11 @@ impl ThreadHistory { } } - fn add_list_separators( - &self, - entries: Vec, - cx: &App, - ) -> Task> { - cx.background_spawn(async move { - let mut items = Vec::with_capacity(entries.len() + 1); - let mut bucket = None; - let today = Local::now().naive_local().date(); - - for entry in entries.into_iter() { - let entry_bucket = entry - .updated_at - .map(|timestamp| { - let entry_date = timestamp.with_timezone(&Local).naive_local().date(); - TimeBucket::from_dates(today, entry_date) - }) - .unwrap_or(TimeBucket::All); - - if Some(entry_bucket) != bucket { - bucket = Some(entry_bucket); - items.push(ListItemType::BucketSeparator(entry_bucket)); - } - - items.push(ListItemType::Entry { - entry, - format: entry_bucket.into(), - }); - } - items - }) - } - - fn filter_search_results( - &self, - entries: Vec, - cx: &App, - ) -> Task> { - let query = self.search_query.clone(); - cx.background_spawn({ - let executor = cx.background_executor().clone(); - async move { - let mut candidates = Vec::with_capacity(entries.len()); - - for (idx, entry) in entries.iter().enumerate() { - candidates.push(StringMatchCandidate::new(idx, thread_title(entry))); - } - - const MAX_MATCHES: usize = 100; - - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - MAX_MATCHES, - &Default::default(), - executor, - ) - .await; - - matches - .into_iter() - .map(|search_match| ListItemType::SearchResult { - entry: entries[search_match.candidate_id].clone(), - positions: search_match.positions, - }) - .collect() - } - }) - } - - fn search_produced_no_matches(&self) -> bool { - self.visible_items.is_empty() && !self.search_query.is_empty() - } - - fn selected_history_entry(&self) -> Option<&AgentSessionInfo> { - self.get_history_entry(self.selected_index) - } - - fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> { - self.visible_items.get(visible_items_ix)?.history_entry() - } - - fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { - if self.visible_items.len() == 0 { - self.selected_index = 0; - return; - } - while matches!( - self.visible_items.get(index), - None | Some(ListItemType::BucketSeparator(..)) - ) { - index = match bias { - Bias::Left => { - if index == 0 { - self.visible_items.len() - 1 - } else { - index - 1 - } - } - Bias::Right => { - if index >= self.visible_items.len() - 1 { - 0 - } else { - index + 1 - } - } - }; - } - self.selected_index = index; - self.scroll_handle - .scroll_to_item(index, ScrollStrategy::Top); - cx.notify() - } - - pub fn select_previous( - &mut self, - _: &menu::SelectPrevious, - _window: &mut Window, - cx: &mut Context, - ) { - if self.selected_index == 0 { - self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); - } else { - self.set_selected_index(self.selected_index - 1, Bias::Left, cx); - } - } - - pub fn select_next( - &mut self, - _: &menu::SelectNext, - _window: &mut Window, - cx: &mut Context, - ) { - if self.selected_index == self.visible_items.len() - 1 { - self.set_selected_index(0, Bias::Right, cx); + pub(crate) fn delete_sessions(&self, cx: &mut App) -> Task> { + if let Some(session_list) = self.session_list.as_ref() { + session_list.delete_sessions(cx) } else { - self.set_selected_index(self.selected_index + 1, Bias::Right, cx); - } - } - - fn select_first( - &mut self, - _: &menu::SelectFirst, - _window: &mut Window, - cx: &mut Context, - ) { - self.set_selected_index(0, Bias::Right, cx); - } - - fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); - } - - fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - self.confirm_entry(self.selected_index, cx); - } - - fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { - let Some(entry) = self.get_history_entry(ix) else { - return; - }; - cx.emit(ThreadHistoryEvent::Open(entry.clone())); - } - - fn remove_selected_thread( - &mut self, - _: &RemoveSelectedThread, - _window: &mut Window, - cx: &mut Context, - ) { - self.remove_thread(self.selected_index, cx) - } - - fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { - let Some(entry) = self.get_history_entry(visible_item_ix) else { - return; - }; - let Some(session_list) = self.session_list.as_ref() else { - return; - }; - if !session_list.supports_delete() { - return; - } - let task = session_list.delete_session(&entry.session_id, cx); - task.detach_and_log_err(cx); - } - - fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(session_list) = self.session_list.as_ref() else { - return; - }; - if !session_list.supports_delete() { - return; - } - session_list.delete_sessions(cx).detach_and_log_err(cx); - self.confirming_delete_history = false; - cx.notify(); - } - - fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.confirming_delete_history = true; - cx.notify(); - } - - fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.confirming_delete_history = false; - cx.notify(); - } - - fn render_list_items( - &mut self, - range: Range, - _window: &mut Window, - cx: &mut Context, - ) -> Vec { - self.visible_items - .get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) - .collect() - } - - fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { - match item { - ListItemType::Entry { entry, format } => self - .render_history_entry(entry, *format, ix, Vec::default(), cx) - .into_any(), - ListItemType::SearchResult { entry, positions } => self.render_history_entry( - entry, - EntryTimeFormat::DateAndTime, - ix, - positions.clone(), - cx, - ), - ListItemType::BucketSeparator(bucket) => div() - .px(DynamicSpacing::Base06.rems(cx)) - .pt_2() - .pb_1() - .child( - Label::new(bucket.to_string()) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element(), - } - } - - fn render_history_entry( - &self, - entry: &AgentSessionInfo, - format: EntryTimeFormat, - ix: usize, - highlight_positions: Vec, - cx: &Context, - ) -> AnyElement { - let selected = ix == self.selected_index; - let hovered = Some(ix) == self.hovered_index; - let entry_time = entry.updated_at; - let display_text = match (format, entry_time) { - (EntryTimeFormat::DateAndTime, Some(entry_time)) => { - let now = Utc::now(); - let duration = now.signed_duration_since(entry_time); - let days = duration.num_days(); - - format!("{}d", days) - } - (EntryTimeFormat::TimeOnly, Some(entry_time)) => { - format.format_timestamp(entry_time.timestamp(), self.local_timezone) - } - (_, None) => "—".to_string(), - }; - - let title = thread_title(entry).clone(); - let full_date = entry_time - .map(|time| { - EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone) - }) - .unwrap_or_else(|| "Unknown".to_string()); - - h_flex() - .w_full() - .pb_1() - .child( - ListItem::new(ix) - .rounded() - .toggle_state(selected) - .spacing(ListItemSpacing::Sparse) - .start_slot( - h_flex() - .w_full() - .gap_2() - .justify_between() - .child( - HighlightedLabel::new(thread_title(entry), highlight_positions) - .size(LabelSize::Small) - .truncate(), - ) - .child( - Label::new(display_text) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) - .tooltip(move |_, cx| { - Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) - }) - .on_hover(cx.listener(move |this, is_hovered, _window, cx| { - if *is_hovered { - this.hovered_index = Some(ix); - } else if this.hovered_index == Some(ix) { - this.hovered_index = None; - } - - cx.notify(); - })) - .end_slot::(if hovered && self.supports_delete() { - Some( - IconButton::new("delete", IconName::Trash) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(move |_window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, cx) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.remove_thread(ix, cx); - cx.stop_propagation() - })), - ) - } else { - None - }) - .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), - ) - .into_any_element() - } -} - -impl Focusable for ThreadHistory { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.search_editor.focus_handle(cx) - } -} - -impl Render for ThreadHistory { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let has_no_history = self.is_empty(); - - v_flex() - .key_context("ThreadHistory") - .size_full() - .bg(cx.theme().colors().panel_background) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_first)) - .on_action(cx.listener(Self::select_last)) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::remove_selected_thread)) - .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { - this.remove_history(window, cx); - })) - .child( - h_flex() - .h(Tab::container_height(cx)) - .w_full() - .py_1() - .px_2() - .gap_2() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - Icon::new(IconName::MagnifyingGlass) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child(self.search_editor.clone()), - ) - .child({ - let view = v_flex() - .id("list-container") - .relative() - .overflow_hidden() - .flex_grow(); - - if has_no_history { - view.justify_center().items_center().child( - Label::new("You don't have any past threads yet.") - .size(LabelSize::Small) - .color(Color::Muted), - ) - } else if self.search_produced_no_matches() { - view.justify_center() - .items_center() - .child(Label::new("No threads match your search.").size(LabelSize::Small)) - } else { - view.child( - uniform_list( - "thread-history", - self.visible_items.len(), - cx.processor(|this, range: Range, window, cx| { - this.render_list_items(range, window, cx) - }), - ) - .p_1() - .pr_4() - .track_scroll(&self.scroll_handle) - .flex_grow(), - ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx) - } - }) - .when(!has_no_history && self.supports_delete(), |this| { - this.child( - h_flex() - .p_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .when(!self.confirming_delete_history, |this| { - this.child( - Button::new("delete_history", "Delete All History") - .full_width() - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.prompt_delete_history(window, cx); - })), - ) - }) - .when(self.confirming_delete_history, |this| { - this.w_full() - .gap_2() - .flex_wrap() - .justify_between() - .child( - h_flex() - .flex_wrap() - .gap_1() - .child( - Label::new("Delete all threads?") - .size(LabelSize::Small), - ) - .child( - Label::new("You won't be able to recover them later.") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("cancel_delete", "Cancel") - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.cancel_delete_history(window, cx); - })), - ) - .child( - Button::new("confirm_delete", "Delete") - .style(ButtonStyle::Tinted(ui::TintColor::Error)) - .color(Color::Error) - .label_size(LabelSize::Small) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action( - Box::new(RemoveHistory), - cx, - ); - })), - ), - ) - }), - ) - }) - } -} - -#[derive(IntoElement)] -pub struct HistoryEntryElement { - entry: AgentSessionInfo, - thread_view: WeakEntity, - selected: bool, - hovered: bool, - supports_delete: bool, - on_hover: Box, -} - -impl HistoryEntryElement { - pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity) -> Self { - Self { - entry, - thread_view, - selected: false, - hovered: false, - supports_delete: false, - on_hover: Box::new(|_, _, _| {}), - } - } - - pub fn supports_delete(mut self, supports_delete: bool) -> Self { - self.supports_delete = supports_delete; - self - } - - pub fn hovered(mut self, hovered: bool) -> Self { - self.hovered = hovered; - self - } - - pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { - self.on_hover = Box::new(on_hover); - self - } -} - -impl RenderOnce for HistoryEntryElement { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let id = ElementId::Name(self.entry.session_id.0.clone().into()); - let title = thread_title(&self.entry).clone(); - let formatted_time = self - .entry - .updated_at - .map(|timestamp| { - let now = chrono::Utc::now(); - let duration = now.signed_duration_since(timestamp); - - if duration.num_days() > 0 { - format!("{}d", duration.num_days()) - } else if duration.num_hours() > 0 { - format!("{}h ago", duration.num_hours()) - } else if duration.num_minutes() > 0 { - format!("{}m ago", duration.num_minutes()) - } else { - "Just now".to_string() - } - }) - .unwrap_or_else(|| "Unknown".to_string()); - - ListItem::new(id) - .rounded() - .toggle_state(self.selected) - .spacing(ListItemSpacing::Sparse) - .start_slot( - h_flex() - .w_full() - .gap_2() - .justify_between() - .child(Label::new(title).size(LabelSize::Small).truncate()) - .child( - Label::new(formatted_time) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) - .on_hover(self.on_hover) - .end_slot::(if (self.hovered || self.selected) && self.supports_delete { - Some( - IconButton::new("delete", IconName::Trash) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(move |_window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, cx) - }) - .on_click({ - let thread_view = self.thread_view.clone(); - let session_id = self.entry.session_id.clone(); - - move |_event, _window, cx| { - if let Some(thread_view) = thread_view.upgrade() { - thread_view.update(cx, |thread_view, cx| { - thread_view.delete_history_entry(&session_id, cx); - }); - } - } - }), - ) - } else { - None - }) - .on_click({ - let thread_view = self.thread_view.clone(); - let entry = self.entry; - - move |_event, window, cx| { - if let Some(workspace) = thread_view - .upgrade() - .and_then(|view| view.read(cx).workspace().upgrade()) - { - if let Some(panel) = workspace.read(cx).panel::(cx) { - panel.update(cx, |panel, cx| { - panel.load_agent_thread( - entry.session_id.clone(), - entry.cwd.clone(), - entry.title.clone(), - window, - cx, - ); - }); - } - } - } - }) - } -} - -#[derive(Clone, Copy)] -pub enum EntryTimeFormat { - DateAndTime, - TimeOnly, -} - -impl EntryTimeFormat { - fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { - let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); - - match self { - EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( - timestamp, - OffsetDateTime::now_utc(), - timezone, - time_format::TimestampFormat::EnhancedAbsolute, - ), - EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), - } - } -} - -impl From for EntryTimeFormat { - fn from(bucket: TimeBucket) -> Self { - match bucket { - TimeBucket::Today => EntryTimeFormat::TimeOnly, - TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, - TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, - TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, - TimeBucket::All => EntryTimeFormat::DateAndTime, - } - } -} - -#[derive(PartialEq, Eq, Clone, Copy, Debug)] -enum TimeBucket { - Today, - Yesterday, - ThisWeek, - PastWeek, - All, -} - -impl TimeBucket { - fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { - if date == reference { - return TimeBucket::Today; - } - - if date == reference - TimeDelta::days(1) { - return TimeBucket::Yesterday; - } - - let week = date.iso_week(); - - if reference.iso_week() == week { - return TimeBucket::ThisWeek; - } - - let last_week = (reference - TimeDelta::days(7)).iso_week(); - - if week == last_week { - return TimeBucket::PastWeek; - } - - TimeBucket::All - } -} - -impl Display for TimeBucket { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TimeBucket::Today => write!(f, "Today"), - TimeBucket::Yesterday => write!(f, "Yesterday"), - TimeBucket::ThisWeek => write!(f, "This Week"), - TimeBucket::PastWeek => write!(f, "Past Week"), - TimeBucket::All => write!(f, "All"), + Task::ready(Ok(())) } } } @@ -1073,7 +244,6 @@ impl Display for TimeBucket { mod tests { use super::*; use acp_thread::AgentSessionListResponse; - use chrono::NaiveDate; use gpui::TestAppContext; use std::{ any::Any, @@ -1246,9 +416,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, _cx| { @@ -1270,9 +438,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); session_list.clear_requested_cursors(); @@ -1307,9 +473,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, cx| history.refresh_full_history(cx)); @@ -1340,9 +504,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, cx| history.refresh_full_history(cx)); @@ -1371,9 +533,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, cx| history.refresh_full_history(cx)); @@ -1416,9 +576,7 @@ mod tests { .with_async_responses(), ); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); session_list.clear_requested_cursors(); @@ -1449,19 +607,15 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send a title update session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title("New Title"), }); cx.run_until_parked(); - // Check that the title was updated history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1486,19 +640,15 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an update that clears the title (null) session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title(None::), }); cx.run_until_parked(); - // Check that the title was cleared history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!(session.unwrap().title, None); @@ -1520,19 +670,15 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an update with no fields set (all undefined) session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new(), }); cx.run_until_parked(); - // Check that the title is unchanged history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1557,12 +703,9 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send multiple updates before the executor runs session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title("First Title"), @@ -1573,7 +716,6 @@ mod tests { }); cx.run_until_parked(); - // Check that the final title is "Second Title" (both applied in order) history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1598,12 +740,9 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an info update followed by a refresh session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title("Local Update"), @@ -1611,7 +750,6 @@ mod tests { session_list.send_update(SessionListUpdate::Refresh); cx.run_until_parked(); - // The refresh should have fetched from server, getting "Server Title" history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1636,19 +774,15 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an update for an unknown session session_list.send_update(SessionListUpdate::SessionInfo { session_id: acp::SessionId::new("unknown-session"), update: acp::SessionInfoUpdate::new().title("Should Be Ignored"), }); cx.run_until_parked(); - // Check that the known session is unchanged and no crash occurred history.update(cx, |history, _cx| { assert_eq!(history.sessions.len(), 1); assert_eq!( @@ -1657,43 +791,4 @@ mod tests { ); }); } - - #[test] - fn test_time_bucket_from_dates() { - let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(); - - let date = today; - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today); - - let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday); - - let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); - - // All: not in this week or last week - let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); - - // Test year boundary cases - let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); - - let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(); - assert_eq!( - TimeBucket::from_dates(new_year, date), - TimeBucket::Yesterday - ); - - let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); - assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); - } } diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..1756fc46ed48e86dc4bf9c78f2c2ef79618ed43b --- /dev/null +++ b/crates/agent_ui/src/thread_history_view.rs @@ -0,0 +1,878 @@ +use crate::thread_history::ThreadHistory; +use crate::{AgentPanel, ConnectionView, RemoveHistory, RemoveSelectedThread}; +use acp_thread::AgentSessionInfo; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; +use editor::{Editor, EditorEvent}; +use fuzzy::StringMatchCandidate; +use gpui::{ + AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, + UniformListScrollHandle, WeakEntity, Window, uniform_list, +}; +use std::{fmt::Display, ops::Range}; +use text::Bias; +use time::{OffsetDateTime, UtcOffset}; +use ui::{ + ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, + WithScrollbar, prelude::*, +}; + +const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); + +pub(crate) fn thread_title(entry: &AgentSessionInfo) -> &SharedString { + entry + .title + .as_ref() + .filter(|title| !title.is_empty()) + .unwrap_or(DEFAULT_TITLE) +} + +pub struct ThreadHistoryView { + history: Entity, + scroll_handle: UniformListScrollHandle, + selected_index: usize, + hovered_index: Option, + search_editor: Entity, + search_query: SharedString, + visible_items: Vec, + local_timezone: UtcOffset, + confirming_delete_history: bool, + _visible_items_task: Task<()>, + _subscriptions: Vec, +} + +enum ListItemType { + BucketSeparator(TimeBucket), + Entry { + entry: AgentSessionInfo, + format: EntryTimeFormat, + }, + SearchResult { + entry: AgentSessionInfo, + positions: Vec, + }, +} + +impl ListItemType { + fn history_entry(&self) -> Option<&AgentSessionInfo> { + match self { + ListItemType::Entry { entry, .. } => Some(entry), + ListItemType::SearchResult { entry, .. } => Some(entry), + _ => None, + } + } +} + +pub enum ThreadHistoryViewEvent { + Open(AgentSessionInfo), +} + +impl EventEmitter for ThreadHistoryView {} + +impl ThreadHistoryView { + pub fn new( + history: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search threads...", window, cx); + editor + }); + + let search_editor_subscription = + cx.subscribe(&search_editor, |this, search_editor, event, cx| { + if let EditorEvent::BufferEdited = event { + let query = search_editor.read(cx).text(cx); + if this.search_query != query { + this.search_query = query.into(); + this.update_visible_items(false, cx); + } + } + }); + + let history_subscription = cx.observe(&history, |this, _, cx| { + this.update_visible_items(true, cx); + }); + + let scroll_handle = UniformListScrollHandle::default(); + + let mut this = Self { + history, + scroll_handle, + selected_index: 0, + hovered_index: None, + visible_items: Default::default(), + search_editor, + local_timezone: UtcOffset::from_whole_seconds( + chrono::Local::now().offset().local_minus_utc(), + ) + .unwrap(), + search_query: SharedString::default(), + confirming_delete_history: false, + _subscriptions: vec![search_editor_subscription, history_subscription], + _visible_items_task: Task::ready(()), + }; + this.update_visible_items(false, cx); + this + } + + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let entries = self.history.read(cx).sessions().to_vec(); + let new_list_items = if self.search_query.is_empty() { + self.add_list_separators(entries, cx) + } else { + self.filter_search_results(entries, cx) + }; + let selected_history_entry = if preserve_selected_item { + self.selected_history_entry().cloned() + } else { + None + }; + + self._visible_items_task = cx.spawn(async move |this, cx| { + let new_visible_items = new_list_items.await; + this.update(cx, |this, cx| { + let new_selected_index = if let Some(history_entry) = selected_history_entry { + new_visible_items + .iter() + .position(|visible_entry| { + visible_entry + .history_entry() + .is_some_and(|entry| entry.session_id == history_entry.session_id) + }) + .unwrap_or(0) + } else { + 0 + }; + + this.visible_items = new_visible_items; + this.set_selected_index(new_selected_index, Bias::Right, cx); + cx.notify(); + }) + .ok(); + }); + } + + fn add_list_separators( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + cx.background_spawn(async move { + let mut items = Vec::with_capacity(entries.len() + 1); + let mut bucket = None; + let today = Local::now().naive_local().date(); + + for entry in entries.into_iter() { + let entry_bucket = entry + .updated_at + .map(|timestamp| { + let entry_date = timestamp.with_timezone(&Local).naive_local().date(); + TimeBucket::from_dates(today, entry_date) + }) + .unwrap_or(TimeBucket::All); + + if Some(entry_bucket) != bucket { + bucket = Some(entry_bucket); + items.push(ListItemType::BucketSeparator(entry_bucket)); + } + + items.push(ListItemType::Entry { + entry, + format: entry_bucket.into(), + }); + } + items + }) + } + + fn filter_search_results( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + let query = self.search_query.clone(); + cx.background_spawn({ + let executor = cx.background_executor().clone(); + async move { + let mut candidates = Vec::with_capacity(entries.len()); + + for (idx, entry) in entries.iter().enumerate() { + candidates.push(StringMatchCandidate::new(idx, thread_title(entry))); + } + + const MAX_MATCHES: usize = 100; + + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + MAX_MATCHES, + &Default::default(), + executor, + ) + .await; + + matches + .into_iter() + .map(|search_match| ListItemType::SearchResult { + entry: entries[search_match.candidate_id].clone(), + positions: search_match.positions, + }) + .collect() + } + }) + } + + fn search_produced_no_matches(&self) -> bool { + self.visible_items.is_empty() && !self.search_query.is_empty() + } + + fn selected_history_entry(&self) -> Option<&AgentSessionInfo> { + self.get_history_entry(self.selected_index) + } + + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> { + self.visible_items.get(visible_items_ix)?.history_entry() + } + + fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { + if self.visible_items.len() == 0 { + self.selected_index = 0; + return; + } + while matches!( + self.visible_items.get(index), + None | Some(ListItemType::BucketSeparator(..)) + ) { + index = match bias { + Bias::Left => { + if index == 0 { + self.visible_items.len() - 1 + } else { + index - 1 + } + } + Bias::Right => { + if index >= self.visible_items.len() - 1 { + 0 + } else { + index + 1 + } + } + }; + } + self.selected_index = index; + self.scroll_handle + .scroll_to_item(index, ScrollStrategy::Top); + cx.notify() + } + + fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == 0 { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } else { + self.set_selected_index(self.selected_index - 1, Bias::Left, cx); + } + } + + fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) { + if self.selected_index == self.visible_items.len() - 1 { + self.set_selected_index(0, Bias::Right, cx); + } else { + self.set_selected_index(self.selected_index + 1, Bias::Right, cx); + } + } + + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + self.set_selected_index(0, Bias::Right, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + self.confirm_entry(self.selected_index, cx); + } + + fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(ix) else { + return; + }; + cx.emit(ThreadHistoryViewEvent::Open(entry.clone())); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + self.remove_thread(self.selected_index, cx) + } + + fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(visible_item_ix) else { + return; + }; + if !self.history.read(cx).supports_delete() { + return; + } + let session_id = entry.session_id.clone(); + self.history.update(cx, |history, cx| { + history + .delete_session(&session_id, cx) + .detach_and_log_err(cx); + }); + } + + fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { + if !self.history.read(cx).supports_delete() { + return; + } + self.history.update(cx, |history, cx| { + history.delete_sessions(cx).detach_and_log_err(cx); + }); + self.confirming_delete_history = false; + cx.notify(); + } + + fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = true; + cx.notify(); + } + + fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = false; + cx.notify(); + } + + fn render_list_items( + &mut self, + range: Range, + _window: &mut Window, + cx: &mut Context, + ) -> Vec { + self.visible_items + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) + .collect() + } + + fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { + match item { + ListItemType::Entry { entry, format } => self + .render_history_entry(entry, *format, ix, Vec::default(), cx) + .into_any(), + ListItemType::SearchResult { entry, positions } => self.render_history_entry( + entry, + EntryTimeFormat::DateAndTime, + ix, + positions.clone(), + cx, + ), + ListItemType::BucketSeparator(bucket) => div() + .px(DynamicSpacing::Base06.rems(cx)) + .pt_2() + .pb_1() + .child( + Label::new(bucket.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + } + } + + fn render_history_entry( + &self, + entry: &AgentSessionInfo, + format: EntryTimeFormat, + ix: usize, + highlight_positions: Vec, + cx: &Context, + ) -> AnyElement { + let selected = ix == self.selected_index; + let hovered = Some(ix) == self.hovered_index; + let entry_time = entry.updated_at; + let display_text = match (format, entry_time) { + (EntryTimeFormat::DateAndTime, Some(entry_time)) => { + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + let days = duration.num_days(); + + format!("{}d", days) + } + (EntryTimeFormat::TimeOnly, Some(entry_time)) => { + format.format_timestamp(entry_time.timestamp(), self.local_timezone) + } + (_, None) => "—".to_string(), + }; + + let title = thread_title(entry).clone(); + let full_date = entry_time + .map(|time| { + EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone) + }) + .unwrap_or_else(|| "Unknown".to_string()); + + let supports_delete = self.history.read(cx).supports_delete(); + + h_flex() + .w_full() + .pb_1() + .child( + ListItem::new(ix) + .rounded() + .toggle_state(selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child( + HighlightedLabel::new(thread_title(entry), highlight_positions) + .size(LabelSize::Small) + .truncate(), + ) + .child( + Label::new(display_text) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .tooltip(move |_, cx| { + Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) + }) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_index = Some(ix); + } else if this.hovered_index == Some(ix) { + this.hovered_index = None; + } + + cx.notify(); + })) + .end_slot::(if hovered && supports_delete { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_thread(ix, cx); + cx.stop_propagation() + })), + ) + } else { + None + }) + .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), + ) + .into_any_element() + } +} + +impl Focusable for ThreadHistoryView { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.search_editor.focus_handle(cx) + } +} + +impl Render for ThreadHistoryView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_no_history = self.history.read(cx).is_empty(); + let supports_delete = self.history.read(cx).supports_delete(); + + v_flex() + .key_context("ThreadHistory") + .size_full() + .bg(cx.theme().colors().panel_background) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::remove_selected_thread)) + .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { + this.remove_history(window, cx); + })) + .child( + h_flex() + .h(Tab::container_height(cx)) + .w_full() + .py_1() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(self.search_editor.clone()), + ) + .child({ + let view = v_flex() + .id("list-container") + .relative() + .overflow_hidden() + .flex_grow(); + + if has_no_history { + view.justify_center().items_center().child( + Label::new("You don't have any past threads yet.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else if self.search_produced_no_matches() { + view.justify_center() + .items_center() + .child(Label::new("No threads match your search.").size(LabelSize::Small)) + } else { + view.child( + uniform_list( + "thread-history", + self.visible_items.len(), + cx.processor(|this, range: Range, window, cx| { + this.render_list_items(range, window, cx) + }), + ) + .p_1() + .pr_4() + .track_scroll(&self.scroll_handle) + .flex_grow(), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + } + }) + .when(!has_no_history && supports_delete, |this| { + this.child( + h_flex() + .p_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .when(!self.confirming_delete_history, |this| { + this.child( + Button::new("delete_history", "Delete All History") + .full_width() + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.prompt_delete_history(window, cx); + })), + ) + }) + .when(self.confirming_delete_history, |this| { + this.w_full() + .gap_2() + .flex_wrap() + .justify_between() + .child( + h_flex() + .flex_wrap() + .gap_1() + .child( + Label::new("Delete all threads?") + .size(LabelSize::Small), + ) + .child( + Label::new("You won't be able to recover them later.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("cancel_delete", "Cancel") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_delete_history(window, cx); + })), + ) + .child( + Button::new("confirm_delete", "Delete") + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action( + Box::new(RemoveHistory), + cx, + ); + })), + ), + ) + }), + ) + }) + } +} + +#[derive(IntoElement)] +pub struct HistoryEntryElement { + entry: AgentSessionInfo, + thread_view: WeakEntity, + selected: bool, + hovered: bool, + supports_delete: bool, + on_hover: Box, +} + +impl HistoryEntryElement { + pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity) -> Self { + Self { + entry, + thread_view, + selected: false, + hovered: false, + supports_delete: false, + on_hover: Box::new(|_, _, _| {}), + } + } + + pub fn supports_delete(mut self, supports_delete: bool) -> Self { + self.supports_delete = supports_delete; + self + } + + pub fn hovered(mut self, hovered: bool) -> Self { + self.hovered = hovered; + self + } + + pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { + self.on_hover = Box::new(on_hover); + self + } +} + +impl RenderOnce for HistoryEntryElement { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let id = ElementId::Name(self.entry.session_id.0.clone().into()); + let title = thread_title(&self.entry).clone(); + let formatted_time = self + .entry + .updated_at + .map(|timestamp| { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(timestamp); + + if duration.num_days() > 0 { + format!("{}d", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{}h ago", duration.num_hours()) + } else if duration.num_minutes() > 0 { + format!("{}m ago", duration.num_minutes()) + } else { + "Just now".to_string() + } + }) + .unwrap_or_else(|| "Unknown".to_string()); + + ListItem::new(id) + .rounded() + .toggle_state(self.selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child(Label::new(title).size(LabelSize::Small).truncate()) + .child( + Label::new(formatted_time) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .on_hover(self.on_hover) + .end_slot::(if (self.hovered || self.selected) && self.supports_delete { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click({ + let thread_view = self.thread_view.clone(); + let session_id = self.entry.session_id.clone(); + + move |_event, _window, cx| { + if let Some(thread_view) = thread_view.upgrade() { + thread_view.update(cx, |thread_view, cx| { + thread_view.delete_history_entry(&session_id, cx); + }); + } + } + }), + ) + } else { + None + }) + .on_click({ + let thread_view = self.thread_view.clone(); + let entry = self.entry; + + move |_event, window, cx| { + if let Some(workspace) = thread_view + .upgrade() + .and_then(|view| view.read(cx).workspace().upgrade()) + { + if let Some(panel) = workspace.read(cx).panel::(cx) { + panel.update(cx, |panel, cx| { + panel.load_agent_thread( + entry.session_id.clone(), + entry.cwd.clone(), + entry.title.clone(), + window, + cx, + ); + }); + } + } + } + }) + } +} + +#[derive(Clone, Copy)] +pub enum EntryTimeFormat { + DateAndTime, + TimeOnly, +} + +impl EntryTimeFormat { + fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { + let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); + + match self { + EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( + timestamp, + OffsetDateTime::now_utc(), + timezone, + time_format::TimestampFormat::EnhancedAbsolute, + ), + EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), + } + } +} + +impl From for EntryTimeFormat { + fn from(bucket: TimeBucket) -> Self { + match bucket { + TimeBucket::Today => EntryTimeFormat::TimeOnly, + TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, + TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, + TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, + TimeBucket::All => EntryTimeFormat::DateAndTime, + } + } +} + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + All, +} + +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + + let week = date.iso_week(); + + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + + let last_week = (reference - TimeDelta::days(7)).iso_week(); + + if week == last_week { + return TimeBucket::PastWeek; + } + + TimeBucket::All + } +} + +impl Display for TimeBucket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TimeBucket::Today => write!(f, "Today"), + TimeBucket::Yesterday => write!(f, "Yesterday"), + TimeBucket::ThisWeek => write!(f, "This Week"), + TimeBucket::PastWeek => write!(f, "Past Week"), + TimeBucket::All => write!(f, "All"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn test_time_bucket_from_dates() { + let today = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(); + + assert_eq!(TimeBucket::from_dates(today, today), TimeBucket::Today); + + let yesterday = NaiveDate::from_ymd_opt(2025, 1, 14).unwrap(); + assert_eq!( + TimeBucket::from_dates(today, yesterday), + TimeBucket::Yesterday + ); + + let this_week = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap(); + assert_eq!( + TimeBucket::from_dates(today, this_week), + TimeBucket::ThisWeek + ); + + let past_week = NaiveDate::from_ymd_opt(2025, 1, 7).unwrap(); + assert_eq!( + TimeBucket::from_dates(today, past_week), + TimeBucket::PastWeek + ); + + let old = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap(); + assert_eq!(TimeBucket::from_dates(today, old), TimeBucket::All); + } +} From 34407d62eaeda4b87c109ad5497328a70553cfae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:31:46 +0100 Subject: [PATCH 135/219] Delete unused workspace dependencies (#51285) Just a small opportunistic cleanup. Release Notes: - N/A --- Cargo.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f650dace84b1b2e6491acf2806077f72000605f5..36e7ca8cc7129af0ed7ab29dc5db338cdf33f7d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -511,7 +511,6 @@ aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] } aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] } backtrace = "0.3" base64 = "0.22" -bincode = "1.2.1" bitflags = "2.6.0" brotli = "8.0.2" bytes = "1.0" @@ -570,7 +569,6 @@ human_bytes = "0.4.1" html5ever = "0.27.0" http = "1.1" http-body = "1.0" -hyper = "0.14" ignore = "0.4.22" image = "0.25.1" imara-diff = "0.1.8" @@ -688,7 +686,6 @@ serde_json_lenient = { version = "0.2", features = [ "raw_value", ] } serde_path_to_error = "0.1.17" -serde_repr = "0.1" serde_urlencoded = "0.7" sha2 = "0.10" shellexpand = "2.1.0" @@ -719,7 +716,6 @@ time = { version = "0.3", features = [ ] } tiny_http = "0.8" tokio = { version = "1" } -tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] } tokio-socks = { version = "0.5.2", default-features = false, features = [ "futures-io", "tokio", From f713373d1026a4e7fa7caf289b33b5009e885e0c Mon Sep 17 00:00:00 2001 From: daydalek <90121301+daydalek@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:06:22 +0800 Subject: [PATCH 136/219] editor: Persist multi-line diagnostic hovers in whitespace areas (#47471) When the mouse cursor moves into the whitespace of a line within a multi-line diagnostic range, the hover popover would previously disappear. This change adds a check to keep the diagnostic hover visible if the mouse row intersects with the active diagnostic's range. Fixes #46841 Release Notes: - Improved hover behavior for multi-line diagnostics to persist when hovering over whitespace. https://github.com/user-attachments/assets/0965cb25-6207-4d4a-9165-0d51157fc6e4 --- crates/editor/src/editor.rs | 1 + crates/editor/src/element.rs | 6 +- crates/editor/src/hover_links.rs | 4 +- crates/editor/src/hover_popover.rs | 174 ++++++++++++++++++++++-- crates/editor/src/inlays/inlay_hints.rs | 5 +- 5 files changed, 173 insertions(+), 17 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ca3dd81ab072d0e20389318515049793a8c827ef..dc2696eb2ca83999934cab6cdee82e364657c70e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -8389,6 +8389,7 @@ impl Editor { self.update_hovered_link( position_map.point_for_position(mouse_position), + Some(mouse_position), &position_map.snapshot, modifiers, window, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 3b1356525960654ea88c6cfa84115f1e67ac2e5b..5de14d80681ca1ad07534e8764217ef75cc90305 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1462,6 +1462,7 @@ impl EditorElement { if text_hovered { editor.update_hovered_link( point_for_position, + Some(event.position), &position_map.snapshot, modifiers, window, @@ -1473,12 +1474,13 @@ impl EditorElement { .snapshot .buffer_snapshot() .anchor_before(point.to_offset(&position_map.snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx); + hover_at(editor, Some(anchor), Some(event.position), window, cx); Self::update_visible_cursor(editor, point, position_map, window, cx); } else { editor.update_inlay_link_and_hover_points( &position_map.snapshot, point_for_position, + Some(event.position), modifiers.secondary(), modifiers.shift, window, @@ -1487,7 +1489,7 @@ impl EditorElement { } } else { editor.hide_hovered_link(cx); - hover_at(editor, None, window, cx); + hover_at(editor, None, Some(event.position), window, cx); } } diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 659a383d6b20129909b4c3f2d7bdbfbe5e580f4e..3a6ff4ec0e4fc53d19bfb51a10b1f7790933b175 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -4,7 +4,7 @@ use crate::{ HighlightKey, Navigated, PointForPosition, SelectPhase, editor_settings::GoToDefinitionFallback, scroll::ScrollAmount, }; -use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Task, Window, px}; +use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Pixels, Task, Window, px}; use language::{Bias, ToOffset}; use linkify::{LinkFinder, LinkKind}; use lsp::LanguageServerId; @@ -113,6 +113,7 @@ impl Editor { pub(crate) fn update_hovered_link( &mut self, point_for_position: PointForPosition, + mouse_position: Option>, snapshot: &EditorSnapshot, modifiers: Modifiers, window: &mut Window, @@ -138,6 +139,7 @@ impl Editor { self.update_inlay_link_and_hover_points( snapshot, point_for_position, + mouse_position, hovered_link_modifier, modifiers.shift, window, diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index f5d5e6d5ab69d690bd5f3aee29bf9aa493cf0059..ad54d6105ca3896d21857d548d80f991a1a76ecc 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -8,10 +8,10 @@ use crate::{ }; use anyhow::Context as _; use gpui::{ - AnyElement, AsyncWindowContext, Context, Entity, Focusable as _, FontWeight, Hsla, - InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size, - StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TextStyleRefinement, - Window, div, px, + AnyElement, App, AsyncApp, AsyncWindowContext, Bounds, Context, Entity, Focusable as _, + FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, + ScrollHandle, Size, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, + TextStyleRefinement, WeakEntity, Window, canvas, div, px, }; use itertools::Itertools; use language::{DiagnosticEntry, Language, LanguageRegistry}; @@ -20,7 +20,10 @@ use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint}; use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart}; use settings::Settings; -use std::{borrow::Cow, cell::RefCell}; +use std::{ + borrow::Cow, + cell::{Cell, RefCell}, +}; use std::{ops::Range, sync::Arc, time::Duration}; use std::{path::PathBuf, rc::Rc}; use theme::ThemeSettings; @@ -45,6 +48,7 @@ pub fn hover(editor: &mut Editor, _: &Hover, window: &mut Window, cx: &mut Conte pub fn hover_at( editor: &mut Editor, anchor: Option, + mouse_position: Option>, window: &mut Window, cx: &mut Context, ) { @@ -52,10 +56,37 @@ pub fn hover_at( if show_keyboard_hover(editor, window, cx) { return; } + if let Some(anchor) = anchor { + editor.hover_state.hiding_delay_task = None; + editor.hover_state.closest_mouse_distance = None; show_hover(editor, anchor, false, window, cx); } else { - hide_hover(editor, cx); + let mut getting_closer = false; + if let Some(mouse_position) = mouse_position { + getting_closer = editor.hover_state.is_mouse_getting_closer(mouse_position); + } + + // If we are moving away and a timer is already running, just let it count down. + if !getting_closer && editor.hover_state.hiding_delay_task.is_some() { + return; + } + + // If we are moving closer, or if no timer is running at all, start/restart the 300ms timer. + let delay = 300u64; + let task = cx.spawn(move |this: WeakEntity, cx: &mut AsyncApp| { + let mut cx = cx.clone(); + async move { + cx.background_executor() + .timer(Duration::from_millis(delay)) + .await; + this.update(&mut cx, |editor, cx| { + hide_hover(editor, cx); + }) + .ok(); + } + }); + editor.hover_state.hiding_delay_task = Some(task); } } } @@ -156,6 +187,9 @@ pub fn hover_at_inlay( let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; + editor.hover_state.hiding_delay_task = None; + editor.hover_state.closest_mouse_distance = None; + let task = cx.spawn_in(window, async move |this, cx| { async move { cx.background_executor() @@ -187,6 +221,7 @@ pub fn hover_at_inlay( scroll_handle, keyboard_grace: Rc::new(RefCell::new(false)), anchor: None, + last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }; @@ -216,6 +251,8 @@ pub fn hide_hover(editor: &mut Editor, cx: &mut Context) -> bool { editor.hover_state.info_task = None; editor.hover_state.triggered_from = None; + editor.hover_state.hiding_delay_task = None; + editor.hover_state.closest_mouse_distance = None; editor.clear_background_highlights(HighlightKey::HoverState, cx); @@ -254,6 +291,9 @@ fn show_hover( .map(|project| project.read(cx).languages().clone()); let provider = editor.semantics_provider.clone()?; + editor.hover_state.hiding_delay_task = None; + editor.hover_state.closest_mouse_distance = None; + if !ignore_timeout { if same_info_hover(editor, &snapshot, anchor) || same_diagnostic_hover(editor, &snapshot, anchor) @@ -398,6 +438,7 @@ fn show_hover( background_color, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor, + last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }) } else { @@ -466,6 +507,7 @@ fn show_hover( scroll_handle, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), + last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }) } @@ -507,6 +549,7 @@ fn show_hover( scroll_handle, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), + last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }); } @@ -778,6 +821,8 @@ pub struct HoverState { pub diagnostic_popover: Option, pub triggered_from: Option, pub info_task: Option>>, + pub closest_mouse_distance: Option, + pub hiding_delay_task: Option>, } impl HoverState { @@ -785,6 +830,60 @@ impl HoverState { !self.info_popovers.is_empty() || self.diagnostic_popover.is_some() } + pub fn is_mouse_getting_closer(&mut self, mouse_position: gpui::Point) -> bool { + if !self.visible() { + return false; + } + + let mut popover_bounds = Vec::new(); + for info_popover in &self.info_popovers { + if let Some(bounds) = info_popover.last_bounds.get() { + popover_bounds.push(bounds); + } + } + if let Some(diagnostic_popover) = &self.diagnostic_popover { + if let Some(bounds) = diagnostic_popover.last_bounds.get() { + popover_bounds.push(bounds); + } + } + + if popover_bounds.is_empty() { + return false; + } + + let distance = popover_bounds + .iter() + .map(|bounds| self.distance_from_point_to_bounds(mouse_position, *bounds)) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .unwrap_or(px(f32::MAX)); + + if let Some(closest_distance) = self.closest_mouse_distance { + if distance > closest_distance + px(4.0) { + return false; + } + } + + self.closest_mouse_distance = + Some(distance.min(self.closest_mouse_distance.unwrap_or(distance))); + true + } + + fn distance_from_point_to_bounds( + &self, + point: gpui::Point, + bounds: Bounds, + ) -> Pixels { + let center_x = bounds.origin.x + bounds.size.width / 2.; + let center_y = bounds.origin.y + bounds.size.height / 2.; + let dx: f32 = ((point.x - center_x).abs() - bounds.size.width / 2.) + .max(px(0.0)) + .into(); + let dy: f32 = ((point.y - center_y).abs() - bounds.size.height / 2.) + .max(px(0.0)) + .into(); + px((dx.powi(2) + dy.powi(2)).sqrt()) + } + pub(crate) fn render( &mut self, snapshot: &EditorSnapshot, @@ -887,6 +986,7 @@ pub struct InfoPopover { pub scroll_handle: ScrollHandle, pub keyboard_grace: Rc>, pub anchor: Option, + pub last_bounds: Rc>>>, _subscription: Option, } @@ -898,13 +998,36 @@ impl InfoPopover { cx: &mut Context, ) -> AnyElement { let keyboard_grace = Rc::clone(&self.keyboard_grace); + let this = cx.entity().downgrade(); + let bounds_cell = self.last_bounds.clone(); div() .id("info_popover") .occlude() .elevation_2(cx) + .child( + canvas( + { + move |bounds, _window, _cx| { + bounds_cell.set(Some(bounds)); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) // Prevent a mouse down/move on the popover from being propagated to the editor, // because that would dismiss the popover. - .on_mouse_move(|_, _, cx| cx.stop_propagation()) + .on_mouse_move({ + move |_, _, cx: &mut App| { + this.update(cx, |editor, _| { + editor.hover_state.closest_mouse_distance = Some(px(0.0)); + editor.hover_state.hiding_delay_task = None; + }) + .ok(); + cx.stop_propagation() + } + }) .on_mouse_down(MouseButton::Left, move |_, _, cx| { let mut keyboard_grace = keyboard_grace.borrow_mut(); *keyboard_grace = false; @@ -957,6 +1080,7 @@ pub struct DiagnosticPopover { background_color: Hsla, pub keyboard_grace: Rc>, pub anchor: Anchor, + pub last_bounds: Rc>>>, _subscription: Subscription, pub scroll_handle: ScrollHandle, } @@ -970,10 +1094,23 @@ impl DiagnosticPopover { ) -> AnyElement { let keyboard_grace = Rc::clone(&self.keyboard_grace); let this = cx.entity().downgrade(); + let bounds_cell = self.last_bounds.clone(); div() .id("diagnostic") .occlude() .elevation_2_borderless(cx) + .child( + canvas( + { + move |bounds, _window, _cx| { + bounds_cell.set(Some(bounds)); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) // Don't draw the background color if the theme // allows transparent surfaces. .when(theme_is_transparent(cx), |this| { @@ -981,7 +1118,17 @@ impl DiagnosticPopover { }) // Prevent a mouse move on the popover from being propagated to the editor, // because that would dismiss the popover. - .on_mouse_move(|_, _, cx| cx.stop_propagation()) + .on_mouse_move({ + let this = this.clone(); + move |_, _, cx: &mut App| { + this.update(cx, |editor, _| { + editor.hover_state.closest_mouse_distance = Some(px(0.0)); + editor.hover_state.hiding_delay_task = None; + }) + .ok(); + cx.stop_propagation() + } + }) // Prevent a mouse down on the popover from being propagated to the editor, // because that would move the cursor. .on_mouse_down(MouseButton::Left, move |_, _, cx| { @@ -1151,7 +1298,7 @@ mod tests { let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx) + hover_at(editor, Some(anchor), None, window, cx) }); assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible())); @@ -1251,7 +1398,7 @@ mod tests { let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx) + hover_at(editor, Some(anchor), None, window, cx) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); @@ -1289,7 +1436,7 @@ mod tests { let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx) + hover_at(editor, Some(anchor), None, window, cx) }); assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible())); @@ -1343,7 +1490,7 @@ mod tests { let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx) + hover_at(editor, Some(anchor), None, window, cx) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); @@ -1752,6 +1899,7 @@ mod tests { editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), new_type_hint_part_hover_position, + None, true, false, window, @@ -1822,6 +1970,7 @@ mod tests { editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), new_type_hint_part_hover_position, + None, true, false, window, @@ -1877,6 +2026,7 @@ mod tests { editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), struct_hint_part_hover_position, + None, true, false, window, diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs index 62eb35f1ac85227c9b52737660da0d1834e1bbfa..414829dc3bbcd89f5f4e4337a955cfff5bb57fca 100644 --- a/crates/editor/src/inlays/inlay_hints.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -7,7 +7,7 @@ use std::{ use clock::Global; use collections::{HashMap, HashSet}; use futures::future::join_all; -use gpui::{App, Entity, Task}; +use gpui::{App, Entity, Pixels, Task}; use itertools::Itertools; use language::{ BufferRow, @@ -569,6 +569,7 @@ impl Editor { &mut self, snapshot: &EditorSnapshot, point_for_position: PointForPosition, + mouse_position: Option>, secondary_held: bool, shift_held: bool, window: &mut Window, @@ -748,7 +749,7 @@ impl Editor { self.hide_hovered_link(cx) } if !hover_updated { - hover_popover::hover_at(self, None, window, cx); + hover_popover::hover_at(self, None, mouse_position, window, cx); } } From ac2f097559ecbaab6f55ca5a519f53b80ac54afb Mon Sep 17 00:00:00 2001 From: MostlyK <135974627+MostlyKIGuess@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:42:48 +0530 Subject: [PATCH 137/219] image_viewer: Add pinch event support (#47351) This change implements pinch / magnification gesture handling. This uses the following wayland [protocol](https://wayland.app/protocols/pointer-gestures-unstable-v1). And the following [API](https://developer.apple.com/documentation/appkit/nsevent/magnification) for mac. - Original: https://github.com/gpui-ce/gpui-ce/pull/11 Release Notes: - Zooming works with pinching in and out inside Image Viewer --- crates/gpui/src/elements/div.rs | 92 ++++++++++++++++ crates/gpui/src/interactive.rs | 55 ++++++++++ crates/gpui/src/window.rs | 6 ++ crates/gpui_linux/src/linux/wayland/client.rs | 100 ++++++++++++++++++ crates/gpui_macos/src/events.rs | 25 ++++- crates/gpui_macos/src/window.rs | 4 + crates/image_viewer/src/image_viewer.rs | 38 ++++++- 7 files changed, 314 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 3599affc3c792f3c93b3b94cfc44740d7c38caf7..bf185b1b6cc20e0f0f484fd0029c78a6211e6a3a 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -15,6 +15,8 @@ //! and Tailwind-like styling that you can use to build your own custom elements. Div is //! constructed by combining these two systems into an all-in-one element. +#[cfg(any(target_os = "linux", target_os = "macos"))] +use crate::PinchEvent; use crate::{ AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, @@ -353,6 +355,43 @@ impl Interactivity { })); } + /// Bind the given callback to pinch gesture events during the bubble phase. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub fn on_pinch(&mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) { + self.pinch_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { + (listener)(event, window, cx); + } + })); + } + + /// Bind the given callback to pinch gesture events during the capture phase. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub fn capture_pinch( + &mut self, + listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, + ) { + self.pinch_listeners + .push(Box::new(move |event, phase, _hitbox, window, cx| { + if phase == DispatchPhase::Capture { + (listener)(event, window, cx); + } else { + cx.propagate(); + } + })); + } + /// Bind the given callback to an action dispatch during the capture phase. /// The imperative API equivalent to [`InteractiveElement::capture_action`]. /// @@ -635,6 +674,16 @@ impl Interactivity { pub fn block_mouse_except_scroll(&mut self) { self.hitbox_behavior = HitboxBehavior::BlockMouseExceptScroll; } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn has_pinch_listeners(&self) -> bool { + !self.pinch_listeners.is_empty() + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + fn has_pinch_listeners(&self) -> bool { + false + } } /// A trait for elements that want to use the standard GPUI event handlers that don't @@ -905,6 +954,34 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to pinch gesture events during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_pinch`]. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn on_pinch(mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) -> Self { + self.interactivity().on_pinch(listener); + self + } + + /// Bind the given callback to pinch gesture events during the capture phase. + /// The fluent API equivalent to [`Interactivity::capture_pinch`]. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn capture_pinch( + mut self, + listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().capture_pinch(listener); + self + } /// Capture the given action, before normal action dispatch can fire. /// The fluent API equivalent to [`Interactivity::capture_action`]. /// @@ -1290,6 +1367,10 @@ pub(crate) type MouseMoveListener = pub(crate) type ScrollWheelListener = Box; +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) type PinchListener = + Box; + pub(crate) type ClickListener = Rc; pub(crate) type DragListener = @@ -1644,6 +1725,8 @@ pub struct Interactivity { pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) pinch_listeners: Vec, pub(crate) key_down_listeners: Vec, pub(crate) key_up_listeners: Vec, pub(crate) modifiers_changed_listeners: Vec, @@ -1847,6 +1930,7 @@ impl Interactivity { || !self.click_listeners.is_empty() || !self.aux_click_listeners.is_empty() || !self.scroll_wheel_listeners.is_empty() + || self.has_pinch_listeners() || self.drag_listener.is_some() || !self.drop_listeners.is_empty() || self.tooltip_builder.is_some() @@ -2213,6 +2297,14 @@ impl Interactivity { }) } + #[cfg(any(target_os = "linux", target_os = "macos"))] + for listener in self.pinch_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &PinchEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + if self.hover_style.is_some() || self.base_style.mouse_cursor.is_some() || cx.active_drag.is_some() && !self.drag_over_styles.is_empty() diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 5316a5992bb41d11ef5b6518555a9a20795f894c..3d3ddb49f70b2f96772627d085c93ce31b6dc0b5 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -17,6 +17,9 @@ pub trait KeyEvent: InputEvent {} /// A mouse event from the platform. pub trait MouseEvent: InputEvent {} +/// A gesture event from the platform. +pub trait GestureEvent: InputEvent {} + /// The key down event equivalent for the platform. #[derive(Clone, Debug, Eq, PartialEq)] pub struct KeyDownEvent { @@ -467,6 +470,51 @@ impl Default for ScrollDelta { } } +/// A pinch gesture event from the platform, generated when the user performs +/// a pinch-to-zoom gesture (typically on a trackpad). +/// +/// Note: This event is only available on macOS and Wayland (Linux). +/// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. +#[derive(Clone, Debug, Default)] +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub struct PinchEvent { + /// The position of the pinch center on the window. + pub position: Point, + + /// The zoom delta for this event. + /// Positive values indicate zooming in, negative values indicate zooming out. + /// For example, 0.1 represents a 10% zoom increase. + pub delta: f32, + + /// The modifiers that were held down during the pinch gesture. + pub modifiers: Modifiers, + + /// The phase of the pinch gesture. + pub phase: TouchPhase, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Sealed for PinchEvent {} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl InputEvent for PinchEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::Pinch(self) + } +} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl GestureEvent for PinchEvent {} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl MouseEvent for PinchEvent {} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Deref for PinchEvent { + type Target = Modifiers; + + fn deref(&self) -> &Self::Target { + &self.modifiers + } +} + impl ScrollDelta { /// Returns true if this is a precise scroll delta in pixels. pub fn precise(&self) -> bool { @@ -626,6 +674,9 @@ pub enum PlatformInput { MouseExited(MouseExitEvent), /// The scroll wheel was used. ScrollWheel(ScrollWheelEvent), + /// A pinch gesture was performed. + #[cfg(any(target_os = "linux", target_os = "macos"))] + Pinch(PinchEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), } @@ -642,6 +693,8 @@ impl PlatformInput { PlatformInput::MousePressure(event) => Some(event), PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), } } @@ -657,6 +710,8 @@ impl PlatformInput { PlatformInput::MousePressure(_) => None, PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(_) => None, PlatformInput::FileDrop(_) => None, } } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 3fcb911d2c58f8968bc6b0c66f26ed2de365dd53..e3c61a4fd31f35df591f20075221907270e352c8 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3945,6 +3945,12 @@ impl Window { self.modifiers = scroll_wheel.modifiers; PlatformInput::ScrollWheel(scroll_wheel) } + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(pinch) => { + self.mouse_position = pinch.position; + self.modifiers = pinch.modifiers; + PlatformInput::Pinch(pinch) + } // Translate dragging and dropping of external files from the operating system // to internal drag and drop events. PlatformInput::FileDrop(file_drop) => match file_drop { diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 8dd48b878cc1ffcb87201e9b1b252966bfce5efb..ce49fca37232f256e570f584272519d8d6f34dd8 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -36,6 +36,9 @@ use wayland_client::{ wl_shm_pool, wl_surface, }, }; +use wayland_protocols::wp::pointer_gestures::zv1::client::{ + zwp_pointer_gesture_pinch_v1, zwp_pointer_gestures_v1, +}; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{ self, ZwpPrimarySelectionOfferV1, }; @@ -124,6 +127,7 @@ pub struct Globals { pub layer_shell: Option, pub blur_manager: Option, pub text_input_manager: Option, + pub gesture_manager: Option, pub dialog: Option, pub executor: ForegroundExecutor, } @@ -164,6 +168,7 @@ impl Globals { layer_shell: globals.bind(&qh, 1..=5, ()).ok(), blur_manager: globals.bind(&qh, 1..=1, ()).ok(), text_input_manager: globals.bind(&qh, 1..=1, ()).ok(), + gesture_manager: globals.bind(&qh, 1..=3, ()).ok(), dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(), executor, qh, @@ -208,6 +213,8 @@ pub(crate) struct WaylandClientState { pub compositor_gpu: Option, wl_seat: wl_seat::WlSeat, // TODO: Multi seat support wl_pointer: Option, + pinch_gesture: Option, + pinch_scale: f32, wl_keyboard: Option, cursor_shape_device: Option, data_device: Option, @@ -584,6 +591,8 @@ impl WaylandClient { wl_seat: seat, wl_pointer: None, wl_keyboard: None, + pinch_gesture: None, + pinch_scale: 1.0, cursor_shape_device: None, data_device, primary_selection, @@ -1325,6 +1334,12 @@ impl Dispatch for WaylandClientStatePtr { .as_ref() .map(|cursor_shape_manager| cursor_shape_manager.get_pointer(&pointer, qh, ())); + state.pinch_gesture = state.globals.gesture_manager.as_ref().map( + |gesture_manager: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1| { + gesture_manager.get_pinch_gesture(&pointer, qh, ()) + }, + ); + if let Some(wl_pointer) = &state.wl_pointer { wl_pointer.release(); } @@ -1998,6 +2013,91 @@ impl Dispatch for WaylandClientStatePtr { } } +impl Dispatch for WaylandClientStatePtr { + fn event( + _this: &mut Self, + _: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + // The gesture manager doesn't generate events + } +} + +impl Dispatch + for WaylandClientStatePtr +{ + fn event( + this: &mut Self, + _: &zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1, + event: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + use gpui::PinchEvent; + + let client = this.get_client(); + let mut state = client.borrow_mut(); + + let Some(window) = state.mouse_focused_window.clone() else { + return; + }; + + match event { + zwp_pointer_gesture_pinch_v1::Event::Begin { + serial: _, + time: _, + surface: _, + fingers: _, + } => { + state.pinch_scale = 1.0; + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: 0.0, + modifiers: state.modifiers, + phase: TouchPhase::Started, + }); + drop(state); + window.handle_input(input); + } + zwp_pointer_gesture_pinch_v1::Event::Update { time: _, scale, .. } => { + let new_absolute_scale = scale as f32; + let previous_scale = state.pinch_scale; + let zoom_delta = new_absolute_scale - previous_scale; + state.pinch_scale = new_absolute_scale; + + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: zoom_delta, + modifiers: state.modifiers, + phase: TouchPhase::Moved, + }); + drop(state); + window.handle_input(input); + } + zwp_pointer_gesture_pinch_v1::Event::End { + serial: _, + time: _, + cancelled: _, + } => { + state.pinch_scale = 1.0; + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: 0.0, + modifiers: state.modifiers, + phase: TouchPhase::Ended, + }); + drop(state); + window.handle_input(input); + } + _ => {} + } + } +} + impl Dispatch for WaylandClientStatePtr { fn event( this: &mut Self, diff --git a/crates/gpui_macos/src/events.rs b/crates/gpui_macos/src/events.rs index 5970488a17fbf9395f4ba29f5b98a135f6d55f7f..71bcb105e8aa8c6c43fd5b7864881535454c5ec3 100644 --- a/crates/gpui_macos/src/events.rs +++ b/crates/gpui_macos/src/events.rs @@ -1,8 +1,8 @@ use gpui::{ Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, - NavigationDirection, Pixels, PlatformInput, PressureStage, ScrollDelta, ScrollWheelEvent, - TouchPhase, point, px, + NavigationDirection, PinchEvent, Pixels, PlatformInput, PressureStage, ScrollDelta, + ScrollWheelEvent, TouchPhase, point, px, }; use crate::{ @@ -234,6 +234,27 @@ pub(crate) unsafe fn platform_input_from_native( _ => None, } } + NSEventType::NSEventTypeMagnify => window_height.map(|window_height| { + let phase = match native_event.phase() { + NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { + TouchPhase::Started + } + NSEventPhase::NSEventPhaseEnded => TouchPhase::Ended, + _ => TouchPhase::Moved, + }; + + let magnification = native_event.magnification() as f32; + + PlatformInput::Pinch(PinchEvent { + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + delta: magnification, + modifiers: read_modifiers(native_event), + phase, + }) + }), NSEventType::NSScrollWheel => window_height.map(|window_height| { let phase = match native_event.phase() { NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 456ee31ac3b03780e68267621d66435b1ceab4a9..c20c86026a102464343fc7c8cfb03b69b19b7641 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -172,6 +172,10 @@ unsafe fn build_classes() { sel!(mouseExited:), handle_view_event as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(magnifyWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); decl.add_method( sel!(mouseDragged:), handle_view_event as extern "C" fn(&Object, Sel, id), diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index c223494bd709217439bdff9f6a7ba17e1a65494e..291603b2b3f1544f6c60f9c3bdbbb87d3f77c424 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -6,6 +6,8 @@ use std::path::Path; use anyhow::Context as _; use editor::{EditorSettings, items::entry_git_aware_label_color}; use file_icons::FileIcons; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use gpui::PinchEvent; use gpui::{ AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, @@ -260,6 +262,12 @@ impl ImageView { cx.notify(); } } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn handle_pinch(&mut self, event: &PinchEvent, _window: &mut Window, cx: &mut Context) { + let zoom_factor = 1.0 + event.delta; + self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx); + } } struct ImageContentElement { @@ -679,8 +687,9 @@ impl Render for ImageView { .size_full() .relative() .bg(cx.theme().colors().editor_background) - .child( - div() + .child({ + #[cfg(any(target_os = "linux", target_os = "macos"))] + let container = div() .id("image-container") .size_full() .overflow_hidden() @@ -690,13 +699,34 @@ impl Render for ImageView { gpui::CursorStyle::OpenHand }) .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel)) + .on_pinch(cx.listener(Self::handle_pinch)) .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) .on_mouse_move(cx.listener(Self::handle_mouse_move)) - .child(ImageContentElement::new(cx.entity())), - ) + .child(ImageContentElement::new(cx.entity())); + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let container = div() + .id("image-container") + .size_full() + .overflow_hidden() + .cursor(if self.is_dragging() { + gpui::CursorStyle::ClosedHand + } else { + gpui::CursorStyle::OpenHand + }) + .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel)) + .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) + .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) + .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) + .on_mouse_move(cx.listener(Self::handle_mouse_move)) + .child(ImageContentElement::new(cx.entity())); + + container + }) } } From 480e269097a55b8250b20e1550f3139df6c0d3f1 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:21:28 -0300 Subject: [PATCH 138/219] agent_ui: Add UI refinements to the sidebar (#51307) Release Notes: - N/A --- assets/icons/threads_sidebar_left_closed.svg | 5 + assets/icons/threads_sidebar_left_open.svg | 5 + assets/icons/threads_sidebar_right_closed.svg | 5 + assets/icons/threads_sidebar_right_open.svg | 5 + assets/icons/workspace_nav_closed.svg | 5 - assets/icons/workspace_nav_open.svg | 5 - crates/agent_ui/src/agent_panel.rs | 133 +++++++++++------- crates/agent_ui/src/sidebar.rs | 87 ++++++++---- crates/icons/src/icons.rs | 6 +- crates/ui/src/components/ai/thread_item.rs | 67 +++++---- crates/ui/src/components/list/list_item.rs | 8 ++ 11 files changed, 211 insertions(+), 120 deletions(-) create mode 100644 assets/icons/threads_sidebar_left_closed.svg create mode 100644 assets/icons/threads_sidebar_left_open.svg create mode 100644 assets/icons/threads_sidebar_right_closed.svg create mode 100644 assets/icons/threads_sidebar_right_open.svg delete mode 100644 assets/icons/workspace_nav_closed.svg delete mode 100644 assets/icons/workspace_nav_open.svg diff --git a/assets/icons/threads_sidebar_left_closed.svg b/assets/icons/threads_sidebar_left_closed.svg new file mode 100644 index 0000000000000000000000000000000000000000..feb1015254635ef65f90f2c9ea38efab74d01d60 --- /dev/null +++ b/assets/icons/threads_sidebar_left_closed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/threads_sidebar_left_open.svg b/assets/icons/threads_sidebar_left_open.svg new file mode 100644 index 0000000000000000000000000000000000000000..8057b060a84d7d7ffcf29aff1c0c79a8764edc22 --- /dev/null +++ b/assets/icons/threads_sidebar_left_open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/threads_sidebar_right_closed.svg b/assets/icons/threads_sidebar_right_closed.svg new file mode 100644 index 0000000000000000000000000000000000000000..10fa4b792fd65b5875dcf2cadab1fc12a123ab47 --- /dev/null +++ b/assets/icons/threads_sidebar_right_closed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/threads_sidebar_right_open.svg b/assets/icons/threads_sidebar_right_open.svg new file mode 100644 index 0000000000000000000000000000000000000000..23a01eb3f82a5866157220172c868ed9ded46033 --- /dev/null +++ b/assets/icons/threads_sidebar_right_open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/workspace_nav_closed.svg b/assets/icons/workspace_nav_closed.svg deleted file mode 100644 index ed1fce52d6826a4d10299f331358ff84e4caa973..0000000000000000000000000000000000000000 --- a/assets/icons/workspace_nav_closed.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/workspace_nav_open.svg b/assets/icons/workspace_nav_open.svg deleted file mode 100644 index 464b6aac73c2aeaa9463a805aabc4559377bbfd3..0000000000000000000000000000000000000000 --- a/assets/icons/workspace_nav_open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 1537c05096ec81f1b3f354cac236bfdda52c9f6f..50346bd752cec4432fb5a87e4df7cb4ce09aca83 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -483,9 +483,17 @@ pub fn init(cx: &mut App) { } if let Some(panel) = workspace.panel::(cx) { if let Some(sidebar) = panel.read(cx).sidebar.clone() { + let was_open = sidebar.read(cx).is_open(); sidebar.update(cx, |sidebar, cx| { sidebar.toggle(window, cx); }); + // When closing the sidebar, restore focus to the active pane + // to avoid "zombie focus" on the now-hidden sidebar elements + if was_open { + let active_pane = workspace.active_pane().clone(); + let pane_focus = active_pane.read(cx).focus_handle(cx); + window.focus(&pane_focus, cx); + } } } }) @@ -3623,7 +3631,7 @@ impl AgentPanel { Some((view, width, is_open)) } - fn render_sidebar_toggle(&self, cx: &Context) -> Option { + fn render_sidebar_toggle(&self, docked_right: bool, cx: &Context) -> Option { if !multi_workspace_enabled(cx) { return None; } @@ -3634,20 +3642,41 @@ impl AgentPanel { } let has_notifications = sidebar_read.has_notifications(cx); + let icon = if docked_right { + IconName::ThreadsSidebarRightClosed + } else { + IconName::ThreadsSidebarLeftClosed + }; + Some( - IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed) - .icon_size(IconSize::Small) - .when(has_notifications, |button| { - button - .indicator(Indicator::dot().color(Color::Accent)) - .indicator_border_color(Some(cx.theme().colors().tab_bar_background)) - }) - .tooltip(move |_, cx| { - Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) - }) - .on_click(|_, window, cx| { - window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + h_flex() + .h_full() + .px_1() + .map(|this| { + if docked_right { + this.border_l_1() + } else { + this.border_r_1() + } }) + .border_color(cx.theme().colors().border_variant) + .child( + IconButton::new("toggle-workspace-sidebar", icon) + .icon_size(IconSize::Small) + .when(has_notifications, |button| { + button + .indicator(Indicator::dot().color(Color::Accent)) + .indicator_border_color(Some( + cx.theme().colors().tab_bar_background, + )) + }) + .tooltip(move |_, cx| { + Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + }), + ) .into_any_element(), ) } @@ -4104,6 +4133,23 @@ impl AgentPanel { let use_v2_empty_toolbar = has_v2_flag && is_empty_state && !is_in_history_or_config; + let is_sidebar_open = self + .sidebar + .as_ref() + .map(|s| s.read(cx).is_open()) + .unwrap_or(false); + + let base_container = h_flex() + .id("agent-panel-toolbar") + .h(Tab::container_height(cx)) + .max_w_full() + .flex_none() + .justify_between() + .gap_2() + .bg(cx.theme().colors().tab_bar_background) + .border_b_1() + .border_color(cx.theme().colors().border); + if use_v2_empty_toolbar { let (chevron_icon, icon_color, label_color) = if self.new_thread_menu_handle.is_deployed() { @@ -4162,34 +4208,26 @@ impl AgentPanel { y: px(1.0), }); - h_flex() - .id("agent-panel-toolbar") - .h(Tab::container_height(cx)) - .max_w_full() - .flex_none() - .justify_between() - .gap_2() - .bg(cx.theme().colors().tab_bar_background) - .border_b_1() - .border_color(cx.theme().colors().border) + base_container .child( h_flex() .size_full() - .gap(DynamicSpacing::Base04.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) + .gap_1() + .when(is_sidebar_open || docked_right, |this| this.pl_1()) .when(!docked_right, |this| { - this.children(self.render_sidebar_toggle(cx)) + this.children(self.render_sidebar_toggle(false, cx)) }) .child(agent_selector_menu) .child(self.render_start_thread_in_selector(cx)), ) .child( h_flex() + .h_full() .flex_none() - .gap(DynamicSpacing::Base02.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) - .pr(DynamicSpacing::Base06.rems(cx)) - .when(show_history_menu, |this| { + .gap_1() + .pl_1() + .pr_1() + .when(show_history_menu && !has_v2_flag, |this| { this.child(self.render_recent_entries_menu( IconName::MenuAltTemp, Corner::TopRight, @@ -4198,7 +4236,7 @@ impl AgentPanel { }) .child(self.render_panel_options_menu(window, cx)) .when(docked_right, |this| { - this.children(self.render_sidebar_toggle(cx)) + this.children(self.render_sidebar_toggle(true, cx)) }), ) .into_any_element() @@ -4222,23 +4260,19 @@ impl AgentPanel { .with_handle(self.new_thread_menu_handle.clone()) .menu(move |window, cx| new_thread_menu_builder(window, cx)); - h_flex() - .id("agent-panel-toolbar") - .h(Tab::container_height(cx)) - .max_w_full() - .flex_none() - .justify_between() - .gap_2() - .bg(cx.theme().colors().tab_bar_background) - .border_b_1() - .border_color(cx.theme().colors().border) + base_container .child( h_flex() .size_full() - .gap(DynamicSpacing::Base04.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) + .map(|this| { + if is_sidebar_open || docked_right { + this.pl_1().gap_1() + } else { + this.pl_0().gap_0p5() + } + }) .when(!docked_right, |this| { - this.children(self.render_sidebar_toggle(cx)) + this.children(self.render_sidebar_toggle(false, cx)) }) .child(match &self.active_view { ActiveView::History { .. } | ActiveView::Configuration => { @@ -4250,12 +4284,13 @@ impl AgentPanel { ) .child( h_flex() + .h_full() .flex_none() - .gap(DynamicSpacing::Base02.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) - .pr(DynamicSpacing::Base06.rems(cx)) + .gap_1() + .pl_1() + .pr_1() .child(new_thread_menu) - .when(show_history_menu, |this| { + .when(show_history_menu && !has_v2_flag, |this| { this.child(self.render_recent_entries_menu( IconName::MenuAltTemp, Corner::TopRight, @@ -4264,7 +4299,7 @@ impl AgentPanel { }) .child(self.render_panel_options_menu(window, cx)) .when(docked_right, |this| { - this.children(self.render_sidebar_toggle(cx)) + this.children(self.render_sidebar_toggle(true, cx)) }), ) .into_any_element() diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index ae3a4f0ccb9df6073ae24a9c482b6c56de0ea968..e36cb750b4a74dc8d749501eed07941cd30c7b6f 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -713,6 +713,8 @@ impl Sidebar { let is_group_header_after_first = ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. }); + let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right; + let rendered = match entry { ListEntry::ProjectHeader { path_list, @@ -728,9 +730,12 @@ impl Sidebar { highlight_positions, *has_threads, is_selected, + docked_right, cx, ), - ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx), + ListEntry::Thread(thread) => { + self.render_thread(ix, thread, is_selected, docked_right, cx) + } ListEntry::ViewMore { path_list, remaining_count, @@ -770,6 +775,7 @@ impl Sidebar { highlight_positions: &[usize], has_threads: bool, is_selected: bool, + docked_right: bool, cx: &mut Context, ) -> AnyElement { let id = SharedString::from(format!("project-header-{}", ix)); @@ -815,12 +821,13 @@ impl Sidebar { .group_name(group_name) .toggle_state(is_active_workspace) .focused(is_selected) + .docked_right(docked_right) .child( h_flex() .relative() .min_w_0() .w_full() - .p_1() + .py_1() .gap_1p5() .child( Icon::new(disclosure_icon) @@ -969,7 +976,7 @@ impl Sidebar { } fn has_filter_query(&self, cx: &App) -> bool { - self.filter_editor.read(cx).buffer().read(cx).is_empty() + !self.filter_editor.read(cx).text(cx).is_empty() } fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { @@ -1156,6 +1163,7 @@ impl Sidebar { ix: usize, thread: &ThreadEntry, is_selected: bool, + docked_right: bool, cx: &mut Context, ) -> AnyElement { let has_notification = self @@ -1171,6 +1179,7 @@ impl Sidebar { let workspace = thread.workspace.clone(); let id = SharedString::from(format!("thread-entry-{}", ix)); + ThreadItem::new(id, title) .icon(thread.icon) .when_some(thread.icon_from_external_svg.clone(), |this, svg| { @@ -1187,6 +1196,7 @@ impl Sidebar { }) .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) .focused(is_selected) + .docked_right(docked_right) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; this.activate_thread(session_info.clone(), &workspace, window, cx); @@ -1301,6 +1311,7 @@ impl Sidebar { div() .w_full() .p_2() + .pt_1p5() .child( Button::new( SharedString::from(format!("new-thread-btn-{}", ix)), @@ -1320,6 +1331,40 @@ impl Sidebar { ) .into_any_element() } + + fn render_sidebar_toggle_button( + &self, + docked_right: bool, + cx: &mut Context, + ) -> impl IntoElement { + let icon = if docked_right { + IconName::ThreadsSidebarRightOpen + } else { + IconName::ThreadsSidebarLeftOpen + }; + + h_flex() + .h_full() + .px_1() + .map(|this| { + if docked_right { + this.pr_1p5().border_l_1() + } else { + this.border_r_1() + } + }) + .border_color(cx.theme().colors().border_variant) + .child( + IconButton::new("sidebar-close-toggle", icon) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action("Close Threads Sidebar", &ToggleWorkspaceSidebar, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + }), + ) + } } impl Sidebar { @@ -1416,37 +1461,19 @@ impl Render for Sidebar { .child({ let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right; - let render_close_button = || { - IconButton::new("sidebar-close-toggle", IconName::WorkspaceNavOpen) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| { - Tooltip::for_action( - "Close Threads Sidebar", - &ToggleWorkspaceSidebar, - cx, - ) - }) - .on_click(|_, window, cx| { - window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); - }) - }; h_flex() - .flex_none() - .px_2p5() .h(Tab::container_height(cx)) - .gap_2() + .flex_none() + .gap_1p5() .border_b_1() .border_color(cx.theme().colors().border) - .when(!docked_right, |this| this.child(render_close_button())) - .child( - Icon::new(IconName::MagnifyingGlass) - .size(IconSize::Small) - .color(Color::Muted), - ) + .when(!docked_right, |this| { + this.child(self.render_sidebar_toggle_button(false, cx)) + }) .child(self.render_filter_input(cx)) .when(has_query, |this| { - this.pr_1().child( + this.when(!docked_right, |this| this.pr_1p5()).child( IconButton::new("clear_filter", IconName::Close) .shape(IconButtonShape::Square) .tooltip(Tooltip::text("Clear Search")) @@ -1456,7 +1483,11 @@ impl Render for Sidebar { })), ) }) - .when(docked_right, |this| this.child(render_close_button())) + .when(docked_right, |this| { + this.pl_2() + .pr_0p5() + .child(self.render_sidebar_toggle_button(true, cx)) + }) }) .child( v_flex() diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 7c06eaef92ece60e8b4a9ad78976b68aee854226..94fed7f03f46e64ef0ac929e60cf6ae848145e72 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -244,6 +244,10 @@ pub enum IconName { ThinkingModeOff, Thread, ThreadFromSummary, + ThreadsSidebarLeftClosed, + ThreadsSidebarLeftOpen, + ThreadsSidebarRightClosed, + ThreadsSidebarRightOpen, ThumbsDown, ThumbsUp, TodoComplete, @@ -272,8 +276,6 @@ pub enum IconName { UserRoundPen, Warning, WholeWord, - WorkspaceNavClosed, - WorkspaceNavOpen, XCircle, XCircleFilled, ZedAgent, diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index edc685159f5c9edc5fa872e9d453d0b81fa9cb16..1ab516b0cbbcb20c98bf61525779d2bd760ef260 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -1,6 +1,6 @@ use crate::{ - DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration, IconDecorationKind, - SpinnerLabel, prelude::*, + CommonAnimationExt, DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration, + IconDecorationKind, prelude::*, }; use gpui::{AnyView, ClickEvent, Hsla, SharedString}; @@ -26,6 +26,7 @@ pub struct ThreadItem { selected: bool, focused: bool, hovered: bool, + docked_right: bool, added: Option, removed: Option, worktree: Option, @@ -50,6 +51,7 @@ impl ThreadItem { selected: false, focused: false, hovered: false, + docked_right: false, added: None, removed: None, worktree: None, @@ -107,6 +109,11 @@ impl ThreadItem { self } + pub fn docked_right(mut self, docked_right: bool) -> Self { + self.docked_right = docked_right; + self + } + pub fn worktree(mut self, worktree: impl Into) -> Self { self.worktree = Some(worktree.into()); self @@ -154,12 +161,12 @@ impl ThreadItem { impl RenderOnce for ThreadItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let color = cx.theme().colors(); - // let dot_separator = || { - // Label::new("•") - // .size(LabelSize::Small) - // .color(Color::Muted) - // .alpha(0.5) - // }; + let dot_separator = || { + Label::new("•") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.5) + }; let icon_container = || h_flex().size_4().flex_none().justify_center(); let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg { @@ -194,17 +201,23 @@ impl RenderOnce for ThreadItem { None }; - let icon = if let Some(decoration) = decoration { - icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration))) - } else { - icon_container().child(agent_icon) - }; - let is_running = matches!( self.status, AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation ); - let running_or_action = is_running || (self.hovered && self.action_slot.is_some()); + + let icon = if is_running { + icon_container().child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + } else if let Some(decoration) = decoration { + icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration))) + } else { + icon_container().child(agent_icon) + }; let title = self.title; let highlight_positions = self.highlight_positions; @@ -244,13 +257,16 @@ impl RenderOnce for ThreadItem { if has_worktree || has_diff_stats { this.p_2() } else { - this.px_2().py_1() + this.p_1() } }) .when(self.selected, |s| s.bg(color.element_active)) .border_1() .border_color(gpui::transparent_black()) - .when(self.focused, |s| s.border_color(color.panel_focused_border)) + .when(self.focused, |s| { + s.when(self.docked_right, |s| s.border_r_2()) + .border_color(color.border_focused) + }) .hover(|s| s.bg(color.element_hover)) .on_hover(self.on_hover) .child( @@ -270,20 +286,8 @@ impl RenderOnce for ThreadItem { .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)), ) .child(gradient_overlay) - .when(running_or_action, |this| { - this.child( - h_flex() - .gap_1() - .when(is_running, |this| { - this.child( - icon_container() - .child(SpinnerLabel::new().color(Color::Accent)), - ) - }) - .when(self.hovered, |this| { - this.when_some(self.action_slot, |this, slot| this.child(slot)) - }), - ) + .when(self.hovered, |this| { + this.when_some(self.action_slot, |this, slot| this.child(slot)) }), ) .when_some(self.worktree, |this, worktree| { @@ -306,6 +310,7 @@ impl RenderOnce for ThreadItem { .gap_1p5() .child(icon_container()) // Icon Spacing .child(worktree_label) + .child(dot_separator()) .when(has_diff_stats, |this| { this.child(DiffStat::new( diff_stat_id.clone(), diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 01e88e1fe666fa2038b05af055a0e02b195e9bac..d707df82f4d19b0a3f519e9d6ac9ccdb22965e27 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -48,6 +48,7 @@ pub struct ListItem { rounded: bool, overflow_x: bool, focused: Option, + docked_right: bool, } impl ListItem { @@ -78,6 +79,7 @@ impl ListItem { rounded: false, overflow_x: false, focused: None, + docked_right: false, } } @@ -194,6 +196,11 @@ impl ListItem { self.focused = Some(focused); self } + + pub fn docked_right(mut self, docked_right: bool) -> Self { + self.docked_right = docked_right; + self + } } impl Disableable for ListItem { @@ -247,6 +254,7 @@ impl RenderOnce for ListItem { this.when_some(self.focused, |this, focused| { if focused { this.border_1() + .when(self.docked_right, |this| this.border_r_2()) .border_color(cx.theme().colors().border_focused) } else { this.border_1() From 45072109221790776b03b686b6973dd7f8227cb0 Mon Sep 17 00:00:00 2001 From: Jack Date: Thu, 12 Mar 2026 01:55:15 +0800 Subject: [PATCH 139/219] languages: Exclude angle brackets from rainbow bracket colorization for TSX (#51311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Angle brackets in TSX (`<`, `>`, `/>`, ` Screenshots: I don't have a built copy of Zed handy to attach — happy to add one if a maintainer needs it before merging. Release Notes: - Removed rainbow bracket colorization for angled brackets within TSX. --- crates/languages/src/tsx/brackets.scm | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/languages/src/tsx/brackets.scm b/crates/languages/src/tsx/brackets.scm index d72fcb26005a0021907558bbbee7471cfeaec603..cd59d553783f685775e45ba883210272b168c3b8 100644 --- a/crates/languages/src/tsx/brackets.scm +++ b/crates/languages/src/tsx/brackets.scm @@ -7,14 +7,17 @@ ("{" @open "}" @close) -("<" @open +(("<" @open ">" @close) + (#set! rainbow.exclude)) -("<" @open +(("<" @open "/>" @close) + (#set! rainbow.exclude)) -("" @close) + (#set! rainbow.exclude)) (("\"" @open "\"" @close) From 546dacc29bf9edd75cff4083ba7ac7d203947fb3 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Wed, 11 Mar 2026 19:48:12 +0100 Subject: [PATCH 140/219] nix: Correctly handle commitSha == null in nix devshell (#51319) Release Notes: - N/A --- nix/build.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/build.nix b/nix/build.nix index d96a7e51ca08d23572b01f0c387d6ef9e4f2dd70..a5ced61bbbfd145c1e3f9fc9909ae69779ba133a 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -224,7 +224,7 @@ let }; ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled."; RELEASE_VERSION = version; - ZED_COMMIT_SHA = commitSha; + ZED_COMMIT_SHA = lib.optionalString (commitSha != null) "${commitSha}"; LK_CUSTOM_WEBRTC = pkgs.callPackage ./livekit-libwebrtc/package.nix { }; PROTOC = "${protobuf}/bin/protoc"; From 56b2eae745d3ae8317810fa129a633b3268be1d0 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:26:19 +0100 Subject: [PATCH 141/219] audio: Run webrtc receiver task on the realtime-priority thread as well (#51315) Co-authored-by: Jakub Konka Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed issues with tremendous audio latency in long-running collab calls. Co-authored-by: Jakub Konka --- crates/livekit_client/src/livekit_client/playback.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index f62de78b4f9fb702f03943b06270abb41aa68e34..88ebdfd389498ae00ad434eb22726a84a5fe1e01 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -111,7 +111,7 @@ impl AudioStack { source.num_channels as i32, ); - let receive_task = self.executor.spawn({ + let receive_task = self.executor.spawn_with_priority(Priority::RealtimeAudio, { let source = source.clone(); async move { while let Some(frame) = stream.next().await { @@ -202,7 +202,7 @@ impl AudioStack { let apm = self.apm.clone(); let (frame_tx, mut frame_rx) = futures::channel::mpsc::unbounded(); - let transmit_task = self.executor.spawn({ + let transmit_task = self.executor.spawn_with_priority(Priority::RealtimeAudio, { async move { while let Some(frame) = frame_rx.next().await { source.capture_frame(&frame).await.log_err(); From 9fb57b0daf1933e965b22543257b78bc4f22d376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:54:51 +0100 Subject: [PATCH 142/219] language_model: Centralize LlmApiToken to a singleton (#51225) The edit prediction, web search and completions endpoints in Cloud all use tokens called LlmApiToken. These were independently created, cached, and refreshed in three places: the cloud language model provider, the edit prediction store, and the cloud web search provider. Each held its own LlmApiToken instance, meaning three separate requests to get these tokens at startup / login and three redundant refreshes whenever the server signaled a token update was needed. We already had a global singleton reacting to the refresh signals: RefreshLlmTokenListener. It now holds a single LlmApiToken that all three services use, performs the refresh itself, and emits RefreshLlmTokenEvent only after the token is fresh. That event is used by the language model provider to re-fetch models after a refresh. The singleton is accessed only through `LlmApiToken::global()`. I have tested this manually, and it token acquisition and usage appear to be working fine. Edit: I've tested it with a long running session, and refresh seems to be working fine too. Release Notes: - N/A --------- Co-authored-by: Marshall Bowers --- crates/edit_prediction/src/edit_prediction.rs | 25 ++---------- .../src/edit_prediction_tests.rs | 1 + .../src/{model/mod.rs => model.rs} | 0 .../language_model/src/model/cloud_model.rs | 38 ++++++++++++++++--- crates/language_models/src/provider/cloud.rs | 6 +-- crates/web_search_providers/src/cloud.rs | 26 ++----------- 6 files changed, 43 insertions(+), 53 deletions(-) rename crates/language_model/src/{model/mod.rs => model.rs} (100%) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 5e1c9f9a03ec0c4bff0bbd60a9aefc6a06fa5368..63240ddd53108f0b2450386150958e23f975d7ed 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -23,14 +23,14 @@ use futures::{ use gpui::BackgroundExecutor; use gpui::http_client::Url; use gpui::{ - App, AsyncApp, Entity, EntityId, Global, SharedString, Subscription, Task, WeakEntity, actions, + App, AsyncApp, Entity, EntityId, Global, SharedString, Task, WeakEntity, actions, http_client::{self, AsyncBody, Method}, prelude::*, }; use language::language_settings::all_language_settings; use language::{Anchor, Buffer, File, Point, TextBufferSnapshot, ToOffset, ToPoint}; use language::{BufferSnapshot, OffsetRangeExt}; -use language_model::{LlmApiToken, NeedsLlmTokenRefresh, RefreshLlmTokenListener}; +use language_model::{LlmApiToken, NeedsLlmTokenRefresh}; use project::{DisableAiSettings, Project, ProjectPath, WorktreeId}; use release_channel::AppVersion; use semver::Version; @@ -133,7 +133,6 @@ pub struct EditPredictionStore { client: Arc, user_store: Entity, llm_token: LlmApiToken, - _llm_token_subscription: Subscription, _fetch_experiments_task: Task<()>, projects: HashMap, update_required: bool, @@ -674,10 +673,9 @@ impl EditPredictionStore { } pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { - let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); let data_collection_choice = Self::load_data_collection_choice(); - let llm_token = LlmApiToken::default(); + let llm_token = LlmApiToken::global(cx); let (reject_tx, reject_rx) = mpsc::unbounded(); cx.background_spawn({ @@ -721,23 +719,6 @@ impl EditPredictionStore { user_store, llm_token, _fetch_experiments_task: fetch_experiments_task, - _llm_token_subscription: cx.subscribe( - &refresh_llm_token_listener, - |this, _listener, _event, cx| { - let client = this.client.clone(); - let llm_token = this.llm_token.clone(); - let organization_id = this - .user_store - .read(cx) - .current_organization() - .map(|organization| organization.id.clone()); - cx.spawn(async move |_this, _cx| { - llm_token.refresh(&client, organization_id).await?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - }, - ), update_required: false, edit_prediction_model: EditPredictionModel::Zeta, zeta2_raw_config: Self::zeta2_raw_config_from_env(), diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index ad237e6f8fb31708dbabc6e8332ce0c164877004..8f97df2c308980e1c2c89838609b30e1aedb1917 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -21,6 +21,7 @@ use language::{ Anchor, Buffer, CursorShape, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSeverity, Operation, Point, Selection, SelectionGoal, }; +use language_model::RefreshLlmTokenListener; use lsp::LanguageServerId; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_matches}; diff --git a/crates/language_model/src/model/mod.rs b/crates/language_model/src/model.rs similarity index 100% rename from crates/language_model/src/model/mod.rs rename to crates/language_model/src/model.rs diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index e64cc43edd8eef6cfaf0c6c966365c81d37b611c..e384ce05fa390677529235442c4cb91186520a02 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -30,6 +30,13 @@ impl fmt::Display for PaymentRequiredError { pub struct LlmApiToken(Arc>>); impl LlmApiToken { + pub fn global(cx: &App) -> Self { + RefreshLlmTokenListener::global(cx) + .read(cx) + .llm_api_token + .clone() + } + pub async fn acquire( &self, client: &Arc, @@ -102,13 +109,16 @@ struct GlobalRefreshLlmTokenListener(Entity); impl Global for GlobalRefreshLlmTokenListener {} -pub struct RefreshLlmTokenEvent; +pub struct LlmTokenRefreshedEvent; pub struct RefreshLlmTokenListener { + client: Arc, + user_store: Entity, + llm_api_token: LlmApiToken, _subscription: Subscription, } -impl EventEmitter for RefreshLlmTokenListener {} +impl EventEmitter for RefreshLlmTokenListener {} impl RefreshLlmTokenListener { pub fn register(client: Arc, user_store: Entity, cx: &mut App) { @@ -128,21 +138,39 @@ impl RefreshLlmTokenListener { } }); - let subscription = cx.subscribe(&user_store, |_this, _user_store, event, cx| { + let subscription = cx.subscribe(&user_store, |this, _user_store, event, cx| { if matches!(event, client::user::Event::OrganizationChanged) { - cx.emit(RefreshLlmTokenEvent); + this.refresh(cx); } }); Self { + client, + user_store, + llm_api_token: LlmApiToken::default(), _subscription: subscription, } } + fn refresh(&self, cx: &mut Context) { + let client = self.client.clone(); + let llm_api_token = self.llm_api_token.clone(); + let organization_id = self + .user_store + .read(cx) + .current_organization() + .map(|o| o.id.clone()); + cx.spawn(async move |this, cx| { + llm_api_token.refresh(&client, organization_id).await?; + this.update(cx, |_this, cx| cx.emit(LlmTokenRefreshedEvent)) + }) + .detach_and_log_err(cx); + } + fn handle_refresh_llm_token(this: Entity, message: &MessageToClient, cx: &mut App) { match message { MessageToClient::UserUpdated => { - this.update(cx, |_this, cx| cx.emit(RefreshLlmTokenEvent)); + this.update(cx, |this, cx| this.refresh(cx)); } } } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 4e705a8d62a5446b17bcc95a7dc75152b0c3269c..610b0167b86f8bf4426b671cedad45a28c3fdc6d 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -109,9 +109,10 @@ impl State { cx: &mut Context, ) -> Self { let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); + let llm_api_token = LlmApiToken::global(cx); Self { client: client.clone(), - llm_api_token: LlmApiToken::default(), + llm_api_token, user_store: user_store.clone(), status, models: Vec::new(), @@ -158,9 +159,6 @@ impl State { .current_organization() .map(|o| o.id.clone()); cx.spawn(async move |this, cx| { - llm_api_token - .refresh(&client, organization_id.clone()) - .await?; let response = Self::fetch_models(client, llm_api_token, organization_id).await?; this.update(cx, |this, cx| { diff --git a/crates/web_search_providers/src/cloud.rs b/crates/web_search_providers/src/cloud.rs index c8bc89953f2b2d3ec62bac07e80f2737522824f7..51be6c9ddff01a956eebabe3e44166ae15de4515 100644 --- a/crates/web_search_providers/src/cloud.rs +++ b/crates/web_search_providers/src/cloud.rs @@ -5,9 +5,9 @@ use client::{Client, UserStore}; use cloud_api_types::OrganizationId; use cloud_llm_client::{WebSearchBody, WebSearchResponse}; use futures::AsyncReadExt as _; -use gpui::{App, AppContext, Context, Entity, Subscription, Task}; +use gpui::{App, AppContext, Context, Entity, Task}; use http_client::{HttpClient, Method}; -use language_model::{LlmApiToken, NeedsLlmTokenRefresh, RefreshLlmTokenListener}; +use language_model::{LlmApiToken, NeedsLlmTokenRefresh}; use web_search::{WebSearchProvider, WebSearchProviderId}; pub struct CloudWebSearchProvider { @@ -26,34 +26,16 @@ pub struct State { client: Arc, user_store: Entity, llm_api_token: LlmApiToken, - _llm_token_subscription: Subscription, } impl State { pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { - let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); + let llm_api_token = LlmApiToken::global(cx); Self { client, user_store, - llm_api_token: LlmApiToken::default(), - _llm_token_subscription: cx.subscribe( - &refresh_llm_token_listener, - |this, _, _event, cx| { - let client = this.client.clone(); - let llm_api_token = this.llm_api_token.clone(); - let organization_id = this - .user_store - .read(cx) - .current_organization() - .map(|o| o.id.clone()); - cx.spawn(async move |_this, _cx| { - llm_api_token.refresh(&client, organization_id).await?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - }, - ), + llm_api_token, } } } From 9d2e2c859b9af0821f2c65a26845b49b571c2433 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:34:03 -0300 Subject: [PATCH 143/219] agent_ui: Add more UI refinements to the sidebar (#51325) Adjust the settings view and removes the new empty state from text threads. Release Notes: - N/A --- crates/agent_ui/src/agent_configuration.rs | 13 ++++++++-- crates/agent_ui/src/agent_panel.rs | 5 +++- crates/agent_ui/src/sidebar.rs | 2 +- crates/ui/src/components/chip.rs | 3 +-- crates/ui/src/components/label/label.rs | 28 ++++++++++++++++++++++ 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index aa316ba7c5efe5f679764cd7d4626a1f1310e4c6..46f92bfb2cfd60158bfb7c7aae9c16f3d9184695 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -228,6 +228,7 @@ impl AgentConfiguration { .unwrap_or(false); v_flex() + .min_w_0() .w_full() .when(is_expanded, |this| this.mb_2()) .child( @@ -312,6 +313,7 @@ impl AgentConfiguration { ) .child( v_flex() + .min_w_0() .w_full() .px_2() .gap_1() @@ -459,6 +461,7 @@ impl AgentConfiguration { }); v_flex() + .min_w_0() .w_full() .child(self.render_section_title( "LLM Providers", @@ -559,6 +562,7 @@ impl AgentConfiguration { }); v_flex() + .min_w_0() .border_b_1() .border_color(cx.theme().colors().border) .child(self.render_section_title( @@ -802,9 +806,12 @@ impl AgentConfiguration { }); v_flex() + .min_w_0() .id(item_id.clone()) .child( h_flex() + .min_w_0() + .w_full() .justify_between() .child( h_flex() @@ -820,13 +827,13 @@ impl AgentConfiguration { .tooltip(Tooltip::text(tooltip_text)) .child(status_indicator), ) - .child(Label::new(item_id).truncate()) + .child(Label::new(item_id).flex_shrink_0().truncate()) .child( div() .id("extension-source") + .min_w_0() .mt_0p5() .mx_1() - .flex_none() .tooltip(Tooltip::text(source_tooltip)) .child( Icon::new(source_icon) @@ -1019,6 +1026,7 @@ impl AgentConfiguration { }); v_flex() + .min_w_0() .border_b_1() .border_color(cx.theme().colors().border) .child( @@ -1217,6 +1225,7 @@ impl Render for AgentConfiguration { .id("assistant-configuration-content") .track_scroll(&self.scroll_handle) .size_full() + .min_w_0() .overflow_y_scroll() .child(self.render_agent_servers_section(cx)) .child(self.render_context_servers_section(window, cx)) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 50346bd752cec4432fb5a87e4df7cb4ce09aca83..1aefc99c020409a764ad2c44fe8477665f73c4bc 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -4131,7 +4131,10 @@ impl AgentPanel { ActiveView::History { .. } | ActiveView::Configuration ); - let use_v2_empty_toolbar = has_v2_flag && is_empty_state && !is_in_history_or_config; + let is_text_thread = matches!(&self.active_view, ActiveView::TextThread { .. }); + + let use_v2_empty_toolbar = + has_v2_flag && is_empty_state && !is_in_history_or_config && !is_text_thread; let is_sidebar_open = self .sidebar diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index e36cb750b4a74dc8d749501eed07941cd30c7b6f..595366dd0484254ed641e69713b519199547e8e3 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1250,7 +1250,7 @@ impl Sidebar { .focused(is_selected) .child( h_flex() - .p_1() + .py_1() .gap_1p5() .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) .child(Label::new(label).color(Color::Muted)) diff --git a/crates/ui/src/components/chip.rs b/crates/ui/src/components/chip.rs index ce709fe3962f742f5208808315f3bdac09c1f513..06dc7e6afa6fa8723985913dfece4205e360511e 100644 --- a/crates/ui/src/components/chip.rs +++ b/crates/ui/src/components/chip.rs @@ -81,8 +81,7 @@ impl RenderOnce for Chip { h_flex() .when_some(self.height, |this, h| this.h(h)) - .min_w_0() - .flex_initial() + .flex_none() .px_1() .border_1() .rounded_sm() diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index d0f50c00336eb971621e2da7bbaf53cf09569caa..405948ea06c7e86fcb3dec217186596bdaaf0aeb 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -73,6 +73,34 @@ impl Label { gpui::margin_style_methods!({ visibility: pub }); + + pub fn flex_1(mut self) -> Self { + self.style().flex_grow = Some(1.); + self.style().flex_shrink = Some(1.); + self.style().flex_basis = Some(gpui::relative(0.).into()); + self + } + + pub fn flex_none(mut self) -> Self { + self.style().flex_grow = Some(0.); + self.style().flex_shrink = Some(0.); + self + } + + pub fn flex_grow(mut self) -> Self { + self.style().flex_grow = Some(1.); + self + } + + pub fn flex_shrink(mut self) -> Self { + self.style().flex_shrink = Some(1.); + self + } + + pub fn flex_shrink_0(mut self) -> Self { + self.style().flex_shrink = Some(0.); + self + } } impl LabelCommon for Label { From 3dff4c57877c8a1b6bc2f6e2444b3b58ab9e637d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:15:43 -0300 Subject: [PATCH 144/219] agent_ui: Add timestamp to thread item in the sidebar (#51327) Release Notes: - N/A --- crates/agent_ui/src/sidebar.rs | 26 ++++ crates/ui/src/components/ai/thread_item.rs | 140 +++++++++++++++++---- 2 files changed, 143 insertions(+), 23 deletions(-) diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 595366dd0484254ed641e69713b519199547e8e3..3804e3f63678bcf771b27b2f05929a958531ab39 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1180,11 +1180,37 @@ impl Sidebar { let id = SharedString::from(format!("thread-entry-{}", ix)); + let timestamp = thread + .session_info + .created_at + .or(thread.session_info.updated_at) + .map(|entry_time| { + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + + let minutes = duration.num_minutes(); + let hours = duration.num_hours(); + let days = duration.num_days(); + let weeks = days / 7; + let months = days / 30; + + if minutes < 60 { + format!("{}m", minutes.max(1)) + } else if hours < 24 { + format!("{}h", hours) + } else if weeks < 4 { + format!("{}w", weeks.max(1)) + } else { + format!("{}mo", months.max(1)) + } + }); + ThreadItem::new(id, title) .icon(thread.icon) .when_some(thread.icon_from_external_svg.clone(), |this, svg| { this.custom_icon_from_external_svg(svg) }) + .when_some(timestamp, |this, ts| this.timestamp(ts)) .highlight_positions(thread.highlight_positions.to_vec()) .status(thread.status) .notified(has_notification) diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 1ab516b0cbbcb20c98bf61525779d2bd760ef260..5be91e9d98a1219dcfbbba70a5541ba7b827cfc5 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -245,6 +245,8 @@ impl RenderOnce for ThreadItem { let removed_count = self.removed.unwrap_or(0); let diff_stat_id = self.id.clone(); let has_worktree = self.worktree.is_some(); + let has_timestamp = !self.timestamp.is_empty(); + let timestamp = self.timestamp; v_flex() .id(self.id.clone()) @@ -253,13 +255,7 @@ impl RenderOnce for ThreadItem { .overflow_hidden() .cursor_pointer() .w_full() - .map(|this| { - if has_worktree || has_diff_stats { - this.p_2() - } else { - this.p_1() - } - }) + .p_1() .when(self.selected, |s| s.bg(color.element_active)) .border_1() .border_color(gpui::transparent_black()) @@ -310,23 +306,47 @@ impl RenderOnce for ThreadItem { .gap_1p5() .child(icon_container()) // Icon Spacing .child(worktree_label) - .child(dot_separator()) + .when(has_diff_stats || has_timestamp, |this| { + this.child(dot_separator()) + }) .when(has_diff_stats, |this| { this.child(DiffStat::new( diff_stat_id.clone(), added_count, removed_count, )) + }) + .when(has_diff_stats && has_timestamp, |this| { + this.child(dot_separator()) + }) + .when(has_timestamp, |this| { + this.child( + Label::new(timestamp.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) }), ) }) - .when(!has_worktree && has_diff_stats, |this| { + .when(!has_worktree && (has_diff_stats || has_timestamp), |this| { this.child( h_flex() .min_w_0() .gap_1p5() .child(icon_container()) // Icon Spacing - .child(DiffStat::new(diff_stat_id, added_count, removed_count)), + .when(has_diff_stats, |this| { + this.child(DiffStat::new(diff_stat_id, added_count, removed_count)) + }) + .when(has_diff_stats && has_timestamp, |this| { + this.child(dot_separator()) + }) + .when(has_timestamp, |this| { + this.child( + Label::new(timestamp.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), ) }) .when_some(self.on_click, |this, on_click| this.on_click(on_click)) @@ -349,21 +369,31 @@ impl Component for ThreadItem { let thread_item_examples = vec![ single_example( - "Default", + "Default (minutes)", container() .child( ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings") .icon(IconName::AiOpenAi) - .timestamp("1:33 AM"), + .timestamp("15m"), + ) + .into_any_element(), + ), + single_example( + "Timestamp Only (hours)", + container() + .child( + ThreadItem::new("ti-1b", "Thread with just a timestamp") + .icon(IconName::AiClaude) + .timestamp("3h"), ) .into_any_element(), ), single_example( - "Notified", + "Notified (weeks)", container() .child( ThreadItem::new("ti-2", "Refine thread view scrolling behavior") - .timestamp("12:12 AM") + .timestamp("1w") .notified(true), ) .into_any_element(), @@ -373,7 +403,7 @@ impl Component for ThreadItem { container() .child( ThreadItem::new("ti-2b", "Execute shell command in terminal") - .timestamp("12:15 AM") + .timestamp("2h") .status(AgentThreadStatus::WaitingForConfirmation), ) .into_any_element(), @@ -383,7 +413,7 @@ impl Component for ThreadItem { container() .child( ThreadItem::new("ti-2c", "Failed to connect to language server") - .timestamp("12:20 AM") + .timestamp("5h") .status(AgentThreadStatus::Error), ) .into_any_element(), @@ -394,7 +424,7 @@ impl Component for ThreadItem { .child( ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock") .icon(IconName::AiClaude) - .timestamp("7:30 PM") + .timestamp("23h") .status(AgentThreadStatus::Running), ) .into_any_element(), @@ -405,30 +435,43 @@ impl Component for ThreadItem { .child( ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock") .icon(IconName::AiClaude) - .timestamp("7:37 PM") + .timestamp("2w") .worktree("link-agent-panel"), ) .into_any_element(), ), single_example( - "With Changes", + "With Changes (months)", container() .child( ThreadItem::new("ti-5", "Managing user and project settings interactions") .icon(IconName::AiClaude) - .timestamp("7:37 PM") + .timestamp("1mo") .added(10) .removed(3), ) .into_any_element(), ), + single_example( + "Worktree + Changes + Timestamp", + container() + .child( + ThreadItem::new("ti-5b", "Full metadata example") + .icon(IconName::AiClaude) + .worktree("my-project") + .added(42) + .removed(17) + .timestamp("3w"), + ) + .into_any_element(), + ), single_example( "Selected Item", container() .child( ThreadItem::new("ti-6", "Refine textarea interaction behavior") .icon(IconName::AiGemini) - .timestamp("3:00 PM") + .timestamp("45m") .selected(true), ) .into_any_element(), @@ -439,23 +482,74 @@ impl Component for ThreadItem { .child( ThreadItem::new("ti-7", "Implement keyboard navigation") .icon(IconName::AiClaude) - .timestamp("4:00 PM") + .timestamp("12h") .focused(true), ) .into_any_element(), ), + single_example( + "Focused + Docked Right", + container() + .child( + ThreadItem::new("ti-7b", "Focused with right dock border") + .icon(IconName::AiClaude) + .timestamp("1w") + .focused(true) + .docked_right(true), + ) + .into_any_element(), + ), single_example( "Selected + Focused", container() .child( ThreadItem::new("ti-8", "Active and keyboard-focused thread") .icon(IconName::AiGemini) - .timestamp("5:00 PM") + .timestamp("2mo") .selected(true) .focused(true), ) .into_any_element(), ), + single_example( + "Hovered with Action Slot", + container() + .child( + ThreadItem::new("ti-9", "Hover to see action button") + .icon(IconName::AiClaude) + .timestamp("6h") + .hovered(true) + .action_slot( + IconButton::new("delete", IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ), + ) + .into_any_element(), + ), + single_example( + "Search Highlight", + container() + .child( + ThreadItem::new("ti-10", "Implement keyboard navigation") + .icon(IconName::AiClaude) + .timestamp("4w") + .highlight_positions(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + ) + .into_any_element(), + ), + single_example( + "Worktree Search Highlight", + container() + .child( + 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]), + ) + .into_any_element(), + ), ]; Some( From becb24cd19405b6842cb4f0fb656a1a3853a0137 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 11 Mar 2026 17:28:29 -0400 Subject: [PATCH 145/219] cloud_api_types: Add `ZedBusiness` variant to `Plan` (#51329) This PR adds a `ZedBusiness` variant to the `Plan` enum. Closes CLO-480. Release Notes: - N/A --- crates/agent_ui/src/agent_configuration.rs | 1 + crates/ai_onboarding/src/ai_onboarding.rs | 19 +++++++++++++++++++ crates/ai_onboarding/src/ai_upsell_card.rs | 20 ++++++++++++++++++++ crates/ai_onboarding/src/plan_definitions.rs | 6 ++++++ crates/cloud_api_types/src/plan.rs | 1 + crates/title_bar/src/plan_chip.rs | 1 + 6 files changed, 48 insertions(+) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 46f92bfb2cfd60158bfb7c7aae9c16f3d9184695..ef3f3fdacc3d155554f3e2576ed1ed27c1d9ff0d 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -501,6 +501,7 @@ impl AgentConfiguration { Plan::ZedFree => ("Free", Color::Default, free_chip_bg), Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), + Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg), Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg), }; diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 0b1ccb4088e67de332c2bd2940ca5bdf77f1d3df..8b578d2e7f00a4f0dd139e074259d28e09932908 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -266,6 +266,20 @@ impl ZedAiOnboarding { .into_any_element() } + fn render_business_plan_state(&self, _cx: &mut App) -> AnyElement { + v_flex() + .gap_1() + .child(Headline::new("Welcome to Zed Business")) + .child( + Label::new("Here's what you get:") + .color(Color::Muted) + .mb_2(), + ) + .child(PlanDefinitions.business_plan()) + .children(self.render_dismiss_button()) + .into_any_element() + } + fn render_student_plan_state(&self, _cx: &mut App) -> AnyElement { v_flex() .gap_1() @@ -289,6 +303,7 @@ impl RenderOnce for ZedAiOnboarding { Some(Plan::ZedFree) => self.render_free_plan_state(cx), Some(Plan::ZedProTrial) => self.render_trial_state(cx), Some(Plan::ZedPro) => self.render_pro_plan_state(cx), + Some(Plan::ZedBusiness) => self.render_business_plan_state(cx), Some(Plan::ZedStudent) => self.render_student_plan_state(cx), } } else { @@ -353,6 +368,10 @@ impl Component for ZedAiOnboarding { "Pro Plan", onboarding(SignInStatus::SignedIn, Some(Plan::ZedPro), false), ), + single_example( + "Business Plan", + onboarding(SignInStatus::SignedIn, Some(Plan::ZedBusiness), false), + ), ]) .into_any_element(), ) diff --git a/crates/ai_onboarding/src/ai_upsell_card.rs b/crates/ai_onboarding/src/ai_upsell_card.rs index f1a1c4310def0b9b4dbabbc6a59eae940396fbb9..40a35f590d87a9928d4299199a99f223264e5ef3 100644 --- a/crates/ai_onboarding/src/ai_upsell_card.rs +++ b/crates/ai_onboarding/src/ai_upsell_card.rs @@ -250,6 +250,15 @@ impl RenderOnce for AiUpsellCard { .mb_2(), ) .child(PlanDefinitions.pro_plan()), + Some(Plan::ZedBusiness) => card + .child(certified_user_stamp) + .child(Label::new("You're in the Zed Business plan").size(LabelSize::Large)) + .child( + Label::new("Here's what you get:") + .color(Color::Muted) + .mb_2(), + ) + .child(PlanDefinitions.business_plan()), Some(Plan::ZedStudent) => card .child(certified_user_stamp) .child(Label::new("You're in the Zed Student plan").size(LabelSize::Large)) @@ -368,6 +377,17 @@ impl Component for AiUpsellCard { } .into_any_element(), ), + single_example( + "Business Plan", + AiUpsellCard { + sign_in_status: SignInStatus::SignedIn, + sign_in: Arc::new(|_, _| {}), + account_too_young: false, + user_plan: Some(Plan::ZedBusiness), + tab_index: Some(1), + } + .into_any_element(), + ), ], )) .into_any_element(), diff --git a/crates/ai_onboarding/src/plan_definitions.rs b/crates/ai_onboarding/src/plan_definitions.rs index 6d46a598c385b300fa579c69b0c58cfe51610c68..184815bcad9babb1892335c6207a79e1fe193c04 100644 --- a/crates/ai_onboarding/src/plan_definitions.rs +++ b/crates/ai_onboarding/src/plan_definitions.rs @@ -36,6 +36,12 @@ impl PlanDefinitions { .child(ListBulletItem::new("Usage-based billing beyond $5")) } + pub fn business_plan(&self) -> impl IntoElement { + List::new() + .child(ListBulletItem::new("Unlimited edit predictions")) + .child(ListBulletItem::new("Usage-based billing")) + } + pub fn student_plan(&self) -> impl IntoElement { List::new() .child(ListBulletItem::new("Unlimited edit predictions")) diff --git a/crates/cloud_api_types/src/plan.rs b/crates/cloud_api_types/src/plan.rs index e4a33e3c1933717f642848acc13dcf19b173e902..1f40d1ddb5f0e72871d5ecaee62b884132c158e4 100644 --- a/crates/cloud_api_types/src/plan.rs +++ b/crates/cloud_api_types/src/plan.rs @@ -9,6 +9,7 @@ pub enum Plan { ZedFree, ZedPro, ZedProTrial, + ZedBusiness, ZedStudent, } diff --git a/crates/title_bar/src/plan_chip.rs b/crates/title_bar/src/plan_chip.rs index edec0da2dea317bd122ece14d6afb90a31990c96..237e507ed8e4d1a5f63a7df116bf08fd69086bc2 100644 --- a/crates/title_bar/src/plan_chip.rs +++ b/crates/title_bar/src/plan_chip.rs @@ -33,6 +33,7 @@ impl RenderOnce for PlanChip { Plan::ZedFree => ("Free", Color::Default, free_chip_bg), Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), + Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg), Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg), }; From bb4f771f0e28a07d980edf8ca8fa6a6f596d1512 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 11 Mar 2026 18:22:17 -0400 Subject: [PATCH 146/219] client: Populate plans for organizations (#51334) This PR makes it so we populate the `plans_by_organization` collection with the plans returned from the server. Release Notes: - N/A --- crates/client/src/test.rs | 8 ++++++-- crates/client/src/user.rs | 17 ++++++++++++++++- crates/cloud_api_types/src/cloud_api_types.rs | 5 ++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/client/src/test.rs b/crates/client/src/test.rs index 5102664a8c08ba336f3ae506aadb68eb2a537935..b506cee822ff9c2e4e31f262886a26ac1acbd134 100644 --- a/crates/client/src/test.rs +++ b/crates/client/src/test.rs @@ -1,4 +1,6 @@ -use crate::{Client, Connection, Credentials, EstablishConnectionError, UserStore}; +use std::collections::BTreeMap; +use std::sync::Arc; + use anyhow::{Context as _, Result, anyhow}; use cloud_api_client::{ AuthenticatedUser, GetAuthenticatedUserResponse, KnownOrUnknown, Plan, PlanInfo, @@ -9,7 +11,8 @@ use gpui::{AppContext as _, Entity, TestAppContext}; use http_client::{AsyncBody, Method, Request, http}; use parking_lot::Mutex; use rpc::{ConnectionId, Peer, Receipt, TypedEnvelope, proto}; -use std::sync::Arc; + +use crate::{Client, Connection, Credentials, EstablishConnectionError, UserStore}; pub struct FakeServer { peer: Arc, @@ -266,6 +269,7 @@ pub fn make_get_authenticated_user_response( }, feature_flags: vec![], organizations: vec![], + plans_by_organization: BTreeMap::new(), plan: PlanInfo { plan: KnownOrUnknown::Known(Plan::ZedPro), subscription_period: None, diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 5d38569cfd86c38e5b4780621db40d1f2a3b745c..71b05dc58f54379f8dfb2ec46d4c280926a56bea 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -3,7 +3,7 @@ use anyhow::{Context as _, Result}; use chrono::{DateTime, Utc}; use cloud_api_client::websocket_protocol::MessageToClient; use cloud_api_client::{ - GetAuthenticatedUserResponse, Organization, OrganizationId, Plan, PlanInfo, + GetAuthenticatedUserResponse, KnownOrUnknown, Organization, OrganizationId, Plan, PlanInfo, }; use cloud_llm_client::{ EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, UsageLimit, @@ -817,6 +817,21 @@ impl UserStore { self.organizations = response.organizations.into_iter().map(Arc::new).collect(); self.current_organization = self.organizations.first().cloned(); + self.plans_by_organization = response + .plans_by_organization + .into_iter() + .map(|(organization_id, plan)| { + let plan = match plan { + KnownOrUnknown::Known(plan) => plan, + KnownOrUnknown::Unknown(_) => { + // If we get a plan that we don't recognize, fall back to the Free plan. + Plan::ZedFree + } + }; + + (organization_id, plan) + }) + .collect(); self.edit_prediction_usage = Some(EditPredictionUsage(RequestUsage { limit: response.plan.usage.edit_predictions.limit, diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index 42d3442bfc016f5cb1a39ba421ccdfe386bcbc65..e2c517edcc78e37bc2eab7055c5ac8d79c9db5b2 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -4,6 +4,7 @@ mod plan; mod timestamp; pub mod websocket_protocol; +use std::collections::BTreeMap; use std::sync::Arc; use serde::{Deserialize, Serialize}; @@ -21,6 +22,8 @@ pub struct GetAuthenticatedUserResponse { pub feature_flags: Vec, #[serde(default)] pub organizations: Vec, + #[serde(default)] + pub plans_by_organization: BTreeMap>, pub plan: PlanInfo, } @@ -35,7 +38,7 @@ pub struct AuthenticatedUser { pub accepted_tos_at: Option, } -#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)] pub struct OrganizationId(pub Arc); #[derive(Debug, PartialEq, Serialize, Deserialize)] From 6034961499c180c56c41e9647f8f5950b2a808ef Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 11 Mar 2026 19:21:57 -0400 Subject: [PATCH 147/219] ai_onboarding: Add student plan examples to component preview (#51338) This PR adds examples for the student plan to the component preview. Release Notes: - N/A --- crates/ai_onboarding/src/ai_onboarding.rs | 4 ++++ crates/ai_onboarding/src/ai_upsell_card.rs | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 8b578d2e7f00a4f0dd139e074259d28e09932908..e05853fa167267c505d4424365c29844e0ce08db 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -372,6 +372,10 @@ impl Component for ZedAiOnboarding { "Business Plan", onboarding(SignInStatus::SignedIn, Some(Plan::ZedBusiness), false), ), + single_example( + "Student Plan", + onboarding(SignInStatus::SignedIn, Some(Plan::ZedStudent), false), + ), ]) .into_any_element(), ) diff --git a/crates/ai_onboarding/src/ai_upsell_card.rs b/crates/ai_onboarding/src/ai_upsell_card.rs index 40a35f590d87a9928d4299199a99f223264e5ef3..cbaa9785db9e5471dd76a3add2cb9f19ca1b7ae1 100644 --- a/crates/ai_onboarding/src/ai_upsell_card.rs +++ b/crates/ai_onboarding/src/ai_upsell_card.rs @@ -388,6 +388,17 @@ impl Component for AiUpsellCard { } .into_any_element(), ), + single_example( + "Student Plan", + AiUpsellCard { + sign_in_status: SignInStatus::SignedIn, + sign_in: Arc::new(|_, _| {}), + account_too_young: false, + user_plan: Some(Plan::ZedStudent), + tab_index: Some(1), + } + .into_any_element(), + ), ], )) .into_any_element(), From f627c43ea1e4a8dc5788b2136b7c78aedb6b87d3 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 12 Mar 2026 11:49:32 +0530 Subject: [PATCH 148/219] languages: Prevent `bsn` macro from injecting rust layer (#51353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/issues/51240 We don’t parse bsn as embedded Rust anymore. We expect bsn to get its own Tree-sitter implementation in the future, which should improve this. This fixes broken syntax highlighting for string literals. See line 66 in the comparison below. image Release Notes: - N/A Co-authored-by: Christopher Biscardi --- crates/languages/src/rust/injections.scm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/languages/src/rust/injections.scm b/crates/languages/src/rust/injections.scm index 89d839282d3388f450f9ebdb923167f0986f349c..c50694dc9e0b90d3e31bc1147e59eea7ff402efa 100644 --- a/crates/languages/src/rust/injections.scm +++ b/crates/languages/src/rust/injections.scm @@ -10,7 +10,7 @@ (scoped_identifier (identifier) @_macro_name .) ] - (#not-any-of? @_macro_name "view" "html") + (#not-any-of? @_macro_name "view" "html" "bsn") (token_tree) @injection.content (#set! injection.language "rust")) From 81da953acfdcba1951875abb9664cd371d4c7f86 Mon Sep 17 00:00:00 2001 From: Josh Robson Chase Date: Thu, 12 Mar 2026 05:07:58 -0400 Subject: [PATCH 149/219] helix: Always offset cursor on selection (#46311) https://github.com/zed-industries/zed/pull/42837 added the `cursor_offset_on_selection` field, which displays the cursor *after* the end of the selection unless a vim visual mode is enabled, in which case it gets displayed *at* the end of the selection. However, the real helix is effectively *always* in select mode, and will always display the cursor at the end of the selection, whether that selection is made via its visual mode, a movement key, or with the mouse. This makes it so that the helix mode setting is taken into account regardless of the visual-ness of the vim mode in the `sync_vim_settings` method. I also considered simply moving `Mode::HelixNormal` up to the `true` arm of the match in the `is_visual` method since helix is kinda *always* in visual mode, but I figured that could have some unintended consequences and chose to err on the side of caution. Possibly related to #20121 Closes #46998 Release Notes: - Fixed the cursor offset in non-visual helix selections Co-authored-by: Nils Koch --- crates/vim/src/state.rs | 4 ++++ crates/vim/src/vim.rs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 69b2816cc0bdc5aeed2af787b9a92166e2c93956..4e71a698ff0789a462e5ec2e83d673421621c884 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -73,6 +73,10 @@ impl Mode { Self::Normal | Self::Insert | Self::Replace | Self::HelixNormal => false, } } + + pub fn is_helix(&self) -> bool { + matches!(self, Self::HelixNormal | Self::HelixSelect) + } } #[derive(Clone, Debug, PartialEq)] diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 8c551bcd2768043ae416157c80d4d2f9faa19092..3085dc5b3763222eb4b06d2ee551e026feba0002 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -2070,7 +2070,7 @@ impl Vim { input_enabled: self.editor_input_enabled(), expects_character_input: self.expects_character_input(), autoindent: self.should_autoindent(), - cursor_offset_on_selection: self.mode.is_visual(), + cursor_offset_on_selection: self.mode.is_visual() || self.mode.is_helix(), line_mode: matches!(self.mode, Mode::VisualLine), hide_edit_predictions: !matches!(self.mode, Mode::Insert | Mode::Replace), } From 5ebdbe2aacfe7d21a96c3bd1ca759ac4101ffb5c Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 12 Mar 2026 10:22:05 +0100 Subject: [PATCH 150/219] agent_ui: No global thread history (#51362) 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: - N/A --- crates/agent_ui/src/agent_connection_store.rs | 73 +++-- crates/agent_ui/src/agent_panel.rs | 293 +++++++++++------- crates/agent_ui/src/connection_view.rs | 110 ++++--- crates/agent_ui/src/inline_assistant.rs | 31 +- crates/agent_ui/src/text_thread_history.rs | 4 + crates/agent_ui/src/thread_history.rs | 11 +- crates/agent_ui/src/thread_history_view.rs | 4 + 7 files changed, 332 insertions(+), 194 deletions(-) diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs index c0c4519bcc64d53690dd782a55e6b9da4f498fe0..936b9b7a2de984f20f59c8f050ecb3bff1386595 100644 --- a/crates/agent_ui/src/agent_connection_store.rs +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -9,42 +9,51 @@ use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Subscription use project::{AgentServerStore, AgentServersUpdated, Project}; use watch::Receiver; -use crate::ExternalAgent; +use crate::{ExternalAgent, ThreadHistory}; use project::ExternalAgentServerName; -pub enum ConnectionEntry { +pub enum AgentConnectionEntry { Connecting { - connect_task: Shared, LoadError>>>, - }, - Connected { - connection: Rc, + connect_task: Shared>>, }, + Connected(AgentConnectedState), Error { error: LoadError, }, } -impl ConnectionEntry { - pub fn wait_for_connection(&self) -> Shared, LoadError>>> { +#[derive(Clone)] +pub struct AgentConnectedState { + pub connection: Rc, + pub history: Entity, +} + +impl AgentConnectionEntry { + pub fn wait_for_connection(&self) -> Shared>> { match self { - ConnectionEntry::Connecting { connect_task } => connect_task.clone(), - ConnectionEntry::Connected { connection } => { - Task::ready(Ok(connection.clone())).shared() - } - ConnectionEntry::Error { error } => Task::ready(Err(error.clone())).shared(), + AgentConnectionEntry::Connecting { connect_task } => connect_task.clone(), + AgentConnectionEntry::Connected(state) => Task::ready(Ok(state.clone())).shared(), + AgentConnectionEntry::Error { error } => Task::ready(Err(error.clone())).shared(), + } + } + + pub fn history(&self) -> Option<&Entity> { + match self { + AgentConnectionEntry::Connected(state) => Some(&state.history), + _ => None, } } } -pub enum ConnectionEntryEvent { +pub enum AgentConnectionEntryEvent { NewVersionAvailable(SharedString), } -impl EventEmitter for ConnectionEntry {} +impl EventEmitter for AgentConnectionEntry {} pub struct AgentConnectionStore { project: Entity, - entries: HashMap>, + entries: HashMap>, _subscriptions: Vec, } @@ -59,17 +68,21 @@ impl AgentConnectionStore { } } + pub fn entry(&self, key: &ExternalAgent) -> Option<&Entity> { + self.entries.get(key) + } + pub fn request_connection( &mut self, key: ExternalAgent, server: Rc, cx: &mut Context, - ) -> Entity { + ) -> Entity { self.entries.get(&key).cloned().unwrap_or_else(|| { let (mut new_version_rx, connect_task) = self.start_connection(server.clone(), cx); let connect_task = connect_task.shared(); - let entry = cx.new(|_cx| ConnectionEntry::Connecting { + let entry = cx.new(|_cx| AgentConnectionEntry::Connecting { connect_task: connect_task.clone(), }); @@ -79,18 +92,18 @@ impl AgentConnectionStore { let key = key.clone(); let entry = entry.clone(); async move |this, cx| match connect_task.await { - Ok(connection) => { + Ok(connected_state) => { entry.update(cx, |entry, cx| { - if let ConnectionEntry::Connecting { .. } = entry { - *entry = ConnectionEntry::Connected { connection }; + if let AgentConnectionEntry::Connecting { .. } = entry { + *entry = AgentConnectionEntry::Connected(connected_state); cx.notify(); } }); } Err(error) => { entry.update(cx, |entry, cx| { - if let ConnectionEntry::Connecting { .. } = entry { - *entry = ConnectionEntry::Error { error }; + if let AgentConnectionEntry::Connecting { .. } = entry { + *entry = AgentConnectionEntry::Error { error }; cx.notify(); } }); @@ -106,7 +119,7 @@ impl AgentConnectionStore { while let Ok(version) = new_version_rx.recv().await { if let Some(version) = version { entry.update(cx, |_entry, cx| { - cx.emit(ConnectionEntryEvent::NewVersionAvailable( + cx.emit(AgentConnectionEntryEvent::NewVersionAvailable( version.clone().into(), )); }); @@ -143,7 +156,7 @@ impl AgentConnectionStore { cx: &mut Context, ) -> ( Receiver>, - Task, LoadError>>, + Task>, ) { let (new_version_tx, new_version_rx) = watch::channel::>(None); @@ -151,8 +164,14 @@ impl AgentConnectionStore { let delegate = AgentServerDelegate::new(agent_server_store, Some(new_version_tx)); let connect_task = server.connect(delegate, cx); - let connect_task = cx.spawn(async move |_this, _cx| match connect_task.await { - Ok(connection) => Ok(connection), + let connect_task = cx.spawn(async move |_this, cx| match connect_task.await { + Ok(connection) => cx.update(|cx| { + let history = cx.new(|cx| ThreadHistory::new(connection.session_list(cx), cx)); + Ok(AgentConnectedState { + connection, + history, + }) + }), Err(err) => match err.downcast::() { Ok(load_error) => Err(load_error), Err(err) => Err(LoadError::Other(SharedString::from(err.to_string()))), diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 1aefc99c020409a764ad2c44fe8477665f73c4bc..741e995c8f1b2e44677ec7c7de7bef22a3421f3c 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -29,8 +29,6 @@ use zed_actions::agent::{ ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, ReviewBranchDiff, }; -use crate::ManageProfiles; -use crate::agent_connection_store::AgentConnectionStore; use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; use crate::{ AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow, @@ -48,12 +46,14 @@ use crate::{ NewNativeAgentThreadFromSummary, }; use crate::{ - ExpandMessageEditor, ThreadHistory, ThreadHistoryView, ThreadHistoryViewEvent, + ExpandMessageEditor, ThreadHistoryView, text_thread_history::{TextThreadHistory, TextThreadHistoryEvent}, }; +use crate::{ManageProfiles, ThreadHistoryViewEvent}; +use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore}; use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; -use anyhow::{Result, anyhow}; +use anyhow::{Context as _, Result, anyhow}; use assistant_slash_command::SlashCommandWorkingSet; use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary}; use client::UserStore; @@ -621,9 +621,9 @@ fn build_conflicted_files_resolution_prompt( content } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum HistoryKind { - AgentThreads, +#[derive(Clone, Debug, PartialEq, Eq)] +enum History { + AgentThreads { view: Entity }, TextThreads, } @@ -639,7 +639,7 @@ enum ActiveView { _subscriptions: Vec, }, History { - kind: HistoryKind, + history: History, }, Configuration, } @@ -870,8 +870,6 @@ pub struct AgentPanel { project: Entity, fs: Arc, language_registry: Arc, - acp_history: Entity, - acp_history_view: Entity, text_thread_history: Entity, thread_store: Entity, text_thread_store: Entity, @@ -1081,26 +1079,9 @@ impl AgentPanel { cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let thread_store = ThreadStore::global(cx); - let acp_history = cx.new(|cx| ThreadHistory::new(None, cx)); - let acp_history_view = cx.new(|cx| ThreadHistoryView::new(acp_history.clone(), window, cx)); let text_thread_history = cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx)); - cx.subscribe_in( - &acp_history_view, - window, - |this, _, event, window, cx| match event { - ThreadHistoryViewEvent::Open(thread) => { - this.load_agent_thread( - thread.session_id.clone(), - thread.cwd.clone(), - thread.title.clone(), - window, - cx, - ); - } - }, - ) - .detach(); + cx.subscribe_in( &text_thread_history, window, @@ -1120,15 +1101,18 @@ impl AgentPanel { window.defer(cx, move |window, cx| { let panel = weak_panel.clone(); let agent_navigation_menu = - ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| { + ContextMenu::build_persistent(window, cx, move |mut menu, window, cx| { if let Some(panel) = panel.upgrade() { - if let Some(kind) = panel.read(cx).history_kind_for_selected_agent(cx) { - menu = - Self::populate_recently_updated_menu_section(menu, panel, kind, cx); - let view_all_label = match kind { - HistoryKind::AgentThreads => "View All", - HistoryKind::TextThreads => "View All Text Threads", + if let Some(history) = panel + .update(cx, |panel, cx| panel.history_for_selected_agent(window, cx)) + { + let view_all_label = match history { + History::AgentThreads { .. } => "View All", + History::TextThreads => "View All Text Threads", }; + menu = Self::populate_recently_updated_menu_section( + menu, panel, history, cx, + ); menu = menu.action(view_all_label, Box::new(OpenHistory)); } } @@ -1222,8 +1206,6 @@ impl AgentPanel { zoomed: false, pending_serialization: None, onboarding, - acp_history, - acp_history_view, text_thread_history, thread_store, selected_agent: AgentType::default(), @@ -1288,8 +1270,8 @@ impl AgentPanel { &self.thread_store } - pub fn history(&self) -> &Entity { - &self.acp_history + pub fn connection_store(&self) -> &Entity { + &self.connection_store } pub fn open_thread( @@ -1353,27 +1335,41 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - let Some(thread) = self - .acp_history - .read(cx) - .session_for_id(&action.from_session_id) - else { - return; - }; + let agent = ExternalAgent::NativeAgent; - self.external_thread( - Some(ExternalAgent::NativeAgent), - None, - None, - None, - Some(AgentInitialContent::ThreadSummary { - session_id: thread.session_id, - title: thread.title, - }), - true, - window, - cx, - ); + let server = agent.server(self.fs.clone(), self.thread_store.clone()); + let session_id = action.from_session_id.clone(); + + let entry = self.connection_store.update(cx, |store, cx| { + store.request_connection(agent.clone(), server, cx) + }); + let connect_task = entry.read(cx).wait_for_connection(); + + cx.spawn_in(window, async move |this, cx| { + let history = connect_task.await?.history; + this.update_in(cx, |this, window, cx| { + let thread = history + .read(cx) + .session_for_id(&session_id) + .context("Session not found")?; + + this.external_thread( + Some(agent), + None, + None, + None, + Some(AgentInitialContent::ThreadSummary { + session_id: thread.session_id, + title: thread.title, + }), + true, + window, + cx, + ); + anyhow::Ok(()) + }) + }) + .detach_and_log_err(cx); } fn new_text_thread(&mut self, window: &mut Window, cx: &mut Context) { @@ -1554,13 +1550,52 @@ impl AgentPanel { }) } - fn history_kind_for_selected_agent(&self, cx: &App) -> Option { - match self.selected_agent { - AgentType::NativeAgent => Some(HistoryKind::AgentThreads), - AgentType::TextThread => Some(HistoryKind::TextThreads), - AgentType::Custom { .. } => { - if self.acp_history.read(cx).has_session_list() { - Some(HistoryKind::AgentThreads) + fn has_history_for_selected_agent(&self, cx: &App) -> bool { + match &self.selected_agent { + AgentType::TextThread | AgentType::NativeAgent => true, + AgentType::Custom { name } => { + let agent = ExternalAgent::Custom { name: name.clone() }; + self.connection_store + .read(cx) + .entry(&agent) + .map_or(false, |entry| entry.read(cx).history().is_some()) + } + } + } + + fn history_for_selected_agent( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + match &self.selected_agent { + AgentType::TextThread => Some(History::TextThreads), + AgentType::NativeAgent => { + let history = self + .connection_store + .read(cx) + .entry(&ExternalAgent::NativeAgent)? + .read(cx) + .history()? + .clone(); + + Some(History::AgentThreads { + view: self.create_thread_history_view(history, window, cx), + }) + } + AgentType::Custom { name } => { + let agent = ExternalAgent::Custom { name: name.clone() }; + let history = self + .connection_store + .read(cx) + .entry(&agent)? + .read(cx) + .history()? + .clone(); + if history.read(cx).has_session_list() { + Some(History::AgentThreads { + view: self.create_thread_history_view(history, window, cx), + }) } else { None } @@ -1568,13 +1603,38 @@ impl AgentPanel { } } + fn create_thread_history_view( + &self, + history: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let view = cx.new(|cx| ThreadHistoryView::new(history.clone(), window, cx)); + cx.subscribe_in(&view, window, |this, _, event, window, cx| match event { + ThreadHistoryViewEvent::Open(thread) => { + this.load_agent_thread( + thread.session_id.clone(), + thread.cwd.clone(), + thread.title.clone(), + window, + cx, + ); + } + }) + .detach(); + view + } + fn open_history(&mut self, window: &mut Window, cx: &mut Context) { - let Some(kind) = self.history_kind_for_selected_agent(cx) else { + let Some(history) = self.history_for_selected_agent(window, cx) else { return; }; - if let ActiveView::History { kind: active_kind } = self.active_view { - if active_kind == kind { + if let ActiveView::History { + history: active_history, + } = &self.active_view + { + if active_history == &history { if let Some(previous_view) = self.previous_view.take() { self.set_active_view(previous_view, true, window, cx); } @@ -1582,7 +1642,7 @@ impl AgentPanel { } } - self.set_active_view(ActiveView::History { kind }, true, window, cx); + self.set_active_view(ActiveView::History { history }, true, window, cx); cx.notify(); } @@ -1655,7 +1715,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - if self.history_kind_for_selected_agent(cx).is_none() { + if !self.has_history_for_selected_agent(cx) { return; } self.agent_navigation_menu_handle.toggle(window, cx); @@ -2096,7 +2156,7 @@ impl AgentPanel { let was_in_agent_history = matches!( self.active_view, ActiveView::History { - kind: HistoryKind::AgentThreads + history: History::AgentThreads { .. } } ); let current_is_uninitialized = matches!(self.active_view, ActiveView::Uninitialized); @@ -2154,16 +2214,13 @@ impl AgentPanel { } }; - let is_in_agent_history = matches!( - self.active_view, - ActiveView::History { - kind: HistoryKind::AgentThreads + if let ActiveView::History { history } = &self.active_view { + if !was_in_agent_history && let History::AgentThreads { view } = history { + view.update(cx, |view, cx| { + view.history() + .update(cx, |history, cx| history.refresh_full_history(cx)) + }); } - ); - - if !was_in_agent_history && is_in_agent_history { - self.acp_history - .update(cx, |history, cx| history.refresh_full_history(cx)); } if focus { @@ -2175,14 +2232,14 @@ impl AgentPanel { fn populate_recently_updated_menu_section( mut menu: ContextMenu, panel: Entity, - kind: HistoryKind, + history: History, cx: &mut Context, ) -> ContextMenu { - match kind { - HistoryKind::AgentThreads => { - let entries = panel + match history { + History::AgentThreads { view } => { + let entries = view .read(cx) - .acp_history + .history() .read(cx) .sessions() .iter() @@ -2224,7 +2281,7 @@ impl AgentPanel { }); } } - HistoryKind::TextThreads => { + History::TextThreads => { let entries = panel .read(cx) .text_thread_store @@ -2518,7 +2575,6 @@ impl AgentPanel { project, thread_store, self.prompt_store.clone(), - self.acp_history.clone(), window, cx, ) @@ -3056,9 +3112,9 @@ impl Focusable for AgentPanel { match &self.active_view { ActiveView::Uninitialized => self.focus_handle.clone(), ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx), - ActiveView::History { kind } => match kind { - HistoryKind::AgentThreads => self.acp_history_view.focus_handle(cx), - HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx), + ActiveView::History { history: kind } => match kind { + History::AgentThreads { view } => view.read(cx).focus_handle(cx), + History::TextThreads => self.text_thread_history.focus_handle(cx), }, ActiveView::TextThread { text_thread_editor, .. @@ -3292,10 +3348,10 @@ impl AgentPanel { .into_any_element(), } } - ActiveView::History { kind } => { + ActiveView::History { history: kind } => { let title = match kind { - HistoryKind::AgentThreads => "History", - HistoryKind::TextThreads => "Text Thread History", + History::AgentThreads { .. } => "History", + History::TextThreads => "Text Thread History", }; Label::new(title).truncate().into_any_element() } @@ -4122,7 +4178,7 @@ impl AgentPanel { selected_agent.into_any_element() }; - let show_history_menu = self.history_kind_for_selected_agent(cx).is_some(); + let show_history_menu = self.has_history_for_selected_agent(cx); let has_v2_flag = cx.has_flag::(); let is_empty_state = !self.active_thread_has_messages(cx); @@ -4402,6 +4458,14 @@ impl AgentPanel { return false; } + let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx) + .visible_providers() + .iter() + .any(|provider| { + provider.is_authenticated(cx) + && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID + }); + match &self.active_view { ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => { false @@ -4411,17 +4475,15 @@ impl AgentPanel { { false } - _ => { - let history_is_empty = self.acp_history.read(cx).is_empty(); - - let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx) - .visible_providers() - .iter() - .any(|provider| { - provider.is_authenticated(cx) - && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID - }); - + ActiveView::AgentThread { server_view } => { + let history_is_empty = server_view + .read(cx) + .history() + .is_none_or(|h| h.read(cx).is_empty()); + history_is_empty || !has_configured_non_zed_providers + } + ActiveView::TextThread { .. } => { + let history_is_empty = self.text_thread_history.read(cx).is_empty(); history_is_empty || !has_configured_non_zed_providers } } @@ -4803,9 +4865,9 @@ impl Render for AgentPanel { ActiveView::AgentThread { server_view, .. } => parent .child(server_view.clone()) .child(self.render_drag_target(cx)), - ActiveView::History { kind } => match kind { - HistoryKind::AgentThreads => parent.child(self.acp_history_view.clone()), - HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()), + ActiveView::History { history: kind } => match kind { + History::AgentThreads { view } => parent.child(view.clone()), + History::TextThreads => parent.child(self.text_thread_history.clone()), }, ActiveView::TextThread { text_thread_editor, @@ -4910,17 +4972,26 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { let Some(panel) = workspace.read(cx).panel::(cx) else { return; }; + let Some(history) = panel + .read(cx) + .connection_store() + .read(cx) + .entry(&crate::ExternalAgent::NativeAgent) + .and_then(|s| s.read(cx).history()) + else { + log::error!("No connection entry found for native agent"); + return; + }; let project = workspace.read(cx).project().downgrade(); let panel = panel.read(cx); let thread_store = panel.thread_store().clone(); - let history = panel.history().downgrade(); assistant.assist( prompt_editor, self.workspace.clone(), project, thread_store, None, - history, + history.downgrade(), initial_prompt, window, cx, diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index b562688a83b75b75a1b95c065b14d0484daef055..8aeacbd61ad404f94c39efbd14a846a3b52150d9 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -67,7 +67,9 @@ use super::entry_view_state::EntryViewState; use super::thread_history::ThreadHistory; use crate::ModeSelector; use crate::ModelSelectorPopover; -use crate::agent_connection_store::{AgentConnectionStore, ConnectionEntryEvent}; +use crate::agent_connection_store::{ + AgentConnectedState, AgentConnectionEntryEvent, AgentConnectionStore, +}; use crate::agent_diff::AgentDiff; use crate::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::message_editor::{MessageEditor, MessageEditorEvent}; @@ -314,7 +316,6 @@ pub struct ConnectionView { thread_store: Option>, prompt_store: Option>, server_state: ServerState, - history: Entity, focus_handle: FocusHandle, notifications: Vec>, notification_subscriptions: HashMap, Vec>, @@ -418,6 +419,7 @@ pub struct ConnectedServerState { active_id: Option, threads: HashMap>, connection: Rc, + history: Entity, conversation: Entity, _connection_entry_subscription: Subscription, } @@ -484,7 +486,6 @@ impl ConnectionView { project: Entity, thread_store: Option>, prompt_store: Option>, - history: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -537,7 +538,6 @@ impl ConnectionView { notifications: Vec::new(), notification_subscriptions: HashMap::default(), auth_task: None, - history, _subscriptions: subscriptions, focus_handle: cx.focus_handle(), } @@ -660,7 +660,7 @@ impl ConnectionView { let connection_entry_subscription = cx.subscribe(&connection_entry, |this, _entry, event, cx| match event { - ConnectionEntryEvent::NewVersionAvailable(version) => { + AgentConnectionEntryEvent::NewVersionAvailable(version) => { if let Some(thread) = this.active_thread() { thread.update(cx, |thread, cx| { thread.new_server_version_available = Some(version.clone()); @@ -674,8 +674,11 @@ impl ConnectionView { let load_session_id = resume_session_id.clone(); let load_task = cx.spawn_in(window, async move |this, cx| { - let connection = match connect_result.await { - Ok(connection) => connection, + let (connection, history) = match connect_result.await { + Ok(AgentConnectedState { + connection, + history, + }) => (connection, history), Err(err) => { this.update_in(cx, |this, window, cx| { this.handle_load_error(load_session_id.clone(), err, window, cx); @@ -764,6 +767,7 @@ impl ConnectionView { conversation.clone(), resumed_without_history, initial_content, + history.clone(), window, cx, ); @@ -777,14 +781,6 @@ impl ConnectionView { } let id = current.read(cx).thread.read(cx).session_id().clone(); - let session_list = if connection.supports_session_history() { - connection.session_list(cx) - } else { - None - }; - this.history.update(cx, |history, cx| { - history.set_session_list(session_list, cx); - }); this.set_server_state( ServerState::Connected(ConnectedServerState { connection, @@ -792,6 +788,7 @@ impl ConnectionView { active_id: Some(id.clone()), threads: HashMap::from_iter([(id, current)]), conversation, + history, _connection_entry_subscription: connection_entry_subscription, }), cx, @@ -825,6 +822,7 @@ impl ConnectionView { conversation: Entity, resumed_without_history: bool, initial_content: Option, + history: Entity, window: &mut Window, cx: &mut Context, ) -> Entity { @@ -841,7 +839,7 @@ impl ConnectionView { self.workspace.clone(), self.project.downgrade(), self.thread_store.clone(), - self.history.downgrade(), + history.downgrade(), self.prompt_store.clone(), prompt_capabilities.clone(), available_commands.clone(), @@ -1008,7 +1006,7 @@ impl ConnectionView { resumed_without_history, self.project.downgrade(), self.thread_store.clone(), - self.history.clone(), + history, self.prompt_store.clone(), initial_content, subscriptions, @@ -1090,6 +1088,7 @@ impl ConnectionView { threads: HashMap::default(), connection, conversation: cx.new(|_cx| Conversation::default()), + history: cx.new(|cx| ThreadHistory::new(None, cx)), _connection_entry_subscription: Subscription::new(|| {}), }), cx, @@ -1694,10 +1693,10 @@ impl ConnectionView { cx.spawn_in(window, async move |this, cx| { let subagent_thread = subagent_thread_task.await?; this.update_in(cx, |this, window, cx| { - let conversation = this + let Some((conversation, history)) = this .as_connected() - .map(|connected| connected.conversation.clone()); - let Some(conversation) = conversation else { + .map(|connected| (connected.conversation.clone(), connected.history.clone())) + else { return; }; conversation.update(cx, |conversation, cx| { @@ -1709,6 +1708,7 @@ impl ConnectionView { conversation, false, None, + history, window, cx, ); @@ -2215,9 +2215,11 @@ impl ConnectionView { let agent_name = self.agent.name(); let workspace = self.workspace.clone(); let project = self.project.downgrade(); - let history = self.history.downgrade(); - - let Some(thread) = self.active_thread() else { + let Some(connected) = self.as_connected() else { + return; + }; + let history = connected.history.downgrade(); + let Some(thread) = connected.active_view() else { return; }; let prompt_capabilities = thread.read(cx).prompt_capabilities.clone(); @@ -2610,8 +2612,16 @@ impl ConnectionView { }) } + pub fn history(&self) -> Option<&Entity> { + self.as_connected().map(|c| &c.history) + } + pub fn delete_history_entry(&mut self, session_id: &acp::SessionId, cx: &mut Context) { - let task = self + let Some(connected) = self.as_connected() else { + return; + }; + + let task = connected .history .update(cx, |history, cx| history.delete_session(&session_id, cx)); task.detach_and_log_err(cx); @@ -2900,8 +2910,6 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - // Create history without an initial session list - it will be set after connection - let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -2921,7 +2929,6 @@ pub(crate) mod tests { project, Some(thread_store), None, - history.clone(), window, cx, ) @@ -2931,6 +2938,14 @@ pub(crate) mod tests { // Wait for connection to establish cx.run_until_parked(); + let history = cx.update(|_window, cx| { + thread_view + .read(cx) + .history() + .expect("Missing history") + .clone() + }); + // Initially empty because StubAgentConnection.session_list() returns None active_thread(&thread_view, cx).read_with(cx, |view, _cx| { assert_eq!(view.recent_history_entries.len(), 0); @@ -3007,7 +3022,6 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3027,7 +3041,6 @@ pub(crate) mod tests { project, Some(thread_store), None, - history, window, cx, ) @@ -3066,7 +3079,6 @@ pub(crate) mod tests { let captured_cwd = connection.captured_cwd.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3086,7 +3098,6 @@ pub(crate) mod tests { project, Some(thread_store), None, - history, window, cx, ) @@ -3123,7 +3134,6 @@ pub(crate) mod tests { let captured_cwd = connection.captured_cwd.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3143,7 +3153,6 @@ pub(crate) mod tests { project, Some(thread_store), None, - history, window, cx, ) @@ -3180,7 +3189,6 @@ pub(crate) mod tests { let captured_cwd = connection.captured_cwd.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3200,7 +3208,6 @@ pub(crate) mod tests { project, Some(thread_store), None, - history, window, cx, ) @@ -3498,7 +3505,6 @@ pub(crate) mod tests { // Set up thread view in workspace 1 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx))); @@ -3519,7 +3525,6 @@ pub(crate) mod tests { project1.clone(), Some(thread_store), None, - history, window, cx, ) @@ -3676,7 +3681,8 @@ pub(crate) mod tests { agent: impl AgentServer + 'static, cx: &mut TestAppContext, ) -> (Entity, &mut VisualTestContext) { - let (thread_view, _history, cx) = setup_thread_view_with_history(agent, cx).await; + let (thread_view, _history, cx) = + setup_thread_view_with_history_and_initial_content(agent, None, cx).await; (thread_view, cx) } @@ -3688,7 +3694,9 @@ pub(crate) mod tests { Entity, &mut VisualTestContext, ) { - setup_thread_view_with_history_and_initial_content(agent, None, cx).await + let (thread_view, history, cx) = + setup_thread_view_with_history_and_initial_content(agent, None, cx).await; + (thread_view, history.expect("Missing history"), cx) } async fn setup_thread_view_with_initial_content( @@ -3708,7 +3716,7 @@ pub(crate) mod tests { cx: &mut TestAppContext, ) -> ( Entity, - Entity, + Option>, &mut VisualTestContext, ) { let fs = FakeFs::new(cx.executor()); @@ -3718,18 +3726,19 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); + let agent_key = ExternalAgent::Custom { + name: "Test".into(), + }; + let thread_view = cx.update(|window, cx| { cx.new(|cx| { ConnectionView::new( Rc::new(agent), - connection_store, - ExternalAgent::Custom { - name: "Test".into(), - }, + connection_store.clone(), + agent_key.clone(), None, None, None, @@ -3738,13 +3747,20 @@ pub(crate) mod tests { project, Some(thread_store), None, - history.clone(), window, cx, ) }) }); cx.run_until_parked(); + + let history = cx.update(|_window, cx| { + connection_store + .read(cx) + .entry(&agent_key) + .and_then(|e| e.read(cx).history().cloned()) + }); + (thread_view, history, cx) } @@ -4454,7 +4470,6 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -4475,7 +4490,6 @@ pub(crate) mod tests { project.clone(), Some(thread_store.clone()), None, - history, window, cx, ) diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 2aee2b4601e126b25a977cf92d314970049026da..8fde876183db385c019e6ccb1f2e5a0d4b121892 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -266,7 +266,7 @@ impl InlineAssistant { return; }; - let configuration_error = || { + let configuration_error = |cx| { let model_registry = LanguageModelRegistry::read_global(cx); model_registry.configuration_error(model_registry.inline_assistant_model(), cx) }; @@ -278,7 +278,15 @@ impl InlineAssistant { let prompt_store = agent_panel.prompt_store().as_ref().cloned(); let thread_store = agent_panel.thread_store().clone(); - let history = agent_panel.history().downgrade(); + let Some(history) = agent_panel + .connection_store() + .read(cx) + .entry(&crate::ExternalAgent::NativeAgent) + .and_then(|s| s.read(cx).history().cloned()) + else { + log::error!("No connection entry found for native agent"); + return; + }; let handle_assist = |window: &mut Window, cx: &mut Context| match inline_assist_target { @@ -290,7 +298,7 @@ impl InlineAssistant { workspace.project().downgrade(), thread_store, prompt_store, - history, + history.downgrade(), action.prompt.clone(), window, cx, @@ -305,7 +313,7 @@ impl InlineAssistant { workspace.project().downgrade(), thread_store, prompt_store, - history, + history.downgrade(), action.prompt.clone(), window, cx, @@ -314,7 +322,7 @@ impl InlineAssistant { } }; - if let Some(error) = configuration_error() { + if let Some(error) = configuration_error(cx) { if let ConfigurationError::ProviderNotAuthenticated(provider) = error { cx.spawn(async move |_, cx| { cx.update(|cx| provider.authenticate(cx)).await?; @@ -322,7 +330,7 @@ impl InlineAssistant { }) .detach_and_log_err(cx); - if configuration_error().is_none() { + if configuration_error(cx).is_none() { handle_assist(window, cx); } } else { @@ -1969,7 +1977,16 @@ impl CodeActionProvider for AssistantCodeActionProvider { .panel::(cx) .context("missing agent panel")? .read(cx); - anyhow::Ok((panel.thread_store().clone(), panel.history().downgrade())) + + let history = panel + .connection_store() + .read(cx) + .entry(&crate::ExternalAgent::NativeAgent) + .and_then(|e| e.read(cx).history()) + .context("no history found for native agent")? + .downgrade(); + + anyhow::Ok((panel.thread_store().clone(), history)) })??; let editor = editor.upgrade().context("editor was released")?; let range = editor diff --git a/crates/agent_ui/src/text_thread_history.rs b/crates/agent_ui/src/text_thread_history.rs index c19f64bc3503ab38c83dc9534d64fae5c23cc21c..7a2a4ff91ddae0531df200118b55151a8dbb4499 100644 --- a/crates/agent_ui/src/text_thread_history.rs +++ b/crates/agent_ui/src/text_thread_history.rs @@ -116,6 +116,10 @@ impl TextThreadHistory { this } + pub fn is_empty(&self) -> bool { + self.visible_items.is_empty() + } + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { let entries = self.text_thread_store.update(cx, |store, _| { store.ordered_text_threads().cloned().collect::>() diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs index 5e66d4468767e7002b8b5f6c79ffe8aaecf77127..1ca763cb6a64f1d1b680e31c1ac55a4717762157 100644 --- a/crates/agent_ui/src/thread_history.rs +++ b/crates/agent_ui/src/thread_history.rs @@ -19,14 +19,23 @@ impl ThreadHistory { _refresh_task: Task::ready(()), _watch_task: None, }; - this.set_session_list(session_list, cx); + this.set_session_list_impl(session_list, cx); this } + #[cfg(any(test, feature = "test-support"))] pub fn set_session_list( &mut self, session_list: Option>, cx: &mut Context, + ) { + self.set_session_list_impl(session_list, cx); + } + + fn set_session_list_impl( + &mut self, + session_list: Option>, + cx: &mut Context, ) { if let (Some(current), Some(next)) = (&self.session_list, &session_list) && Rc::ptr_eq(current, next) diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs index 1756fc46ed48e86dc4bf9c78f2c2ef79618ed43b..4e43748911ba0559485e7a4d991e5dc9d2d4c524 100644 --- a/crates/agent_ui/src/thread_history_view.rs +++ b/crates/agent_ui/src/thread_history_view.rs @@ -117,6 +117,10 @@ impl ThreadHistoryView { this } + pub fn history(&self) -> &Entity { + &self.history + } + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { let entries = self.history.read(cx).sessions().to_vec(); let new_list_items = if self.search_query.is_empty() { From 4d5e25f4088025ee4f837c25c7832ac2fe9c7fad Mon Sep 17 00:00:00 2001 From: Shashank Suresh <52377159+shashank-suresh@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:52:55 +0530 Subject: [PATCH 151/219] editor: Add line range support to editor::CopyFileLocation command (#51328) Closes #51309 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: - Improved `editor::CopyFileLocation` command to include the full selected line range (e.g. 'src/main.rs:12-18') when multiple lines are selected, rather than only the first line number. --- .../collab/tests/integration/editor_tests.rs | 48 +++++++++++++++++++ crates/editor/src/editor.rs | 24 +++++++--- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 0d0569182d5a9ff235642d61c39f0b5bc15b6cb0..6b23780156e03d62543cf597e82959083685f0c0 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -4721,6 +4721,54 @@ async fn test_copy_file_location(cx_a: &mut TestAppContext, cx_b: &mut TestAppCo cx_b.read_from_clipboard().and_then(|item| item.text()), Some(format!("{}:2", path!("src/main.rs"))) ); + + editor_a.update_in(cx_a, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(44)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + + assert_eq!( + cx_a.read_from_clipboard().and_then(|item| item.text()), + Some(format!("{}:2-3", path!("src/main.rs"))) + ); + + editor_b.update_in(cx_b, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(44)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + + assert_eq!( + cx_b.read_from_clipboard().and_then(|item| item.text()), + Some(format!("{}:2-3", path!("src/main.rs"))) + ); + + editor_a.update_in(cx_a, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(43)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + + assert_eq!( + cx_a.read_from_clipboard().and_then(|item| item.text()), + Some(format!("{}:2", path!("src/main.rs"))) + ); + + editor_b.update_in(cx_b, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(43)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + + assert_eq!( + cx_b.read_from_clipboard().and_then(|item| item.text()), + Some(format!("{}:2", path!("src/main.rs"))) + ); } #[track_caller] diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index dc2696eb2ca83999934cab6cdee82e364657c70e..18a02e9773b3952d99b71f6d337f3c8950aff78e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -22846,18 +22846,28 @@ impl Editor { _: &mut Window, cx: &mut Context, ) { - let selection = self - .selections - .newest::(&self.display_snapshot(cx)) - .start - .row - + 1; + let selection = self.selections.newest::(&self.display_snapshot(cx)); + + let start_line = selection.start.row + 1; + let end_line = selection.end.row + 1; + + let end_line = if selection.end.column == 0 && end_line > start_line { + end_line - 1 + } else { + end_line + }; + if let Some(file_location) = self.active_excerpt(cx).and_then(|(_, buffer, _)| { let project = self.project()?.read(cx); let file = buffer.read(cx).file()?; let path = file.path().display(project.path_style(cx)); - Some(format!("{path}:{selection}")) + let location = if start_line == end_line { + format!("{path}:{start_line}") + } else { + format!("{path}:{start_line}-{end_line}") + }; + Some(location) }) { cx.write_to_clipboard(ClipboardItem::new_string(file_location)); } From eeb034c31cea9618d445287c906576c1e0aa0898 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 12 Mar 2026 10:38:54 +0100 Subject: [PATCH 152/219] agent: Fix race condition when loading threads (#51366) This fixes a race condition that could occur when using the sidebar: `Failed to launch: project state not found` We were accessing/creating the project state before an await point, meaning that we could remove the state if session/close was called in the meantime. - [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: - N/A --- crates/agent/src/agent.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 95346d665732b40599b096d480178264601ce6d6..2ac341dc997b016f3e723fad99a4a57007510c52 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -870,7 +870,6 @@ impl NativeAgent { project: Entity, cx: &mut Context, ) -> Task>> { - let project_id = self.get_or_create_project_state(&project, cx); let database_future = ThreadsDatabase::connect(cx); cx.spawn(async move |this, cx| { let database = database_future.await.map_err(|err| anyhow!(err))?; @@ -880,6 +879,7 @@ impl NativeAgent { .with_context(|| format!("no thread found with ID: {id:?}"))?; this.update(cx, |this, cx| { + let project_id = this.get_or_create_project_state(&project, cx); let project_state = this .projects .get(&project_id) @@ -915,11 +915,11 @@ impl NativeAgent { return Task::ready(Ok(session.acp_thread.clone())); } - let project_id = self.get_or_create_project_state(&project, cx); - let task = self.load_thread(id, project, cx); + let task = self.load_thread(id, project.clone(), cx); cx.spawn(async move |this, cx| { let thread = task.await?; let acp_thread = this.update(cx, |this, cx| { + let project_id = this.get_or_create_project_state(&project, cx); this.register_session(thread.clone(), project_id, cx) })?; let events = thread.update(cx, |thread, cx| thread.replay(cx)); From ff89bcfca077180c8430f6d57b5584f4ac619df6 Mon Sep 17 00:00:00 2001 From: Dibash Thapa <47865470+dibashthapa@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:02:22 +0545 Subject: [PATCH 153/219] Fix hidden files in remote Open Folder dialog (#50846) Fixes https://github.com/zed-industries/zed/issues/48457 Hidden files (like .config, .ssh, etc.) were not showing in the Open Folder dialog when browsing remote servers via SSH. This was because the `OpenPathDelegate` was not configured to show hidden files. This fix adds .show_hidden() when creating the delegate for remote project picker. Release Notes: - Fixed the hidden files not showing in remote project's open folder action --- crates/recent_projects/src/remote_servers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index b094ff6c5bc5499e7ed1f3e6c9e0b9331b6bb7c2..60ebf85dd23460a8a0ce0c70da2d7b69761690db 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -390,7 +390,7 @@ impl ProjectPicker { ) -> Entity { let (tx, rx) = oneshot::channel(); let lister = project::DirectoryLister::Project(project.clone()); - let delegate = open_path_prompt::OpenPathDelegate::new(tx, lister, false, cx); + let delegate = open_path_prompt::OpenPathDelegate::new(tx, lister, false, cx).show_hidden(); let picker = cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) From 1fd8ee74e2960a8d7d5e79952449a4a0e9a40870 Mon Sep 17 00:00:00 2001 From: Henrique Ferreiro Date: Thu, 12 Mar 2026 11:48:27 +0100 Subject: [PATCH 154/219] Fix Tree-sitter link in documentation (#51370) --- docs/src/extensions/languages.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/extensions/languages.md b/docs/src/extensions/languages.md index eee29cc57d1ce5e1a5a7608c70ece98bf4a233ee..c8e6958db683a5a3e2c9903c590f564b0ef4cb93 100644 --- a/docs/src/extensions/languages.md +++ b/docs/src/extensions/languages.md @@ -52,7 +52,7 @@ TBD: Document `language_name/config.toml` keys ## Grammar -Zed uses the [Tree-sitter](https://tree-sitter.github.io) parsing library to provide built-in language-specific features. There are grammars available for many languages, and you can also [develop your own grammar](https://tree-sitter.github.io/tree-sitter/creating-parsers#writing-the-grammar). A growing list of Zed features are built using pattern matching over syntax trees with Tree-sitter queries. As mentioned above, every language that is defined in an extension must specify the name of a Tree-sitter grammar that is used for parsing. These grammars are then registered separately in extensions' `extension.toml` file, like this: +Zed uses the [Tree-sitter](https://tree-sitter.github.io) parsing library to provide built-in language-specific features. There are grammars available for many languages, and you can also [develop your own grammar](https://tree-sitter.github.io/tree-sitter/creating-parsers/3-writing-the-grammar.html). A growing list of Zed features are built using pattern matching over syntax trees with Tree-sitter queries. As mentioned above, every language that is defined in an extension must specify the name of a Tree-sitter grammar that is used for parsing. These grammars are then registered separately in extensions' `extension.toml` file, like this: ```toml [grammars.gleam] From 9e50ee040e965086dd9d7c072f79add6d8772d23 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Thu, 12 Mar 2026 10:54:07 +0000 Subject: [PATCH 155/219] agent: Thread switcher sticky workspace header (#51372) --- crates/agent_ui/src/sidebar.rs | 116 +++++++++++++++++++++++++++++---- 1 file changed, 105 insertions(+), 11 deletions(-) diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 3804e3f63678bcf771b27b2f05929a958531ab39..e204205819a8eb41a0624fb8a4a8ba9a96174add 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -134,6 +134,7 @@ impl From for ListEntry { struct SidebarContents { entries: Vec, notified_threads: HashSet, + project_header_indices: Vec, } impl SidebarContents { @@ -663,10 +664,17 @@ impl Sidebar { // the build pass (no extra scan needed). notified_threads.retain(|id| current_session_ids.contains(id)); + let project_header_indices = entries + .iter() + .enumerate() + .filter_map(|(i, e)| matches!(e, ListEntry::ProjectHeader { .. }).then_some(i)) + .collect(); + self.active_entry_index = active_entry_index; self.contents = SidebarContents { entries, notified_threads, + project_header_indices, }; } @@ -724,6 +732,7 @@ impl Sidebar { has_threads, } => self.render_project_header( ix, + false, path_list, label, workspace, @@ -769,6 +778,7 @@ impl Sidebar { fn render_project_header( &self, ix: usize, + is_sticky: bool, path_list: &PathList, label: &SharedString, workspace: &Entity, @@ -778,9 +788,10 @@ impl Sidebar { docked_right: bool, cx: &mut Context, ) -> AnyElement { - let id = SharedString::from(format!("project-header-{}", ix)); - let group_name = SharedString::from(format!("header-group-{}", ix)); - let ib_id = SharedString::from(format!("project-header-new-thread-{}", ix)); + let id_prefix = if is_sticky { "sticky-" } else { "" }; + let id = SharedString::from(format!("{id_prefix}project-header-{ix}")); + let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}")); + let ib_id = SharedString::from(format!("{id_prefix}project-header-new-thread-{ix}")); let is_collapsed = self.collapsed_groups.contains(path_list); let disclosure_icon = if is_collapsed { @@ -842,7 +853,9 @@ impl Sidebar { .when(workspace_count > 1, |this| { this.child( IconButton::new( - SharedString::from(format!("project-header-remove-{}", ix)), + SharedString::from(format!( + "{id_prefix}project-header-remove-{ix}", + )), IconName::Close, ) .icon_size(IconSize::Small) @@ -858,7 +871,9 @@ impl Sidebar { .when(view_more_expanded && !is_collapsed, |this| { this.child( IconButton::new( - SharedString::from(format!("project-header-collapse-{}", ix)), + SharedString::from(format!( + "{id_prefix}project-header-collapse-{ix}", + )), IconName::ListCollapse, ) .icon_size(IconSize::Small) @@ -899,6 +914,84 @@ impl Sidebar { .into_any_element() } + fn render_sticky_header( + &self, + docked_right: bool, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let scroll_top = self.list_state.logical_scroll_top(); + + let &header_idx = self + .contents + .project_header_indices + .iter() + .rev() + .find(|&&idx| idx <= scroll_top.item_ix)?; + + let needs_sticky = header_idx < scroll_top.item_ix + || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.)); + + if !needs_sticky { + return None; + } + + let ListEntry::ProjectHeader { + path_list, + label, + workspace, + highlight_positions, + has_threads, + } = self.contents.entries.get(header_idx)? + else { + return None; + }; + + let is_focused = self.focus_handle.is_focused(window) + || self.filter_editor.focus_handle(cx).is_focused(window); + let is_selected = is_focused && self.selection == Some(header_idx); + + let header_element = self.render_project_header( + header_idx, + true, + &path_list, + &label, + &workspace, + &highlight_positions, + *has_threads, + is_selected, + docked_right, + cx, + ); + + let top_offset = self + .contents + .project_header_indices + .iter() + .find(|&&idx| idx > header_idx) + .and_then(|&next_idx| { + let bounds = self.list_state.bounds_for_item(next_idx)?; + let viewport = self.list_state.viewport_bounds(); + let y_in_viewport = bounds.origin.y - viewport.origin.y; + let header_height = bounds.size.height; + (y_in_viewport < header_height).then_some(y_in_viewport - header_height) + }) + .unwrap_or(px(0.)); + + let element = v_flex() + .absolute() + .top(top_offset) + .left_0() + .w_full() + .bg(cx.theme().colors().surface_background) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(header_element) + .into_any_element(); + + Some(element) + } + fn activate_workspace( &mut self, workspace: &Entity, @@ -1466,6 +1559,8 @@ impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let ui_font = theme::setup_ui_font(window, cx); let has_query = self.has_filter_query(cx); + let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right; + let sticky_header = self.render_sticky_header(docked_right, window, cx); v_flex() .id("workspace-sidebar") @@ -1484,10 +1579,7 @@ impl Render for Sidebar { .font(ui_font) .size_full() .bg(cx.theme().colors().surface_background) - .child({ - let docked_right = - AgentSettings::get_global(cx).dock == settings::DockPosition::Right; - + .child( h_flex() .h(Tab::container_height(cx)) .flex_none() @@ -1513,10 +1605,11 @@ impl Render for Sidebar { this.pl_2() .pr_0p5() .child(self.render_sidebar_toggle_button(true, cx)) - }) - }) + }), + ) .child( v_flex() + .relative() .flex_1() .overflow_hidden() .child( @@ -1527,6 +1620,7 @@ impl Render for Sidebar { .flex_1() .size_full(), ) + .when_some(sticky_header, |this, header| this.child(header)) .vertical_scrollbar_for(&self.list_state, window, cx), ) } From 39721045f93714100517ad12b4173fd3580340ca Mon Sep 17 00:00:00 2001 From: Daniel Eichman <61132910+zfz7@users.noreply.github.com> Date: Thu, 12 Mar 2026 04:25:24 -0700 Subject: [PATCH 156/219] Add missing ctrl-shift-g binding for editor::UndoSelection to the JetBrains/IntelliJ keymap on macOS (#51130) ## Description: This PR adds the missing `ctrl-shift-g` binding for editor::UndoSelection to the JetBrains/IntelliJ keymap on macOS. ## Problem In IntelliJ IDEA, when using multiple cursors: - ctrl+g (macOS) adds the next occurrence to the selection - ctrl+shift+g (macOS) removes the last added occurrence from the selection The current Zed JetBrains keymap has `ctrl-g` for SelectNext but is missing the corresponding `ctrl-shift-g` for undoing/removing the last selection. ## Reference - Press Ctrl+G (macOS) to find and select the next occurrence [link](https://www.jetbrains.com/help/idea/multicursor.html#multiple_words) - To remove selection from the last selected occurrence, press Ctrl+Shift+G (macOS) [link](https://www.jetbrains.com/help/idea/multicursor.html#multiple_words) This change improves parity with IntelliJ for users transitioning to Zed. ### Demo https://github.com/user-attachments/assets/0c7f699f-697d-4b81-a929-53f765d254d8 Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [X] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - JetBrains macOS bindings: added the missing `ctrl-shift-g` binding for `editor::UndoSelection` --- assets/keymaps/macos/jetbrains.json | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 8612e07c4719dfdbf67762c89505cc2da0cfa000..304ffb86e8c2fd08fb756b015490f8c4ac424f58 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -33,6 +33,7 @@ "cmd-+": "editor::UnfoldLines", "alt-shift-g": "editor::SplitSelectionIntoLines", "ctrl-g": ["editor::SelectNext", { "replace_newest": false }], + "ctrl-shift-g": "editor::UndoSelection", "ctrl-cmd-g": ["editor::SelectPrevious", { "replace_newest": false }], "cmd-/": ["editor::ToggleComments", { "advance_downwards": true }], "alt-up": "editor::SelectLargerSyntaxNode", From efc6b0ce70f1a95297be20c2e66804c88feca32e Mon Sep 17 00:00:00 2001 From: Sebastian Kootz <63540046+Skxxtz@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:36:45 +0100 Subject: [PATCH 157/219] gpui: Add `aspect-ratio` builder method to `Styled` (#48751) # Summary This PR simply adds the missing `aspect_ratio` and `aspect_square` helper functions to the `Styled` trait. Release Notes: - N/A Co-authored-by: MrSubidubi --- crates/gpui/src/styled.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index 3d0b86a9523f5ac05e51941c826e32379368c464..f83e9103572b9b708ef4b9a8f99bf73244be71a4 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -384,6 +384,20 @@ pub trait Styled: Sized { self } + /// Sets the aspect ratio of the element. + /// [Docs](https://tailwindcss.com/docs/aspect-ratio) + fn aspect_ratio(mut self, ratio: f32) -> Self { + self.style().aspect_ratio = Some(ratio); + self + } + + /// Sets the aspect ratio of the element to 1/1 – equal width and height. + /// [Docs](https://tailwindcss.com/docs/aspect-ratio) + fn aspect_square(mut self) -> Self { + self.style().aspect_ratio = Some(1.0); + self + } + /// Sets the background color of the element. fn bg(mut self, fill: F) -> Self where From 3bcef8b1f2bddd4507f0823c09fcea761c7c78a9 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 12 Mar 2026 12:38:35 +0100 Subject: [PATCH 158/219] agent_ui: Rename `ExternalAgent` to `Agent` (#51377) Name is confusing, since the `NativeAgent` variant is not an external agent Release Notes: - N/A --- crates/agent_ui/src/agent_connection_store.rs | 12 ++--- crates/agent_ui/src/agent_panel.rs | 44 +++++++++---------- crates/agent_ui/src/agent_ui.rs | 28 ++++++------ crates/agent_ui/src/connection_view.rs | 28 ++++++------ crates/agent_ui/src/inline_assistant.rs | 4 +- 5 files changed, 58 insertions(+), 58 deletions(-) diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs index 936b9b7a2de984f20f59c8f050ecb3bff1386595..c9be46aea3ad99dec77724710db9088ae459696e 100644 --- a/crates/agent_ui/src/agent_connection_store.rs +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -9,7 +9,7 @@ use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Subscription use project::{AgentServerStore, AgentServersUpdated, Project}; use watch::Receiver; -use crate::{ExternalAgent, ThreadHistory}; +use crate::{Agent, ThreadHistory}; use project::ExternalAgentServerName; pub enum AgentConnectionEntry { @@ -53,7 +53,7 @@ impl EventEmitter for AgentConnectionEntry {} pub struct AgentConnectionStore { project: Entity, - entries: HashMap>, + entries: HashMap>, _subscriptions: Vec, } @@ -68,13 +68,13 @@ impl AgentConnectionStore { } } - pub fn entry(&self, key: &ExternalAgent) -> Option<&Entity> { + pub fn entry(&self, key: &Agent) -> Option<&Entity> { self.entries.get(key) } pub fn request_connection( &mut self, - key: ExternalAgent, + key: Agent, server: Rc, cx: &mut Context, ) -> Entity { @@ -142,8 +142,8 @@ impl AgentConnectionStore { ) { let store = store.read(cx); self.entries.retain(|key, _| match key { - ExternalAgent::NativeAgent => true, - ExternalAgent::Custom { name } => store + Agent::NativeAgent => true, + Agent::Custom { name } => store .external_agents .contains_key(&ExternalAgentServerName(name.clone())), }); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 741e995c8f1b2e44677ec7c7de7bef22a3421f3c..09d52b6000392693d435217b4739ddc452b8de6d 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -42,7 +42,7 @@ use crate::{ ui::EndTrialUpsell, }; use crate::{ - AgentInitialContent, ExternalAgent, ExternalSourcePrompt, NewExternalAgentThread, + Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread, NewNativeAgentThreadFromSummary, }; use crate::{ @@ -738,11 +738,11 @@ impl AgentType { } } -impl From for AgentType { - fn from(value: ExternalAgent) -> Self { +impl From for AgentType { + fn from(value: Agent) -> Self { match value { - ExternalAgent::Custom { name } => Self::Custom { name }, - ExternalAgent::NativeAgent => Self::NativeAgent, + Agent::Custom { name } => Self::Custom { name }, + Agent::NativeAgent => Self::NativeAgent, } } } @@ -1283,7 +1283,7 @@ impl AgentPanel { cx: &mut Context, ) { self.external_thread( - Some(crate::ExternalAgent::NativeAgent), + Some(crate::Agent::NativeAgent), Some(session_id), cwd, title, @@ -1335,7 +1335,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - let agent = ExternalAgent::NativeAgent; + let agent = Agent::NativeAgent; let server = agent.server(self.fs.clone(), self.thread_store.clone()); let session_id = action.from_session_id.clone(); @@ -1417,7 +1417,7 @@ impl AgentPanel { fn external_thread( &mut self, - agent_choice: Option, + agent_choice: Option, resume_session_id: Option, cwd: Option, title: Option, @@ -1435,7 +1435,7 @@ impl AgentPanel { #[derive(Serialize, Deserialize)] struct LastUsedExternalAgent { - agent: crate::ExternalAgent, + agent: crate::Agent, } let thread_store = self.thread_store.clone(); @@ -1473,7 +1473,7 @@ impl AgentPanel { } else { cx.spawn_in(window, async move |this, cx| { let ext_agent = if is_via_collab { - ExternalAgent::NativeAgent + Agent::NativeAgent } else { cx.background_spawn(async move { KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY) @@ -1485,7 +1485,7 @@ impl AgentPanel { serde_json::from_str::(&value).log_err() }) .map(|agent| agent.agent) - .unwrap_or(ExternalAgent::NativeAgent) + .unwrap_or(Agent::NativeAgent) }; let server = ext_agent.server(fs, thread_store); @@ -1554,7 +1554,7 @@ impl AgentPanel { match &self.selected_agent { AgentType::TextThread | AgentType::NativeAgent => true, AgentType::Custom { name } => { - let agent = ExternalAgent::Custom { name: name.clone() }; + let agent = Agent::Custom { name: name.clone() }; self.connection_store .read(cx) .entry(&agent) @@ -1574,7 +1574,7 @@ impl AgentPanel { let history = self .connection_store .read(cx) - .entry(&ExternalAgent::NativeAgent)? + .entry(&Agent::NativeAgent)? .read(cx) .history()? .clone(); @@ -1584,7 +1584,7 @@ impl AgentPanel { }) } AgentType::Custom { name } => { - let agent = ExternalAgent::Custom { name: name.clone() }; + let agent = Agent::Custom { name: name.clone() }; let history = self .connection_store .read(cx) @@ -2376,10 +2376,10 @@ impl AgentPanel { cx.notify(); } - fn selected_external_agent(&self) -> Option { + fn selected_external_agent(&self) -> Option { match &self.selected_agent { - AgentType::NativeAgent => Some(ExternalAgent::NativeAgent), - AgentType::Custom { name } => Some(ExternalAgent::Custom { name: name.clone() }), + AgentType::NativeAgent => Some(Agent::NativeAgent), + AgentType::Custom { name } => Some(Agent::Custom { name: name.clone() }), AgentType::TextThread => None, } } @@ -2448,7 +2448,7 @@ impl AgentPanel { window.dispatch_action(NewTextThread.boxed_clone(), cx); } AgentType::NativeAgent => self.external_thread( - Some(crate::ExternalAgent::NativeAgent), + Some(crate::Agent::NativeAgent), None, None, None, @@ -2458,7 +2458,7 @@ impl AgentPanel { cx, ), AgentType::Custom { name } => self.external_thread( - Some(crate::ExternalAgent::Custom { name }), + Some(crate::Agent::Custom { name }), None, None, None, @@ -2544,7 +2544,7 @@ impl AgentPanel { initial_content: Option, workspace: WeakEntity, project: Entity, - ext_agent: ExternalAgent, + ext_agent: Agent, focus: bool, window: &mut Window, cx: &mut Context, @@ -4976,7 +4976,7 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { .read(cx) .connection_store() .read(cx) - .entry(&crate::ExternalAgent::NativeAgent) + .entry(&crate::Agent::NativeAgent) .and_then(|s| s.read(cx).history()) else { log::error!("No connection entry found for native agent"); @@ -5158,7 +5158,7 @@ impl AgentPanel { let workspace = self.workspace.clone(); let project = self.project.clone(); - let ext_agent = ExternalAgent::Custom { + let ext_agent = Agent::Custom { name: server.name(), }; diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 52ce6f0bd7a312966b6602fb43be4074d7f3e620..fbf47615cb23b75eaeff1f785ada8bf8605556d3 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -205,7 +205,7 @@ pub struct NewThread; #[serde(deny_unknown_fields)] pub struct NewExternalAgentThread { /// Which agent to use for the conversation. - agent: Option, + agent: Option, } #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] @@ -218,7 +218,7 @@ pub struct NewNativeAgentThreadFromSummary { // TODO unify this with AgentType #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] -pub enum ExternalAgent { +pub enum Agent { NativeAgent, Custom { name: SharedString }, } @@ -227,7 +227,7 @@ pub enum ExternalAgent { // the registry: "claude_code" -> Custom { name: "claude-acp" }, "codex" -> Custom { name: // "codex-acp" }, "gemini" -> Custom { name: "gemini" }. // Can be removed at some point in the future and go back to #[derive(Deserialize)]. -impl<'de> serde::Deserialize<'de> for ExternalAgent { +impl<'de> serde::Deserialize<'de> for Agent { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -280,7 +280,7 @@ impl<'de> serde::Deserialize<'de> for ExternalAgent { } } -impl ExternalAgent { +impl Agent { pub fn server( &self, fs: Arc, @@ -752,20 +752,20 @@ mod tests { use project::agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME}; assert_eq!( - serde_json::from_str::(r#""claude_code""#).unwrap(), - ExternalAgent::Custom { + serde_json::from_str::(r#""claude_code""#).unwrap(), + Agent::Custom { name: CLAUDE_AGENT_NAME.into(), }, ); assert_eq!( - serde_json::from_str::(r#""codex""#).unwrap(), - ExternalAgent::Custom { + serde_json::from_str::(r#""codex""#).unwrap(), + Agent::Custom { name: CODEX_NAME.into(), }, ); assert_eq!( - serde_json::from_str::(r#""gemini""#).unwrap(), - ExternalAgent::Custom { + serde_json::from_str::(r#""gemini""#).unwrap(), + Agent::Custom { name: GEMINI_NAME.into(), }, ); @@ -774,12 +774,12 @@ mod tests { #[test] fn test_deserialize_current_external_agent_variants() { assert_eq!( - serde_json::from_str::(r#""native_agent""#).unwrap(), - ExternalAgent::NativeAgent, + serde_json::from_str::(r#""native_agent""#).unwrap(), + Agent::NativeAgent, ); assert_eq!( - serde_json::from_str::(r#"{"custom":{"name":"my-agent"}}"#).unwrap(), - ExternalAgent::Custom { + serde_json::from_str::(r#"{"custom":{"name":"my-agent"}}"#).unwrap(), + Agent::Custom { name: "my-agent".into(), }, ); diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 8aeacbd61ad404f94c39efbd14a846a3b52150d9..e84e18e645ed4a84bd667564416682298b35ce17 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -76,9 +76,9 @@ use crate::message_editor::{MessageEditor, MessageEditorEvent}; use crate::profile_selector::{ProfileProvider, ProfileSelector}; use crate::ui::{AgentNotification, AgentNotificationEvent}; use crate::{ - AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, - ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort, - EditFirstQueuedMessage, ExpandMessageEditor, ExternalAgent, Follow, KeepAll, NewThread, + Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, + AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, + CycleThinkingEffort, EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, RemoveFirstQueuedMessage, SendImmediately, SendNextQueuedMessage, ToggleFastMode, ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject, @@ -309,7 +309,7 @@ impl EventEmitter for ConnectionView {} pub struct ConnectionView { agent: Rc, connection_store: Entity, - connection_key: ExternalAgent, + connection_key: Agent, agent_server_store: Entity, workspace: WeakEntity, project: Entity, @@ -477,7 +477,7 @@ impl ConnectionView { pub fn new( agent: Rc, connection_store: Entity, - connection_key: ExternalAgent, + connection_key: Agent, resume_session_id: Option, cwd: Option, title: Option, @@ -597,7 +597,7 @@ impl ConnectionView { fn initial_state( agent: Rc, connection_store: Entity, - connection_key: ExternalAgent, + connection_key: Agent, resume_session_id: Option, cwd: Option, title: Option, @@ -2918,7 +2918,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::default_response()), connection_store, - ExternalAgent::Custom { + Agent::Custom { name: "Test".into(), }, None, @@ -3030,7 +3030,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)), connection_store, - ExternalAgent::Custom { + Agent::Custom { name: "Test".into(), }, Some(SessionId::new("resume-session")), @@ -3087,7 +3087,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), connection_store, - ExternalAgent::Custom { + Agent::Custom { name: "Test".into(), }, Some(SessionId::new("session-1")), @@ -3142,7 +3142,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), connection_store, - ExternalAgent::Custom { + Agent::Custom { name: "Test".into(), }, Some(SessionId::new("session-1")), @@ -3197,7 +3197,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), connection_store, - ExternalAgent::Custom { + Agent::Custom { name: "Test".into(), }, Some(SessionId::new("session-1")), @@ -3514,7 +3514,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(agent), connection_store, - ExternalAgent::Custom { + Agent::Custom { name: "Test".into(), }, None, @@ -3729,7 +3729,7 @@ pub(crate) mod tests { let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); - let agent_key = ExternalAgent::Custom { + let agent_key = Agent::Custom { name: "Test".into(), }; @@ -4479,7 +4479,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::new(connection.as_ref().clone())), connection_store, - ExternalAgent::Custom { + Agent::Custom { name: "Test".into(), }, None, diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 8fde876183db385c019e6ccb1f2e5a0d4b121892..1fc66f6079fa146440a1f5a594d9f160e4580ab2 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -281,7 +281,7 @@ impl InlineAssistant { let Some(history) = agent_panel .connection_store() .read(cx) - .entry(&crate::ExternalAgent::NativeAgent) + .entry(&crate::Agent::NativeAgent) .and_then(|s| s.read(cx).history().cloned()) else { log::error!("No connection entry found for native agent"); @@ -1981,7 +1981,7 @@ impl CodeActionProvider for AssistantCodeActionProvider { let history = panel .connection_store() .read(cx) - .entry(&crate::ExternalAgent::NativeAgent) + .entry(&crate::Agent::NativeAgent) .and_then(|e| e.read(cx).history()) .context("no history found for native agent")? .downgrade(); From d28fc4e241f1574f6c6638165e6867f296903183 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 12 Mar 2026 12:52:29 +0100 Subject: [PATCH 159/219] agent_ui: Register native agent when creating agent panel (#51379) This ensures that in places like the inline assist we can just rely on it being available. Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 34 ++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 09d52b6000392693d435217b4739ddc452b8de6d..f7c07abe5541187c7daf0dc037c00286c606f5bb 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -84,7 +84,7 @@ use ui::{ KeyBinding, PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*, utils::WithRemSize, }; -use util::ResultExt as _; +use util::{ResultExt as _, debug_panic}; use workspace::{ CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar, MultiWorkspace, SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, ToggleZoom, @@ -1178,6 +1178,17 @@ impl AgentPanel { None }; + let connection_store = cx.new(|cx| { + let mut store = AgentConnectionStore::new(project.clone(), cx); + // Register the native agent right away, so that it is available for + // the inline assistant etc. + store.request_connection( + Agent::NativeAgent, + Agent::NativeAgent.server(fs.clone(), thread_store.clone()), + cx, + ); + store + }); let mut panel = Self { workspace_id, active_view, @@ -1188,7 +1199,7 @@ impl AgentPanel { language_registry, text_thread_store, prompt_store, - connection_store: cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)), + connection_store, configuration: None, configuration_subscription: None, focus_handle: cx.focus_handle(), @@ -1335,18 +1346,19 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - let agent = Agent::NativeAgent; - - let server = agent.server(self.fs.clone(), self.thread_store.clone()); let session_id = action.from_session_id.clone(); - let entry = self.connection_store.update(cx, |store, cx| { - store.request_connection(agent.clone(), server, cx) - }); - let connect_task = entry.read(cx).wait_for_connection(); + let Some(history) = self + .connection_store + .read(cx) + .entry(&Agent::NativeAgent) + .and_then(|e| e.read(cx).history().cloned()) + else { + debug_panic!("Native agent is not registered"); + return; + }; cx.spawn_in(window, async move |this, cx| { - let history = connect_task.await?.history; this.update_in(cx, |this, window, cx| { let thread = history .read(cx) @@ -1354,7 +1366,7 @@ impl AgentPanel { .context("Session not found")?; this.external_thread( - Some(agent), + Some(Agent::NativeAgent), None, None, None, From e0881e38f91b87623795208615ca466415d1970e Mon Sep 17 00:00:00 2001 From: Xin Zhao Date: Thu, 12 Mar 2026 20:24:51 +0800 Subject: [PATCH 160/219] python: Add `label_for_symbol` for ty adapter (#51355) Ported `label_for_symbol` logic directly from the basedpyright adapter without adjustments. Given Python's dynamic nature, the current implementation provides sufficient coverage. No further modifications are needed for now. Before you mark this PR as ready for review, make sure that you have: - [ ] 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 missing syntax highlighting in symbol search when using the ty language server. --- crates/languages/src/python.rs | 224 ++++++++++++--------------------- 1 file changed, 82 insertions(+), 142 deletions(-) diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 078db5ba027c4d089b7c2f62cbd7e8468e526171..e109d2685efaac6aaacddb7f467180ae48ba54e4 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -159,6 +159,75 @@ fn process_pyright_completions(items: &mut [lsp::CompletionItem]) { } } +fn label_for_pyright_completion( + item: &lsp::CompletionItem, + language: &Arc, +) -> Option { + let label = &item.label; + let label_len = label.len(); + let grammar = language.grammar()?; + let highlight_id = match item.kind? { + lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"), + lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"), + lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"), + lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"), + lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"), + _ => { + return None; + } + }; + let mut text = label.clone(); + if let Some(completion_details) = item + .label_details + .as_ref() + .and_then(|details| details.description.as_ref()) + { + write!(&mut text, " {}", completion_details).ok(); + } + Some(language::CodeLabel::filtered( + text, + label_len, + item.filter_text.as_deref(), + highlight_id + .map(|id| (0..label_len, id)) + .into_iter() + .collect(), + )) +} + +fn label_for_python_symbol( + symbol: &Symbol, + language: &Arc, +) -> Option { + let name = &symbol.name; + let (text, filter_range, display_range) = match symbol.kind { + lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { + let text = format!("def {}():\n", name); + let filter_range = 4..4 + name.len(); + let display_range = 0..filter_range.end; + (text, filter_range, display_range) + } + lsp::SymbolKind::CLASS => { + let text = format!("class {}:", name); + let filter_range = 6..6 + name.len(); + let display_range = 0..filter_range.end; + (text, filter_range, display_range) + } + lsp::SymbolKind::CONSTANT => { + let text = format!("{} = 0", name); + let filter_range = 0..name.len(); + let display_range = 0..filter_range.end; + (text, filter_range, display_range) + } + _ => return None, + }; + Some(language::CodeLabel::new( + text[display_range.clone()].to_string(), + filter_range, + language.highlight_text(&text.as_str().into(), display_range), + )) +} + pub struct TyLspAdapter { fs: Arc, } @@ -255,6 +324,14 @@ impl LspAdapter for TyLspAdapter { )) } + async fn label_for_symbol( + &self, + symbol: &language::Symbol, + language: &Arc, + ) -> Option { + label_for_python_symbol(symbol, language) + } + async fn workspace_configuration( self: Arc, delegate: &Arc, @@ -531,36 +608,7 @@ impl LspAdapter for PyrightLspAdapter { item: &lsp::CompletionItem, language: &Arc, ) -> Option { - let label = &item.label; - let label_len = label.len(); - let grammar = language.grammar()?; - let highlight_id = match item.kind? { - lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"), - lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"), - lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"), - lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"), - lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"), - _ => { - return None; - } - }; - let mut text = label.clone(); - if let Some(completion_details) = item - .label_details - .as_ref() - .and_then(|details| details.description.as_ref()) - { - write!(&mut text, " {}", completion_details).ok(); - } - Some(language::CodeLabel::filtered( - text, - label_len, - item.filter_text.as_deref(), - highlight_id - .map(|id| (0..label_len, id)) - .into_iter() - .collect(), - )) + label_for_pyright_completion(item, language) } async fn label_for_symbol( @@ -568,34 +616,7 @@ impl LspAdapter for PyrightLspAdapter { symbol: &language::Symbol, language: &Arc, ) -> Option { - let name = &symbol.name; - let (text, filter_range, display_range) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { - let text = format!("def {}():\n", name); - let filter_range = 4..4 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - lsp::SymbolKind::CLASS => { - let text = format!("class {}:", name); - let filter_range = 6..6 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - lsp::SymbolKind::CONSTANT => { - let text = format!("{} = 0", name); - let filter_range = 0..name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - _ => return None, - }; - - Some(language::CodeLabel::new( - text[display_range.clone()].to_string(), - filter_range, - language.highlight_text(&text.as_str().into(), display_range), - )) + label_for_python_symbol(symbol, language) } async fn workspace_configuration( @@ -1738,33 +1759,7 @@ impl LspAdapter for PyLspAdapter { symbol: &language::Symbol, language: &Arc, ) -> Option { - let name = &symbol.name; - let (text, filter_range, display_range) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { - let text = format!("def {}():\n", name); - let filter_range = 4..4 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - lsp::SymbolKind::CLASS => { - let text = format!("class {}:", name); - let filter_range = 6..6 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - lsp::SymbolKind::CONSTANT => { - let text = format!("{} = 0", name); - let filter_range = 0..name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - _ => return None, - }; - Some(language::CodeLabel::new( - text[display_range.clone()].to_string(), - filter_range, - language.highlight_text(&text.as_str().into(), display_range), - )) + label_for_python_symbol(symbol, language) } async fn workspace_configuration( @@ -2019,36 +2014,7 @@ impl LspAdapter for BasedPyrightLspAdapter { item: &lsp::CompletionItem, language: &Arc, ) -> Option { - let label = &item.label; - let label_len = label.len(); - let grammar = language.grammar()?; - let highlight_id = match item.kind? { - lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"), - lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"), - lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"), - lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"), - lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"), - _ => { - return None; - } - }; - let mut text = label.clone(); - if let Some(completion_details) = item - .label_details - .as_ref() - .and_then(|details| details.description.as_ref()) - { - write!(&mut text, " {}", completion_details).ok(); - } - Some(language::CodeLabel::filtered( - text, - label_len, - item.filter_text.as_deref(), - highlight_id - .map(|id| (0..label.len(), id)) - .into_iter() - .collect(), - )) + label_for_pyright_completion(item, language) } async fn label_for_symbol( @@ -2056,33 +2022,7 @@ impl LspAdapter for BasedPyrightLspAdapter { symbol: &Symbol, language: &Arc, ) -> Option { - let name = &symbol.name; - let (text, filter_range, display_range) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { - let text = format!("def {}():\n", name); - let filter_range = 4..4 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - lsp::SymbolKind::CLASS => { - let text = format!("class {}:", name); - let filter_range = 6..6 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - lsp::SymbolKind::CONSTANT => { - let text = format!("{} = 0", name); - let filter_range = 0..name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - _ => return None, - }; - Some(language::CodeLabel::new( - text[display_range.clone()].to_string(), - filter_range, - language.highlight_text(&text.as_str().into(), display_range), - )) + label_for_python_symbol(symbol, language) } async fn workspace_configuration( From 314b7e55fb3fb2d8277a38217a64d335668ef473 Mon Sep 17 00:00:00 2001 From: Nelson Campos <60667230+nelsoncampos-cloudwalk@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:05:52 -0300 Subject: [PATCH 161/219] debugger: Fix restart only working once per session (#51247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Session::restart_task` is set to `Some` when a restart is initiated but never cleared back to `None`. The guard at the top of `restart()` checks `self.restart_task.is_some()` and returns early, so only the first restart attempt succeeds. This primarily affects debug adapters that advertise `supportsRestartRequest` dynamically via a `CapabilitiesEvent` after launch, such as the Flutter debug adapter. Related: https://github.com/zed-extensions/dart/issues/45 Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) (N/A — no UI changes) Release Notes: - debugger: Fixed debug session restart only working once when the adapter supports DAP restart requests. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Anthony Eid --- crates/debugger_ui/src/tests.rs | 8 +- .../debugger_ui/src/tests/debugger_panel.rs | 74 ++++++++++++++++++- crates/project/src/debugger/session.rs | 28 ++++--- 3 files changed, 97 insertions(+), 13 deletions(-) diff --git a/crates/debugger_ui/src/tests.rs b/crates/debugger_ui/src/tests.rs index c183f8941c3f30cb43ffaa638eae4e6b387e226d..cc407dfd810ceedb11c4d8030c46a6f17065b34b 100644 --- a/crates/debugger_ui/src/tests.rs +++ b/crates/debugger_ui/src/tests.rs @@ -132,7 +132,13 @@ pub fn start_debug_session_with) + 'static>( .workspace() .read(cx) .panel::(cx) - .and_then(|panel| panel.read(cx).active_session()) + .and_then(|panel| { + panel + .read(cx) + .sessions_with_children + .keys() + .max_by_key(|session| session.read(cx).session_id(cx)) + }) .map(|session| session.read(cx).running_state().read(cx).session()) .cloned() .context("Failed to get active session") diff --git a/crates/debugger_ui/src/tests/debugger_panel.rs b/crates/debugger_ui/src/tests/debugger_panel.rs index 207e82b4958941e04ea04fc47c9471141e61a64d..e4c258a8d2af0b865f13c28430c44a66117a11cd 100644 --- a/crates/debugger_ui/src/tests/debugger_panel.rs +++ b/crates/debugger_ui/src/tests/debugger_panel.rs @@ -27,7 +27,7 @@ use std::{ path::Path, sync::{ Arc, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }, }; use terminal_view::terminal_panel::TerminalPanel; @@ -2481,3 +2481,75 @@ async fn test_adapter_shutdown_with_child_sessions_on_app_quit( "Child session should have received disconnect request" ); } + +#[gpui::test] +async fn test_restart_request_is_not_sent_more_than_once_until_response( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + path!("/project"), + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let session = start_debug_session(&workspace, cx, move |client| { + client.on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_restart_request: Some(true), + ..Default::default() + }) + }); + }) + .unwrap(); + + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + let restart_count = Arc::new(AtomicUsize::new(0)); + + client.on_request::({ + let restart_count = restart_count.clone(); + move |_, _| { + restart_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + }); + + // This works because the restart request sender is on the foreground thread + // so it will start running after the gpui update stack is cleared + session.update(cx, |session, cx| { + session.restart(None, cx); + session.restart(None, cx); + session.restart(None, cx); + }); + + cx.run_until_parked(); + + assert_eq!( + restart_count.load(Ordering::SeqCst), + 1, + "Only one restart request should be sent while a restart is in-flight" + ); + + session.update(cx, |session, cx| { + session.restart(None, cx); + }); + + cx.run_until_parked(); + + assert_eq!( + restart_count.load(Ordering::SeqCst), + 2, + "A second restart should be allowed after the first one completes" + ); +} diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index a6c3f52b17a4a6cf241aa49329f3f14f0b5cefbc..87e11cfd97a2f63bba3cefca671e4413deb6765f 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -2187,21 +2187,27 @@ impl Session { self.capabilities.supports_restart_request.unwrap_or(false) && !self.is_terminated(); self.restart_task = Some(cx.spawn(async move |this, cx| { - let _ = this.update(cx, |session, cx| { + this.update(cx, |session, cx| { if supports_dap_restart { - session - .request( - RestartCommand { - raw: args.unwrap_or(Value::Null), - }, - Self::fallback_to_manual_restart, - cx, - ) - .detach(); + session.request( + RestartCommand { + raw: args.unwrap_or(Value::Null), + }, + Self::fallback_to_manual_restart, + cx, + ) } else { cx.emit(SessionStateEvent::Restart); + Task::ready(None) } - }); + }) + .unwrap_or_else(|_| Task::ready(None)) + .await; + + this.update(cx, |session, _cx| { + session.restart_task = None; + }) + .ok(); })); } From 47cc0bac418c9b7ab63e4116961301fd51c745fb Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:14:10 -0300 Subject: [PATCH 162/219] agent_ui: Add keybinding to cycle through new thread location options & settings (#51384) This PR adds the ability to save in the settings whether new threads should start in the current project or in a new Git worktree. Additionally, it also adds a keybinding that allows cycling through the menu options easily, with the ability to use cmd-click/enter to choose which one is set as the default. No release notes because this feature/settings depends on a feature flag that isn't out yet. Release Notes: - N/A --- assets/keymaps/default-linux.json | 2 +- assets/keymaps/default-macos.json | 2 +- assets/keymaps/default-windows.json | 2 +- crates/agent/src/tool_permissions.rs | 1 + crates/agent_settings/src/agent_settings.rs | 4 +- crates/agent_ui/src/agent_panel.rs | 125 ++++++++++++++------ crates/agent_ui/src/agent_ui.rs | 5 +- crates/agent_ui/src/ui/hold_for_default.rs | 19 ++- crates/settings_content/src/agent.rs | 21 ++++ crates/settings_ui/src/page_data.rs | 24 +++- 10 files changed, 159 insertions(+), 46 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index cb5cef24c50f9f9ac637f3ac70adb24d37e56d61..5780eedb4445f613cbbd4e9a09976f2d475b28c7 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -258,7 +258,7 @@ "ctrl-shift-j": "agent::ToggleNavigationMenu", "ctrl-alt-i": "agent::ToggleOptionsMenu", "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu", - "ctrl-alt-shift-t": "agent::ToggleStartThreadInSelector", + "ctrl-shift-t": "agent::CycleStartThreadIn", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl->": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 08fb63868be875f41f6c461354b46f1081a2026f..6fc6905dd5f4502ff7ee90e7f6f9499b2e03fa6a 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -297,7 +297,7 @@ "cmd-shift-j": "agent::ToggleNavigationMenu", "cmd-alt-m": "agent::ToggleOptionsMenu", "cmd-alt-shift-n": "agent::ToggleNewThreadMenu", - "cmd-alt-shift-t": "agent::ToggleStartThreadInSelector", + "cmd-shift-t": "agent::CycleStartThreadIn", "shift-alt-escape": "agent::ExpandMessageEditor", "cmd->": "agent::AddSelectionToThread", "cmd-shift-e": "project_panel::ToggleFocus", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 600025e2069978f3020afb5cb978d05a53317682..ac23d45695e11ec46172c566282ea65bf7774ac8 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -259,7 +259,7 @@ "shift-alt-j": "agent::ToggleNavigationMenu", "shift-alt-i": "agent::ToggleOptionsMenu", "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", - "ctrl-shift-alt-t": "agent::ToggleStartThreadInSelector", + "ctrl-shift-t": "agent::CycleStartThreadIn", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl-shift-.": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index 79564bbddea7063d00e18d97c8eab89533b20da5..4cb4d265b3170429430b815d7490099a50678714 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -560,6 +560,7 @@ mod tests { message_editor_min_lines: 1, tool_permissions, show_turn_stats: false, + new_thread_location: Default::default(), } } diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 02341af42b9247ba07cb3f8c771a51626cd721ed..d5d4f16eb742a92f6abf8081c43709f161ef4038 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -12,7 +12,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection, - NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode, + NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode, }; pub use crate::agent_profile::*; @@ -51,6 +51,7 @@ pub struct AgentSettings { pub message_editor_min_lines: usize, pub show_turn_stats: bool, pub tool_permissions: ToolPermissions, + pub new_thread_location: NewThreadLocation, } impl AgentSettings { @@ -438,6 +439,7 @@ impl Settings for AgentSettings { message_editor_min_lines: agent.message_editor_min_lines.unwrap(), show_turn_stats: agent.show_turn_stats.unwrap(), tool_permissions: compile_tool_permissions(agent.tool_permissions), + new_thread_location: agent.new_thread_location.unwrap_or_default(), } } } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index f7c07abe5541187c7daf0dc037c00286c606f5bb..4fc6e3dd1f257377e3f5213b1ae216115fd01fff 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -29,12 +29,12 @@ use zed_actions::agent::{ ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, ReviewBranchDiff, }; -use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; +use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal, HoldForDefault}; use crate::{ - AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow, - InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, - OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn, - ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, ToggleStartThreadInSelector, + AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, CycleStartThreadIn, + Follow, InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread, + OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, + StartThreadIn, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, connection_view::{AcpThreadViewEvent, ThreadView}, slash_command::SlashCommandCompletionProvider, @@ -312,18 +312,6 @@ pub fn init(cx: &mut App) { }); } }) - .register_action(|workspace, _: &ToggleStartThreadInSelector, window, cx| { - if let Some(panel) = workspace.panel::(cx) { - workspace.focus_panel::(window, cx); - panel.update(cx, |panel, cx| { - panel.toggle_start_thread_in_selector( - &ToggleStartThreadInSelector, - window, - cx, - ); - }); - } - }) .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| { AcpOnboardingModal::toggle(workspace, window, cx) }) @@ -477,6 +465,13 @@ pub fn init(cx: &mut App) { }); } }) + .register_action(|workspace, _: &CycleStartThreadIn, _window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.cycle_start_thread_in(cx); + }); + } + }) .register_action(|workspace, _: &ToggleWorkspaceSidebar, window, cx| { if !multi_workspace_enabled(cx) { return; @@ -1751,15 +1746,6 @@ impl AgentPanel { self.new_thread_menu_handle.toggle(window, cx); } - pub fn toggle_start_thread_in_selector( - &mut self, - _: &ToggleStartThreadInSelector, - window: &mut Window, - cx: &mut Context, - ) { - self.start_thread_in_menu_handle.toggle(window, cx); - } - pub fn increase_font_size( &mut self, action: &IncreaseBufferFontSize, @@ -2388,6 +2374,28 @@ impl AgentPanel { cx.notify(); } + fn cycle_start_thread_in(&mut self, cx: &mut Context) { + let next = match self.start_thread_in { + StartThreadIn::LocalProject => StartThreadIn::NewWorktree, + StartThreadIn::NewWorktree => StartThreadIn::LocalProject, + }; + self.set_start_thread_in(&next, cx); + } + + fn reset_start_thread_in_to_default(&mut self, cx: &mut Context) { + use settings::{NewThreadLocation, Settings}; + let default = AgentSettings::get_global(cx).new_thread_location; + let start_thread_in = match default { + NewThreadLocation::LocalProject => StartThreadIn::LocalProject, + NewThreadLocation::NewWorktree => StartThreadIn::NewWorktree, + }; + if self.start_thread_in != start_thread_in { + self.start_thread_in = start_thread_in; + self.serialize(cx); + cx.notify(); + } + } + fn selected_external_agent(&self) -> Option { match &self.selected_agent { AgentType::NativeAgent => Some(Agent::NativeAgent), @@ -2445,6 +2453,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + self.reset_start_thread_in_to_default(cx); self.new_agent_thread_inner(agent, true, window, cx); } @@ -3592,9 +3601,12 @@ impl AgentPanel { } fn render_start_thread_in_selector(&self, cx: &mut Context) -> impl IntoElement { + use settings::{NewThreadLocation, Settings}; + let focus_handle = self.focus_handle(cx); let has_git_repo = self.project_has_git_repository(cx); let is_via_collab = self.project.read(cx).is_via_collab(); + let fs = self.fs.clone(); let is_creating = matches!( self.worktree_creation_status, @@ -3604,6 +3616,10 @@ impl AgentPanel { let current_target = self.start_thread_in; let trigger_label = self.start_thread_in.label(); + let new_thread_location = AgentSettings::get_global(cx).new_thread_location; + let is_local_default = new_thread_location == NewThreadLocation::LocalProject; + let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree; + let icon = if self.start_thread_in_menu_handle.is_deployed() { IconName::ChevronUp } else { @@ -3631,7 +3647,7 @@ impl AgentPanel { move |_window, cx| { Tooltip::for_action_in( "Start Thread In…", - &ToggleStartThreadInSelector, + &CycleStartThreadIn, &focus_handle, cx, ) @@ -3640,6 +3656,7 @@ impl AgentPanel { .menu(move |window, cx| { let is_local_selected = current_target == StartThreadIn::LocalProject; let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree; + let fs = fs.clone(); Some(ContextMenu::build(window, cx, move |menu, _window, _cx| { let new_worktree_disabled = !has_git_repo || is_via_collab; @@ -3648,18 +3665,53 @@ impl AgentPanel { .item( ContextMenuEntry::new("Current Project") .toggleable(IconPosition::End, is_local_selected) - .handler(|window, cx| { - window - .dispatch_action(Box::new(StartThreadIn::LocalProject), cx); + .documentation_aside(documentation_side, move |_| { + HoldForDefault::new(is_local_default) + .more_content(false) + .into_any_element() + }) + .handler({ + let fs = fs.clone(); + move |window, cx| { + if window.modifiers().secondary() { + update_settings_file(fs.clone(), cx, |settings, _| { + settings + .agent + .get_or_insert_default() + .set_new_thread_location( + NewThreadLocation::LocalProject, + ); + }); + } + window.dispatch_action( + Box::new(StartThreadIn::LocalProject), + cx, + ); + } }), ) .item({ let entry = ContextMenuEntry::new("New Worktree") .toggleable(IconPosition::End, is_new_worktree_selected) .disabled(new_worktree_disabled) - .handler(|window, cx| { - window - .dispatch_action(Box::new(StartThreadIn::NewWorktree), cx); + .handler({ + let fs = fs.clone(); + move |window, cx| { + if window.modifiers().secondary() { + update_settings_file(fs.clone(), cx, |settings, _| { + settings + .agent + .get_or_insert_default() + .set_new_thread_location( + NewThreadLocation::NewWorktree, + ); + }); + } + window.dispatch_action( + Box::new(StartThreadIn::NewWorktree), + cx, + ); + } }); if new_worktree_disabled { @@ -3675,7 +3727,11 @@ impl AgentPanel { .into_any_element() }) } else { - entry + entry.documentation_aside(documentation_side, move |_| { + HoldForDefault::new(is_new_worktree_default) + .more_content(false) + .into_any_element() + }) } }) })) @@ -4849,7 +4905,6 @@ impl Render for AgentPanel { .on_action(cx.listener(Self::go_back)) .on_action(cx.listener(Self::toggle_navigation_menu)) .on_action(cx.listener(Self::toggle_options_menu)) - .on_action(cx.listener(Self::toggle_start_thread_in_selector)) .on_action(cx.listener(Self::increase_font_size)) .on_action(cx.listener(Self::decrease_font_size)) .on_action(cx.listener(Self::reset_font_size)) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index fbf47615cb23b75eaeff1f785ada8bf8605556d3..ea70d155b79e190dcfe9138b620aff4415b6d935 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -86,8 +86,8 @@ actions!( NewTextThread, /// Toggles the menu to create new agent threads. ToggleNewThreadMenu, - /// Toggles the selector for choosing where new threads start (current project or new worktree). - ToggleStartThreadInSelector, + /// Cycles through the options for where new threads start (current project or new worktree). + CycleStartThreadIn, /// Toggles the navigation menu for switching between threads and views. ToggleNavigationMenu, /// Toggles the options menu for agent settings and preferences. @@ -655,6 +655,7 @@ mod tests { message_editor_min_lines: 1, tool_permissions: Default::default(), show_turn_stats: false, + new_thread_location: Default::default(), }; cx.update(|cx| { diff --git a/crates/agent_ui/src/ui/hold_for_default.rs b/crates/agent_ui/src/ui/hold_for_default.rs index 1972f5de4d38fd5ba47ff91709be6ded302b61ae..436ca65ddd93b977a09c8de8eaeb25dc6c0eb1a0 100644 --- a/crates/agent_ui/src/ui/hold_for_default.rs +++ b/crates/agent_ui/src/ui/hold_for_default.rs @@ -4,20 +4,31 @@ use ui::{prelude::*, render_modifiers}; #[derive(IntoElement)] pub struct HoldForDefault { is_default: bool, + more_content: bool, } impl HoldForDefault { pub fn new(is_default: bool) -> Self { - Self { is_default } + Self { + is_default, + more_content: true, + } + } + + pub fn more_content(mut self, more_content: bool) -> Self { + self.more_content = more_content; + self } } impl RenderOnce for HoldForDefault { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { h_flex() - .pt_1() - .border_t_1() - .border_color(cx.theme().colors().border_variant) + .when(self.more_content, |this| { + this.pt_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + }) .gap_0p5() .text_sm() .text_color(Color::Muted.color(cx)) diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 87e117b8b0bbdd9a789bae18c3f9dce98a6f1bc0..8061e591b0a3f81e8b8081a0b363c112fb388ce4 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -9,6 +9,19 @@ use crate::ExtendingVec; use crate::DockPosition; +/// Where new threads should start by default. +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom, +)] +#[serde(rename_all = "snake_case")] +pub enum NewThreadLocation { + /// Start threads in the current project. + #[default] + LocalProject, + /// Start threads in a new worktree. + NewWorktree, +} + #[with_fallible_options] #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)] pub struct AgentSettingsContent { @@ -59,6 +72,10 @@ pub struct AgentSettingsContent { /// /// Default: "thread" pub default_view: Option, + /// Where new threads should start by default. + /// + /// Default: "local_project" + pub new_thread_location: Option, /// The available agent profiles. pub profiles: Option, AgentProfileContent>>, /// Where to show a popup notification when the agent is waiting for user input. @@ -146,6 +163,10 @@ impl AgentSettingsContent { self.default_profile = Some(profile_id); } + pub fn set_new_thread_location(&mut self, value: NewThreadLocation) { + self.new_thread_location = Some(value); + } + pub fn add_favorite_model(&mut self, model: LanguageModelSelection) { if !self.favorite_models.contains(&model) { self.favorite_models.push(model); diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index dbac4d7ba350fcff07016a2ccfa483f3d84472c7..708840668d7502ae0c34e9f1751fd7b76da2ca07 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -6972,7 +6972,7 @@ fn ai_page() -> SettingsPage { ] } - fn agent_configuration_section() -> [SettingsPageItem; 12] { + fn agent_configuration_section() -> [SettingsPageItem; 13] { [ SettingsPageItem::SectionHeader("Agent Configuration"), SettingsPageItem::SubPageLink(SubPageLink { @@ -6984,6 +6984,28 @@ fn ai_page() -> SettingsPage { files: USER, render: render_tool_permissions_setup_page, }), + SettingsPageItem::SettingItem(SettingItem { + title: "New Thread Location", + description: "Whether to start a new thread in the current local project or in a new Git worktree.", + field: Box::new(SettingField { + json_path: Some("agent.default_start_thread_in"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .new_thread_location + .as_ref() + }, + write: |settings_content, value| { + settings_content + .agent + .get_or_insert_default() + .new_thread_location = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Single File Review", description: "When enabled, agent edits will also be displayed in single-file buffers for review.", From 9ddf672d57a4faa93e7d84a610465838ddf52d6b Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Thu, 12 Mar 2026 14:26:16 +0100 Subject: [PATCH 163/219] project: Fix semantic tokens coloring deleted diff hunks (#51386) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/project/src/lsp_store/semantic_tokens.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/project/src/lsp_store/semantic_tokens.rs b/crates/project/src/lsp_store/semantic_tokens.rs index cfcd74ad7de7baaf60833cd9db1085d60307c20e..2927e5c0af77c50420462e95c271e61828b020e5 100644 --- a/crates/project/src/lsp_store/semantic_tokens.rs +++ b/crates/project/src/lsp_store/semantic_tokens.rs @@ -585,8 +585,7 @@ async fn raw_to_buffer_semantic_tokens( } Some(BufferSemanticToken { - range: buffer_snapshot.anchor_before(start) - ..buffer_snapshot.anchor_after(end), + range: buffer_snapshot.anchor_range_around(start..end), token_type: token.token_type, token_modifiers: token.token_modifiers, }) From 8e78b9fa97fe494198a94501ce93f8edb7a72851 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 12 Mar 2026 21:16:48 +0530 Subject: [PATCH 164/219] Fix window drags when dragging button/input on titlebar in macOS (#51400) Closes https://github.com/zed-industries/zed/issues/27500 This PR fixes an issue on macOS where dragging title bar buttons and other UI elements would drag the window instead of no-op, like in native Mac apps. That made interactions like selecting text with the mouse impossible in those areas, including the title input in Rules Library. We don't want to handle this at GPUI level, since you might still want this dragging behavior while having no native titlebar for some apps, and without implementing your own handler. So, we just handle this for Zed. On macOS, we now set `is_movable: false` on all windows, which disables that drag-anything behavior and relies on the native window drag handler for window dragging instead. This also meant implementing a platform title bar for the sidebar in Rules Library, since dragging there was previously handled by the `is_movable` behavior. We already had a full-width platform title bar there on other platforms. On macOS, it is sidebar-only to keep existing design. Release Notes: - N/A --- .../src/platform_title_bar.rs | 10 +++ crates/rules_library/src/rules_library.rs | 74 ++++++++++++------- crates/zed/src/zed.rs | 2 +- 3 files changed, 57 insertions(+), 29 deletions(-) diff --git a/crates/platform_title_bar/src/platform_title_bar.rs b/crates/platform_title_bar/src/platform_title_bar.rs index 1db29b0f53d9e7b185e6c3cd3029ed2e6077753e..70d24812974ee00caaad7005a593733e30788060 100644 --- a/crates/platform_title_bar/src/platform_title_bar.rs +++ b/crates/platform_title_bar/src/platform_title_bar.rs @@ -30,6 +30,7 @@ pub struct PlatformTitleBar { platform_style: PlatformStyle, children: SmallVec<[AnyElement; 2]>, should_move: bool, + background_color: Option, system_window_tabs: Entity, } @@ -43,11 +44,16 @@ impl PlatformTitleBar { platform_style, children: SmallVec::new(), should_move: false, + background_color: None, system_window_tabs, } } pub fn title_bar_color(&self, window: &mut Window, cx: &mut Context) -> Hsla { + if let Some(background_color) = self.background_color { + return background_color; + } + if cfg!(any(target_os = "linux", target_os = "freebsd")) { if window.is_window_active() && !self.should_move { cx.theme().colors().title_bar_background @@ -66,6 +72,10 @@ impl PlatformTitleBar { self.children = children.into_iter().collect(); } + pub fn set_background_color(&mut self, background_color: Option) { + self.background_color = background_color; + } + pub fn init(cx: &mut App) { SystemWindowTabs::init(cx); } diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index 73bf5fdd8fcaaf1437013d300102a9e593823c7b..dd4bbcfaeb7a14ea4bda8c546f5cf2539734eb73 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -3,9 +3,9 @@ use collections::{HashMap, HashSet}; use editor::{CompletionProvider, SelectionEffects}; use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab}; use gpui::{ - App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, PromptLevel, - Subscription, Task, TextStyle, Tiling, TitlebarOptions, WindowBounds, WindowHandle, - WindowOptions, actions, point, size, transparent_black, + App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, MouseButton, + PromptLevel, Subscription, Task, TextStyle, Tiling, TitlebarOptions, WindowBounds, + WindowHandle, WindowOptions, actions, point, size, transparent_black, }; use language::{Buffer, LanguageRegistry, language_settings::SoftWrap}; use language_model::{ @@ -133,6 +133,7 @@ pub fn open_rules_library( window_decorations: Some(window_decorations), window_min_size: Some(DEFAULT_ADDITIONAL_WINDOW_SIZE), kind: gpui::WindowKind::Floating, + is_movable: !cfg!(target_os = "macos"), ..Default::default() }, |window, cx| { @@ -503,11 +504,7 @@ impl RulesLibrary { }); Self { - title_bar: if !cfg!(target_os = "macos") { - Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx))) - } else { - None - }, + title_bar: Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx))), store, language_registry, rule_editors: HashMap::default(), @@ -1129,30 +1126,44 @@ impl RulesLibrary { v_flex() .id("rule-list") .capture_action(cx.listener(Self::focus_active_rule)) - .px_1p5() .h_full() .w_64() .overflow_x_hidden() .bg(cx.theme().colors().panel_background) + .when(!cfg!(target_os = "macos"), |this| this.px_1p5()) .map(|this| { if cfg!(target_os = "macos") { - this.child( - h_flex() - .p(DynamicSpacing::Base04.rems(cx)) - .h_9() - .w_full() - .flex_none() - .justify_end() - .child( - IconButton::new("new-rule", IconName::Plus) - .tooltip(move |_window, cx| { - Tooltip::for_action("New Rule", &NewRule, cx) - }) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(NewRule), cx); - }), - ), - ) + let Some(title_bar) = self.title_bar.as_ref() else { + return this; + }; + let button_padding = DynamicSpacing::Base08.rems(cx); + let panel_background = cx.theme().colors().panel_background; + title_bar.update(cx, |title_bar, _cx| { + title_bar.set_background_color(Some(panel_background)); + title_bar.set_children(Some( + h_flex() + .w_full() + .pr(button_padding) + .justify_end() + .child( + div() + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .child( + IconButton::new("new-rule", IconName::Plus) + .tooltip(move |_window, cx| { + Tooltip::for_action("New Rule", &NewRule, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(NewRule), cx); + }), + ), + ) + .into_any_element(), + )); + }); + this.child(title_bar.clone()) } else { this.child( h_flex().p_1().w_full().child( @@ -1170,7 +1181,12 @@ impl RulesLibrary { ) } }) - .child(div().flex_grow().child(self.picker.clone())) + .child( + div() + .flex_grow() + .when(cfg!(target_os = "macos"), |this| this.px_1p5()) + .child(self.picker.clone()), + ) } fn render_active_rule_editor( @@ -1417,7 +1433,9 @@ impl Render for RulesLibrary { .overflow_hidden() .font(ui_font) .text_color(theme.colors().text) - .children(self.title_bar.clone()) + .when(!cfg!(target_os = "macos"), |this| { + this.children(self.title_bar.clone()) + }) .bg(theme.colors().background) .child( h_flex() diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index b64bcbf3ab9ab5e29fdd473a200c2367e3f6f777..25defa1dde5977bd94935dafd60d97ae84b5a323 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -342,7 +342,7 @@ pub fn build_window_options(display_uuid: Option, cx: &mut App) -> WindowO focus: false, show: false, kind: WindowKind::Normal, - is_movable: true, + is_movable: !cfg!(target_os = "macos"), display_id: display.map(|display| display.id()), window_background: cx.theme().window_background_appearance(), app_id: Some(app_id.to_owned()), From 7b6932485679b7492893542ca7b15630f6ea0ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Soares?= <37777652+Dnreikronos@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:25:52 -0300 Subject: [PATCH 165/219] Truncate long diagnostic messages in the status bar (#51031) Closes #50186 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) ## Screenshots: ### Before: image ### After: image Release Notes: - Fixed long diagnostic messages in the status bar pushing right-side buttons (terminal, agent, etc.) off screen --- crates/diagnostics/src/items.rs | 3 ++- crates/workspace/src/status_bar.rs | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index b4ca52ea7239b6e4e76160a475d703ddd2933f44..67a6877bbe95778815d9470c0d9c8360657328f3 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -28,7 +28,7 @@ pub struct DiagnosticIndicator { impl Render for DiagnosticIndicator { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let indicator = h_flex().gap_2(); + let indicator = h_flex().gap_2().min_w_0().overflow_x_hidden(); if !ProjectSettings::get_global(cx).diagnostics.button { return indicator.hidden(); } @@ -67,6 +67,7 @@ impl Render for DiagnosticIndicator { Some( Button::new("diagnostic_message", SharedString::new(message)) .label_size(LabelSize::Small) + .truncate(true) .tooltip(|_window, cx| { Tooltip::for_action( "Next Diagnostic", diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 9087cbba42b054c1b247bdf3d9402688de4b7add..6164ff3f7f1ba3ee2b578beb6aa0c3ccced50884 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -68,12 +68,14 @@ impl StatusBar { fn render_left_tools(&self) -> impl IntoElement { h_flex() .gap_1() + .min_w_0() .overflow_x_hidden() .children(self.left_items.iter().map(|item| item.to_any())) } fn render_right_tools(&self) -> impl IntoElement { h_flex() + .flex_shrink_0() .gap_1() .overflow_x_hidden() .children(self.right_items.iter().rev().map(|item| item.to_any())) From 4842e095d968dfee9b9837d3ef1626532263af33 Mon Sep 17 00:00:00 2001 From: Viraj Bhartiya Date: Thu, 12 Mar 2026 21:58:21 +0530 Subject: [PATCH 166/219] editor: Skip `stop_at_indent` for single-line editors (#50681) In single-line editors like the Find bar, MoveToBeginningOfLine with stop_at_indent should go directly to column 0 instead of stopping at the indentation level. Closes #50634 Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zedindustries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed `MoveToBeginningOfLine` stopping at indentation in single-line editors like the Find bar instead of moving to column 0. --- crates/editor/src/editor.rs | 6 ++-- crates/editor/src/editor_tests.rs | 50 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 18a02e9773b3952d99b71f6d337f3c8950aff78e..bec381506060435419e86727051cda53ab220316 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -14703,6 +14703,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { + let stop_at_indent = action.stop_at_indent && !self.mode.is_single_line(); self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); self.change_selections(Default::default(), window, cx, |s| { s.move_cursors_with(&mut |map, head, _| { @@ -14711,7 +14712,7 @@ impl Editor { map, head, action.stop_at_soft_wraps, - action.stop_at_indent, + stop_at_indent, ), SelectionGoal::None, ) @@ -14725,6 +14726,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { + let stop_at_indent = action.stop_at_indent && !self.mode.is_single_line(); self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); self.change_selections(Default::default(), window, cx, |s| { s.move_heads_with(&mut |map, head, _| { @@ -14733,7 +14735,7 @@ impl Editor { map, head, action.stop_at_soft_wraps, - action.stop_at_indent, + stop_at_indent, ), SelectionGoal::None, ) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index fe71cb76f0f16dc7a928ccff725585c0e857c62e..0da80a2a73f22afac7085b579494d708be2444a4 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -1868,6 +1868,56 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) { }); } +#[gpui::test] +fn test_beginning_of_line_single_line_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + _ = editor.update(cx, |editor, window, cx| { + editor.set_text(" indented text", window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 10) + ]); + }); + + editor.move_to_beginning_of_line( + &MoveToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: true, + }, + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 10) + ]); + }); + + editor.select_to_beginning_of_line( + &SelectToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: true, + }, + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); +} + #[gpui::test] fn test_beginning_end_of_line_ignore_soft_wrap(cx: &mut TestAppContext) { init_test(cx, |_| {}); From edc8255da6b3f84cf37220d101aff223a07c4cc7 Mon Sep 17 00:00:00 2001 From: Kunall Banerjee Date: Thu, 12 Mar 2026 12:30:21 -0400 Subject: [PATCH 167/219] docs: Add Vue language server configuration (#51356) Follow-up to https://github.com/zed-extensions/vue/pull/87. Release Notes: - N/A --- docs/src/languages/vue.md | 54 ++++++++++++++++++++++++++++++++++++++- typos.toml | 4 ++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/docs/src/languages/vue.md b/docs/src/languages/vue.md index 607d2b18a5243a5b552db96308faab6aebeb8b6c..3c2336119dfceb4aeea226bb2ccc2484dd438cbc 100644 --- a/docs/src/languages/vue.md +++ b/docs/src/languages/vue.md @@ -8,7 +8,59 @@ description: "Configure Vue language support in Zed, including language servers, Vue support is available through the [Vue extension](https://github.com/zed-extensions/vue). - Tree-sitter: [tree-sitter-grammars/tree-sitter-vue](https://github.com/tree-sitter-grammars/tree-sitter-vue) -- Language Server: [vuejs/language-tools/](https://github.com/vuejs/language-tools/) +- Language Server: [vuejs/language-tools](https://github.com/vuejs/language-tools) + +## Initialization Options + +### Specifying location of TypeScript SDK + +By default, this extension assumes that you are working in a project with a `node_modules` directory, and searches for +the TypeScript SDK inside that directory. + +This may not always be true; for example, when working in a project that uses Yarn PnP, there is no `node_modules`. For +editor support, the [documented](https://yarnpkg.com/getting-started/editor-sdks) approach is to run something like +`yarn dlx @yarnpkg/sdks`. In that case, you can provide the following initialization options in your Zed settings: + +```json +{ + "lsp": { + "vue": { + "initialization_options": { + "typescript": { + "tsdk": ".yarn/sdks/typescript/lib" + } + } + } + } +} +``` + +## Settings Options + +`lsp.vue.settings` is passed through to the Vue language server (Volar / [`vuejs/language-tools`](https://github.com/vuejs/language-tools)). The following settings are enabled by default: + +```json +{ + "lsp": { + "vue": { + "settings": { + // Display inlay hints for the `$event` parameter in inline event handlers. + "vue.inlayHints.inlineHandlerLeading": true, + // Display hints when required component props are missing in templates. + "vue.inlayHints.missingProps": true, + // Display inlay hints for patterns that wrap component options. + "vue.inlayHints.optionsWrapper": true, + // Display inlay hints related to `v-bind` shorthand (`:`). + "vue.inlayHints.vBindShorthand": true + } + } + } +} +``` + +You can find the upstream settings configuration schema [`here`](https://github.com/vuejs/language-tools/blob/ee5041d27940cf6f9a5150635d3b13140a9dff54/extensions/vscode/package.json#L252). + +> Note: Some settings (e.g. `vue.editor.focusMode`) may not take effect. ## Using the Tailwind CSS Language Server with Vue diff --git a/typos.toml b/typos.toml index 863fea3822d62a51f737c3d7fa87a4c198710cfa..8c57caaf0417efdb01013e76f179515d9629a47c 100644 --- a/typos.toml +++ b/typos.toml @@ -92,6 +92,8 @@ extend-ignore-re = [ # AMD GPU Services "ags", # AMD GPU Services - "AGS" + "AGS", + # Yarn Plug'n'Play + "PnP" ] check-filename = true From 2ddb1e6c4d609c21a22baf2bef303f4f13f909dd Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:05:30 -0300 Subject: [PATCH 168/219] agent_ui: Add archive view to the sidebar (#51336) This PR adds a button to the bottom of the sidebar that opens the archive view, which at the moment, only shows the same, uncategorized thread list available in the regular agent panel's history view. Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner Co-authored-by: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com> Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> --- assets/icons/archive.svg | 5 + crates/agent_ui/src/agent_ui.rs | 1 + crates/agent_ui/src/sidebar.rs | 232 ++++--- crates/agent_ui/src/threads_archive_view.rs | 654 ++++++++++++++++++++ crates/icons/src/icons.rs | 1 + 5 files changed, 823 insertions(+), 70 deletions(-) create mode 100644 assets/icons/archive.svg create mode 100644 crates/agent_ui/src/threads_archive_view.rs diff --git a/assets/icons/archive.svg b/assets/icons/archive.svg new file mode 100644 index 0000000000000000000000000000000000000000..9ffe3f39d27c7fe5cbb532a4f263c8800398e96f --- /dev/null +++ b/assets/icons/archive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index ea70d155b79e190dcfe9138b620aff4415b6d935..db0cf873418ea38f8d5771c13b281528218fb94e 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -34,6 +34,7 @@ mod text_thread_editor; mod text_thread_history; mod thread_history; mod thread_history_view; +mod threads_archive_view; mod ui; use std::rc::Rc; diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index e204205819a8eb41a0624fb8a4a8ba9a96174add..2d4259717d160521ddd4884cbb6a1a1241456b64 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1,3 +1,4 @@ +use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent}; use crate::{AgentPanel, AgentPanelEvent, NewThread}; use acp_thread::ThreadStatus; use action_log::DiffStats; @@ -6,19 +7,18 @@ use agent_client_protocol as acp; use agent_settings::AgentSettings; use chrono::Utc; use db::kvp::KEY_VALUE_STORE; -use editor::{Editor, EditorElement, EditorStyle}; +use editor::Editor; use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ - Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, FontStyle, ListState, - Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px, - relative, rems, + Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels, + Render, SharedString, WeakEntity, Window, actions, list, prelude::*, px, }; use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::Event as ProjectEvent; use settings::Settings; use std::collections::{HashMap, HashSet}; use std::mem; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; use ui::{ AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem, Tooltip, WithScrollbar, prelude::*, @@ -46,6 +46,13 @@ const MAX_WIDTH: Pixels = px(800.0); const DEFAULT_THREADS_SHOWN: usize = 5; const SIDEBAR_STATE_KEY: &str = "sidebar_state"; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum SidebarView { + #[default] + ThreadList, + Archive, +} + fn read_sidebar_open_state(multi_workspace_id: u64) -> bool { KEY_VALUE_STORE .scoped(SIDEBAR_STATE_KEY) @@ -212,6 +219,9 @@ pub struct Sidebar { active_entry_index: Option, collapsed_groups: HashSet, expanded_groups: HashMap, + view: SidebarView, + archive_view: Option>, + _subscriptions: Vec, } impl Sidebar { @@ -311,6 +321,9 @@ impl Sidebar { active_entry_index: None, collapsed_groups: HashSet::new(), expanded_groups: HashMap::new(), + view: SidebarView::default(), + archive_view: None, + _subscriptions: Vec::new(), } } @@ -1323,28 +1336,8 @@ impl Sidebar { .into_any_element() } - fn render_filter_input(&self, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - font_style: FontStyle::Normal, - line_height: relative(1.3), - ..Default::default() - }; - - EditorElement::new( - &self.filter_editor, - EditorStyle { - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) + fn render_filter_input(&self) -> impl IntoElement { + self.filter_editor.clone() } fn render_view_more( @@ -1451,6 +1444,61 @@ impl Sidebar { .into_any_element() } + fn render_thread_list_header( + &self, + docked_right: bool, + cx: &mut Context, + ) -> impl IntoElement { + let has_query = self.has_filter_query(cx); + + h_flex() + .h(Tab::container_height(cx)) + .flex_none() + .gap_1p5() + .border_b_1() + .border_color(cx.theme().colors().border) + .when(!docked_right, |this| { + this.child(self.render_sidebar_toggle_button(false, cx)) + }) + .child(self.render_filter_input()) + .when(has_query, |this| { + this.when(!docked_right, |this| this.pr_1p5()).child( + IconButton::new("clear_filter", IconName::Close) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::text("Clear Search")) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_filter_editor_text(window, cx); + this.update_entries(cx); + })), + ) + }) + .when(docked_right, |this| { + this.pl_2() + .pr_0p5() + .child(self.render_sidebar_toggle_button(true, cx)) + }) + } + + fn render_thread_list_footer(&self, cx: &mut Context) -> impl IntoElement { + h_flex() + .p_1p5() + .border_t_1() + .border_color(cx.theme().colors().border) + .child( + Button::new("view-archive", "Archive") + .full_width() + .label_size(LabelSize::Small) + .style(ButtonStyle::Outlined) + .icon(IconName::Archive) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::Start) + .on_click(cx.listener(|this, _, window, cx| { + this.show_archive(window, cx); + })), + ) + } + fn render_sidebar_toggle_button( &self, docked_right: bool, @@ -1491,6 +1539,67 @@ impl Sidebar { self.is_open } + fn show_archive(&mut self, window: &mut Window, cx: &mut Context) { + let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| { + w.read(cx) + .workspaces() + .get(w.read(cx).active_workspace_index()) + .cloned() + }) else { + return; + }; + + let Some(agent_panel) = active_workspace.read(cx).panel::(cx) else { + return; + }; + + let thread_store = agent_panel.read(cx).thread_store().clone(); + let fs = active_workspace.read(cx).project().read(cx).fs().clone(); + let agent_connection_store = agent_panel.read(cx).connection_store().clone(); + let agent_server_store = active_workspace + .read(cx) + .project() + .read(cx) + .agent_server_store() + .clone(); + + let archive_view = cx.new(|cx| { + ThreadsArchiveView::new( + agent_connection_store, + agent_server_store, + thread_store, + fs, + window, + cx, + ) + }); + let subscription = cx.subscribe_in( + &archive_view, + window, + |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event { + ThreadsArchiveViewEvent::Close => { + this.show_thread_list(window, cx); + } + ThreadsArchiveViewEvent::OpenThread(_session_info) => { + //TODO: Actually open thread once we support it + } + }, + ); + + self._subscriptions.push(subscription); + self.archive_view = Some(archive_view); + self.view = SidebarView::Archive; + cx.notify(); + } + + fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context) { + self.view = SidebarView::ThreadList; + self.archive_view = None; + self._subscriptions.clear(); + window.focus(&self.focus_handle, cx); + cx.notify(); + } + pub fn set_open(&mut self, open: bool, cx: &mut Context) { if self.is_open == open { return; @@ -1558,7 +1667,6 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let ui_font = theme::setup_ui_font(window, cx); - let has_query = self.has_filter_query(cx); let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right; let sticky_header = self.render_sticky_header(docked_right, window, cx); @@ -1579,50 +1687,34 @@ impl Render for Sidebar { .font(ui_font) .size_full() .bg(cx.theme().colors().surface_background) - .child( - h_flex() - .h(Tab::container_height(cx)) - .flex_none() - .gap_1p5() - .border_b_1() - .border_color(cx.theme().colors().border) - .when(!docked_right, |this| { - this.child(self.render_sidebar_toggle_button(false, cx)) - }) - .child(self.render_filter_input(cx)) - .when(has_query, |this| { - this.when(!docked_right, |this| this.pr_1p5()).child( - IconButton::new("clear_filter", IconName::Close) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::text("Clear Search")) - .on_click(cx.listener(|this, _, window, cx| { - this.reset_filter_editor_text(window, cx); - this.update_entries(cx); - })), - ) - }) - .when(docked_right, |this| { - this.pl_2() - .pr_0p5() - .child(self.render_sidebar_toggle_button(true, cx)) - }), - ) - .child( - v_flex() - .relative() - .flex_1() - .overflow_hidden() + .map(|this| match self.view { + SidebarView::ThreadList => this + .child(self.render_thread_list_header(docked_right, cx)) .child( - list( - self.list_state.clone(), - cx.processor(Self::render_list_entry), - ) - .flex_1() - .size_full(), + v_flex() + .relative() + .flex_1() + .overflow_hidden() + .child( + list( + self.list_state.clone(), + cx.processor(Self::render_list_entry), + ) + .flex_1() + .size_full(), + ) + .when_some(sticky_header, |this, header| this.child(header)) + .vertical_scrollbar_for(&self.list_state, window, cx), ) - .when_some(sticky_header, |this, header| this.child(header)) - .vertical_scrollbar_for(&self.list_state, window, cx), - ) + .child(self.render_thread_list_footer(cx)), + SidebarView::Archive => { + if let Some(archive_view) = &self.archive_view { + this.child(archive_view.clone()) + } else { + this + } + } + }) } } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..8ee0eedbd8702c7901258087af5d149fcf210648 --- /dev/null +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -0,0 +1,654 @@ +use std::sync::Arc; + +use crate::{Agent, agent_connection_store::AgentConnectionStore, thread_history::ThreadHistory}; +use acp_thread::AgentSessionInfo; +use agent::ThreadStore; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; +use editor::Editor; +use fs::Fs; +use gpui::{ + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render, + SharedString, Subscription, Task, Window, list, prelude::*, px, +}; +use itertools::Itertools as _; +use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; +use project::{AgentServerStore, ExternalAgentServerName}; +use theme::ActiveTheme; +use ui::{ + ButtonLike, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem, PopoverMenu, + PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, +}; +use util::ResultExt as _; +use zed_actions::editor::{MoveDown, MoveUp}; + +#[derive(Clone)] +enum ArchiveListItem { + BucketSeparator(TimeBucket), + Entry { + session: AgentSessionInfo, + highlight_positions: Vec, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + Older, +} + +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + let week = date.iso_week(); + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + let last_week = (reference - TimeDelta::days(7)).iso_week(); + if week == last_week { + return TimeBucket::PastWeek; + } + TimeBucket::Older + } + + fn label(&self) -> &'static str { + match self { + TimeBucket::Today => "Today", + TimeBucket::Yesterday => "Yesterday", + TimeBucket::ThisWeek => "This Week", + TimeBucket::PastWeek => "Past Week", + TimeBucket::Older => "Older", + } + } +} + +fn fuzzy_match_positions(query: &str, text: &str) -> Option> { + let query = query.to_lowercase(); + let text_lower = text.to_lowercase(); + let mut positions = Vec::new(); + let mut query_chars = query.chars().peekable(); + for (i, c) in text_lower.chars().enumerate() { + if query_chars.peek() == Some(&c) { + positions.push(i); + query_chars.next(); + } + } + if query_chars.peek().is_none() { + Some(positions) + } else { + None + } +} + +pub enum ThreadsArchiveViewEvent { + Close, + OpenThread(AgentSessionInfo), +} + +impl EventEmitter for ThreadsArchiveView {} + +pub struct ThreadsArchiveView { + agent_connection_store: Entity, + agent_server_store: Entity, + thread_store: Entity, + fs: Arc, + history: Option>, + _history_subscription: Subscription, + selected_agent: Agent, + focus_handle: FocusHandle, + list_state: ListState, + items: Vec, + selection: Option, + filter_editor: Entity, + _subscriptions: Vec, + selected_agent_menu: PopoverMenuHandle, + _refresh_history_task: Task<()>, +} + +impl ThreadsArchiveView { + pub fn new( + agent_connection_store: Entity, + agent_server_store: Entity, + thread_store: Entity, + fs: Arc, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + + let filter_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search archive…", window, cx); + editor + }); + + let filter_editor_subscription = + cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| { + if let editor::EditorEvent::BufferEdited = event { + this.update_items(cx); + } + }); + + let mut this = Self { + agent_connection_store, + agent_server_store, + thread_store, + fs, + history: None, + _history_subscription: Subscription::new(|| {}), + selected_agent: Agent::NativeAgent, + focus_handle, + list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), + items: Vec::new(), + selection: None, + filter_editor, + _subscriptions: vec![filter_editor_subscription], + selected_agent_menu: PopoverMenuHandle::default(), + _refresh_history_task: Task::ready(()), + }; + this.set_selected_agent(Agent::NativeAgent, cx); + this + } + + fn set_selected_agent(&mut self, agent: Agent, cx: &mut Context) { + self.selected_agent = agent.clone(); + + let server = agent.server(self.fs.clone(), self.thread_store.clone()); + let connection = self + .agent_connection_store + .update(cx, |store, cx| store.request_connection(agent, server, cx)); + + let task = connection.read(cx).wait_for_connection(); + self._refresh_history_task = cx.spawn(async move |this, cx| { + if let Some(state) = task.await.log_err() { + this.update(cx, |this, cx| this.set_history(state.history, cx)) + .ok(); + } + }); + + cx.notify(); + } + + fn set_history(&mut self, history: Entity, cx: &mut Context) { + self._history_subscription = cx.observe(&history, |this, _, cx| { + this.update_items(cx); + }); + history.update(cx, |history, cx| { + history.refresh_full_history(cx); + }); + self.history = Some(history); + self.update_items(cx); + cx.notify(); + } + + fn update_items(&mut self, cx: &mut Context) { + let Some(history) = self.history.as_ref() else { + return; + }; + + let sessions = history.read(cx).sessions().to_vec(); + let query = self.filter_editor.read(cx).text(cx).to_lowercase(); + let today = Local::now().naive_local().date(); + + let mut items = Vec::with_capacity(sessions.len() + 5); + let mut current_bucket: Option = None; + + for session in sessions { + let highlight_positions = if !query.is_empty() { + let title = session.title.as_ref().map(|t| t.as_ref()).unwrap_or(""); + match fuzzy_match_positions(&query, title) { + Some(positions) => positions, + None => continue, + } + } else { + Vec::new() + }; + + let entry_bucket = session + .updated_at + .map(|timestamp| { + let entry_date = timestamp.with_timezone(&Local).naive_local().date(); + TimeBucket::from_dates(today, entry_date) + }) + .unwrap_or(TimeBucket::Older); + + if Some(entry_bucket) != current_bucket { + current_bucket = Some(entry_bucket); + items.push(ArchiveListItem::BucketSeparator(entry_bucket)); + } + + items.push(ArchiveListItem::Entry { + session, + highlight_positions, + }); + } + + self.list_state.reset(items.len()); + self.items = items; + cx.notify(); + } + + fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context) { + self.filter_editor.update(cx, |editor, cx| { + editor.set_text("", window, cx); + }); + } + + fn go_back(&mut self, window: &mut Window, cx: &mut Context) { + self.reset_filter_editor_text(window, cx); + cx.emit(ThreadsArchiveViewEvent::Close); + } + + fn open_thread( + &mut self, + session_info: AgentSessionInfo, + window: &mut Window, + cx: &mut Context, + ) { + self.selection = None; + self.reset_filter_editor_text(window, cx); + cx.emit(ThreadsArchiveViewEvent::OpenThread(session_info)); + } + + fn is_selectable_item(&self, ix: usize) -> bool { + matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. })) + } + + fn find_next_selectable(&self, start: usize) -> Option { + (start..self.items.len()).find(|&i| self.is_selectable_item(i)) + } + + fn find_previous_selectable(&self, start: usize) -> Option { + (0..=start).rev().find(|&i| self.is_selectable_item(i)) + } + + fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { + self.select_next(&SelectNext, window, cx); + } + + fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { + self.select_previous(&SelectPrevious, window, cx); + } + + fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { + let next = match self.selection { + Some(ix) => self.find_next_selectable(ix + 1), + None => self.find_next_selectable(0), + }; + if let Some(next) = next { + self.selection = Some(next); + self.list_state.scroll_to_reveal_item(next); + cx.notify(); + } + } + + fn select_previous( + &mut self, + _: &SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + let prev = match self.selection { + Some(ix) if ix > 0 => self.find_previous_selectable(ix - 1), + None => { + let last = self.items.len().saturating_sub(1); + self.find_previous_selectable(last) + } + _ => return, + }; + if let Some(prev) = prev { + self.selection = Some(prev); + self.list_state.scroll_to_reveal_item(prev); + cx.notify(); + } + } + + fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { + if let Some(first) = self.find_next_selectable(0) { + self.selection = Some(first); + self.list_state.scroll_to_reveal_item(first); + cx.notify(); + } + } + + fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { + let last = self.items.len().saturating_sub(1); + if let Some(last) = self.find_previous_selectable(last) { + self.selection = Some(last); + self.list_state.scroll_to_reveal_item(last); + cx.notify(); + } + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let Some(ix) = self.selection else { return }; + let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else { + return; + }; + self.open_thread(session.clone(), window, cx); + } + + fn render_list_entry( + &mut self, + ix: usize, + _window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(item) = self.items.get(ix) else { + return div().into_any_element(); + }; + + match item { + ArchiveListItem::BucketSeparator(bucket) => div() + .w_full() + .px_2() + .pt_3() + .pb_1() + .child( + Label::new(bucket.label()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element(), + ArchiveListItem::Entry { + session, + highlight_positions, + } => { + let is_selected = self.selection == Some(ix); + let title: SharedString = + session.title.clone().unwrap_or_else(|| "Untitled".into()); + let session_info = session.clone(); + let highlight_positions = highlight_positions.clone(); + + let timestamp = session.created_at.or(session.updated_at).map(|entry_time| { + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + + let minutes = duration.num_minutes(); + let hours = duration.num_hours(); + let days = duration.num_days(); + let weeks = days / 7; + let months = days / 30; + + if minutes < 60 { + format!("{}m", minutes.max(1)) + } else if hours < 24 { + format!("{}h", hours) + } else if weeks < 4 { + format!("{}w", weeks.max(1)) + } else { + format!("{}mo", months.max(1)) + } + }); + + let id = SharedString::from(format!("archive-entry-{}", ix)); + + let title_label = if highlight_positions.is_empty() { + Label::new(title) + .size(LabelSize::Small) + .truncate() + .into_any_element() + } else { + HighlightedLabel::new(title, highlight_positions) + .size(LabelSize::Small) + .truncate() + .into_any_element() + }; + + ListItem::new(id) + .toggle_state(is_selected) + .disabled(true) + .child( + h_flex() + .min_w_0() + .w_full() + .py_1() + .pl_0p5() + .pr_1p5() + .gap_2() + .justify_between() + .child(title_label) + .when_some(timestamp, |this, ts| { + this.child( + Label::new(ts).size(LabelSize::Small).color(Color::Muted), + ) + }), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_thread(session_info.clone(), window, cx); + })) + .into_any_element() + } + } + } + + fn render_agent_picker(&self, cx: &mut Context) -> PopoverMenu { + let agent_server_store = self.agent_server_store.clone(); + + let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() { + (IconName::ChevronUp, Color::Accent) + } else { + (IconName::ChevronDown, Color::Muted) + }; + + let selected_agent_icon = if let Agent::Custom { name } = &self.selected_agent { + let store = agent_server_store.read(cx); + let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); + + if let Some(icon) = icon { + Icon::from_external_svg(icon) + } else { + Icon::new(IconName::Sparkle) + } + .color(Color::Muted) + .size(IconSize::Small) + } else { + Icon::new(IconName::ZedAgent) + .color(Color::Muted) + .size(IconSize::Small) + }; + + let this = cx.weak_entity(); + + PopoverMenu::new("agent_history_menu") + .trigger( + ButtonLike::new("selected_agent") + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .child( + h_flex().gap_1().child(selected_agent_icon).child( + Icon::new(chevron_icon) + .color(icon_color) + .size(IconSize::XSmall), + ), + ), + ) + .menu(move |window, cx| { + Some(ContextMenu::build(window, cx, |menu, _window, cx| { + menu.item( + ContextMenuEntry::new("Zed Agent") + .icon(IconName::ZedAgent) + .icon_color(Color::Muted) + .handler({ + let this = this.clone(); + move |_, cx| { + this.update(cx, |this, cx| { + this.set_selected_agent(Agent::NativeAgent, cx) + }) + .ok(); + } + }), + ) + .separator() + .map(|mut menu| { + let agent_server_store = agent_server_store.read(cx); + let registry_store = project::AgentRegistryStore::try_global(cx); + let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx)); + + struct AgentMenuItem { + id: ExternalAgentServerName, + display_name: SharedString, + } + + let agent_items = agent_server_store + .external_agents() + .map(|name| { + let display_name = agent_server_store + .agent_display_name(name) + .or_else(|| { + registry_store_ref + .as_ref() + .and_then(|store| store.agent(name.0.as_ref())) + .map(|a| a.name().clone()) + }) + .unwrap_or_else(|| name.0.clone()); + AgentMenuItem { + id: name.clone(), + display_name, + } + }) + .sorted_unstable_by_key(|e| e.display_name.to_lowercase()) + .collect::>(); + + for item in &agent_items { + let mut entry = ContextMenuEntry::new(item.display_name.clone()); + + let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| { + registry_store_ref + .as_ref() + .and_then(|store| store.agent(item.id.0.as_str())) + .and_then(|a| a.icon_path().cloned()) + }); + + if let Some(icon_path) = icon_path { + entry = entry.custom_icon_svg(icon_path); + } else { + entry = entry.icon(IconName::ZedAgent); + } + + entry = entry.icon_color(Color::Muted).handler({ + let this = this.clone(); + let agent = Agent::Custom { + name: item.id.0.clone(), + }; + move |_, cx| { + this.update(cx, |this, cx| { + this.set_selected_agent(agent.clone(), cx) + }) + .ok(); + } + }); + + menu = menu.item(entry); + } + menu + }) + })) + }) + .with_handle(self.selected_agent_menu.clone()) + .anchor(gpui::Corner::TopRight) + .offset(gpui::Point { + x: px(1.0), + y: px(1.0), + }) + } + + fn render_header(&self, cx: &mut Context) -> impl IntoElement { + let has_query = !self.filter_editor.read(cx).text(cx).is_empty(); + + h_flex() + .h(Tab::container_height(cx)) + .px_1() + .gap_1p5() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + h_flex() + .flex_1() + .w_full() + .gap_1p5() + .child( + IconButton::new("back", IconName::ArrowLeft) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Back to Sidebar")) + .on_click(cx.listener(|this, _, window, cx| { + this.go_back(window, cx); + })), + ) + .child(self.filter_editor.clone()) + .when(has_query, |this| { + this.border_r_1().child( + IconButton::new("clear_archive_filter", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Clear Search")) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_filter_editor_text(window, cx); + this.update_items(cx); + })), + ) + }), + ) + .child(self.render_agent_picker(cx)) + } +} + +impl Focusable for ThreadsArchiveView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ThreadsArchiveView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_empty = self.items.is_empty(); + let has_query = !self.filter_editor.read(cx).text(cx).is_empty(); + + let empty_state_container = |label: SharedString| { + v_flex() + .flex_1() + .justify_center() + .items_center() + .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)) + }; + + v_flex() + .key_context("ThreadsArchiveView") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::editor_move_down)) + .on_action(cx.listener(Self::editor_move_up)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .size_full() + .bg(cx.theme().colors().surface_background) + .child(self.render_header(cx)) + .child(if is_empty && has_query { + empty_state_container("No threads match your search.".into()).into_any_element() + } else if is_empty { + empty_state_container("No archived threads yet.".into()).into_any_element() + } else { + v_flex() + .flex_1() + .overflow_hidden() + .child( + list( + self.list_state.clone(), + cx.processor(Self::render_list_entry), + ) + .flex_1() + .size_full(), + ) + .vertical_scrollbar_for(&self.list_state, window, cx) + .into_any_element() + }) + } +} diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 94fed7f03f46e64ef0ac929e60cf6ae848145e72..17db6371114e1623280c22a23dd44e8efc6fa594 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -27,6 +27,7 @@ pub enum IconName { AiVZero, AiXAi, AiZed, + Archive, ArrowCircle, ArrowDown, ArrowDown10, From 86b5e92108b3eb95f92ef77d4fec392d711c80c9 Mon Sep 17 00:00:00 2001 From: Anikesh kumar Date: Thu, 12 Mar 2026 22:35:56 +0530 Subject: [PATCH 169/219] docs: Fix incorrect binary name for `visual_test_runner` (#51153) Fix binary name in macOS's development guide. Closes #51151 Release Notes: - N/A --- docs/src/development/macos.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/development/macos.md b/docs/src/development/macos.md index 62c2218e52751c1117da90e76ae13554b7e8f792..82d7264e2bb123b52ece8abcc44c3563d49de453 100644 --- a/docs/src/development/macos.md +++ b/docs/src/development/macos.md @@ -89,7 +89,7 @@ Before making any UI changes, generate baseline images from a known-good state: ```sh git checkout origin/main -UPDATE_BASELINE=1 cargo run -p zed --bin visual_test_runner --features visual-tests +UPDATE_BASELINE=1 cargo run -p zed --bin zed_visual_test_runner --features visual-tests git checkout - ``` From 4bd1a090d939534ceec725307d43e7b154832950 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 13 Mar 2026 02:09:23 +0900 Subject: [PATCH 170/219] editor: Fix bracket colorization with folds and large functions (#51108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #47846 `visible_excerpts` computed the visible buffer range by adding display line count directly to the buffer start row: ```rust // Before multi_buffer_visible_start + Point::new(visible_line_count, 0) ``` This ignores folds entirely. When a 700-line function is folded into one display line, content after the fold is visible on screen but falls outside the computed buffer range, so its brackets are never colorized. The fix converts through display coordinates so the fold/wrap layers are respected: ```rust // After let display_end = DisplayPoint::new(display_start.row + visible_line_count, 0); let multi_buffer_visible_end = display_end.to_point(&display_snapshot); ``` ### Results **Before Fix** 스크린샷 2026-03-10 오후 8 29 10 **After Fix** 스크린샷 2026-03-10 오후 8 32 27 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 - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed bracket colorization not working for content after folded regions and for functions with large bodies. --------- Co-authored-by: Kirill Bulatov --- crates/editor/src/bracket_colorization.rs | 54 +++++++++++++++++++++++ crates/editor/src/editor.rs | 24 +++++----- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index 16fe29a7fa4aa066cf045a63c477fbb569d80334..657f1e1b23d91ca421da6a38fbeaa382a65863db 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -1455,6 +1455,60 @@ mod foo «1{ ); } + #[gpui::test] + // reproduction of #47846 + async fn test_bracket_colorization_with_folds(cx: &mut gpui::TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + let mut cx = EditorLspTestContext::new( + Arc::into_inner(rust_lang()).unwrap(), + lsp::ServerCapabilities::default(), + cx, + ) + .await; + + // Generate a large function body. When folded, this collapses + // to a single display line, making small_function visible on screen. + let mut big_body = String::new(); + for i in 0..700 { + big_body.push_str(&format!(" let var_{i:04} = ({i});\n")); + } + let source = format!( + "ˇfn big_function() {{\n{big_body}}}\n\nfn small_function() {{\n let x = (1, (2, 3));\n}}\n" + ); + + cx.set_state(&source); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.fold_ranges( + vec![Point::new(0, 0)..Point::new(701, 1)], + false, + window, + cx, + ); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + indoc! {r#" +⋯1» + +fn small_function«1()1» «1{ + let x = «2(1, «3(2, 3)3»)2»; +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +"#,}, + bracket_colors_markup(&mut cx), + ); + } + fn separate_with_comment_lines(head: &str, tail: &str, comment_lines: usize) -> String { let mut result = head.to_string(); result.push_str("\n"); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bec381506060435419e86727051cda53ab220316..707fb43cc3b573772ef24b7fe7eea69a2ad3c8ec 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2621,16 +2621,7 @@ impl Editor { .await; editor .update_in(cx, |editor, window, cx| { - editor.register_visible_buffers(cx); - editor.colorize_brackets(false, cx); - editor.refresh_inlay_hints( - InlayHintRefreshReason::NewLinesShown, - cx, - ); - if !editor.buffer().read(cx).is_singleton() { - editor.update_lsp_data(None, window, cx); - editor.refresh_runnables(window, cx); - } + editor.update_data_on_scroll(window, cx) }) .ok(); }); @@ -20055,7 +20046,7 @@ impl Editor { &mut self, creases: Vec>, auto_scroll: bool, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { if creases.is_empty() { @@ -20071,6 +20062,7 @@ impl Editor { cx.notify(); self.scrollbar_marker_state.dirty = true; + self.update_data_on_scroll(window, cx); self.folds_did_change(cx); } @@ -25367,6 +25359,16 @@ impl Editor { fn disable_runnables(&mut self) { self.enable_runnables = false; } + + fn update_data_on_scroll(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) { + self.register_visible_buffers(cx); + self.colorize_brackets(false, cx); + self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + if !self.buffer().read(cx).is_singleton() { + self.update_lsp_data(None, window, cx); + self.refresh_runnables(window, cx); + } + } } fn edit_for_markdown_paste<'a>( From a8d0cdb5598b0775aefa39e8567698a38deeec20 Mon Sep 17 00:00:00 2001 From: AdamJedl <100023363+AdamJedl@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:15:17 +0100 Subject: [PATCH 171/219] project_panel: Improve wording around file deletion (#43801) Make it clear in the UI that "Delete" of file or folder is permanent action. For example in windows explorer and VS Code "Delete" means move to trash. Or maybe also remove permanent delete from the context menu completely and allow it only through keyboard shortcut, like it's in Windows Explorer, VS Code and KDE Dolphin file manager. Release Notes: - Improved wording within file deletion prompts in the projetct panel. --------- Co-authored-by: MrSubidubi --- crates/project_panel/src/project_panel.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 068fb8d71fa883e9d2b518c7d19adacea74fadcb..2984bb49c6a961c77adc1b82c806f7ec57d54a3e 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -2371,6 +2371,11 @@ impl ProjectPanel { } let answer = if !skip_prompt { let operation = if trash { "Trash" } else { "Delete" }; + let message_start = if trash { + "Do you want to trash" + } else { + "Are you sure you want to permanently delete" + }; let prompt = match file_paths.first() { Some((_, path)) if file_paths.len() == 1 => { let unsaved_warning = if dirty_buffers > 0 { @@ -2379,7 +2384,7 @@ impl ProjectPanel { "" }; - format!("{operation} {path}?{unsaved_warning}") + format!("{message_start} {path}?{unsaved_warning}") } _ => { const CUTOFF_POINT: usize = 10; @@ -2411,14 +2416,20 @@ impl ProjectPanel { }; format!( - "Do you want to {} the following {} files?\n{}{unsaved_warning}", - operation.to_lowercase(), + "{message_start} the following {} files?\n{}{unsaved_warning}", file_paths.len(), names.join("\n") ) } }; - Some(window.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"], cx)) + let detail = (!trash).then_some("This cannot be undone."); + Some(window.prompt( + PromptLevel::Info, + &prompt, + detail, + &[operation, "Cancel"], + cx, + )) } else { None }; From dcab4646086d952207feebefb11d85f8af1ae32e Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Thu, 12 Mar 2026 18:49:36 +0100 Subject: [PATCH 172/219] editor: Fix gutter hitbox hover check (#51405) The gutter hitbox would previously check the hover using the position, ignoring any occluding hitboxes rendered above it. This would then trigger the crease toggles to show which should not happen in that case, since the gutter was not really hovered. Release Notes: - Fixed an issue where the crease toggles in the gutter would sometimes show when interacting with a popover present over the editor gutter. --- crates/editor/src/element.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 5de14d80681ca1ad07534e8764217ef75cc90305..dcbd00ef8c89de8c4a3e3334ae1804ebe9e7b042 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1243,7 +1243,7 @@ impl EditorElement { let gutter_hitbox = &position_map.gutter_hitbox; let modifiers = event.modifiers; let text_hovered = text_hitbox.is_hovered(window); - let gutter_hovered = gutter_hitbox.bounds.contains(&event.position); + let gutter_hovered = gutter_hitbox.is_hovered(window); editor.set_gutter_hovered(gutter_hovered, cx); editor.show_mouse_cursor(cx); From 17adc40d61387c7db26fe278ab8cf6e67c9bce4c Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 12 Mar 2026 10:53:38 -0700 Subject: [PATCH 173/219] Implement sidebar rendering of the configured worktrees (#51342) Implements worktree support for the agent panel sidebar 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: - N/A --- crates/agent_ui/src/agent_panel.rs | 6 + crates/agent_ui/src/sidebar.rs | 484 +++++++++++++++++- .../20221109000000_test_schema.sql | 1 + .../migrations/20251208000000_test_schema.sql | 3 +- crates/collab/src/db/queries/projects.rs | 9 + crates/collab/src/db/queries/rooms.rs | 5 + .../src/db/tables/project_repository.rs | 2 + crates/collab/tests/integration/git_tests.rs | 233 ++++++++- crates/fs/src/fake_git_repo.rs | 2 +- crates/project/src/git_store.rs | 39 +- crates/proto/proto/git.proto | 1 + 11 files changed, 777 insertions(+), 8 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 4fc6e3dd1f257377e3f5213b1ae216115fd01fff..f9a136c10fe26ce1763fbde52c532f065e097463 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2642,6 +2642,12 @@ impl AgentPanel { } } + // TODO: The mapping from workspace root paths to git repositories needs a + // unified approach across the codebase: this method, `sidebar::is_root_repo`, + // thread persistence (which PathList is saved to the database), and thread + // querying (which PathList is used to read threads back). All of these need + // to agree on how repos are resolved for a given workspace, especially in + // multi-root and nested-repo configurations. /// Partitions the project's visible worktrees into git-backed repositories /// and plain (non-git) paths. Git repos will have worktrees created for /// them; non-git paths are carried over to the new workspace as-is. diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 2d4259717d160521ddd4884cbb6a1a1241456b64..24c5d5f5e5295a7e25af9f486323a16a2405c8e0 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -18,6 +18,8 @@ use project::Event as ProjectEvent; use settings::Settings; use std::collections::{HashMap, HashSet}; use std::mem; +use std::path::Path; +use std::sync::Arc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem, @@ -107,6 +109,8 @@ struct ThreadEntry { is_live: bool, is_background: bool, highlight_positions: Vec, + worktree_name: Option, + worktree_highlight_positions: Vec, diff_stats: DiffStats, } @@ -172,6 +176,32 @@ fn fuzzy_match_positions(query: &str, candidate: &str) -> Option> { } } +// TODO: The mapping from workspace root paths to git repositories needs a +// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`, +// thread persistence (which PathList is saved to the database), and thread +// querying (which PathList is used to read threads back). All of these need +// to agree on how repos are resolved for a given workspace, especially in +// multi-root and nested-repo configurations. +fn root_repository_snapshots( + workspace: &Entity, + cx: &App, +) -> Vec { + let (path_list, _) = workspace_path_list_and_label(workspace, cx); + let project = workspace.read(cx).project().read(cx); + project + .repositories(cx) + .values() + .filter_map(|repo| { + let snapshot = repo.read(cx).snapshot(); + let is_root = path_list + .paths() + .iter() + .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref()); + is_root.then_some(snapshot) + }) + .collect() +} + fn workspace_path_list_and_label( workspace: &Entity, cx: &App, @@ -348,6 +378,26 @@ impl Sidebar { ) .detach(); + let git_store = workspace.read(cx).project().read(cx).git_store().clone(); + cx.subscribe_in( + &git_store, + window, + |this, _, event: &project::git_store::GitStoreEvent, window, cx| { + if matches!( + event, + project::git_store::GitStoreEvent::RepositoryUpdated( + _, + project::git_store::RepositoryEvent::GitWorktreeListChanged, + _, + ) + ) { + this.prune_stale_worktree_workspaces(window, cx); + this.update_entries(cx); + } + }, + ) + .detach(); + cx.subscribe_in( workspace, window, @@ -472,7 +522,52 @@ impl Sidebar { // Compute active_entry_index inline during the build pass. let mut active_entry_index: Option = None; - for workspace in workspaces.iter() { + // 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)>> = HashMap::new(); + + for (i, workspace) in workspaces.iter().enumerate() { + for snapshot in root_repository_snapshots(workspace, cx) { + if snapshot.work_directory_abs_path == snapshot.original_repo_abs_path { + main_repo_workspace + .entry(snapshot.work_directory_abs_path.clone()) + .or_insert(i); + if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) { + for (ws_idx, name) in waiting { + absorbed.insert(ws_idx, (i, name)); + } + } + } 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)); + } else { + pending + .entry(snapshot.original_repo_abs_path.clone()) + .or_default() + .push((i, name)); + } + } + } + } + + for (ws_index, workspace) in workspaces.iter().enumerate() { + if absorbed.contains_key(&ws_index) { + continue; + } + let (path_list, label) = workspace_path_list_and_label(workspace, cx); let is_collapsed = self.collapsed_groups.contains(&path_list); @@ -481,8 +576,11 @@ impl Sidebar { let mut threads: Vec = Vec::new(); if should_load_threads { + let mut seen_session_ids: HashSet = HashSet::new(); + if let Some(ref thread_store) = thread_store { for meta in thread_store.read(cx).threads_for_paths(&path_list) { + seen_session_ids.insert(meta.id.clone()); threads.push(ThreadEntry { session_info: meta.into(), icon: IconName::ZedAgent, @@ -492,11 +590,56 @@ impl Sidebar { is_live: false, is_background: false, highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }); } } + // Load threads from linked git worktrees of this workspace's repos. + if let Some(ref thread_store) = thread_store { + let mut linked_worktree_queries: Vec<(PathList, SharedString)> = Vec::new(); + for snapshot in root_repository_snapshots(workspace, cx) { + if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path { + continue; + } + for git_worktree in snapshot.linked_worktrees() { + let name = git_worktree + .path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + linked_worktree_queries.push(( + PathList::new(std::slice::from_ref(&git_worktree.path)), + name.into(), + )); + } + } + + for (worktree_path_list, worktree_name) in &linked_worktree_queries { + for meta in thread_store.read(cx).threads_for_paths(worktree_path_list) { + if !seen_session_ids.insert(meta.id.clone()) { + continue; + } + threads.push(ThreadEntry { + session_info: meta.into(), + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::default(), + workspace: workspace.clone(), + is_live: false, + is_background: false, + highlight_positions: Vec::new(), + worktree_name: Some(worktree_name.clone()), + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }); + } + } + } + let live_infos = Self::all_thread_infos_for_workspace(workspace, cx); if !live_infos.is_empty() { @@ -570,7 +713,16 @@ impl Sidebar { if let Some(positions) = fuzzy_match_positions(&query, title) { thread.highlight_positions = positions; } - if workspace_matched || !thread.highlight_positions.is_empty() { + if let Some(worktree_name) = &thread.worktree_name { + if let Some(positions) = fuzzy_match_positions(&query, worktree_name) { + thread.worktree_highlight_positions = positions; + } + } + let worktree_matched = !thread.worktree_highlight_positions.is_empty(); + if workspace_matched + || !thread.highlight_positions.is_empty() + || worktree_matched + { matched_threads.push(thread); } } @@ -1024,6 +1176,52 @@ impl Sidebar { }); } + 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.work_directory_abs_path != snapshot.original_repo_abs_path { + 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_and_label(workspace, cx); + if path_list.paths().len() != 1 { + continue; + } + let should_prune = root_repository_snapshots(workspace, cx) + .iter() + .any(|snapshot| { + snapshot.work_directory_abs_path != snapshot.original_repo_abs_path + && !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, @@ -1316,6 +1514,10 @@ 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| { + this.worktree(name) + }) + .worktree_highlight_positions(thread.worktree_highlight_positions.clone()) .when_some(timestamp, |this, ts| this.timestamp(ts)) .highlight_positions(thread.highlight_positions.to_vec()) .status(thread.status) @@ -1913,9 +2115,14 @@ mod tests { } else { "" }; + let worktree = thread + .worktree_name + .as_ref() + .map(|name| format!(" {{{}}}", name)) + .unwrap_or_default(); format!( - " {}{}{}{}{}", - title, active, status_str, notified, selected + " {}{}{}{}{}{}", + title, worktree, active, status_str, notified, selected ) } ListEntry::ViewMore { @@ -2244,6 +2451,8 @@ mod tests { is_live: false, is_background: false, highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // Active thread with Running status @@ -2263,6 +2472,8 @@ mod tests { is_live: true, is_background: false, highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // Active thread with Error status @@ -2282,6 +2493,8 @@ mod tests { is_live: true, is_background: false, highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // Thread with WaitingForConfirmation status, not active @@ -2301,6 +2514,8 @@ mod tests { is_live: false, is_background: false, highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // Background thread that completed (should show notification) @@ -2320,6 +2535,8 @@ mod tests { is_live: true, is_background: true, highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // View More entry @@ -3829,4 +4046,263 @@ mod tests { ); }); } + + async fn save_named_thread( + session_id: &str, + title: &str, + path_list: &PathList, + cx: &mut gpui::VisualTestContext, + ) { + let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); + let save_task = thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new(Arc::from(session_id)), + make_test_thread( + title, + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + ), + path_list.clone(), + cx, + ) + }); + save_task.await.unwrap(); + cx.run_until_parked(); + } + + 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: "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("main-t", "Unrelated Thread", &main_paths, cx).await; + save_named_thread("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("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: "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("thread-a", "Thread A", &paths_a, cx).await; + save_named_thread("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 [wt-feature-a]", + " Thread A", + "v [wt-feature-b]", + " Thread 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: "refs/heads/feature-a".into(), + sha: "aaa".into(), + }); + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-b"), + ref_name: "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}",] + ); + } } diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 3e4b5c2ce211f68ef7e12895b542db5e6e3ea47c..75d7dbf194068f78b3d566e54bb0fa18f66a9878 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -109,6 +109,7 @@ CREATE TABLE "project_repositories" ( "head_commit_details" VARCHAR, "remote_upstream_url" VARCHAR, "remote_origin_url" VARCHAR, + "linked_worktrees" VARCHAR, PRIMARY KEY (project_id, id) ); diff --git a/crates/collab/migrations/20251208000000_test_schema.sql b/crates/collab/migrations/20251208000000_test_schema.sql index 53543a23f710e49084a7b1127e7b743df6ef97c8..394deaf2c0d6a80a2ab6ab1b95a333081c816e23 100644 --- a/crates/collab/migrations/20251208000000_test_schema.sql +++ b/crates/collab/migrations/20251208000000_test_schema.sql @@ -307,7 +307,8 @@ CREATE TABLE public.project_repositories ( head_commit_details character varying, merge_message character varying, remote_upstream_url character varying, - remote_origin_url character varying + remote_origin_url character varying, + linked_worktrees text ); CREATE TABLE public.project_repository_statuses ( diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 24cf639a715aa9b88da80375b389debaea0c4295..71365fb3846c1dccbf527d76779ed8816bde243b 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -374,6 +374,9 @@ impl Database { merge_message: ActiveValue::set(update.merge_message.clone()), remote_upstream_url: ActiveValue::set(update.remote_upstream_url.clone()), remote_origin_url: ActiveValue::set(update.remote_origin_url.clone()), + linked_worktrees: ActiveValue::Set(Some( + serde_json::to_string(&update.linked_worktrees).unwrap(), + )), }) .on_conflict( OnConflict::columns([ @@ -388,6 +391,7 @@ impl Database { project_repository::Column::CurrentMergeConflicts, project_repository::Column::HeadCommitDetails, project_repository::Column::MergeMessage, + project_repository::Column::LinkedWorktrees, ]) .to_owned(), ) @@ -883,6 +887,11 @@ impl Database { remote_upstream_url: db_repository_entry.remote_upstream_url.clone(), remote_origin_url: db_repository_entry.remote_origin_url.clone(), original_repo_abs_path: Some(db_repository_entry.abs_path), + linked_worktrees: db_repository_entry + .linked_worktrees + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(), }); } } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index b4cbd83167b227542d8de1022b7e2cf49f5a7645..3197d142cba7a1969e6fdb9423dc94497f6ca53c 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -799,6 +799,11 @@ impl Database { remote_upstream_url: db_repository.remote_upstream_url.clone(), remote_origin_url: db_repository.remote_origin_url.clone(), original_repo_abs_path: Some(db_repository.abs_path), + linked_worktrees: db_repository + .linked_worktrees + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(), }); } } diff --git a/crates/collab/src/db/tables/project_repository.rs b/crates/collab/src/db/tables/project_repository.rs index 190ae8d79c54bb78daef4a1568ec75683eb0b0f2..33b20817e61a137285e27525eb5b2a221d3cfd9e 100644 --- a/crates/collab/src/db/tables/project_repository.rs +++ b/crates/collab/src/db/tables/project_repository.rs @@ -24,6 +24,8 @@ pub struct Model { pub head_commit_details: Option, pub remote_upstream_url: Option, pub remote_origin_url: Option, + // JSON array of linked worktree objects + pub linked_worktrees: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index f8c461b91fc41cc5a0e20271a85e685af2801d24..fc20150d662b96be9b6ad4f99ae1f33032b6fb7b 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -1,9 +1,10 @@ use std::path::{Path, PathBuf}; use call::ActiveCall; +use client::RECEIVE_TIMEOUT; use collections::HashMap; use git::{ - repository::RepoPath, + repository::{RepoPath, Worktree as GitWorktree}, status::{DiffStat, FileStatus, StatusCode, TrackedStatus}, }; use git_ui::{git_panel::GitPanel, project_diff::ProjectDiff}; @@ -365,6 +366,236 @@ async fn test_remote_git_worktrees( ); } +#[gpui::test] +async fn test_linked_worktrees_sync( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + // Set up a git repo with two linked worktrees already present. + client_a + .fs() + .insert_tree( + path!("/project"), + json!({ ".git": {}, "file.txt": "content" }), + ) + .await; + + client_a + .fs() + .with_git_state(Path::new(path!("/project/.git")), true, |state| { + state.worktrees.push(GitWorktree { + path: PathBuf::from(path!("/project")), + ref_name: "refs/heads/main".into(), + sha: "aaa111".into(), + }); + state.worktrees.push(GitWorktree { + path: PathBuf::from(path!("/project/feature-branch")), + ref_name: "refs/heads/feature-branch".into(), + sha: "bbb222".into(), + }); + state.worktrees.push(GitWorktree { + path: PathBuf::from(path!("/project/bugfix-branch")), + ref_name: "refs/heads/bugfix-branch".into(), + sha: "ccc333".into(), + }); + }) + .unwrap(); + + let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await; + + // Wait for git scanning to complete on the host. + executor.run_until_parked(); + + // Verify the host sees 2 linked worktrees (main worktree is filtered out). + let host_linked = project_a.read_with(cx_a, |project, cx| { + let repos = project.repositories(cx); + assert_eq!(repos.len(), 1, "host should have exactly 1 repository"); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + host_linked.len(), + 2, + "host should have 2 linked worktrees (main filtered out)" + ); + assert_eq!( + host_linked[0].path, + PathBuf::from(path!("/project/feature-branch")) + ); + assert_eq!( + host_linked[0].ref_name.as_ref(), + "refs/heads/feature-branch" + ); + assert_eq!(host_linked[0].sha.as_ref(), "bbb222"); + assert_eq!( + host_linked[1].path, + PathBuf::from(path!("/project/bugfix-branch")) + ); + assert_eq!(host_linked[1].ref_name.as_ref(), "refs/heads/bugfix-branch"); + assert_eq!(host_linked[1].sha.as_ref(), "ccc333"); + + // Share the project and have client B join. + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + executor.run_until_parked(); + + // Verify the guest sees the same linked worktrees as the host. + let guest_linked = project_b.read_with(cx_b, |project, cx| { + let repos = project.repositories(cx); + assert_eq!(repos.len(), 1, "guest should have exactly 1 repository"); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + guest_linked, host_linked, + "guest's linked_worktrees should match host's after initial sync" + ); + + // Now mutate: add a third linked worktree on the host side. + client_a + .fs() + .with_git_state(Path::new(path!("/project/.git")), true, |state| { + state.worktrees.push(GitWorktree { + path: PathBuf::from(path!("/project/hotfix-branch")), + ref_name: "refs/heads/hotfix-branch".into(), + sha: "ddd444".into(), + }); + }) + .unwrap(); + + // Wait for the host to re-scan and propagate the update. + executor.run_until_parked(); + + // Verify host now sees 3 linked worktrees. + let host_linked_updated = project_a.read_with(cx_a, |project, cx| { + let repos = project.repositories(cx); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + host_linked_updated.len(), + 3, + "host should now have 3 linked worktrees" + ); + assert_eq!( + host_linked_updated[2].path, + PathBuf::from(path!("/project/hotfix-branch")) + ); + + // Verify the guest also received the update. + let guest_linked_updated = project_b.read_with(cx_b, |project, cx| { + let repos = project.repositories(cx); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + guest_linked_updated, host_linked_updated, + "guest's linked_worktrees should match host's after update" + ); + + // Now mutate: remove one linked worktree from the host side. + client_a + .fs() + .with_git_state(Path::new(path!("/project/.git")), true, |state| { + state + .worktrees + .retain(|wt| wt.ref_name.as_ref() != "refs/heads/bugfix-branch"); + }) + .unwrap(); + + executor.run_until_parked(); + + // Verify host now sees 2 linked worktrees (feature-branch and hotfix-branch). + let host_linked_after_removal = project_a.read_with(cx_a, |project, cx| { + let repos = project.repositories(cx); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + host_linked_after_removal.len(), + 2, + "host should have 2 linked worktrees after removal" + ); + assert!( + host_linked_after_removal + .iter() + .all(|wt| wt.ref_name.as_ref() != "refs/heads/bugfix-branch"), + "bugfix-branch should have been removed" + ); + + // Verify the guest also reflects the removal. + let guest_linked_after_removal = project_b.read_with(cx_b, |project, cx| { + let repos = project.repositories(cx); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + guest_linked_after_removal, host_linked_after_removal, + "guest's linked_worktrees should match host's after removal" + ); + + // Test DB roundtrip: client C joins late, getting state from the database. + // This verifies that linked_worktrees are persisted and restored correctly. + let project_c = client_c.join_remote_project(project_id, cx_c).await; + executor.run_until_parked(); + + let late_joiner_linked = project_c.read_with(cx_c, |project, cx| { + let repos = project.repositories(cx); + assert_eq!( + repos.len(), + 1, + "late joiner should have exactly 1 repository" + ); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + late_joiner_linked, host_linked_after_removal, + "late-joining client's linked_worktrees should match host's (DB roundtrip)" + ); + + // Test reconnection: disconnect client B (guest) and reconnect. + // After rejoining, client B should get linked_worktrees back from the DB. + server.disconnect_client(client_b.peer_id().unwrap()); + executor.advance_clock(RECEIVE_TIMEOUT); + executor.run_until_parked(); + + // Client B reconnects automatically. + executor.advance_clock(RECEIVE_TIMEOUT); + executor.run_until_parked(); + + // Verify client B still has the correct linked worktrees after reconnection. + let guest_linked_after_reconnect = project_b.read_with(cx_b, |project, cx| { + let repos = project.repositories(cx); + assert_eq!( + repos.len(), + 1, + "guest should still have exactly 1 repository after reconnect" + ); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + guest_linked_after_reconnect, host_linked_after_removal, + "guest's linked_worktrees should survive guest disconnect/reconnect" + ); +} + #[gpui::test] async fn test_diff_stat_sync_between_host_and_downstream_client( cx_a: &mut TestAppContext, diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 85489b6057cd8214ee512fb477428c93cdb32219..0cb610f7dd2d4ccf809d907347bf3b3be2c82444 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -790,7 +790,7 @@ impl GitRepository for FakeGitRepository { } fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result> { - unimplemented!() + future::ready(Ok(String::new())).boxed() } fn diff_stat( diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 0572fd1f4f19beebd3674e1b24c828daffb9973c..e9330014c3f066705ac3ea1e54f5e498c5d22348 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -293,6 +293,7 @@ pub struct RepositorySnapshot { pub remote_origin_url: Option, pub remote_upstream_url: Option, pub stash_entries: GitStash, + pub linked_worktrees: Arc<[GitWorktree]>, } type JobId = u64; @@ -429,6 +430,7 @@ pub enum RepositoryEvent { StatusesChanged, BranchChanged, StashEntriesChanged, + GitWorktreeListChanged, PendingOpsChanged { pending_ops: SumTree }, GraphEvent((LogSource, LogOrder), GitGraphEvent), } @@ -3575,6 +3577,7 @@ impl RepositorySnapshot { remote_origin_url: None, remote_upstream_url: None, stash_entries: Default::default(), + linked_worktrees: Arc::from([]), path_style, } } @@ -3613,6 +3616,11 @@ impl RepositorySnapshot { original_repo_abs_path: Some( self.original_repo_abs_path.to_string_lossy().into_owned(), ), + linked_worktrees: self + .linked_worktrees + .iter() + .map(worktree_to_proto) + .collect(), } } @@ -3689,9 +3697,18 @@ impl RepositorySnapshot { original_repo_abs_path: Some( self.original_repo_abs_path.to_string_lossy().into_owned(), ), + linked_worktrees: self + .linked_worktrees + .iter() + .map(worktree_to_proto) + .collect(), } } + pub fn linked_worktrees(&self) -> &[GitWorktree] { + &self.linked_worktrees + } + pub fn status(&self) -> impl Iterator + '_ { self.statuses_by_path.iter().cloned() } @@ -6145,6 +6162,15 @@ impl Repository { cx.emit(RepositoryEvent::StashEntriesChanged) } self.snapshot.stash_entries = new_stash_entries; + let new_linked_worktrees: Arc<[GitWorktree]> = update + .linked_worktrees + .iter() + .map(proto_to_worktree) + .collect(); + if *self.snapshot.linked_worktrees != *new_linked_worktrees { + cx.emit(RepositoryEvent::GitWorktreeListChanged); + } + self.snapshot.linked_worktrees = new_linked_worktrees; self.snapshot.remote_upstream_url = update.remote_upstream_url; self.snapshot.remote_origin_url = update.remote_origin_url; @@ -6901,14 +6927,20 @@ async fn compute_snapshot( })) .boxed() }; - let (statuses, diff_stats) = futures::future::try_join( + let (statuses, diff_stats, all_worktrees) = futures::future::try_join3( backend.status(&[RepoPath::from_rel_path( &RelPath::new(".".as_ref(), PathStyle::local()).unwrap(), )]), diff_stat_future, + backend.worktrees(), ) .await?; + let linked_worktrees: Arc<[GitWorktree]> = all_worktrees + .into_iter() + .filter(|wt| wt.path != *work_directory_abs_path) + .collect(); + let diff_stat_map: HashMap<&RepoPath, DiffStat> = diff_stats.entries.iter().map(|(p, s)| (p, *s)).collect(); let stash_entries = backend.stash_entries().await?; @@ -6938,6 +6970,10 @@ async fn compute_snapshot( events.push(RepositoryEvent::BranchChanged); } + if *linked_worktrees != *prev_snapshot.linked_worktrees { + events.push(RepositoryEvent::GitWorktreeListChanged); + } + let remote_origin_url = backend.remote_url("origin").await; let remote_upstream_url = backend.remote_url("upstream").await; @@ -6954,6 +6990,7 @@ async fn compute_snapshot( remote_origin_url, remote_upstream_url, stash_entries, + linked_worktrees, }; Ok((snapshot, events)) diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 87fdc058f95c045de5f1e8f7ef03c8e32c2fa518..bb6b73ce3b89d51e9bf594c9e01254f5f0d579a4 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -126,6 +126,7 @@ message UpdateRepository { optional string remote_upstream_url = 14; optional string remote_origin_url = 15; optional string original_repo_abs_path = 16; + repeated Worktree linked_worktrees = 17; } message RemoveRepository { From 329df2cecdfb2257bbca03e989732332e324026c Mon Sep 17 00:00:00 2001 From: Katie Geer Date: Thu, 12 Mar 2026 11:34:40 -0700 Subject: [PATCH 174/219] docs: Add voice and tone guidance to agent rules (#51408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding more tone guidance to docs' agents.md file Release Notes: - N/A *or* Added/Fixed/Improved ... --------- Co-authored-by: María Craig Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> --- docs/AGENTS.md | 68 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index fdd61ff6aeaf8cd09ae0b017c5199e7033fba964..54f477472b1b4d22f06623220d5fb4a3eb181db4 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -126,6 +126,59 @@ Images are hosted externally. Reference format: - With anchors: `[Custom Models](./llm-providers.md#anthropic-custom-models)` - Parent directory: `[Telemetry](../telemetry.md)` +## Voice and Tone + +### Core Principles + +- **Practical over promotional**: Focus on what users can do, not on selling Zed. Avoid marketing language like "powerful," "revolutionary," or "best-in-class." +- **Honest about limitations**: When Zed lacks a feature or doesn't match another tool's depth, say so directly. Pair limitations with workarounds or alternative workflows. +- **Direct and concise**: Use short sentences. Get to the point. Developers are scanning, not reading novels. +- **Second person**: Address the reader as "you." Avoid "the user" or "one." +- **Present tense**: "Zed opens the file" not "Zed will open the file." + +### What to Avoid + +- Superlatives without substance ("incredibly fast," "seamlessly integrated") +- Hedging language ("simply," "just," "easily")—if something is simple, the instructions will show it +- Apologetic tone for missing features—state the limitation and move on +- Comparisons that disparage other tools—be factual, not competitive +- Lots of use of em or en dashes. + +## Examples of Good Copy + +### Good: Direct and actionable + +``` +To format on save, open the Settings Editor (`Cmd+,`) and search for `format_on_save`. Set it to `on`. + +Or add this to your settings.json: +{ + "format_on_save": "on" +} +``` + +### Bad: Wordy and promotional + +``` +Zed provides a powerful and seamless formatting experience. Simply navigate to the settings and you'll find the format_on_save option which enables Zed's incredible auto-formatting capabilities. +``` + +### Good: Honest about limitations + +``` +Zed doesn't index your project like IntelliJ does. You open a folder and start working immediately—no waiting. The trade-off: cross-project analysis relies on language servers, which may not go as deep. + +**How to adapt:** +- Use `Cmd+Shift+F` for project-wide text search +- Use `Cmd+O` for symbol search (powered by your language server) +``` + +### Bad: Defensive or dismissive + +``` +While some users might miss indexing, Zed's approach is actually better because it's faster. +``` + ## Scope ### In-Scope Documentation @@ -204,13 +257,14 @@ Inherit all conventions from `docs/.rules`. Key points: ### Terminology -| Use | Instead of | -| --------------- | -------------------------------------- | -| folder | directory | -| project | workspace | -| Settings Editor | settings UI | -| command palette | command bar | -| panel | sidebar (be specific: "Project Panel") | +| Use | Instead of | +| --------------- | --------------------------------------------------------------------- | +| folder | directory | +| project | workspace | +| Settings Editor | settings UI | +| command palette | command bar | +| panel | tool window, sidebar (be specific: "Project Panel," "Terminal Panel") | +| language server | LSP (spell out first use, then LSP is fine) | ## Zed-Specific Conventions From ac16a7891f7278cb7c6734a767d46709bf923bc8 Mon Sep 17 00:00:00 2001 From: Skanda Bhat Date: Thu, 12 Mar 2026 20:05:28 +0100 Subject: [PATCH 175/219] vim: Fix visual mode entry at line end near trailing newline (#50709) In Helix, selecting a line with `x` creates a selection from column 0 of the current row to column 0 of the next row. The default `InsertEndOfLine` uses the selection head (which is on the next row) to find the line end, placing the cursor on the wrong line. This commit introduces a new `HelixInsertEndOfLine`, mapped by default to `shift-a` when Helix mode is enabled, that moves left from the head first to land on the correct line. Release Notes: - Fixed `shift-a` in Helix select mode placing the cursor on the wrong line after selecting with `x` --------- Co-authored-by: SkandaBhat <9384046+SkandaBhat@users.noreply.github.com> Co-authored-by: dino --- assets/keymaps/vim.json | 1 + crates/vim/src/helix.rs | 119 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 1f2742f982bc2165181a797e577b350f5630def9..66693ab0a153a73af1dccb101e0ed36259b774fa 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -427,6 +427,7 @@ "escape": "vim::SwitchToHelixNormalMode", "i": "vim::HelixInsert", "a": "vim::HelixAppend", + "shift-a": "vim::HelixInsertEndOfLine", "ctrl-[": "editor::Cancel", }, }, diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 126683f0b419ae9a44d17d90d760f06b106fad8a..06630d18edfe0d1f3e643f02a1f50e5a1f4a0682 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -36,6 +36,8 @@ actions!( HelixInsert, /// Appends at the end of the selection. HelixAppend, + /// Inserts at the end of the current Helix cursor line. + HelixInsertEndOfLine, /// Goes to the location of the last modification. HelixGotoLastModification, /// Select entire line or multiple lines, extending downwards. @@ -64,6 +66,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, Vim::helix_select_lines); Vim::action(editor, cx, Vim::helix_insert); Vim::action(editor, cx, Vim::helix_append); + Vim::action(editor, cx, Vim::helix_insert_end_of_line); Vim::action(editor, cx, Vim::helix_yank); Vim::action(editor, cx, Vim::helix_goto_last_modification); Vim::action(editor, cx, Vim::helix_paste); @@ -600,6 +603,34 @@ impl Vim { }); } + /// Helix-specific implementation of `shift-a` that accounts for Helix's + /// selection model, where selecting a line with `x` creates a selection + /// from column 0 of the current row to column 0 of the next row, so the + /// default [`vim::normal::InsertEndOfLine`] would move the cursor to the + /// end of the wrong line. + fn helix_insert_end_of_line( + &mut self, + _: &HelixInsertEndOfLine, + window: &mut Window, + cx: &mut Context, + ) { + self.start_recording(cx); + self.switch_mode(Mode::Insert, false, window, cx); + self.update_editor(cx, |_, editor, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.move_with(&mut |map, selection| { + let cursor = if !selection.is_empty() && !selection.reversed { + movement::left(map, selection.head()) + } else { + selection.head() + }; + selection + .collapse_to(motion::next_line_end(map, cursor, 1), SelectionGoal::None); + }); + }); + }); + } + pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context) { self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { @@ -1447,6 +1478,47 @@ mod test { ˇ»line five"}, Mode::HelixNormal, ); + + // Test selecting with an empty line below the current line + cx.set_state( + indoc! {" + line one + line twoˇ + + line four + line five"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + line one + «line two + ˇ» + line four + line five"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + line one + «line two + + ˇ»line four + line five"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + line one + «line two + + line four + ˇ»line five"}, + Mode::HelixNormal, + ); } #[gpui::test] @@ -1848,4 +1920,51 @@ mod test { Mode::HelixSelect, ); } + + #[gpui::test] + async fn test_helix_insert_end_of_line(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Ensure that, when lines are selected using `x`, pressing `shift-a` + // actually puts the cursor at the end of the selected lines and not at + // the end of the line below. + cx.set_state( + indoc! {" + line oˇne + line two"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + «line one + ˇ»line two"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("shift-a"); + cx.assert_state( + indoc! {" + line oneˇ + line two"}, + Mode::Insert, + ); + + cx.set_state( + indoc! {" + line «one + lineˇ» two"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("shift-a"); + cx.assert_state( + indoc! {" + line one + line twoˇ"}, + Mode::Insert, + ); + } } From bc9a3e53af44040fa4c44255527be53aa693645e Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 12 Mar 2026 13:07:39 -0600 Subject: [PATCH 176/219] Tidy up DiffStat (#51411) Release Notes: - Tweaked the git diff status to render + and - using the font instead of icons. --- crates/ui/src/components/diff_stat.rs | 30 ++++++--------------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/crates/ui/src/components/diff_stat.rs b/crates/ui/src/components/diff_stat.rs index ec6d515f1b4f847631fc65fae4ed3ccd3185d271..45539c62869b8c23cb76671d2a7a862c9592a181 100644 --- a/crates/ui/src/components/diff_stat.rs +++ b/crates/ui/src/components/diff_stat.rs @@ -30,32 +30,14 @@ impl RenderOnce for DiffStat { .id(self.id) .gap_1() .child( - h_flex() - .gap_0p5() - .child( - Icon::new(IconName::Plus) - .size(IconSize::XSmall) - .color(Color::Success), - ) - .child( - Label::new(self.added.to_string()) - .color(Color::Success) - .size(self.label_size), - ), + Label::new(format!("+\u{2009}{}", self.added)) + .color(Color::Success) + .size(self.label_size), ) .child( - h_flex() - .gap_0p5() - .child( - Icon::new(IconName::Dash) - .size(IconSize::XSmall) - .color(Color::Error), - ) - .child( - Label::new(self.removed.to_string()) - .color(Color::Error) - .size(self.label_size), - ), + Label::new(format!("\u{2012}\u{2009}{}", self.removed)) + .color(Color::Error) + .size(self.label_size), ) } } From 7a615628457d8ce5dc9a4cd682682726fc9589cd Mon Sep 17 00:00:00 2001 From: Om Chillure Date: Fri, 13 Mar 2026 01:03:48 +0530 Subject: [PATCH 177/219] Fix title/camelCase commands stripping leading indentation Fixes (#50523) Fixes: #48945 Description: The convert:to-title-case, convert:to-upper-camel-case, and convert:to-lower-camel-case editor commands were stripping leading whitespace from each line of a multi-line selection. Root cause: The conversion functions split on whitespace using .split_whitespace() and then joined the resulting words, discarding any leading spaces/tabs before the first word on each line. Fix: Each line now preserves its leading whitespace by capturing and re-prepending it before applying the case conversion. Tests: Added test cases covering multi-line selections with indentation for all three commands. Video : [bug1fix.webm](https://github.com/user-attachments/assets/f4d25c55-bc6d-44e6-a989-7d9b4bc59ac9) Release Notes: - Fixed trailing whitespace handling on text case changes --- crates/editor/src/editor.rs | 36 +++++++++++----- crates/editor/src/editor_tests.rs | 71 +++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 707fb43cc3b573772ef24b7fe7eea69a2ad3c8ec..20d976ad6c0e0a9c82fbaa681efea80f2873d375 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -12438,9 +12438,7 @@ impl Editor { cx: &mut Context, ) { self.manipulate_text(window, cx, |text| { - text.split('\n') - .map(|line| line.to_case(Case::Title)) - .join("\n") + Self::convert_text_case(text, Case::Title) }) } @@ -12450,7 +12448,9 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_text(window, cx, |text| text.to_case(Case::Snake)) + self.manipulate_text(window, cx, |text| { + Self::convert_text_case(text, Case::Snake) + }) } pub fn convert_to_kebab_case( @@ -12459,7 +12459,9 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_text(window, cx, |text| text.to_case(Case::Kebab)) + self.manipulate_text(window, cx, |text| { + Self::convert_text_case(text, Case::Kebab) + }) } pub fn convert_to_upper_camel_case( @@ -12469,9 +12471,7 @@ impl Editor { cx: &mut Context, ) { self.manipulate_text(window, cx, |text| { - text.split('\n') - .map(|line| line.to_case(Case::UpperCamel)) - .join("\n") + Self::convert_text_case(text, Case::UpperCamel) }) } @@ -12481,7 +12481,9 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_text(window, cx, |text| text.to_case(Case::Camel)) + self.manipulate_text(window, cx, |text| { + Self::convert_text_case(text, Case::Camel) + }) } pub fn convert_to_opposite_case( @@ -12509,7 +12511,9 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_text(window, cx, |text| text.to_case(Case::Sentence)) + self.manipulate_text(window, cx, |text| { + Self::convert_text_case(text, Case::Sentence) + }) } pub fn toggle_case(&mut self, _: &ToggleCase, window: &mut Window, cx: &mut Context) { @@ -12540,6 +12544,18 @@ impl Editor { }) } + fn convert_text_case(text: &str, case: Case) -> String { + text.lines() + .map(|line| { + let trimmed_start = line.trim_start(); + let leading = &line[..line.len() - trimmed_start.len()]; + let trimmed = trimmed_start.trim_end(); + let trailing = &trimmed_start[trimmed.len()..]; + format!("{}{}{}", leading, trimmed.to_case(case), trailing) + }) + .join("\n") + } + pub fn convert_to_rot47( &mut self, _: &ConvertToRot47, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 0da80a2a73f22afac7085b579494d708be2444a4..f497881531bf4ba39cb22aca4cf90923f7d10b81 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -6268,6 +6268,77 @@ async fn test_manipulate_text(cx: &mut TestAppContext) { «HeLlO, wOrLD!ˇ» "}); + // Test that case conversions backed by `to_case` preserve leading/trailing whitespace. + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_title_case(&ConvertToTitleCase, window, cx)); + cx.assert_editor_state(indoc! {" + « Hello Worldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_upper_camel_case(&ConvertToUpperCamelCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + « HelloWorldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_lower_camel_case(&ConvertToLowerCamelCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + « helloWorldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx)); + cx.assert_editor_state(indoc! {" + « hello_worldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_kebab_case(&ConvertToKebabCase, window, cx)); + cx.assert_editor_state(indoc! {" + « hello-worldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_sentence_case(&ConvertToSentenceCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + « Hello worldˇ» + "}); + + cx.set_state(indoc! {" + « hello world\t\tˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_title_case(&ConvertToTitleCase, window, cx)); + cx.assert_editor_state(indoc! {" + « Hello World\t\tˇ» + "}); + + cx.set_state(indoc! {" + « hello world\t\tˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx)); + cx.assert_editor_state(indoc! {" + « hello_world\t\tˇ» + "}); + // Test selections with `line_mode() = true`. cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true)); cx.set_state(indoc! {" From ec2659a095c1e073f8918469e2528c277a76567f Mon Sep 17 00:00:00 2001 From: Tommy Han Date: Fri, 13 Mar 2026 03:35:42 +0800 Subject: [PATCH 178/219] Add hotkeys and actions for toggle light and dark theme (#49027) Mentioned in #47258 Release Notes: - Added hotkey options and actions for toggling light and dark theme. - Add default keymap as `cmd/ctrl+k cmd/ctrl+shift+t` --- assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 1 + assets/keymaps/default-windows.json | 1 + crates/theme/src/settings.rs | 12 ++-- crates/workspace/src/workspace.rs | 91 ++++++++++++++++++++++++++++- crates/zed/src/zed.rs | 1 + crates/zed_actions/src/lib.rs | 6 ++ docs/src/appearance.md | 8 ++- docs/src/themes.md | 29 +++++++++ 9 files changed, 139 insertions(+), 11 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 5780eedb4445f613cbbd4e9a09976f2d475b28c7..0516221b6e0849ab631c021d020050be99aaf728 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -624,6 +624,7 @@ "ctrl-shift-t": "pane::ReopenClosedItem", "ctrl-k ctrl-s": "zed::OpenKeymap", "ctrl-k ctrl-t": "theme_selector::Toggle", + "ctrl-k ctrl-shift-t": "theme::ToggleMode", "ctrl-alt-super-p": "settings_profile_selector::Toggle", "ctrl-t": "project_symbols::Toggle", "ctrl-p": "file_finder::Toggle", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 6fc6905dd5f4502ff7ee90e7f6f9499b2e03fa6a..a4aec7cfe8053f3f23b43652f7e58f319c9691f6 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -691,6 +691,7 @@ "cmd-shift-t": "pane::ReopenClosedItem", "cmd-k cmd-s": "zed::OpenKeymap", "cmd-k cmd-t": "theme_selector::Toggle", + "cmd-k cmd-shift-t": "theme::ToggleMode", "ctrl-alt-cmd-p": "settings_profile_selector::Toggle", "cmd-t": "project_symbols::Toggle", "cmd-p": "file_finder::Toggle", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index ac23d45695e11ec46172c566282ea65bf7774ac8..c10054d5813c6deae33b7a790b3639e7f2c802aa 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -616,6 +616,7 @@ "ctrl-shift-t": "pane::ReopenClosedItem", "ctrl-k ctrl-s": "zed::OpenKeymap", "ctrl-k ctrl-t": "theme_selector::Toggle", + "ctrl-k ctrl-shift-t": "theme::ToggleMode", "ctrl-alt-super-p": "settings_profile_selector::Toggle", "ctrl-t": "project_symbols::Toggle", "ctrl-p": "file_finder::Toggle", diff --git a/crates/theme/src/settings.rs b/crates/theme/src/settings.rs index a092e2698722a980f0b2a4b5ea64b9bfa0f33d01..c09d3daf6074f24248de12e56ebc2122e2c123e7 100644 --- a/crates/theme/src/settings.rs +++ b/crates/theme/src/settings.rs @@ -378,14 +378,14 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) { if let Some(selection) = theme.theme.as_mut() { match selection { - settings::ThemeSelection::Static(theme) => { + settings::ThemeSelection::Static(_) => { // If the 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. + // reset to the default dynamic light/dark pair and let users + // customize light/dark themes explicitly afterward. *selection = settings::ThemeSelection::Dynamic { - mode, - light: theme.clone(), - dark: theme.clone(), + mode: ThemeAppearanceMode::System, + light: ThemeName(settings::DEFAULT_LIGHT_THEME.into()), + dark: ThemeName(settings::DEFAULT_DARK_THEME.into()), }; } settings::ThemeSelection::Dynamic { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b57b5028a4e5558b1f90c715463165ba68d914e3..949dc127a7465c4cf3941ee4c4982fad37d06281 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -146,7 +146,7 @@ pub use workspace_settings::{ AutosaveSetting, BottomDockLayout, RestoreOnStartupBehavior, StatusBarSettings, TabBarSettings, WorkspaceSettings, }; -use zed_actions::{Spawn, feedback::FileBugReport}; +use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode}; use crate::{item::ItemBufferKind, notifications::NotificationId}; use crate::{ @@ -6499,6 +6499,7 @@ impl Workspace { .on_action(cx.listener(Self::move_item_to_pane_at_index)) .on_action(cx.listener(Self::move_focused_panel_to_next_position)) .on_action(cx.listener(Self::toggle_edit_predictions_all_files)) + .on_action(cx.listener(Self::toggle_theme_mode)) .on_action(cx.listener(|workspace, _: &Unfollow, window, cx| { let pane = workspace.active_pane().clone(); workspace.unfollow_in_pane(&pane, window, cx); @@ -7153,6 +7154,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, + }, + }; + + let fs = self.project().read(cx).fs().clone(); + settings::update_settings_file(fs, cx, move |settings, _cx| { + theme::set_mode(settings, next_mode); + }); + } + pub fn show_worktree_trust_security_modal( &mut self, toggle: bool, @@ -9964,7 +9982,7 @@ pub fn with_active_or_new_workspace( #[cfg(test)] mod tests { - use std::{cell::RefCell, rc::Rc}; + use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration}; use super::*; use crate::{ @@ -9982,6 +10000,7 @@ mod tests { use project::{Project, ProjectEntryId}; use serde_json::json; use settings::SettingsStore; + use util::path; use util::rel_path::rel_path; #[gpui::test] @@ -13540,6 +13559,74 @@ mod tests { }); } + #[gpui::test] + async fn test_toggle_theme_mode_persists_and_updates_active_theme(cx: &mut TestAppContext) { + use settings::{ThemeName, ThemeSelection}; + use theme::SystemAppearance; + use zed_actions::theme::ToggleMode; + + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let settings_fs: Arc = fs.clone(); + + fs.insert_tree(path!("/root"), json!({ "file.rs": "fn main() {}\n" })) + .await; + + // Build a test project and workspace view so the test can invoke + // the workspace action handler the same way the UI would. + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + // Seed the settings file with a plain static light theme so the + // first toggle always starts from a known persisted state. + workspace.update_in(cx, |_workspace, _window, cx| { + *SystemAppearance::global_mut(cx) = SystemAppearance(theme::Appearance::Light); + settings::update_settings_file(settings_fs.clone(), cx, |settings, _cx| { + settings.theme.theme = Some(ThemeSelection::Static(ThemeName("One Light".into()))); + }); + }); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + + // Confirm the initial persisted settings contain the static theme + // we just wrote before any toggling happens. + let settings_text = SettingsStore::load_settings(&settings_fs).await.unwrap(); + assert!(settings_text.contains(r#""theme": "One Light""#)); + + // Toggle once. This should migrate the persisted theme settings + // into light/dark slots and enable system mode. + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_theme_mode(&ToggleMode, window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + + // 1. Static -> Dynamic + // this assertion checks theme changed from static to dynamic. + let settings_text = SettingsStore::load_settings(&settings_fs).await.unwrap(); + let parsed: serde_json::Value = settings::parse_json_with_comments(&settings_text).unwrap(); + assert_eq!( + parsed["theme"], + serde_json::json!({ + "mode": "system", + "light": "One Light", + "dark": "One Dark" + }) + ); + + // 2. Toggle again, suppose it will change the mode to light + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_theme_mode(&ToggleMode, window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + + let settings_text = SettingsStore::load_settings(&settings_fs).await.unwrap(); + assert!(settings_text.contains(r#""mode": "light""#)); + } + fn dirty_project_item(id: u64, path: &str, cx: &mut App) -> Entity { let item = TestProjectItem::new(id, path, cx); item.update(cx, |item, _| { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 25defa1dde5977bd94935dafd60d97ae84b5a323..511b0edc6ac168fa47b52e66c9632487de86acf4 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4878,6 +4878,7 @@ mod tests { "task", "terminal", "terminal_panel", + "theme", "theme_selector", "toast", "toolchain", diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 854f71175e79c84f03261a3d58f89638b7259e54..8edc80b4ec7816cd9e2ae2d7b995dd74b8128a9a 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -325,6 +325,12 @@ pub mod feedback { ); } +pub mod theme { + use gpui::actions; + + actions!(theme, [ToggleMode]); +} + pub mod theme_selector { use gpui::Action; use schemars::JsonSchema; diff --git a/docs/src/appearance.md b/docs/src/appearance.md index fdf5e239ccf581988e439845d0c2f94e4bb1b95c..1c26d67100379462298c4026dbf578b936b61fb1 100644 --- a/docs/src/appearance.md +++ b/docs/src/appearance.md @@ -15,11 +15,13 @@ Here's how to make Zed feel like home: 1. **Pick a theme**: Press {#kb theme_selector::Toggle} to open the Theme Selector. Arrow through the list to preview themes in real time, and press Enter to apply. -2. **Choose an icon theme**: Run `icon theme selector: toggle` from the command palette to browse icon themes. +2. **Toggle light/dark mode quickly**: Press {#kb theme::ToggleMode}. If you currently use a static `"theme": "..."` value, the first toggle converts it to dynamic mode settings with default themes. -3. **Set your font**: Open the Settings Editor with {#kb zed::OpenSettings} and search for `buffer_font_family`. Set it to your preferred coding font. +3. **Choose an icon theme**: Run `icon theme selector: toggle` from the command palette to browse icon themes. -4. **Adjust font size**: In the same Settings Editor, search for `buffer_font_size` and `ui_font_size` to tweak the editor and interface text sizes. +4. **Set your font**: Open the Settings Editor with {#kb zed::OpenSettings} and search for `buffer_font_family`. Set it to your preferred coding font. + +5. **Adjust font size**: In the same Settings Editor, search for `buffer_font_size` and `ui_font_size` to tweak the editor and interface text sizes. That's it. You now have a personalized Zed setup. diff --git a/docs/src/themes.md b/docs/src/themes.md index 0d3103eaab46fefff22095d14cab02f799ef851d..1dd2c144e2a2a53a50e21f6fc51f3b0c121eca25 100644 --- a/docs/src/themes.md +++ b/docs/src/themes.md @@ -44,6 +44,35 @@ You can set the mode to `"dark"` or `"light"` to ignore the current system mode. } ``` +### Toggle Theme Mode from the Keyboard + +Use {#kb theme::ToggleMode} to switch the current theme mode between light and dark. + +If your settings currently use a static theme value, like: + +```json [settings] +{ + "theme": "Any Theme" +} +``` + +the first toggle converts it to dynamic theme selection with default themes: + +```json [settings] +{ + "theme": { + "mode": "system", + "light": "One Light", + "dark": "One Dark" + } +} +``` + +You are required to set both `light` and `dark` themes manually after the first toggle. + +After that, toggling updates only `theme.mode`. +If `light` and `dark` are the same theme, the first toggle may not produce a visible UI change until you set different values for `light` and `dark`. + ## Theme Overrides To override specific attributes of a theme, use the `theme_overrides` setting. From 5586fbf288b909ade034488e0953a7e95857a16c Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:41:56 -0300 Subject: [PATCH 179/219] agent_ui: Add UI refinements to the sidebar and archive view (#51419) Adds a loading state to the archive view and a couple of other tiny UI tweaks to the thread item and such. Release Notes: - N/A --- crates/agent_ui/src/sidebar.rs | 4 +- crates/agent_ui/src/threads_archive_view.rs | 92 ++++++++++++++------- crates/ui/src/components/ai/thread_item.rs | 5 +- 3 files changed, 67 insertions(+), 34 deletions(-) diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 24c5d5f5e5295a7e25af9f486323a16a2405c8e0..7d7779e75504a93c7923ba26ec87e4fce4bbceb9 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1555,7 +1555,7 @@ impl Sidebar { let id = SharedString::from(format!("view-more-{}", ix)); let (icon, label) = if is_fully_expanded { - (IconName::ListCollapse, "Collapse List") + (IconName::ListCollapse, "Collapse") } else { (IconName::Plus, "View More") }; @@ -1685,7 +1685,7 @@ impl Sidebar { h_flex() .p_1p5() .border_t_1() - .border_color(cx.theme().colors().border) + .border_color(cx.theme().colors().border_variant) .child( Button::new("view-archive", "Archive") .full_width() diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 8ee0eedbd8702c7901258087af5d149fcf210648..3d7dba591dfa60f7408f9710561863791bcd802b 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -15,8 +15,8 @@ use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::{AgentServerStore, ExternalAgentServerName}; use theme::ActiveTheme; use ui::{ - ButtonLike, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem, PopoverMenu, - PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, + ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem, + PopoverMenu, PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::ResultExt as _; use zed_actions::editor::{MoveDown, MoveUp}; @@ -110,6 +110,7 @@ pub struct ThreadsArchiveView { _subscriptions: Vec, selected_agent_menu: PopoverMenuHandle, _refresh_history_task: Task<()>, + is_loading: bool, } impl ThreadsArchiveView { @@ -152,13 +153,20 @@ impl ThreadsArchiveView { _subscriptions: vec![filter_editor_subscription], selected_agent_menu: PopoverMenuHandle::default(), _refresh_history_task: Task::ready(()), + is_loading: true, }; - this.set_selected_agent(Agent::NativeAgent, cx); + this.set_selected_agent(Agent::NativeAgent, window, cx); this } - fn set_selected_agent(&mut self, agent: Agent, cx: &mut Context) { + fn set_selected_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context) { self.selected_agent = agent.clone(); + self.is_loading = true; + self.history = None; + self.items.clear(); + self.selection = None; + self.list_state.reset(0); + self.reset_filter_editor_text(window, cx); let server = agent.server(self.fs.clone(), self.thread_store.clone()); let connection = self @@ -184,6 +192,7 @@ impl ThreadsArchiveView { history.refresh_full_history(cx); }); self.history = Some(history); + self.is_loading = false; self.update_items(cx); cx.notify(); } @@ -477,9 +486,9 @@ impl ThreadsArchiveView { .icon_color(Color::Muted) .handler({ let this = this.clone(); - move |_, cx| { + move |window, cx| { this.update(cx, |this, cx| { - this.set_selected_agent(Agent::NativeAgent, cx) + this.set_selected_agent(Agent::NativeAgent, window, cx) }) .ok(); } @@ -537,9 +546,9 @@ impl ThreadsArchiveView { let agent = Agent::Custom { name: item.id.0.clone(), }; - move |_, cx| { + move |window, cx| { this.update(cx, |this, cx| { - this.set_selected_agent(agent.clone(), cx) + this.set_selected_agent(agent.clone(), window, cx) }) .ok(); } @@ -565,7 +574,6 @@ impl ThreadsArchiveView { h_flex() .h(Tab::container_height(cx)) .px_1() - .gap_1p5() .justify_between() .border_b_1() .border_color(cx.theme().colors().border) @@ -610,12 +618,54 @@ impl Render for ThreadsArchiveView { let is_empty = self.items.is_empty(); let has_query = !self.filter_editor.read(cx).text(cx).is_empty(); - let empty_state_container = |label: SharedString| { + let content = if self.is_loading { v_flex() .flex_1() .justify_center() .items_center() - .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)) + .child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + .into_any_element() + } else if is_empty && has_query { + v_flex() + .flex_1() + .justify_center() + .items_center() + .child( + Label::new("No threads match your search.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element() + } else if is_empty { + v_flex() + .flex_1() + .justify_center() + .items_center() + .child( + Label::new("No archived threads yet.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element() + } else { + v_flex() + .flex_1() + .overflow_hidden() + .child( + list( + self.list_state.clone(), + cx.processor(Self::render_list_entry), + ) + .flex_1() + .size_full(), + ) + .vertical_scrollbar_for(&self.list_state, window, cx) + .into_any_element() }; v_flex() @@ -631,24 +681,6 @@ impl Render for ThreadsArchiveView { .size_full() .bg(cx.theme().colors().surface_background) .child(self.render_header(cx)) - .child(if is_empty && has_query { - empty_state_container("No threads match your search.".into()).into_any_element() - } else if is_empty { - empty_state_container("No archived threads yet.".into()).into_any_element() - } else { - v_flex() - .flex_1() - .overflow_hidden() - .child( - list( - self.list_state.clone(), - cx.processor(Self::render_list_entry), - ) - .flex_1() - .size_full(), - ) - .vertical_scrollbar_for(&self.list_state, window, cx) - .into_any_element() - }) + .child(content) } } diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 5be91e9d98a1219dcfbbba70a5541ba7b827cfc5..13e1db8f483ea251a6f65b61054c205d040a0d53 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -235,9 +235,9 @@ impl RenderOnce for ThreadItem { let gradient_overlay = GradientFade::new(base_bg, color.element_hover, color.element_active) - .width(px(32.0)) + .width(px(64.0)) .right(px(-10.0)) - .gradient_stop(0.8) + .gradient_stop(0.75) .group_name("thread-item"); let has_diff_stats = self.added.is_some() || self.removed.is_some(); @@ -264,6 +264,7 @@ impl RenderOnce for ThreadItem { .border_color(color.border_focused) }) .hover(|s| s.bg(color.element_hover)) + .active(|s| s.bg(color.element_active)) .on_hover(self.on_hover) .child( h_flex() From df8bafdccf88ea4ade0c25707db7fb8d8150ad1e Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Thu, 12 Mar 2026 15:56:37 -0500 Subject: [PATCH 180/219] ep: Avoid including collaborator edits in edit history sent to model (#51343) Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/edit_prediction/src/edit_prediction.rs | 308 +++++++++++++---- .../src/edit_prediction_tests.rs | 326 ++++++++++++++---- crates/zeta_prompt/src/zeta_prompt.rs | 110 +++++- 3 files changed, 603 insertions(+), 141 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 63240ddd53108f0b2450386150958e23f975d7ed..2347a731cb5b5f3590dafcf0a57dc0bab88c380c 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -75,6 +75,7 @@ pub mod zeta; #[cfg(test)] mod edit_prediction_tests; +use crate::cursor_excerpt::expand_context_syntactically_then_linewise; use crate::example_spec::ExampleSpec; use crate::license_detection::LicenseDetectionWatcher; use crate::mercury::Mercury; @@ -99,8 +100,9 @@ actions!( ); /// Maximum number of events to track. -const EVENT_COUNT_MAX: usize = 6; +const EVENT_COUNT_MAX: usize = 10; const CHANGE_GROUPING_LINE_SPAN: u32 = 8; +const COLLABORATOR_EDIT_LOCALITY_CONTEXT_TOKENS: usize = 512; const LAST_CHANGE_GROUPING_TIME: Duration = Duration::from_secs(1); const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice"; const REJECT_REQUEST_DEBOUNCE: Duration = Duration::from_secs(15); @@ -242,21 +244,31 @@ pub enum UserActionType { pub struct StoredEvent { pub event: Arc, pub old_snapshot: TextBufferSnapshot, - pub edit_range: Range, + pub new_snapshot_version: clock::Global, + pub total_edit_range: Range, } impl StoredEvent { fn can_merge( &self, - next_old_event: &&&StoredEvent, - new_snapshot: &TextBufferSnapshot, - last_edit_range: &Range, + next_old_event: &StoredEvent, + latest_snapshot: &TextBufferSnapshot, + latest_edit_range: &Range, ) -> bool { - // Events must be for the same buffer + // Events must be for the same buffer and be contiguous across included snapshots to be mergeable. if self.old_snapshot.remote_id() != next_old_event.old_snapshot.remote_id() { return false; } - if self.old_snapshot.remote_id() != new_snapshot.remote_id() { + if self.old_snapshot.remote_id() != latest_snapshot.remote_id() { + return false; + } + if self.new_snapshot_version != next_old_event.old_snapshot.version { + return false; + } + if !latest_snapshot + .version + .observed_all(&next_old_event.new_snapshot_version) + { return false; } @@ -281,9 +293,9 @@ impl StoredEvent { return false; } - let left_range = self.edit_range.to_point(new_snapshot); - let right_range = next_old_event.edit_range.to_point(new_snapshot); - let latest_range = last_edit_range.to_point(&new_snapshot); + let left_range = self.total_edit_range.to_point(latest_snapshot); + let right_range = next_old_event.total_edit_range.to_point(latest_snapshot); + let latest_range = latest_edit_range.to_point(latest_snapshot); // Events near to the latest edit are not merged if their sources differ. if lines_between_ranges(&left_range, &latest_range) @@ -516,7 +528,9 @@ struct LastEvent { new_snapshot: TextBufferSnapshot, old_file: Option>, new_file: Option>, - edit_range: Option>, + latest_edit_range: Range, + total_edit_range: Range, + total_edit_range_at_last_pause_boundary: Option>, predicted: bool, snapshot_after_last_editing_pause: Option, last_edit_time: Option, @@ -542,8 +556,11 @@ impl LastEvent { }) }); - let (diff, edit_range) = - compute_diff_between_snapshots(&self.old_snapshot, &self.new_snapshot)?; + let (diff, edit_range) = compute_diff_between_snapshots_in_range( + &self.old_snapshot, + &self.new_snapshot, + &self.total_edit_range, + )?; if path == old_path && diff.is_empty() { None @@ -556,9 +573,10 @@ impl LastEvent { in_open_source_repo, predicted: self.predicted, }), - edit_range: self.new_snapshot.anchor_before(edit_range.start) - ..self.new_snapshot.anchor_before(edit_range.end), old_snapshot: self.old_snapshot.clone(), + new_snapshot_version: self.new_snapshot.version.clone(), + total_edit_range: self.new_snapshot.anchor_before(edit_range.start) + ..self.new_snapshot.anchor_before(edit_range.end), }) } } @@ -568,12 +586,28 @@ impl LastEvent { return (self.clone(), None); }; + let total_edit_range_before_pause = self + .total_edit_range_at_last_pause_boundary + .clone() + .unwrap_or_else(|| self.total_edit_range.clone()); + + let Some(total_edit_range_after_pause) = + compute_total_edit_range_between_snapshots(boundary_snapshot, &self.new_snapshot) + else { + return (self.clone(), None); + }; + + let latest_edit_range_before_pause = total_edit_range_before_pause.clone(); + let latest_edit_range_after_pause = total_edit_range_after_pause.clone(); + let before = LastEvent { old_snapshot: self.old_snapshot.clone(), new_snapshot: boundary_snapshot.clone(), old_file: self.old_file.clone(), new_file: self.new_file.clone(), - edit_range: None, + latest_edit_range: latest_edit_range_before_pause, + total_edit_range: total_edit_range_before_pause, + total_edit_range_at_last_pause_boundary: None, predicted: self.predicted, snapshot_after_last_editing_pause: None, last_edit_time: self.last_edit_time, @@ -584,7 +618,9 @@ impl LastEvent { new_snapshot: self.new_snapshot.clone(), old_file: self.old_file.clone(), new_file: self.new_file.clone(), - edit_range: None, + latest_edit_range: latest_edit_range_after_pause, + total_edit_range: total_edit_range_after_pause, + total_edit_range_at_last_pause_boundary: None, predicted: self.predicted, snapshot_after_last_editing_pause: None, last_edit_time: self.last_edit_time, @@ -594,21 +630,78 @@ impl LastEvent { } } -pub(crate) fn compute_diff_between_snapshots( +fn compute_total_edit_range_between_snapshots( old_snapshot: &TextBufferSnapshot, new_snapshot: &TextBufferSnapshot, -) -> Option<(String, Range)> { +) -> Option> { let edits: Vec> = new_snapshot .edits_since::(&old_snapshot.version) .collect(); let (first_edit, last_edit) = edits.first().zip(edits.last())?; - - let old_start_point = old_snapshot.offset_to_point(first_edit.old.start); - let old_end_point = old_snapshot.offset_to_point(last_edit.old.end); let new_start_point = new_snapshot.offset_to_point(first_edit.new.start); let new_end_point = new_snapshot.offset_to_point(last_edit.new.end); + Some(new_snapshot.anchor_before(new_start_point)..new_snapshot.anchor_before(new_end_point)) +} + +fn compute_old_range_for_new_range( + old_snapshot: &TextBufferSnapshot, + new_snapshot: &TextBufferSnapshot, + total_edit_range: &Range, +) -> Option> { + let new_start_offset = total_edit_range.start.to_offset(new_snapshot); + let new_end_offset = total_edit_range.end.to_offset(new_snapshot); + + let edits: Vec> = new_snapshot + .edits_since::(&old_snapshot.version) + .collect(); + let mut old_start_offset = None; + let mut old_end_offset = None; + let mut delta: isize = 0; + + for edit in &edits { + if old_start_offset.is_none() && new_start_offset <= edit.new.end { + old_start_offset = Some(if new_start_offset < edit.new.start { + new_start_offset.checked_add_signed(-delta)? + } else { + edit.old.start + }); + } + + if old_end_offset.is_none() && new_end_offset <= edit.new.end { + old_end_offset = Some(if new_end_offset < edit.new.start { + new_end_offset.checked_add_signed(-delta)? + } else { + edit.old.end + }); + } + + delta += edit.new.len() as isize - edit.old.len() as isize; + } + + let old_start_offset = + old_start_offset.unwrap_or_else(|| new_start_offset.saturating_add_signed(-delta)); + let old_end_offset = + old_end_offset.unwrap_or_else(|| new_end_offset.saturating_add_signed(-delta)); + + Some( + old_snapshot.offset_to_point(old_start_offset) + ..old_snapshot.offset_to_point(old_end_offset), + ) +} + +fn compute_diff_between_snapshots_in_range( + old_snapshot: &TextBufferSnapshot, + new_snapshot: &TextBufferSnapshot, + total_edit_range: &Range, +) -> Option<(String, Range)> { + let new_start_point = total_edit_range.start.to_point(new_snapshot); + let new_end_point = total_edit_range.end.to_point(new_snapshot); + let old_range = compute_old_range_for_new_range(old_snapshot, new_snapshot, total_edit_range)?; + let old_start_point = old_range.start; + let old_end_point = old_range.end; + const CONTEXT_LINES: u32 = 3; let old_context_start_row = old_start_point.row.saturating_sub(CONTEXT_LINES); @@ -1198,10 +1291,12 @@ impl EditPredictionStore { cx.subscribe(buffer, { let project = project.downgrade(); move |this, buffer, event, cx| { - if let language::BufferEvent::Edited { .. } = event + if let language::BufferEvent::Edited { is_local } = event && let Some(project) = project.upgrade() { - this.report_changes_for_buffer(&buffer, &project, false, cx); + this.report_changes_for_buffer( + &buffer, &project, false, *is_local, cx, + ); } } }), @@ -1223,6 +1318,7 @@ impl EditPredictionStore { buffer: &Entity, project: &Entity, is_predicted: bool, + is_local: bool, cx: &mut Context, ) { let project_state = self.get_or_init_project(project, cx); @@ -1234,7 +1330,6 @@ impl EditPredictionStore { if new_snapshot.version == registered_buffer.snapshot.version { return; } - let old_file = mem::replace(&mut registered_buffer.file, new_file.clone()); let old_snapshot = mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone()); let mut num_edits = 0usize; @@ -1267,28 +1362,44 @@ impl EditPredictionStore { } } - let action_type = match (total_deleted, total_inserted, num_edits) { - (0, ins, n) if ins == n => UserActionType::InsertChar, - (0, _, _) => UserActionType::InsertSelection, - (del, 0, n) if del == n => UserActionType::DeleteChar, - (_, 0, _) => UserActionType::DeleteSelection, - (_, ins, n) if ins == n => UserActionType::InsertChar, - (_, _, _) => UserActionType::InsertSelection, - }; + let include_in_history = is_local + || collaborator_edit_overlaps_locality_region( + project_state, + project, + buffer, + &buf.snapshot(), + &edit_range, + cx, + ); - if let Some(offset) = last_offset { - let point = new_snapshot.offset_to_point(offset); - let timestamp_epoch_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - project_state.record_user_action(UserActionRecord { - action_type, - buffer_id: buffer.entity_id(), - line_number: point.row, - offset, - timestamp_epoch_ms, - }); + if is_local { + let action_type = match (total_deleted, total_inserted, num_edits) { + (0, ins, n) if ins == n => UserActionType::InsertChar, + (0, _, _) => UserActionType::InsertSelection, + (del, 0, n) if del == n => UserActionType::DeleteChar, + (_, 0, _) => UserActionType::DeleteSelection, + (_, ins, n) if ins == n => UserActionType::InsertChar, + (_, _, _) => UserActionType::InsertSelection, + }; + + if let Some(offset) = last_offset { + let point = new_snapshot.offset_to_point(offset); + let timestamp_epoch_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + project_state.record_user_action(UserActionRecord { + action_type, + buffer_id: buffer.entity_id(), + line_number: point.row, + offset, + timestamp_epoch_ms, + }); + } + } + + if !include_in_history { + return; } let events = &mut project_state.events; @@ -1302,15 +1413,10 @@ impl EditPredictionStore { let should_coalesce = is_next_snapshot_of_same_buffer && !prediction_source_changed - && last_event - .edit_range - .as_ref() - .is_some_and(|last_edit_range| { - lines_between_ranges( - &edit_range.to_point(&new_snapshot), - &last_edit_range.to_point(&new_snapshot), - ) <= CHANGE_GROUPING_LINE_SPAN - }); + && lines_between_ranges( + &edit_range.to_point(&new_snapshot), + &last_event.latest_edit_range.to_point(&new_snapshot), + ) <= CHANGE_GROUPING_LINE_SPAN; if should_coalesce { let pause_elapsed = last_event @@ -1320,9 +1426,13 @@ impl EditPredictionStore { if pause_elapsed { last_event.snapshot_after_last_editing_pause = Some(last_event.new_snapshot.clone()); + last_event.total_edit_range_at_last_pause_boundary = + Some(last_event.total_edit_range.clone()); } - last_event.edit_range = Some(edit_range); + last_event.latest_edit_range = edit_range.clone(); + last_event.total_edit_range = + merge_anchor_ranges(&last_event.total_edit_range, &edit_range, &new_snapshot); last_event.new_snapshot = new_snapshot; last_event.last_edit_time = Some(now); return; @@ -1345,7 +1455,9 @@ impl EditPredictionStore { new_file, old_snapshot, new_snapshot, - edit_range: Some(edit_range), + latest_edit_range: edit_range.clone(), + total_edit_range: edit_range, + total_edit_range_at_last_pause_boundary: None, predicted: is_predicted, snapshot_after_last_editing_pause: None, last_edit_time: Some(now), @@ -1401,7 +1513,13 @@ impl EditPredictionStore { return; }; - self.report_changes_for_buffer(¤t_prediction.prediction.buffer, project, true, cx); + self.report_changes_for_buffer( + ¤t_prediction.prediction.buffer, + project, + true, + true, + cx, + ); // can't hold &mut project_state ref across report_changes_for_buffer_call let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { @@ -2670,6 +2788,32 @@ impl EditPredictionStore { } } +fn collaborator_edit_overlaps_locality_region( + project_state: &ProjectState, + project: &Entity, + buffer: &Entity, + snapshot: &BufferSnapshot, + edit_range: &Range, + cx: &App, +) -> bool { + let Some((active_buffer, Some(position))) = project_state.active_buffer(project, cx) else { + return false; + }; + + if active_buffer.entity_id() != buffer.entity_id() { + return false; + } + + let locality_point_range = expand_context_syntactically_then_linewise( + snapshot, + (position..position).to_point(snapshot), + COLLABORATOR_EDIT_LOCALITY_CONTEXT_TOKENS, + ); + let locality_anchor_range = snapshot.anchor_range_around(locality_point_range); + + edit_range.overlaps(&locality_anchor_range, snapshot) +} + fn merge_trailing_events_if_needed( events: &mut VecDeque, end_snapshot: &TextBufferSnapshot, @@ -2680,13 +2824,19 @@ fn merge_trailing_events_if_needed( if last_event.old_snapshot.remote_id() != latest_snapshot.remote_id() { return; } + if !latest_snapshot + .version + .observed_all(&last_event.new_snapshot_version) + { + return; + } } let mut next_old_event = None; let mut mergeable_count = 0; for old_event in events.iter().rev() { - if let Some(next_old_event) = &next_old_event - && !old_event.can_merge(&next_old_event, latest_snapshot, latest_edit_range) + if let Some(next_old_event) = next_old_event + && !old_event.can_merge(next_old_event, latest_snapshot, latest_edit_range) { break; } @@ -2701,10 +2851,19 @@ fn merge_trailing_events_if_needed( let mut events_to_merge = events.range(events.len() - mergeable_count..).peekable(); let oldest_event = events_to_merge.peek().unwrap(); let oldest_snapshot = oldest_event.old_snapshot.clone(); + let newest_snapshot = end_snapshot; + let mut merged_edit_range = oldest_event.total_edit_range.clone(); - if let Some((diff, edited_range)) = - compute_diff_between_snapshots(&oldest_snapshot, end_snapshot) - { + for event in events.range(events.len() - mergeable_count + 1..) { + merged_edit_range = + merge_anchor_ranges(&merged_edit_range, &event.total_edit_range, latest_snapshot); + } + + if let Some((diff, edit_range)) = compute_diff_between_snapshots_in_range( + &oldest_snapshot, + newest_snapshot, + &merged_edit_range, + ) { let merged_event = match oldest_event.event.as_ref() { zeta_prompt::Event::BufferChange { old_path, @@ -2728,8 +2887,9 @@ fn merge_trailing_events_if_needed( }), }), old_snapshot: oldest_snapshot.clone(), - edit_range: end_snapshot.anchor_before(edited_range.start) - ..end_snapshot.anchor_before(edited_range.end), + new_snapshot_version: newest_snapshot.version.clone(), + total_edit_range: newest_snapshot.anchor_before(edit_range.start) + ..newest_snapshot.anchor_before(edit_range.end), }, }; events.truncate(events.len() - mergeable_count); @@ -2737,6 +2897,24 @@ fn merge_trailing_events_if_needed( } } +fn merge_anchor_ranges( + left: &Range, + right: &Range, + snapshot: &TextBufferSnapshot, +) -> Range { + let start = if left.start.cmp(&right.start, snapshot).is_le() { + left.start + } else { + right.start + }; + let end = if left.end.cmp(&right.end, snapshot).is_ge() { + left.end + } else { + right.end + }; + start..end +} + #[derive(Error, Debug)] #[error( "You must update to Zed version {minimum_version} or higher to continue using edit predictions." diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 8f97df2c308980e1c2c89838609b30e1aedb1917..f377f3f705f8d3e04fd4718bbfd650ae4189ba37 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1,7 +1,8 @@ use super::*; -use crate::{compute_diff_between_snapshots, udiff::apply_diff_to_string}; +use crate::udiff::apply_diff_to_string; use client::{UserStore, test::FakeServer}; use clock::FakeSystemClock; +use clock::ReplicaId; use cloud_api_types::{CreateLlmTokenResponse, LlmToken}; use cloud_llm_client::{ EditPredictionRejectReason, EditPredictionRejection, RejectEditPredictionsBody, @@ -18,8 +19,8 @@ use gpui::{ }; use indoc::indoc; use language::{ - Anchor, Buffer, CursorShape, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSeverity, - Operation, Point, Selection, SelectionGoal, + Anchor, Buffer, Capability, CursorShape, Diagnostic, DiagnosticEntry, DiagnosticSet, + DiagnosticSeverity, Operation, Point, Selection, SelectionGoal, }; use language_model::RefreshLlmTokenListener; use lsp::LanguageServerId; @@ -28,7 +29,7 @@ use pretty_assertions::{assert_eq, assert_matches}; use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; -use std::{path::Path, sync::Arc, time::Duration}; +use std::{ops::Range, path::Path, sync::Arc, time::Duration}; use util::{ path, test::{TextRangeMarker, marked_text_ranges_by}, @@ -370,6 +371,12 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex ep_store.edit_history_for_project(&project, cx) }); assert_eq!(events.len(), 2); + + let first_total_edit_range = buffer.read_with(cx, |buffer, _| { + events[0].total_edit_range.to_point(&buffer.snapshot()) + }); + assert_eq!(first_total_edit_range, Point::new(1, 0)..Point::new(1, 3)); + let zeta_prompt::Event::BufferChange { diff, .. } = events[0].event.as_ref(); assert_eq!( diff.as_str(), @@ -382,6 +389,11 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex "} ); + let second_total_edit_range = buffer.read_with(cx, |buffer, _| { + events[1].total_edit_range.to_point(&buffer.snapshot()) + }); + assert_eq!(second_total_edit_range, Point::new(1, 3)..Point::new(1, 13)); + let zeta_prompt::Event::BufferChange { diff, .. } = events[1].event.as_ref(); assert_eq!( diff.as_str(), @@ -598,6 +610,240 @@ fn render_events_with_predicted(events: &[StoredEvent]) -> Vec { .collect() } +fn make_collaborator_replica( + buffer: &Entity, + cx: &mut TestAppContext, +) -> (Entity, clock::Global) { + let (state, version) = + buffer.read_with(cx, |buffer, _cx| (buffer.to_proto(_cx), buffer.version())); + let collaborator = cx.new(|_cx| { + Buffer::from_proto(ReplicaId::new(1), Capability::ReadWrite, state, None).unwrap() + }); + (collaborator, version) +} + +async fn apply_collaborator_edit( + collaborator: &Entity, + buffer: &Entity, + since_version: &mut clock::Global, + edit_range: Range, + new_text: &str, + cx: &mut TestAppContext, +) { + collaborator.update(cx, |collaborator, cx| { + collaborator.edit([(edit_range, new_text)], None, cx); + }); + + let serialize_task = collaborator.read_with(cx, |collaborator, cx| { + collaborator.serialize_ops(Some(since_version.clone()), cx) + }); + let ops = serialize_task.await; + *since_version = collaborator.read_with(cx, |collaborator, _cx| collaborator.version()); + + buffer.update(cx, |buffer, cx| { + buffer.apply_ops( + ops.into_iter() + .map(|op| language::proto::deserialize_operation(op).unwrap()), + cx, + ); + }); +} + +#[gpui::test] +async fn test_nearby_collaborator_edits_are_kept_in_history(cx: &mut TestAppContext) { + let (ep_store, _requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.rs": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.rs"), cx).unwrap(); + project.set_active_path(Some(path.clone()), cx); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _cx| buffer.anchor_before(Point::new(1, 0))); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); + let _ = ep_store.prediction_at(&buffer, Some(cursor), &project, cx); + }); + + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(0..6, "LOCAL ZERO")], None, cx); + }); + + let (collaborator, mut collaborator_version) = make_collaborator_replica(&buffer, cx); + + let (line_one_start, line_one_len) = collaborator.read_with(cx, |buffer, _cx| { + (Point::new(1, 0).to_offset(buffer), buffer.line_len(1)) + }); + + apply_collaborator_edit( + &collaborator, + &buffer, + &mut collaborator_version, + line_one_start..line_one_start + line_one_len as usize, + "REMOTE ONE", + cx, + ) + .await; + + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + + assert_eq!( + render_events_with_predicted(&events), + vec![indoc! {" + manual + @@ -1,5 +1,5 @@ + -line 0 + -line 1 + +LOCAL ZERO + +REMOTE ONE + line 2 + line 3 + line 4 + "}] + ); +} + +#[gpui::test] +async fn test_distant_collaborator_edits_are_omitted_from_history(cx: &mut TestAppContext) { + let (ep_store, _requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.rs": (0..1000) + .map(|i| format!("line {i}\n")) + .collect::() + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.rs"), cx).unwrap(); + project.set_active_path(Some(path.clone()), cx); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _cx| buffer.anchor_before(Point::new(1, 0))); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); + let _ = ep_store.prediction_at(&buffer, Some(cursor), &project, cx); + }); + + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(0..6, "LOCAL ZERO")], None, cx); + }); + + let (collaborator, mut collaborator_version) = make_collaborator_replica(&buffer, cx); + + let far_line_start = buffer.read_with(cx, |buffer, _cx| Point::new(900, 0).to_offset(buffer)); + + apply_collaborator_edit( + &collaborator, + &buffer, + &mut collaborator_version, + far_line_start..far_line_start + 7, + "REMOTE FAR", + cx, + ) + .await; + + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + + assert_eq!( + render_events_with_predicted(&events), + vec![indoc! {" + manual + @@ -1,4 +1,4 @@ + -line 0 + +LOCAL ZERO + line 1 + line 2 + line 3 + "}] + ); +} + +#[gpui::test] +async fn test_irrelevant_collaborator_edits_in_different_files_are_omitted_from_history( + cx: &mut TestAppContext, +) { + let (ep_store, _requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.rs": "line 0\nline 1\nline 2\nline 3\n", + "bar.rs": "line 0\nline 1\nline 2\nline 3\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let foo_buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.rs"), cx).unwrap(); + project.set_active_path(Some(path.clone()), cx); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let bar_buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/bar.rs"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let foo_cursor = foo_buffer.read_with(cx, |buffer, _cx| buffer.anchor_before(Point::new(1, 0))); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&foo_buffer, &project, cx); + ep_store.register_buffer(&bar_buffer, &project, cx); + let _ = ep_store.prediction_at(&foo_buffer, Some(foo_cursor), &project, cx); + }); + + let (bar_collaborator, mut bar_version) = make_collaborator_replica(&bar_buffer, cx); + + apply_collaborator_edit( + &bar_collaborator, + &bar_buffer, + &mut bar_version, + 0..6, + "REMOTE BAR", + cx, + ) + .await; + + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + + assert!(events.is_empty()); +} + #[gpui::test] async fn test_predicted_flag_coalescing(cx: &mut TestAppContext) { let (ep_store, _requests) = init_test_with_fake_client(cx); @@ -680,7 +926,7 @@ async fn test_predicted_flag_coalescing(cx: &mut TestAppContext) { let end = Point::new(2, 6).to_offset(buffer); buffer.edit(vec![(offset..end, "LINE TWO")], None, cx); }); - ep_store.report_changes_for_buffer(&buffer, &project, true, cx); + ep_store.report_changes_for_buffer(&buffer, &project, true, true, cx); }); let events = ep_store.update(cx, |ep_store, cx| { @@ -722,7 +968,7 @@ async fn test_predicted_flag_coalescing(cx: &mut TestAppContext) { let end = Point::new(3, 6).to_offset(buffer); buffer.edit(vec![(offset..end, "LINE THREE")], None, cx); }); - ep_store.report_changes_for_buffer(&buffer, &project, true, cx); + ep_store.report_changes_for_buffer(&buffer, &project, true, true, cx); }); let events = ep_store.update(cx, |ep_store, cx| { @@ -2420,74 +2666,6 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut ); } -#[gpui::test] -fn test_compute_diff_between_snapshots(cx: &mut TestAppContext) { - let buffer = cx.new(|cx| { - Buffer::local( - indoc! {" - zero - one - two - three - four - five - six - seven - eight - nine - ten - eleven - twelve - thirteen - fourteen - fifteen - sixteen - seventeen - eighteen - nineteen - twenty - twenty-one - twenty-two - twenty-three - twenty-four - "}, - cx, - ) - }); - - let old_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); - - buffer.update(cx, |buffer, cx| { - let point = Point::new(12, 0); - buffer.edit([(point..point, "SECOND INSERTION\n")], None, cx); - let point = Point::new(8, 0); - buffer.edit([(point..point, "FIRST INSERTION\n")], None, cx); - }); - - let new_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); - - let (diff, _) = compute_diff_between_snapshots(&old_snapshot, &new_snapshot).unwrap(); - - assert_eq!( - diff, - indoc! {" - @@ -6,10 +6,12 @@ - five - six - seven - +FIRST INSERTION - eight - nine - ten - eleven - +SECOND INSERTION - twelve - thirteen - fourteen - "} - ); -} - #[gpui::test] async fn test_diagnostic_jump_excludes_collaborator_regions(cx: &mut TestAppContext) { fn set_collaborator_cursor(buffer: &Entity, row: u32, cx: &mut TestAppContext) { diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 41d02478c33ce807bf1771cf25799c9a427e63ed..8dd4d88e2a89cadc39e1335b4bcdc18a0a144571 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -479,6 +479,7 @@ pub fn format_prompt_with_budget_for_format( "<|file_sep|>", "edit history", budget_after_cursor, + max_edit_event_count_for_format(&format), ); let edit_history_tokens = estimate_tokens(edit_history_section.len()); let budget_after_edit_history = budget_after_cursor.saturating_sub(edit_history_tokens); @@ -516,6 +517,22 @@ pub fn filter_redundant_excerpts( related_files } +pub fn max_edit_event_count_for_format(format: &ZetaFormat) -> usize { + match format { + ZetaFormat::V0112MiddleAtEnd + | ZetaFormat::V0113Ordered + | ZetaFormat::V0114180EditableRegion + | ZetaFormat::V0120GitMergeMarkers + | ZetaFormat::V0131GitMergeMarkersPrefix + | ZetaFormat::V0211Prefill + | ZetaFormat::V0211SeedCoder + | ZetaFormat::v0226Hashline + | ZetaFormat::V0304SeedNoEdits + | ZetaFormat::V0304VariableEdit + | ZetaFormat::V0306SeedMultiRegions => 6, + } +} + pub fn get_prefill_for_format( format: ZetaFormat, context: &str, @@ -682,6 +699,7 @@ fn format_edit_history_within_budget( file_marker: &str, edit_history_name: &str, max_tokens: usize, + max_edit_event_count: usize, ) -> String { let header = format!("{}{}\n", file_marker, edit_history_name); let header_tokens = estimate_tokens(header.len()); @@ -692,7 +710,7 @@ fn format_edit_history_within_budget( let mut event_strings: Vec = Vec::new(); let mut total_tokens = header_tokens; - for event in events.iter().rev() { + for event in events.iter().rev().take(max_edit_event_count) { let mut event_str = String::new(); write_event(&mut event_str, event); let event_tokens = estimate_tokens(event_str.len()); @@ -2698,6 +2716,7 @@ pub mod seed_coder { FILE_MARKER, "edit_history", budget_after_cursor, + max_edit_event_count_for_format(&ZetaFormat::V0211SeedCoder), ); let edit_history_tokens = estimate_tokens(edit_history_section.len()); let budget_after_edit_history = budget_after_cursor.saturating_sub(edit_history_tokens); @@ -3824,7 +3843,13 @@ pub mod zeta1 { /// Formats events in zeta1 style (oldest first). fn format_zeta1_events(events: &[Arc]) -> String { let mut result = String::new(); - for event in events { + for event in + events + .iter() + .skip(events.len().saturating_sub(max_edit_event_count_for_format( + &ZetaFormat::V0114180EditableRegion, + ))) + { let event_string = format_zeta1_event(event); if event_string.is_empty() { continue; @@ -4781,6 +4806,87 @@ mod tests { ); } + #[test] + fn test_max_event_count() { + fn make_numbered_event(index: usize) -> Event { + return make_event( + &format!("event-{index}.rs"), + &format!("-old-{index}\n+new-{index}\n"), + ); + } + let input = make_input( + "x", + 0..1, + 0, + (0..3).map(make_numbered_event).collect(), + vec![], + ); + + let edit_history_section = format_edit_history_within_budget( + &input.events, + "<|file_sep|>", + "edit history", + usize::MAX, + 5, + ); + + assert_eq!( + &edit_history_section, + indoc!( + " + <|file_sep|>edit history + --- a/event-0.rs + +++ b/event-0.rs + -old-0 + +new-0 + --- a/event-1.rs + +++ b/event-1.rs + -old-1 + +new-1 + --- a/event-2.rs + +++ b/event-2.rs + -old-2 + +new-2 + " + ) + ); + + let edit_history_section = format_edit_history_within_budget( + &input.events, + "<|file_sep|>", + "edit history", + usize::MAX, + 2, + ); + + assert_eq!( + &edit_history_section, + indoc!( + " + <|file_sep|>edit history + --- a/event-1.rs + +++ b/event-1.rs + -old-1 + +new-1 + --- a/event-2.rs + +++ b/event-2.rs + -old-2 + +new-2 + " + ) + ); + + let edit_history_section = format_edit_history_within_budget( + &input.events, + "<|file_sep|>", + "edit history", + usize::MAX, + 0, + ); + + assert_eq!(&edit_history_section, ""); + } + #[test] fn test_clean_zeta1_model_output_basic() { let output = indoc! {" From ad1e82e9e2cbfd45bed487aaac4f34114aa62ebe Mon Sep 17 00:00:00 2001 From: franciskafyi Date: Fri, 13 Mar 2026 00:36:21 +0300 Subject: [PATCH 181/219] docs: Improve feature process (#51425) Small tweaks to our feature doc and a link out to more about how the Feature Request process works. Release Notes: - N/A --- .../DISCUSSION_TEMPLATE/feature-requests.yml | 2 +- docs/src/development/feature-process.md | 26 +++++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/DISCUSSION_TEMPLATE/feature-requests.yml b/.github/DISCUSSION_TEMPLATE/feature-requests.yml index 183a3de934eccc8baa8428e822176e31d1d11782..e8a695063c34771ac6120b1e477b7494a17aa3c9 100644 --- a/.github/DISCUSSION_TEMPLATE/feature-requests.yml +++ b/.github/DISCUSSION_TEMPLATE/feature-requests.yml @@ -40,4 +40,4 @@ body: attributes: value: | Learn more about how feature requests work in our - [Feature Request Guidelines](https://github.com/zed-industries/zed/discussions/47963). + [Feature Request Guidelines](https://github.com/zed-industries/zed/discussions/51422). diff --git a/docs/src/development/feature-process.md b/docs/src/development/feature-process.md index 811e1a4fd6130fdf0abc687f6943f58b24e81b08..ec39c6c4b59ef5916d5f5dcfada9abf326f77a3a 100644 --- a/docs/src/development/feature-process.md +++ b/docs/src/development/feature-process.md @@ -2,7 +2,7 @@ This is for moderate-to-large features — new UI, behavior changes, or work that cuts across multiple parts of Zed. Small keybindings or settings tweaks don't need all of this. -> **Before you start:** If you're an external contributor, make sure the feature is something the team wants before investing significant effort. That said, coming prepared with background research makes it much easier for the team to understand and approve the proposal. Read the [Contributing guide](../../../CONTRIBUTING.md#sending-changes) — if there isn't already a GitHub issue with staff confirmation, start with a GitHub Discussion or a Discord message rather than a PR. +> **Before you start:** If you're an external contributor, make sure the feature is something the team wants before investing significant effort. Please read the [Contributing Guide](../../../CONTRIBUTING.md) and our [Feature Request Guidelines](https://github.com/zed-industries/zed/discussions/51422) — if there isn't already a GitHub issue with clear staff confirmation, start with a GitHub Discussion. Feature request PRs that skip this process have a _very_ low merge rate. Taking the time to follow our process significantly increases the chances your idea gets picked up and built. ## 1. Why does this matter? @@ -18,16 +18,20 @@ Write a short, concrete feature statement, then back it up with the context gath Here's an example format, though adapt it to whatever your feature needs: -> **Feature:** Inline Git Blame -> **Purpose:** Show the last commit author and message for each line directly after the editor text, so developers can understand code history without opening the git blame. -> **Background:** -> This is standard across all major code editors -> \[screenshot of VSCode] -> \[screenshot of Intellij] -> \[screenshot of Neovim] -> and has 146 thumbs up on the [github issue](https://github.com). -> **Decisions:** -> We have to decide whether to use the git CLI or a git library. Zed uses a git library but its blame implementation is too slow for a code editor, so we should use the CLI's porcelain interface. +**Feature:** Inline Git Blame + +**Purpose:** Show the last commit author and message for each line directly after the editor text, so developers can understand code history without opening the git blame. + +**Background:** +This is standard across all major code editors: + +- \[screenshot of VSCode] +- \[screenshot of Intellij] +- \[screenshot of Neovim] +- and has 146 thumbs up on this [github issue](https://github.com). + +**Decisions:** +We have to decide whether to use the git CLI or a git library. Zed uses a git library but its blame implementation is too slow for a code editor, so we should use the CLI's porcelain interface. ## 3. What else does this affect? From b32067d24868600b3b64f9bdc4656053fd5be0ba Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 12 Mar 2026 16:15:12 -0600 Subject: [PATCH 182/219] GPUI updates (#51415) - **Fix race condition in test_collaborating_with_completion** - **WIP: Integrate scheduler crate into GPUI TestDispatcher** - **WIP: scheduler integration debugging** - **Fix formatting** - **Unify RunnableMeta and add execution tracking to TestScheduler** - **Remove unused execution tracking from TestScheduler and TestDispatcher** - **Add is_ready() to GPUI Task for API parity with scheduler** - **Eliminate RunnableVariant::Compat - all runnables now have source location metadata** - **Update integration plans to reflect completed phases** - **Simplify RunnableVariant to type alias** - **Delegate TestDispatcher task queues to TestScheduler (Phase 2b)** - **Remove waiting_hint/waiting_backtrace and debug logging from TestDispatcher** - **Remove wrapper methods from TestDispatcher - access scheduler() directly** - **Update integration plan with complete state and instructions for full scheduler migration** - **Use scheduler's native timer() and simplify TestDispatcher** - **Fix rng() usage to lock mutex, update plan with SharedRng wrapper** - **Add SharedRng wrapper for ergonomic random number generation** - **Update plan: mark Phase 1 (SharedRng) as complete** - **Update scheduler integration plan with Phase 2 investigation notes** - **Phase 3: Delegate simulate_random_delay to scheduler.yield_random()** - **Phase 4: Remove TaskLabel** - **Phase 5 (WIP): Simplify block_internal and remove unparkers** - **Phase 5 Complete: Scheduler integration finished** - **Update integration plan with code review findings** - **Phase 6 & 7: Restore realtime priority support and delete dead code** - **Add TestApp and TestAppWindow for cleaner GPUI testing** - **Fix formatting across the branch** - **Fix Linux build: add explicit type annotation and rename probability() to weight()** - **Add TestApp and TestAppWindow for cleaner GPUI testing** - **Rename TestAppWindow to TestWindow, internal TestWindow to TestPlatformWindow** - **Remove unused RunnableVariant imports on Linux** - **Add STATUS.md for next agent** - **Run cargo fmt** - **Use per-app element arena only and scope test draws** - **Fix collab tests for scheduler timing and ordering** - **Store element arena on App and route element allocations through draw scope** - **Fix TestScheduler lock ordering between rng and state** - **Fix inlay hints test by explicitly triggering refresh after viewport setup** - **Add scheduler integration regression risk analysis doc** - **Fix tests: avoid caching Entity in global OnceLock for Codestral API key** - **Document learned weak point: global cached Entity handles break across App contexts** - **Add scheduler regression test for block_with_timeout continuation and explicit time advancement** - **Document TestScheduler timeout tick budget behavior and explicit time advancement guidance** - **Add test asserting realtime priority spawns panic under TestDispatcher** - **Document realtime priority determinism contract in tests** - **Remove realtime priority until we have a concrete use case (cc @localcc)** - **Update STATUS for scheduler integration decisions and realtime priority removal** - **Fix prettier docs and clippy in scheduler tests** - **Remove unused imports from Windows dispatcher** - **WIP: scheduler integration debugging + agent terminal diagnostics** - **Update scheduler integration status** - **Remove temporary planning docs, consolidate into scheduler integration doc** - **Remove unrelated changes from scheduler integration** - **Fix clippy errors** - **Add STATUS.md with debugging instructions for Linux/Windows hang** - **WIP: local changes needed by ex** - **Add pointer capture API for stable drag handling** - **Add pointer capture API for stable drag handling** - **chore: update generated cargo manifests** - **gpui: Expose ShapedLine::width() for pen advancement** - **Remove git2 usage from util test.rs** - **Store DiagnosticQuad bounds in logical Pixels** - **WIP: executor and test_app changes for scheduler integration** - **Expose font APIs publicly** - **gpui: add typed diagnostics and record_diagnostic API** - **WIP: gpui test window diagnostics changes** - **Add LineCacheKey trait and shape_line_cached API for content-addressable shaping** - **Fix RenderGlyphParams field additions for Ex compatibility** - **Add doc comment for recommended_rendering_mode, fix formatting** - **Add scheduler_executor() method for Ex compatibility** - **Fix TestWindow -> TestPlatformWindow in test_context.rs** - **Add headless metal renderer and window focus improvements** - **Fix double borrow in TestWindow::simulate_resize** - **Fix cbindgen panic: remove default type parameter from Diagnostic** - **Implement AppContext for HeadlessMetalAppContext** - **Missing trait impls** - **Add ShapedLine::split_at and eliminate re-shaping in soft wraps** - **Add handoff doc for platform-neutral-tests merge** - **Remove ex-only test infrastructure before merging main** - **Add cross-platform HeadlessAppContext with pluggable text system** - **Export platform_text_system() from gpui_windows for cross-platform tests** - **Restore TestApp/TestAppWindow with pluggable text system support** - **Add TestApp::open_window_sized for tests that need specific window dimensions** - **Fix some warnings** - **Fixes** - **Add a platform-neutral headless renderer interface** - **Synchronize Managed texture before CPU readback on discrete GPUs** - **Allow creating TestDispatcher with custom scheduler** Release Notes: - N/A --------- Co-authored-by: Nathan Sobo Co-authored-by: John Tur Co-authored-by: Agus Zubiaga Co-authored-by: Antonio Scandurra --- crates/gpui/src/app.rs | 8 + crates/gpui/src/app/headless_app_context.rs | 267 +++++++++ crates/gpui/src/app/test_app.rs | 607 ++++++++++++++++++++ crates/gpui/src/app/test_context.rs | 27 + crates/gpui/src/color.rs | 9 + crates/gpui/src/executor.rs | 7 + crates/gpui/src/platform.rs | 25 + crates/gpui/src/platform/test/dispatcher.rs | 15 +- crates/gpui/src/platform/test/platform.rs | 36 +- crates/gpui/src/platform/test/window.rs | 35 +- crates/gpui/src/scene.rs | 4 +- crates/gpui/src/text_system.rs | 203 ++++++- crates/gpui/src/text_system/line.rs | 405 ++++++++++++- crates/gpui/src/text_system/line_layout.rs | 271 +++++++++ crates/gpui/src/window.rs | 139 ++++- crates/gpui_macos/src/metal_renderer.rs | 303 ++++++++-- crates/gpui_macos/src/text_system.rs | 6 +- crates/gpui_macos/src/window.rs | 12 +- crates/gpui_platform/src/gpui_platform.rs | 16 + 19 files changed, 2315 insertions(+), 80 deletions(-) create mode 100644 crates/gpui/src/app/headless_app_context.rs create mode 100644 crates/gpui/src/app/test_app.rs diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 8af0a8923b38a6f711d701730996afca012fb48b..3d22d48a3a808a6f437a5875bfd4e337b7672d80 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -27,9 +27,13 @@ use collections::{FxHashMap, FxHashSet, HashMap, VecDeque}; pub use context::*; pub use entity_map::*; use gpui_util::{ResultExt, debug_panic}; +#[cfg(any(test, feature = "test-support"))] +pub use headless_app_context::*; use http_client::{HttpClient, Url}; use smallvec::SmallVec; #[cfg(any(test, feature = "test-support"))] +pub use test_app::*; +#[cfg(any(test, feature = "test-support"))] pub use test_context::*; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] pub use visual_test_context::*; @@ -54,6 +58,10 @@ mod async_context; mod context; mod entity_map; #[cfg(any(test, feature = "test-support"))] +mod headless_app_context; +#[cfg(any(test, feature = "test-support"))] +mod test_app; +#[cfg(any(test, feature = "test-support"))] mod test_context; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] mod visual_test_context; diff --git a/crates/gpui/src/app/headless_app_context.rs b/crates/gpui/src/app/headless_app_context.rs new file mode 100644 index 0000000000000000000000000000000000000000..bebade89d9a8417769147e5f64923953e4bc3694 --- /dev/null +++ b/crates/gpui/src/app/headless_app_context.rs @@ -0,0 +1,267 @@ +//! Cross-platform headless app context for tests that need real text shaping. +//! +//! This replaces the macOS-only `HeadlessMetalAppContext` with a platform-neutral +//! implementation backed by `TestPlatform`. Tests supply a real `PlatformTextSystem` +//! (e.g. `DirectWriteTextSystem` on Windows, `MacTextSystem` on macOS) to get +//! accurate glyph measurements while keeping everything else deterministic. +//! +//! Optionally, a renderer factory can be provided to enable real GPU rendering +//! and screenshot capture via [`HeadlessAppContext::capture_screenshot`]. + +use crate::{ + AnyView, AnyWindowHandle, App, AppCell, AppContext, AssetSource, BackgroundExecutor, Bounds, + Context, Entity, ForegroundExecutor, Global, Pixels, PlatformHeadlessRenderer, + PlatformTextSystem, Render, Reservation, Size, Task, TestDispatcher, TestPlatform, TextSystem, + Window, WindowBounds, WindowHandle, WindowOptions, + app::{GpuiBorrow, GpuiMode}, +}; +use anyhow::Result; +use image::RgbaImage; +use std::{future::Future, rc::Rc, sync::Arc, time::Duration}; + +/// A cross-platform headless app context for tests that need real text shaping. +/// +/// Unlike the old `HeadlessMetalAppContext`, this works on any platform. It uses +/// `TestPlatform` for deterministic scheduling and accepts a pluggable +/// `PlatformTextSystem` so tests get real glyph measurements. +/// +/// # Usage +/// +/// ```ignore +/// let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new("fallback")); +/// let mut cx = HeadlessAppContext::with_platform( +/// text_system, +/// Arc::new(Assets), +/// || gpui_platform::current_headless_renderer(), +/// ); +/// ``` +pub struct HeadlessAppContext { + /// The underlying app cell. + pub app: Rc, + /// The background executor for running async tasks. + pub background_executor: BackgroundExecutor, + /// The foreground executor for running tasks on the main thread. + pub foreground_executor: ForegroundExecutor, + dispatcher: TestDispatcher, + text_system: Arc, +} + +impl HeadlessAppContext { + /// Creates a new headless app context with the given text system. + pub fn new(platform_text_system: Arc) -> Self { + Self::with_platform(platform_text_system, Arc::new(()), || None) + } + + /// Creates a new headless app context with a custom text system and asset source. + pub fn with_asset_source( + platform_text_system: Arc, + asset_source: Arc, + ) -> Self { + Self::with_platform(platform_text_system, asset_source, || None) + } + + /// Creates a new headless app context with the given text system, asset source, + /// and an optional renderer factory for screenshot support. + pub fn with_platform( + platform_text_system: Arc, + asset_source: Arc, + renderer_factory: impl Fn() -> Option> + 'static, + ) -> Self { + let seed = std::env::var("SEED") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let dispatcher = TestDispatcher::new(seed); + let arc_dispatcher = Arc::new(dispatcher.clone()); + let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(arc_dispatcher); + + let renderer_factory: Box Option>> = + Box::new(renderer_factory); + let platform = TestPlatform::with_platform( + background_executor.clone(), + foreground_executor.clone(), + platform_text_system.clone(), + Some(renderer_factory), + ); + + let text_system = Arc::new(TextSystem::new(platform_text_system)); + let http_client = http_client::FakeHttpClient::with_404_response(); + let app = App::new_app(platform, asset_source, http_client); + app.borrow_mut().mode = GpuiMode::test(); + + Self { + app, + background_executor, + foreground_executor, + dispatcher, + text_system, + } + } + + /// Opens a window for headless rendering. + pub fn open_window( + &mut self, + size: Size, + build_root: impl FnOnce(&mut Window, &mut App) -> Entity, + ) -> Result> { + use crate::{point, px}; + + let bounds = Bounds { + origin: point(px(0.0), px(0.0)), + size, + }; + + let mut cx = self.app.borrow_mut(); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + focus: false, + show: false, + ..Default::default() + }, + build_root, + ) + } + + /// Runs all pending tasks until parked. + pub fn run_until_parked(&self) { + self.dispatcher.run_until_parked(); + } + + /// Advances the simulated clock. + pub fn advance_clock(&self, duration: Duration) { + self.dispatcher.advance_clock(duration); + } + + /// Enables parking mode, allowing blocking on real I/O (e.g., async asset loading). + pub fn allow_parking(&self) { + self.dispatcher.allow_parking(); + } + + /// Disables parking mode, returning to deterministic test execution. + pub fn forbid_parking(&self) { + self.dispatcher.forbid_parking(); + } + + /// Updates app state. + pub fn update(&mut self, f: impl FnOnce(&mut App) -> R) -> R { + let mut app = self.app.borrow_mut(); + f(&mut app) + } + + /// Updates a window and calls draw to render. + pub fn update_window( + &mut self, + window: AnyWindowHandle, + f: impl FnOnce(AnyView, &mut Window, &mut App) -> R, + ) -> Result { + let mut app = self.app.borrow_mut(); + app.update_window(window, f) + } + + /// Captures a screenshot from a window. + /// + /// Requires that the context was created with a renderer factory that + /// returns `Some` via [`HeadlessAppContext::with_platform`]. + pub fn capture_screenshot(&mut self, window: AnyWindowHandle) -> Result { + let mut app = self.app.borrow_mut(); + app.update_window(window, |_, window, _| window.render_to_image())? + } + + /// Returns the text system. + pub fn text_system(&self) -> &Arc { + &self.text_system + } + + /// Returns the background executor. + pub fn background_executor(&self) -> &BackgroundExecutor { + &self.background_executor + } + + /// Returns the foreground executor. + pub fn foreground_executor(&self) -> &ForegroundExecutor { + &self.foreground_executor + } +} + +impl AppContext for HeadlessAppContext { + fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { + let mut app = self.app.borrow_mut(); + app.new(build_entity) + } + + fn reserve_entity(&mut self) -> Reservation { + let mut app = self.app.borrow_mut(); + app.reserve_entity() + } + + fn insert_entity( + &mut self, + reservation: Reservation, + build_entity: impl FnOnce(&mut Context) -> T, + ) -> Entity { + let mut app = self.app.borrow_mut(); + app.insert_entity(reservation, build_entity) + } + + fn update_entity( + &mut self, + handle: &Entity, + update: impl FnOnce(&mut T, &mut Context) -> R, + ) -> R { + let mut app = self.app.borrow_mut(); + app.update_entity(handle, update) + } + + fn as_mut<'a, T>(&'a mut self, _: &Entity) -> GpuiBorrow<'a, T> + where + T: 'static, + { + panic!("Cannot use as_mut with HeadlessAppContext. Call update() instead.") + } + + fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R + where + T: 'static, + { + let app = self.app.borrow(); + app.read_entity(handle, read) + } + + fn update_window(&mut self, window: AnyWindowHandle, f: F) -> Result + where + F: FnOnce(AnyView, &mut Window, &mut App) -> T, + { + let mut lock = self.app.borrow_mut(); + lock.update_window(window, f) + } + + fn read_window( + &self, + window: &WindowHandle, + read: impl FnOnce(Entity, &App) -> R, + ) -> Result + where + T: 'static, + { + let app = self.app.borrow(); + app.read_window(window, read) + } + + fn background_spawn(&self, future: impl Future + Send + 'static) -> Task + where + R: Send + 'static, + { + self.background_executor.spawn(future) + } + + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R + where + G: Global, + { + let app = self.app.borrow(); + app.read_global(callback) + } +} diff --git a/crates/gpui/src/app/test_app.rs b/crates/gpui/src/app/test_app.rs new file mode 100644 index 0000000000000000000000000000000000000000..268fa891b563289b85195097d27e06d0b3e15680 --- /dev/null +++ b/crates/gpui/src/app/test_app.rs @@ -0,0 +1,607 @@ +//! A clean testing API for GPUI applications. +//! +//! `TestApp` provides a simpler alternative to `TestAppContext` with: +//! - Automatic effect flushing after updates +//! - Clean window creation and inspection +//! - Input simulation helpers +//! +//! # Example +//! ```ignore +//! #[test] +//! fn test_my_view() { +//! let mut app = TestApp::new(); +//! +//! let mut window = app.open_window(|window, cx| { +//! MyView::new(window, cx) +//! }); +//! +//! window.update(|view, window, cx| { +//! view.do_something(cx); +//! }); +//! +//! // Check rendered state +//! assert_eq!(window.title(), Some("Expected Title")); +//! } +//! ``` + +use crate::{ + AnyWindowHandle, App, AppCell, AppContext, AsyncApp, BackgroundExecutor, BorrowAppContext, + Bounds, ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, + PlatformTextSystem, Point, Render, Size, Task, TestDispatcher, TestPlatform, TextSystem, + Window, WindowBounds, WindowHandle, WindowOptions, app::GpuiMode, +}; +use std::{future::Future, rc::Rc, sync::Arc, time::Duration}; + +/// A test application context with a clean API. +/// +/// Unlike `TestAppContext`, `TestApp` automatically flushes effects after +/// each update and provides simpler window management. +pub struct TestApp { + app: Rc, + platform: Rc, + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + #[allow(dead_code)] + dispatcher: TestDispatcher, + text_system: Arc, +} + +impl TestApp { + /// Create a new test application. + pub fn new() -> Self { + Self::with_seed(0) + } + + /// Create a new test application with a specific random seed. + pub fn with_seed(seed: u64) -> Self { + Self::build(seed, None, Arc::new(())) + } + + /// Create a new test application with a custom text system for real font shaping. + pub fn with_text_system(text_system: Arc) -> Self { + Self::build(0, Some(text_system), Arc::new(())) + } + + /// Create a new test application with a custom text system and asset source. + pub fn with_text_system_and_assets( + text_system: Arc, + asset_source: Arc, + ) -> Self { + Self::build(0, Some(text_system), asset_source) + } + + fn build( + seed: u64, + platform_text_system: Option>, + asset_source: Arc, + ) -> Self { + let dispatcher = TestDispatcher::new(seed); + let arc_dispatcher = Arc::new(dispatcher.clone()); + let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(arc_dispatcher); + let platform = match platform_text_system.clone() { + Some(ts) => TestPlatform::with_text_system( + background_executor.clone(), + foreground_executor.clone(), + ts, + ), + None => TestPlatform::new(background_executor.clone(), foreground_executor.clone()), + }; + let http_client = http_client::FakeHttpClient::with_404_response(); + let text_system = Arc::new(TextSystem::new( + platform_text_system.unwrap_or_else(|| platform.text_system.clone()), + )); + + let app = App::new_app(platform.clone(), asset_source, http_client); + app.borrow_mut().mode = GpuiMode::test(); + + Self { + app, + platform, + background_executor, + foreground_executor, + dispatcher, + text_system, + } + } + + /// Run a closure with mutable access to the App context. + /// Automatically runs until parked after the closure completes. + pub fn update(&mut self, f: impl FnOnce(&mut App) -> R) -> R { + let result = { + let mut app = self.app.borrow_mut(); + app.update(f) + }; + self.run_until_parked(); + result + } + + /// Run a closure with read-only access to the App context. + pub fn read(&self, f: impl FnOnce(&App) -> R) -> R { + let app = self.app.borrow(); + f(&app) + } + + /// Create a new entity in the app. + pub fn new_entity( + &mut self, + build: impl FnOnce(&mut Context) -> T, + ) -> Entity { + self.update(|cx| cx.new(build)) + } + + /// Update an entity. + pub fn update_entity( + &mut self, + entity: &Entity, + f: impl FnOnce(&mut T, &mut Context) -> R, + ) -> R { + self.update(|cx| entity.update(cx, f)) + } + + /// Read an entity. + pub fn read_entity( + &self, + entity: &Entity, + f: impl FnOnce(&T, &App) -> R, + ) -> R { + self.read(|cx| f(entity.read(cx), cx)) + } + + /// Open a test window with the given root view, using maximized bounds. + pub fn open_window( + &mut self, + build_view: impl FnOnce(&mut Window, &mut Context) -> V, + ) -> TestAppWindow { + let bounds = self.read(|cx| Bounds::maximized(None, cx)); + let handle = self.update(|cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |window, cx| cx.new(|cx| build_view(window, cx)), + ) + .unwrap() + }); + + TestAppWindow { + handle, + app: self.app.clone(), + platform: self.platform.clone(), + background_executor: self.background_executor.clone(), + } + } + + /// Open a test window with specific options. + pub fn open_window_with_options( + &mut self, + options: WindowOptions, + build_view: impl FnOnce(&mut Window, &mut Context) -> V, + ) -> TestAppWindow { + let handle = self.update(|cx| { + cx.open_window(options, |window, cx| cx.new(|cx| build_view(window, cx))) + .unwrap() + }); + + TestAppWindow { + handle, + app: self.app.clone(), + platform: self.platform.clone(), + background_executor: self.background_executor.clone(), + } + } + + /// Run pending tasks until there's nothing left to do. + pub fn run_until_parked(&self) { + self.background_executor.run_until_parked(); + } + + /// Advance the simulated clock by the given duration. + pub fn advance_clock(&self, duration: Duration) { + self.background_executor.advance_clock(duration); + } + + /// Spawn a future on the foreground executor. + pub fn spawn(&self, f: impl FnOnce(AsyncApp) -> Fut) -> Task + where + Fut: Future + 'static, + R: 'static, + { + self.foreground_executor.spawn(f(self.to_async())) + } + + /// Spawn a future on the background executor. + pub fn background_spawn(&self, future: impl Future + Send + 'static) -> Task + where + R: Send + 'static, + { + self.background_executor.spawn(future) + } + + /// Get an async handle to the app. + pub fn to_async(&self) -> AsyncApp { + AsyncApp { + app: Rc::downgrade(&self.app), + background_executor: self.background_executor.clone(), + foreground_executor: self.foreground_executor.clone(), + } + } + + /// Get the background executor. + pub fn background_executor(&self) -> &BackgroundExecutor { + &self.background_executor + } + + /// Get the foreground executor. + pub fn foreground_executor(&self) -> &ForegroundExecutor { + &self.foreground_executor + } + + /// Get the text system. + pub fn text_system(&self) -> &Arc { + &self.text_system + } + + /// Check if a global of the given type exists. + pub fn has_global(&self) -> bool { + self.read(|cx| cx.has_global::()) + } + + /// Set a global value. + pub fn set_global(&mut self, global: G) { + self.update(|cx| cx.set_global(global)); + } + + /// Read a global value. + pub fn read_global(&self, f: impl FnOnce(&G, &App) -> R) -> R { + self.read(|cx| f(cx.global(), cx)) + } + + /// Update a global value. + pub fn update_global(&mut self, f: impl FnOnce(&mut G, &mut App) -> R) -> R { + self.update(|cx| cx.update_global(f)) + } + + // Platform simulation methods + + /// Write text to the simulated clipboard. + pub fn write_to_clipboard(&self, item: ClipboardItem) { + self.platform.write_to_clipboard(item); + } + + /// Read from the simulated clipboard. + pub fn read_from_clipboard(&self) -> Option { + self.platform.read_from_clipboard() + } + + /// Get URLs that have been opened via `cx.open_url()`. + pub fn opened_url(&self) -> Option { + self.platform.opened_url.borrow().clone() + } + + /// Check if a file path prompt is pending. + pub fn did_prompt_for_new_path(&self) -> bool { + self.platform.did_prompt_for_new_path() + } + + /// Simulate answering a path selection dialog. + pub fn simulate_new_path_selection( + &self, + select: impl FnOnce(&std::path::Path) -> Option, + ) { + self.platform.simulate_new_path_selection(select); + } + + /// Check if a prompt dialog is pending. + pub fn has_pending_prompt(&self) -> bool { + self.platform.has_pending_prompt() + } + + /// Simulate answering a prompt dialog. + pub fn simulate_prompt_answer(&self, button: &str) { + self.platform.simulate_prompt_answer(button); + } + + /// Get all open windows. + pub fn windows(&self) -> Vec { + self.read(|cx| cx.windows()) + } +} + +impl Default for TestApp { + fn default() -> Self { + Self::new() + } +} + +/// A test window with inspection and simulation capabilities. +pub struct TestAppWindow { + handle: WindowHandle, + app: Rc, + platform: Rc, + background_executor: BackgroundExecutor, +} + +impl TestAppWindow { + /// Get the window handle. + pub fn handle(&self) -> WindowHandle { + self.handle + } + + /// Get the root view entity. + pub fn root(&self) -> Entity { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |root_view, _, _| { + root_view.downcast::().expect("root view type mismatch") + }) + .expect("window not found") + } + + /// Update the root view. + pub fn update(&mut self, f: impl FnOnce(&mut V, &mut Window, &mut Context) -> R) -> R { + let result = { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |root_view, window, cx| { + let view = root_view.downcast::().expect("root view type mismatch"); + view.update(cx, |view, cx| f(view, window, cx)) + }) + .expect("window not found") + }; + self.background_executor.run_until_parked(); + result + } + + /// Read the root view. + pub fn read(&self, f: impl FnOnce(&V, &App) -> R) -> R { + let app = self.app.borrow(); + let view = self + .app + .borrow() + .windows + .get(self.handle.window_id()) + .and_then(|w| w.as_ref()) + .and_then(|w| w.root.clone()) + .and_then(|r| r.downcast::().ok()) + .expect("window or root view not found"); + f(view.read(&app), &app) + } + + /// Get the window title. + pub fn title(&self) -> Option { + let app = self.app.borrow(); + app.read_window(&self.handle, |_, _cx| { + // TODO: expose title through Window API + None + }) + .unwrap() + } + + /// Simulate a keystroke. + pub fn simulate_keystroke(&mut self, keystroke: &str) { + let keystroke = Keystroke::parse(keystroke).unwrap(); + { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |_, window, cx| { + window.dispatch_keystroke(keystroke, cx); + }) + .unwrap(); + } + self.background_executor.run_until_parked(); + } + + /// Simulate multiple keystrokes (space-separated). + pub fn simulate_keystrokes(&mut self, keystrokes: &str) { + for keystroke in keystrokes.split(' ') { + self.simulate_keystroke(keystroke); + } + } + + /// Simulate typing text. + pub fn simulate_input(&mut self, input: &str) { + for char in input.chars() { + self.simulate_keystroke(&char.to_string()); + } + } + + /// Simulate a mouse move. + pub fn simulate_mouse_move(&mut self, position: Point) { + self.simulate_event(MouseMoveEvent { + position, + modifiers: Default::default(), + pressed_button: None, + }); + } + + /// Simulate a mouse down event. + pub fn simulate_mouse_down(&mut self, position: Point, button: MouseButton) { + self.simulate_event(MouseDownEvent { + position, + button, + modifiers: Default::default(), + click_count: 1, + first_mouse: false, + }); + } + + /// Simulate a mouse up event. + pub fn simulate_mouse_up(&mut self, position: Point, button: MouseButton) { + self.simulate_event(MouseUpEvent { + position, + button, + modifiers: Default::default(), + click_count: 1, + }); + } + + /// Simulate a click at the given position. + pub fn simulate_click(&mut self, position: Point, button: MouseButton) { + self.simulate_mouse_down(position, button); + self.simulate_mouse_up(position, button); + } + + /// Simulate a scroll event. + pub fn simulate_scroll(&mut self, position: Point, delta: Point) { + self.simulate_event(crate::ScrollWheelEvent { + position, + delta: crate::ScrollDelta::Pixels(delta), + modifiers: Default::default(), + touch_phase: crate::TouchPhase::Moved, + }); + } + + /// Simulate an input event. + pub fn simulate_event(&mut self, event: E) { + let platform_input = event.to_platform_input(); + { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |_, window, cx| { + window.dispatch_event(platform_input, cx); + }) + .unwrap(); + } + self.background_executor.run_until_parked(); + } + + /// Simulate resizing the window. + pub fn simulate_resize(&mut self, size: Size) { + let window_id = self.handle.window_id(); + let mut app = self.app.borrow_mut(); + if let Some(Some(window)) = app.windows.get_mut(window_id) { + if let Some(test_window) = window.platform_window.as_test() { + test_window.simulate_resize(size); + } + } + drop(app); + self.background_executor.run_until_parked(); + } + + /// Force a redraw of the window. + pub fn draw(&mut self) { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + } +} + +impl Clone for TestAppWindow { + fn clone(&self) -> Self { + Self { + handle: self.handle, + app: self.app.clone(), + platform: self.platform.clone(), + background_executor: self.background_executor.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FocusHandle, Focusable, div, prelude::*}; + + struct Counter { + count: usize, + focus_handle: FocusHandle, + } + + impl Counter { + fn new(_window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + Self { + count: 0, + focus_handle, + } + } + + fn increment(&mut self, _cx: &mut Context) { + self.count += 1; + } + } + + impl Focusable for Counter { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } + } + + impl Render for Counter { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().child(format!("Count: {}", self.count)) + } + } + + #[test] + fn test_basic_usage() { + let mut app = TestApp::new(); + + let mut window = app.open_window(Counter::new); + + window.update(|counter, _window, cx| { + counter.increment(cx); + }); + + window.read(|counter, _| { + assert_eq!(counter.count, 1); + }); + + drop(window); + app.update(|cx| cx.shutdown()); + } + + #[test] + fn test_entity_creation() { + let mut app = TestApp::new(); + + let entity = app.new_entity(|cx| Counter { + count: 42, + focus_handle: cx.focus_handle(), + }); + + app.read_entity(&entity, |counter, _| { + assert_eq!(counter.count, 42); + }); + + app.update_entity(&entity, |counter, _cx| { + counter.count += 1; + }); + + app.read_entity(&entity, |counter, _| { + assert_eq!(counter.count, 43); + }); + } + + #[test] + fn test_globals() { + let mut app = TestApp::new(); + + struct MyGlobal(String); + impl Global for MyGlobal {} + + assert!(!app.has_global::()); + + app.set_global(MyGlobal("hello".into())); + + assert!(app.has_global::()); + + app.read_global::(|global, _| { + assert_eq!(global.0, "hello"); + }); + + app.update_global::(|global, _| { + global.0 = "world".into(); + }); + + app.read_global::(|global, _| { + assert_eq!(global.0, "world"); + }); + } +} diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 0f0f0e14fbd8565d8f948579ed1ab23381c80108..7fa47191404fd28baf11f27d055e5ac7b85a747d 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -231,6 +231,33 @@ impl TestAppContext { .unwrap() } + /// Opens a new window with a specific size. + /// + /// Unlike `add_window` which uses maximized bounds, this allows controlling + /// the window dimensions, which is important for layout-sensitive tests. + pub fn open_window( + &mut self, + window_size: Size, + build_window: F, + ) -> WindowHandle + where + F: FnOnce(&mut Window, &mut Context) -> V, + V: 'static + Render, + { + let mut cx = self.app.borrow_mut(); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: Point::default(), + size: window_size, + })), + ..Default::default() + }, + |window, cx| cx.new(|cx| build_window(window, cx)), + ) + .unwrap() + } + /// Adds a new window with no content. pub fn add_empty_window(&mut self) -> &mut VisualTestContext { let mut cx = self.app.borrow_mut(); diff --git a/crates/gpui/src/color.rs b/crates/gpui/src/color.rs index bb41a2f996e250b8c73377922f81170bb432321f..75585bcd90881513d835d28d260319d08acf9c4d 100644 --- a/crates/gpui/src/color.rs +++ b/crates/gpui/src/color.rs @@ -820,6 +820,15 @@ impl LinearColorStop { } impl Background { + /// Returns the solid color if this is a solid background, None otherwise. + pub fn as_solid(&self) -> Option { + if self.tag == BackgroundTag::Solid { + Some(self.solid) + } else { + None + } + } + /// Use specified color space for color interpolation. /// /// diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index cb65f758d5a521f15f77e7be266b1b4ed0480d03..f66f58447879afb86b721a9d6d7d2c59c65a8953 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -129,6 +129,13 @@ impl BackgroundExecutor { } } + /// Returns the underlying scheduler::BackgroundExecutor. + /// + /// This is used by Ex to pass the executor to thread/worktree code. + pub fn scheduler_executor(&self) -> scheduler::BackgroundExecutor { + self.inner.clone() + } + /// Enqueues the given future to be run to completion on a background thread. #[track_caller] pub fn spawn(&self, future: impl Future + Send + 'static) -> Task diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 061a055e7ef23bc4a76b44eaadb90bc1660fdb42..885dad0d96dc50993a7098b5d48509e4749894ec 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -555,6 +555,20 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { } } +/// A renderer for headless windows that can produce real rendered output. +#[cfg(any(test, feature = "test-support"))] +pub trait PlatformHeadlessRenderer { + /// Render a scene and return the result as an RGBA image. + fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> Result; + + /// Returns the sprite atlas used by this renderer. + fn sprite_atlas(&self) -> Arc; +} + /// Type alias for runnables with metadata. /// Previously an enum with a single variant, now simplified to a direct type alias. #[doc(hidden)] @@ -573,6 +587,7 @@ pub trait PlatformDispatcher: Send + Sync { fn dispatch(&self, runnable: RunnableVariant, priority: Priority); fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority); fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant); + fn spawn_realtime(&self, f: Box); fn now(&self) -> Instant { @@ -592,19 +607,29 @@ pub trait PlatformDispatcher: Send + Sync { #[expect(missing_docs)] pub trait PlatformTextSystem: Send + Sync { fn add_fonts(&self, fonts: Vec>) -> Result<()>; + /// Get all available font names. fn all_font_names(&self) -> Vec; + /// Get the font ID for a font descriptor. fn font_id(&self, descriptor: &Font) -> Result; + /// Get metrics for a font. fn font_metrics(&self, font_id: FontId) -> FontMetrics; + /// Get typographic bounds for a glyph. fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result>; + /// Get the advance width for a glyph. fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result>; + /// Get the glyph ID for a character. fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option; + /// Get raster bounds for a glyph. fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result>; + /// Rasterize a glyph. fn rasterize_glyph( &self, params: &RenderGlyphParams, raster_bounds: Bounds, ) -> Result<(Size, Vec)>; + /// Layout a line of text with the given font runs. fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout; + /// Returns the recommended text rendering mode for the given font and size. fn recommended_rendering_mode(&self, _font_id: FontId, _font_size: Pixels) -> TextRenderingMode; } diff --git a/crates/gpui/src/platform/test/dispatcher.rs b/crates/gpui/src/platform/test/dispatcher.rs index c40ec8f669d1e2e58f8af3bcf0fbd64fbddbe4d8..29aff84ff9d07f3a558ab68f2ac3117835688cc8 100644 --- a/crates/gpui/src/platform/test/dispatcher.rs +++ b/crates/gpui/src/platform/test/dispatcher.rs @@ -30,11 +30,12 @@ impl TestDispatcher { .map_or(false, |var| var == "1" || var == "true"), timeout_ticks: 0..=1000, })); + Self::from_scheduler(scheduler) + } - let session_id = scheduler.allocate_session_id(); - + pub fn from_scheduler(scheduler: Arc) -> Self { TestDispatcher { - session_id, + session_id: scheduler.allocate_session_id(), scheduler, num_cpus_override: Arc::new(AtomicUsize::new(0)), } @@ -76,6 +77,14 @@ impl TestDispatcher { while self.tick(false) {} } + pub fn allow_parking(&self) { + self.scheduler.allow_parking(); + } + + pub fn forbid_parking(&self) { + self.scheduler.forbid_parking(); + } + /// Override the value returned by `BackgroundExecutor::num_cpus()` in tests. /// A value of 0 means no override (the default of 4 is used). pub fn set_num_cpus(&self, count: usize) { diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 1da42f5742215f9001dcbd09cc42977ea28623ea..a59b21f038a01b48686ee211919afd7c647b7331 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,9 +1,9 @@ use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, - PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton, - ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, Task, - TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size, + PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, + Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size, }; use anyhow::Result; use collections::VecDeque; @@ -34,6 +34,7 @@ pub(crate) struct TestPlatform { pub opened_url: RefCell>, pub text_system: Arc, pub expect_restart: RefCell>>>, + headless_renderer_factory: Option Option>>>, weak: Weak, } @@ -88,8 +89,30 @@ pub(crate) struct TestPrompts { impl TestPlatform { pub fn new(executor: BackgroundExecutor, foreground_executor: ForegroundExecutor) -> Rc { - let text_system = Arc::new(NoopTextSystem); - + Self::with_platform( + executor, + foreground_executor, + Arc::new(NoopTextSystem), + None, + ) + } + + pub fn with_text_system( + executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + text_system: Arc, + ) -> Rc { + Self::with_platform(executor, foreground_executor, text_system, None) + } + + pub fn with_platform( + executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + text_system: Arc, + headless_renderer_factory: Option< + Box Option>>, + >, + ) -> Rc { Rc::new_cyclic(|weak| TestPlatform { background_executor: executor, foreground_executor, @@ -107,6 +130,7 @@ impl TestPlatform { weak: weak.clone(), opened_url: Default::default(), text_system, + headless_renderer_factory, }) } @@ -299,11 +323,13 @@ impl Platform for TestPlatform { handle: AnyWindowHandle, params: WindowParams, ) -> anyhow::Result> { + let renderer = self.headless_renderer_factory.as_ref().and_then(|f| f()); let window = TestWindow::new( handle, params, self.weak.clone(), self.active_display.clone(), + renderer, ); Ok(Box::new(window)) } diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index feb3b162abe09d8cdef008aa9f794b046da22cc6..583450c9e93e6bfdf8f45a4dcd1a83feb9b08111 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -1,10 +1,12 @@ use crate::{ - AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DispatchEventResult, GpuSpecs, - Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, - Point, PromptButton, RequestFrameOptions, Size, TestPlatform, TileId, WindowAppearance, + AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, + DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, + PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, + PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, }; use collections::HashMap; +use image::RgbaImage; use parking_lot::Mutex; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use std::{ @@ -21,6 +23,7 @@ pub(crate) struct TestWindowState { platform: Weak, // TODO: Replace with `Rc` sprite_atlas: Arc, + renderer: Option>, pub(crate) should_close_handler: Option bool>>, hit_test_window_control_callback: Option Option>>, input_callback: Option DispatchEventResult>>, @@ -57,13 +60,19 @@ impl TestWindow { params: WindowParams, platform: Weak, display: Rc, + renderer: Option>, ) -> Self { + let sprite_atlas: Arc = match &renderer { + Some(r) => r.sprite_atlas(), + None => Arc::new(TestAtlas::new()), + }; Self(Rc::new(Mutex::new(TestWindowState { bounds: params.bounds, display, platform, handle, - sprite_atlas: Arc::new(TestAtlas::new()), + sprite_atlas, + renderer, title: Default::default(), edited: false, should_close_handler: None, @@ -81,10 +90,11 @@ impl TestWindow { pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); + // Always update bounds, even if no callback is registered + lock.bounds.size = size; let Some(mut callback) = lock.resize_callback.take() else { return; }; - lock.bounds.size = size; drop(lock); callback(size, scale_factor); self.0.lock().resize_callback = Some(callback); @@ -275,12 +285,25 @@ impl PlatformWindow for TestWindow { fn on_appearance_changed(&self, _callback: Box) {} - fn draw(&self, _scene: &crate::Scene) {} + fn draw(&self, _scene: &Scene) {} fn sprite_atlas(&self) -> sync::Arc { self.0.lock().sprite_atlas.clone() } + #[cfg(any(test, feature = "test-support"))] + fn render_to_image(&self, scene: &Scene) -> anyhow::Result { + let mut state = self.0.lock(); + let size = state.bounds.size; + if let Some(renderer) = &mut state.renderer { + let scale_factor = 2.0; + let device_size: Size = size.to_device_pixels(scale_factor); + renderer.render_scene_to_image(scene, device_size) + } else { + anyhow::bail!("render_to_image not available: no HeadlessRenderer configured") + } + } + fn as_test(&mut self) -> Option<&mut TestWindow> { Some(self) } diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 7e0ffe017024cc7914885df9ea713a3ec3db820e..22b1bb468d84b2897b312c6fc8af00ee5c8523db 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -657,7 +657,7 @@ impl Default for TransformationMatrix { #[expect(missing_docs)] pub struct MonochromeSprite { pub order: DrawOrder, - pub pad: u32, // align to 8 bytes + pub pad: u32, pub bounds: Bounds, pub content_mask: ContentMask, pub color: Hsla, @@ -695,7 +695,7 @@ impl From for Primitive { #[expect(missing_docs)] pub struct PolychromeSprite { pub order: DrawOrder, - pub pad: u32, // align to 8 bytes + pub pad: u32, pub grayscale: bool, pub opacity: f32, pub bounds: Bounds, diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 43982b2666bde8210f770419623cc0b9afd6e2af..b62a0ad6fd4f885b127144bd66e8e3e41747d889 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -63,7 +63,8 @@ pub struct TextSystem { } impl TextSystem { - pub(crate) fn new(platform_text_system: Arc) -> Self { + /// Create a new TextSystem with the given platform text system. + pub fn new(platform_text_system: Arc) -> Self { TextSystem { platform_text_system, font_metrics: RwLock::default(), @@ -372,7 +373,8 @@ pub struct WindowTextSystem { } impl WindowTextSystem { - pub(crate) fn new(text_system: Arc) -> Self { + /// Create a new WindowTextSystem with the given TextSystem. + pub fn new(text_system: Arc) -> Self { Self { line_layout_cache: LineLayoutCache::new(text_system.platform_text_system.clone()), text_system, @@ -438,6 +440,74 @@ impl WindowTextSystem { } } + /// Shape the given line using a caller-provided content hash as the cache key. + /// + /// This enables cache hits without materializing a contiguous `SharedString` for the text. + /// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + /// + /// Like [`Self::shape_line`], this must be used only for single-line text (no `\n`). + pub fn shape_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[TextRun], + force_width: Option, + materialize_text: impl FnOnce() -> SharedString, + ) -> ShapedLine { + let mut decoration_runs = SmallVec::<[DecorationRun; 32]>::new(); + for run in runs { + if let Some(last_run) = decoration_runs.last_mut() + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + && last_run.background_color == run.background_color + { + last_run.len += run.len as u32; + continue; + } + decoration_runs.push(DecorationRun { + len: run.len as u32, + color: run.color, + background_color: run.background_color, + underline: run.underline, + strikethrough: run.strikethrough, + }); + } + + let mut used_force_width = force_width; + let layout = self.layout_line_by_hash( + text_hash, + text_len, + font_size, + runs, + used_force_width, + || { + let text = materialize_text(); + debug_assert!( + text.find('\n').is_none(), + "text argument should not contain newlines" + ); + text + }, + ); + + // We only materialize actual text on cache miss; on hit we avoid allocations. + // Since `ShapedLine` carries a `SharedString`, use an empty placeholder for hits. + // NOTE: Callers must not rely on `ShapedLine.text` for content when using this API. + let text: SharedString = SharedString::new_static(""); + + ShapedLine { + layout, + text, + decoration_runs, + } + } + /// Shape a multi line string of text, at the given font_size, for painting to the screen. /// Subsets of the text can be styled independently with the `runs` parameter. /// If `wrap_width` is provided, the line breaks will be adjusted to fit within the given width. @@ -627,6 +697,130 @@ impl WindowTextSystem { layout } + + /// Probe the line layout cache using a caller-provided content hash, without allocating. + /// + /// Returns `Some(layout)` if the layout is already cached in either the current frame + /// or the previous frame. Returns `None` if it is not cached. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + pub fn try_layout_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[TextRun], + force_width: Option, + ) -> Option> { + let mut last_run = None::<&TextRun>; + let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default(); + font_runs.clear(); + + for run in runs.iter() { + let decoration_changed = if let Some(last_run) = last_run + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + // we do not consider differing background color relevant, as it does not affect glyphs + // && last_run.background_color == run.background_color + { + false + } else { + last_run = Some(run); + true + }; + + let font_id = self.resolve_font(&run.font); + if let Some(font_run) = font_runs.last_mut() + && font_id == font_run.font_id + && !decoration_changed + { + font_run.len += run.len; + } else { + font_runs.push(FontRun { + len: run.len, + font_id, + }); + } + } + + let layout = self.line_layout_cache.try_layout_line_by_hash( + text_hash, + text_len, + font_size, + &font_runs, + force_width, + ); + + self.font_runs_pool.lock().push(font_runs); + + layout + } + + /// Layout the given line of text using a caller-provided content hash as the cache key. + /// + /// This enables cache hits without materializing a contiguous `SharedString` for the text. + /// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + pub fn layout_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[TextRun], + force_width: Option, + materialize_text: impl FnOnce() -> SharedString, + ) -> Arc { + let mut last_run = None::<&TextRun>; + let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default(); + font_runs.clear(); + + for run in runs.iter() { + let decoration_changed = if let Some(last_run) = last_run + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + // we do not consider differing background color relevant, as it does not affect glyphs + // && last_run.background_color == run.background_color + { + false + } else { + last_run = Some(run); + true + }; + + let font_id = self.resolve_font(&run.font); + if let Some(font_run) = font_runs.last_mut() + && font_id == font_run.font_id + && !decoration_changed + { + font_run.len += run.len; + } else { + font_runs.push(FontRun { + len: run.len, + font_id, + }); + } + } + + let layout = self.line_layout_cache.layout_line_by_hash( + text_hash, + text_len, + font_size, + &font_runs, + force_width, + materialize_text, + ); + + self.font_runs_pool.lock().push(font_runs); + + layout + } } #[derive(Hash, Eq, PartialEq)] @@ -802,6 +996,11 @@ impl TextRun { #[repr(C)] pub struct GlyphId(pub u32); +/// Parameters for rendering a glyph, used as cache keys for raster bounds. +/// +/// This struct identifies a specific glyph rendering configuration including +/// font, size, subpixel positioning, and scale factor. It's used to look up +/// cached raster bounds and sprite atlas entries. #[derive(Clone, Debug, PartialEq)] #[expect(missing_docs)] pub struct RenderGlyphParams { diff --git a/crates/gpui/src/text_system/line.rs b/crates/gpui/src/text_system/line.rs index c87e051ad3b4e5fc86d17ad0e6168553108175fa..7b5714188ff97d0169806ac5da9f039f9be2c16a 100644 --- a/crates/gpui/src/text_system/line.rs +++ b/crates/gpui/src/text_system/line.rs @@ -1,12 +1,24 @@ use crate::{ - App, Bounds, Half, Hsla, LineLayout, Pixels, Point, Result, SharedString, StrikethroughStyle, - TextAlign, UnderlineStyle, Window, WrapBoundary, WrappedLineLayout, black, fill, point, px, - size, + App, Bounds, DevicePixels, Half, Hsla, LineLayout, Pixels, Point, RenderGlyphParams, Result, + ShapedGlyph, ShapedRun, SharedString, StrikethroughStyle, TextAlign, UnderlineStyle, Window, + WrapBoundary, WrappedLineLayout, black, fill, point, px, size, }; use derive_more::{Deref, DerefMut}; use smallvec::SmallVec; use std::sync::Arc; +/// Pre-computed glyph data for efficient painting without per-glyph cache lookups. +/// +/// This is produced by `ShapedLine::compute_glyph_raster_data` during prepaint +/// and consumed by `ShapedLine::paint_with_raster_data` during paint. +#[derive(Clone, Debug)] +pub struct GlyphRasterData { + /// The raster bounds for each glyph, in paint order. + pub bounds: Vec>, + /// The render params for each glyph (needed for sprite atlas lookup). + pub params: Vec, +} + /// Set the text decoration for a run of text. #[derive(Debug, Clone)] pub struct DecorationRun { @@ -44,6 +56,14 @@ impl ShapedLine { self.layout.len } + /// The width of the shaped line in pixels. + /// + /// This is the glyph advance width computed by the text shaping system and is useful for + /// incrementally advancing a "pen" when painting multiple fragments on the same row. + pub fn width(&self) -> Pixels { + self.layout.width + } + /// Override the len, useful if you're rendering text a /// as text b (e.g. rendering invisibles). pub fn with_len(mut self, len: usize) -> Self { @@ -108,6 +128,120 @@ impl ShapedLine { Ok(()) } + + /// Split this shaped line at a byte index, returning `(prefix, suffix)`. + /// + /// - `prefix` contains glyphs for bytes `[0, byte_index)` with original positions. + /// Its width equals the x-advance up to the split point. + /// - `suffix` contains glyphs for bytes `[byte_index, len)` with positions + /// shifted left so the first glyph starts at x=0, and byte indices rebased to 0. + /// - Decoration runs are partitioned at the boundary; a run that straddles it is + /// split into two with adjusted lengths. + /// - `font_size`, `ascent`, and `descent` are copied to both halves. + pub fn split_at(&self, byte_index: usize) -> (ShapedLine, ShapedLine) { + let x_offset = self.layout.x_for_index(byte_index); + + // Partition glyph runs. A single run may contribute glyphs to both halves. + let mut left_runs = Vec::new(); + let mut right_runs = Vec::new(); + + for run in &self.layout.runs { + let split_pos = run.glyphs.partition_point(|g| g.index < byte_index); + + if split_pos > 0 { + left_runs.push(ShapedRun { + font_id: run.font_id, + glyphs: run.glyphs[..split_pos].to_vec(), + }); + } + + if split_pos < run.glyphs.len() { + let right_glyphs = run.glyphs[split_pos..] + .iter() + .map(|g| ShapedGlyph { + id: g.id, + position: point(g.position.x - x_offset, g.position.y), + index: g.index - byte_index, + is_emoji: g.is_emoji, + }) + .collect(); + right_runs.push(ShapedRun { + font_id: run.font_id, + glyphs: right_glyphs, + }); + } + } + + // Partition decoration runs. A run straddling the boundary is split into two. + let mut left_decorations = SmallVec::new(); + let mut right_decorations = SmallVec::new(); + let mut decoration_offset = 0u32; + let split_point = byte_index as u32; + + for decoration in &self.decoration_runs { + let run_end = decoration_offset + decoration.len; + + if run_end <= split_point { + left_decorations.push(decoration.clone()); + } else if decoration_offset >= split_point { + right_decorations.push(decoration.clone()); + } else { + let left_len = split_point - decoration_offset; + let right_len = run_end - split_point; + left_decorations.push(DecorationRun { + len: left_len, + color: decoration.color, + background_color: decoration.background_color, + underline: decoration.underline, + strikethrough: decoration.strikethrough, + }); + right_decorations.push(DecorationRun { + len: right_len, + color: decoration.color, + background_color: decoration.background_color, + underline: decoration.underline, + strikethrough: decoration.strikethrough, + }); + } + + decoration_offset = run_end; + } + + // Split text + let left_text = SharedString::new(self.text[..byte_index].to_string()); + let right_text = SharedString::new(self.text[byte_index..].to_string()); + + let left_width = x_offset; + let right_width = self.layout.width - left_width; + + let left = ShapedLine { + layout: Arc::new(LineLayout { + font_size: self.layout.font_size, + width: left_width, + ascent: self.layout.ascent, + descent: self.layout.descent, + runs: left_runs, + len: byte_index, + }), + text: left_text, + decoration_runs: left_decorations, + }; + + let right = ShapedLine { + layout: Arc::new(LineLayout { + font_size: self.layout.font_size, + width: right_width, + ascent: self.layout.ascent, + descent: self.layout.descent, + runs: right_runs, + len: self.layout.len - byte_index, + }), + text: right_text, + decoration_runs: right_decorations, + }; + + (left, right) + } } /// A line of text that has been shaped, decorated, and wrapped by the text layout system. @@ -594,3 +728,268 @@ fn aligned_origin_x( TextAlign::Right => origin.x + align_width - line_width, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FontId, GlyphId}; + + /// Helper: build a ShapedLine from glyph descriptors without the platform text system. + /// Each glyph is described as (byte_index, x_position). + fn make_shaped_line( + text: &str, + glyphs: &[(usize, f32)], + width: f32, + decorations: &[DecorationRun], + ) -> ShapedLine { + let shaped_glyphs: Vec = glyphs + .iter() + .map(|&(index, x)| ShapedGlyph { + id: GlyphId(0), + position: point(px(x), px(0.0)), + index, + is_emoji: false, + }) + .collect(); + + ShapedLine { + layout: Arc::new(LineLayout { + font_size: px(16.0), + width: px(width), + ascent: px(12.0), + descent: px(4.0), + runs: vec![ShapedRun { + font_id: FontId(0), + glyphs: shaped_glyphs, + }], + len: text.len(), + }), + text: SharedString::new(text.to_string()), + decoration_runs: SmallVec::from(decorations.to_vec()), + } + } + + #[test] + fn test_split_at_invariants() { + // Split "abcdef" at every possible byte index and verify structural invariants. + let line = make_shaped_line( + "abcdef", + &[ + (0, 0.0), + (1, 10.0), + (2, 20.0), + (3, 30.0), + (4, 40.0), + (5, 50.0), + ], + 60.0, + &[], + ); + + for i in 0..=6 { + let (left, right) = line.split_at(i); + + assert_eq!( + left.width() + right.width(), + line.width(), + "widths must sum at split={i}" + ); + assert_eq!( + left.len() + right.len(), + line.len(), + "lengths must sum at split={i}" + ); + assert_eq!( + format!("{}{}", left.text.as_ref(), right.text.as_ref()), + "abcdef", + "text must concatenate at split={i}" + ); + assert_eq!(left.font_size, line.font_size, "font_size at split={i}"); + assert_eq!(right.ascent, line.ascent, "ascent at split={i}"); + assert_eq!(right.descent, line.descent, "descent at split={i}"); + } + + // Edge: split at 0 produces no left runs, full content on right + let (left, right) = line.split_at(0); + assert_eq!(left.runs.len(), 0); + assert_eq!(right.runs[0].glyphs.len(), 6); + + // Edge: split at end produces full content on left, no right runs + let (left, right) = line.split_at(6); + assert_eq!(left.runs[0].glyphs.len(), 6); + assert_eq!(right.runs.len(), 0); + } + + #[test] + fn test_split_at_glyph_rebasing() { + // Two font runs (simulating a font fallback boundary at byte 3): + // run A (FontId 0): glyphs at bytes 0,1,2 positions 0,10,20 + // run B (FontId 1): glyphs at bytes 3,4,5 positions 30,40,50 + // Successive splits simulate the incremental splitting done during wrap. + let line = ShapedLine { + layout: Arc::new(LineLayout { + font_size: px(16.0), + width: px(60.0), + ascent: px(12.0), + descent: px(4.0), + runs: vec![ + ShapedRun { + font_id: FontId(0), + glyphs: vec![ + ShapedGlyph { + id: GlyphId(0), + position: point(px(0.0), px(0.0)), + index: 0, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(0), + position: point(px(10.0), px(0.0)), + index: 1, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(0), + position: point(px(20.0), px(0.0)), + index: 2, + is_emoji: false, + }, + ], + }, + ShapedRun { + font_id: FontId(1), + glyphs: vec![ + ShapedGlyph { + id: GlyphId(0), + position: point(px(30.0), px(0.0)), + index: 3, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(0), + position: point(px(40.0), px(0.0)), + index: 4, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(0), + position: point(px(50.0), px(0.0)), + index: 5, + is_emoji: false, + }, + ], + }, + ], + len: 6, + }), + text: SharedString::new("abcdef".to_string()), + decoration_runs: SmallVec::new(), + }; + + // First split at byte 2 — mid-run in run A + let (first, remainder) = line.split_at(2); + assert_eq!(first.text.as_ref(), "ab"); + assert_eq!(first.runs.len(), 1); + assert_eq!(first.runs[0].font_id, FontId(0)); + + // Remainder "cdef" should have two runs: tail of A (1 glyph) + all of B (3 glyphs) + assert_eq!(remainder.text.as_ref(), "cdef"); + assert_eq!(remainder.runs.len(), 2); + assert_eq!(remainder.runs[0].font_id, FontId(0)); + assert_eq!(remainder.runs[0].glyphs.len(), 1); + assert_eq!(remainder.runs[0].glyphs[0].index, 0); + assert_eq!(remainder.runs[0].glyphs[0].position.x, px(0.0)); + assert_eq!(remainder.runs[1].font_id, FontId(1)); + assert_eq!(remainder.runs[1].glyphs[0].index, 1); + assert_eq!(remainder.runs[1].glyphs[0].position.x, px(10.0)); + + // Second split at byte 2 within remainder — crosses the run boundary + let (second, final_part) = remainder.split_at(2); + assert_eq!(second.text.as_ref(), "cd"); + assert_eq!(final_part.text.as_ref(), "ef"); + assert_eq!(final_part.runs[0].glyphs[0].index, 0); + assert_eq!(final_part.runs[0].glyphs[0].position.x, px(0.0)); + + // Widths must sum across all three pieces + assert_eq!( + first.width() + second.width() + final_part.width(), + line.width() + ); + } + + #[test] + fn test_split_at_decorations() { + // Three decoration runs: red [0..2), green [2..5), blue [5..6). + // Split at byte 3 — red goes entirely left, green straddles, blue goes entirely right. + let red = Hsla { + h: 0.0, + s: 1.0, + l: 0.5, + a: 1.0, + }; + let green = Hsla { + h: 0.3, + s: 1.0, + l: 0.5, + a: 1.0, + }; + let blue = Hsla { + h: 0.6, + s: 1.0, + l: 0.5, + a: 1.0, + }; + + let line = make_shaped_line( + "abcdef", + &[ + (0, 0.0), + (1, 10.0), + (2, 20.0), + (3, 30.0), + (4, 40.0), + (5, 50.0), + ], + 60.0, + &[ + DecorationRun { + len: 2, + color: red, + background_color: None, + underline: None, + strikethrough: None, + }, + DecorationRun { + len: 3, + color: green, + background_color: None, + underline: None, + strikethrough: None, + }, + DecorationRun { + len: 1, + color: blue, + background_color: None, + underline: None, + strikethrough: None, + }, + ], + ); + + let (left, right) = line.split_at(3); + + // Left: red(2) + green(1) — green straddled, left portion has len 1 + assert_eq!(left.decoration_runs.len(), 2); + assert_eq!(left.decoration_runs[0].len, 2); + assert_eq!(left.decoration_runs[0].color, red); + assert_eq!(left.decoration_runs[1].len, 1); + assert_eq!(left.decoration_runs[1].color, green); + + // Right: green(2) + blue(1) — green straddled, right portion has len 2 + assert_eq!(right.decoration_runs.len(), 2); + assert_eq!(right.decoration_runs[0].len, 2); + assert_eq!(right.decoration_runs[0].color, green); + assert_eq!(right.decoration_runs[1].len, 1); + assert_eq!(right.decoration_runs[1].color, blue); + } +} diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index 78ab21b3d324674b0f34d9ab418893430df70f2a..8f3d7563d068979defa8b3f93367a2c9b7102cc1 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -401,12 +401,25 @@ struct FrameCache { wrapped_lines: FxHashMap, Arc>, used_lines: Vec>, used_wrapped_lines: Vec>, + + // Content-addressable caches keyed by caller-provided text hash + layout params. + // These allow cache hits without materializing a contiguous `SharedString`. + // + // IMPORTANT: To support allocation-free lookups, we store these maps using a key type + // (`HashedCacheKeyRef`) that can be computed without building a contiguous `&str`/`SharedString`. + // On miss, we allocate once and store under an owned `HashedCacheKey`. + lines_by_hash: FxHashMap, Arc>, + wrapped_lines_by_hash: FxHashMap, Arc>, + used_lines_by_hash: Vec>, + used_wrapped_lines_by_hash: Vec>, } #[derive(Clone, Default)] pub(crate) struct LineLayoutIndex { lines_index: usize, wrapped_lines_index: usize, + lines_by_hash_index: usize, + wrapped_lines_by_hash_index: usize, } impl LineLayoutCache { @@ -423,6 +436,8 @@ impl LineLayoutCache { LineLayoutIndex { lines_index: frame.used_lines.len(), wrapped_lines_index: frame.used_wrapped_lines.len(), + lines_by_hash_index: frame.used_lines_by_hash.len(), + wrapped_lines_by_hash_index: frame.used_wrapped_lines_by_hash.len(), } } @@ -445,6 +460,24 @@ impl LineLayoutCache { } current_frame.used_wrapped_lines.push(key.clone()); } + + for key in &previous_frame.used_lines_by_hash + [range.start.lines_by_hash_index..range.end.lines_by_hash_index] + { + if let Some((key, line)) = previous_frame.lines_by_hash.remove_entry(key) { + current_frame.lines_by_hash.insert(key, line); + } + current_frame.used_lines_by_hash.push(key.clone()); + } + + for key in &previous_frame.used_wrapped_lines_by_hash + [range.start.wrapped_lines_by_hash_index..range.end.wrapped_lines_by_hash_index] + { + if let Some((key, line)) = previous_frame.wrapped_lines_by_hash.remove_entry(key) { + current_frame.wrapped_lines_by_hash.insert(key, line); + } + current_frame.used_wrapped_lines_by_hash.push(key.clone()); + } } pub fn truncate_layouts(&self, index: LineLayoutIndex) { @@ -453,6 +486,12 @@ impl LineLayoutCache { current_frame .used_wrapped_lines .truncate(index.wrapped_lines_index); + current_frame + .used_lines_by_hash + .truncate(index.lines_by_hash_index); + current_frame + .used_wrapped_lines_by_hash + .truncate(index.wrapped_lines_by_hash_index); } pub fn finish_frame(&self) { @@ -463,6 +502,11 @@ impl LineLayoutCache { curr_frame.wrapped_lines.clear(); curr_frame.used_lines.clear(); curr_frame.used_wrapped_lines.clear(); + + curr_frame.lines_by_hash.clear(); + curr_frame.wrapped_lines_by_hash.clear(); + curr_frame.used_lines_by_hash.clear(); + curr_frame.used_wrapped_lines_by_hash.clear(); } pub fn layout_wrapped_line( @@ -590,6 +634,165 @@ impl LineLayoutCache { layout } } + + /// Try to retrieve a previously-shaped line layout using a caller-provided content hash. + /// + /// This is a *non-allocating* cache probe: it does not materialize any text. If the layout + /// is not already cached in either the current frame or previous frame, returns `None`. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + pub fn try_layout_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[FontRun], + force_width: Option, + ) -> Option> { + let key_ref = HashedCacheKeyRef { + text_hash, + text_len, + font_size, + runs, + wrap_width: None, + force_width, + }; + + let current_frame = self.current_frame.read(); + if let Some((_, layout)) = current_frame.lines_by_hash.iter().find(|(key, _)| { + HashedCacheKeyRef { + text_hash: key.text_hash, + text_len: key.text_len, + font_size: key.font_size, + runs: key.runs.as_slice(), + wrap_width: key.wrap_width, + force_width: key.force_width, + } == key_ref + }) { + return Some(layout.clone()); + } + + let previous_frame = self.previous_frame.lock(); + if let Some((_, layout)) = previous_frame.lines_by_hash.iter().find(|(key, _)| { + HashedCacheKeyRef { + text_hash: key.text_hash, + text_len: key.text_len, + font_size: key.font_size, + runs: key.runs.as_slice(), + wrap_width: key.wrap_width, + force_width: key.force_width, + } == key_ref + }) { + return Some(layout.clone()); + } + + None + } + + /// Layout a line of text using a caller-provided content hash as the cache key. + /// + /// This enables cache hits without materializing a contiguous `SharedString` for `text`. + /// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + pub fn layout_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[FontRun], + force_width: Option, + materialize_text: impl FnOnce() -> SharedString, + ) -> Arc { + let key_ref = HashedCacheKeyRef { + text_hash, + text_len, + font_size, + runs, + wrap_width: None, + force_width, + }; + + // Fast path: already cached (no allocation). + let current_frame = self.current_frame.upgradable_read(); + if let Some((_, layout)) = current_frame.lines_by_hash.iter().find(|(key, _)| { + HashedCacheKeyRef { + text_hash: key.text_hash, + text_len: key.text_len, + font_size: key.font_size, + runs: key.runs.as_slice(), + wrap_width: key.wrap_width, + force_width: key.force_width, + } == key_ref + }) { + return layout.clone(); + } + + let mut current_frame = RwLockUpgradableReadGuard::upgrade(current_frame); + + // Try to reuse from previous frame without allocating; do a linear scan to find a matching key. + // (We avoid `drain()` here because it would eagerly move all entries.) + let mut previous_frame = self.previous_frame.lock(); + if let Some(existing_key) = previous_frame + .used_lines_by_hash + .iter() + .find(|key| { + HashedCacheKeyRef { + text_hash: key.text_hash, + text_len: key.text_len, + font_size: key.font_size, + runs: key.runs.as_slice(), + wrap_width: key.wrap_width, + force_width: key.force_width, + } == key_ref + }) + .cloned() + { + if let Some((key, layout)) = previous_frame.lines_by_hash.remove_entry(&existing_key) { + current_frame + .lines_by_hash + .insert(key.clone(), layout.clone()); + current_frame.used_lines_by_hash.push(key); + return layout; + } + } + + let text = materialize_text(); + let mut layout = self + .platform_text_system + .layout_line(&text, font_size, runs); + + if let Some(force_width) = force_width { + let mut glyph_pos = 0; + for run in layout.runs.iter_mut() { + for glyph in run.glyphs.iter_mut() { + if (glyph.position.x - glyph_pos * force_width).abs() > px(1.) { + glyph.position.x = glyph_pos * force_width; + } + glyph_pos += 1; + } + } + } + + let key = Arc::new(HashedCacheKey { + text_hash, + text_len, + font_size, + runs: SmallVec::from(runs), + wrap_width: None, + force_width, + }); + let layout = Arc::new(layout); + current_frame + .lines_by_hash + .insert(key.clone(), layout.clone()); + current_frame.used_lines_by_hash.push(key); + layout + } } /// A run of text with a single font. @@ -622,12 +825,80 @@ struct CacheKeyRef<'a> { force_width: Option, } +#[derive(Clone, Debug)] +struct HashedCacheKey { + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: SmallVec<[FontRun; 1]>, + wrap_width: Option, + force_width: Option, +} + +#[derive(Copy, Clone)] +struct HashedCacheKeyRef<'a> { + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &'a [FontRun], + wrap_width: Option, + force_width: Option, +} + impl PartialEq for dyn AsCacheKeyRef + '_ { fn eq(&self, other: &dyn AsCacheKeyRef) -> bool { self.as_cache_key_ref() == other.as_cache_key_ref() } } +impl PartialEq for HashedCacheKey { + fn eq(&self, other: &Self) -> bool { + self.text_hash == other.text_hash + && self.text_len == other.text_len + && self.font_size == other.font_size + && self.runs.as_slice() == other.runs.as_slice() + && self.wrap_width == other.wrap_width + && self.force_width == other.force_width + } +} + +impl Eq for HashedCacheKey {} + +impl Hash for HashedCacheKey { + fn hash(&self, state: &mut H) { + self.text_hash.hash(state); + self.text_len.hash(state); + self.font_size.hash(state); + self.runs.as_slice().hash(state); + self.wrap_width.hash(state); + self.force_width.hash(state); + } +} + +impl PartialEq for HashedCacheKeyRef<'_> { + fn eq(&self, other: &Self) -> bool { + self.text_hash == other.text_hash + && self.text_len == other.text_len + && self.font_size == other.font_size + && self.runs == other.runs + && self.wrap_width == other.wrap_width + && self.force_width == other.force_width + } +} + +impl Eq for HashedCacheKeyRef<'_> {} + +impl Hash for HashedCacheKeyRef<'_> { + fn hash(&self, state: &mut H) { + self.text_hash.hash(state); + self.text_len.hash(state); + self.font_size.hash(state); + self.runs.hash(state); + self.wrap_width.hash(state); + self.force_width.hash(state); + } +} + impl Eq for dyn AsCacheKeyRef + '_ {} impl Hash for dyn AsCacheKeyRef + '_ { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index e3c61a4fd31f35df591f20075221907270e352c8..2a80f553eb9ff5a36cf1637a1106fd4c13712f15 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -566,6 +566,10 @@ impl HitboxId { /// /// See [`Hitbox::is_hovered`] for details. pub fn is_hovered(self, window: &Window) -> bool { + // If this hitbox has captured the pointer, it's always considered hovered + if window.captured_hitbox == Some(self) { + return true; + } let hit_test = &window.mouse_hit_test; for id in hit_test.ids.iter().take(hit_test.hover_hitbox_count) { if self == *id { @@ -822,6 +826,11 @@ impl Frame { self.tab_stops.clear(); self.focus = None; + #[cfg(any(test, feature = "test-support"))] + { + self.debug_bounds.clear(); + } + #[cfg(any(feature = "inspector", debug_assertions))] { self.next_inspector_instance_ids.clear(); @@ -952,6 +961,9 @@ pub struct Window { pub(crate) pending_input_observers: SubscriberSet<(), AnyObserver>, prompt: Option, pub(crate) client_inset: Option, + /// The hitbox that has captured the pointer, if any. + /// While captured, mouse events route to this hitbox regardless of hit testing. + captured_hitbox: Option, #[cfg(any(feature = "inspector", debug_assertions))] inspector: Option>, } @@ -1439,6 +1451,7 @@ impl Window { prompt: None, client_inset: None, image_cache_stack: Vec::new(), + captured_hitbox: None, #[cfg(any(feature = "inspector", debug_assertions))] inspector: None, }) @@ -1888,7 +1901,12 @@ impl Window { }) } - fn bounds_changed(&mut self, cx: &mut App) { + /// Notify the window that its bounds have changed. + /// + /// This updates internal state like `viewport_size` and `scale_factor` from + /// the platform window, then notifies observers. Normally called automatically + /// by the platform's resize callback, but exposed publicly for test infrastructure. + pub fn bounds_changed(&mut self, cx: &mut App) { self.scale_factor = self.platform_window.scale_factor(); self.viewport_size = self.platform_window.content_size(); self.display_id = self.platform_window.display().map(|display| display.id()); @@ -2144,6 +2162,26 @@ impl Window { self.mouse_position } + /// Captures the pointer for the given hitbox. While captured, all mouse move and mouse up + /// events will be routed to listeners that check this hitbox's `is_hovered` status, + /// regardless of actual hit testing. This enables drag operations that continue + /// even when the pointer moves outside the element's bounds. + /// + /// The capture is automatically released on mouse up. + pub fn capture_pointer(&mut self, hitbox_id: HitboxId) { + self.captured_hitbox = Some(hitbox_id); + } + + /// Releases any active pointer capture. + pub fn release_pointer(&mut self) { + self.captured_hitbox = None; + } + + /// Returns the hitbox that has captured the pointer, if any. + pub fn captured_hitbox(&self) -> Option { + self.captured_hitbox + } + /// The current state of the keyboard's modifiers pub fn modifiers(&self) -> Modifiers { self.modifiers @@ -3295,6 +3333,100 @@ impl Window { Ok(()) } + /// Paints a monochrome glyph with pre-computed raster bounds. + /// + /// This is faster than `paint_glyph` because it skips the per-glyph cache lookup. + /// Use `ShapedLine::compute_glyph_raster_data` to batch-compute raster bounds during prepaint. + pub fn paint_glyph_with_raster_bounds( + &mut self, + origin: Point, + _font_id: FontId, + _glyph_id: GlyphId, + _font_size: Pixels, + color: Hsla, + raster_bounds: Bounds, + params: &RenderGlyphParams, + ) -> Result<()> { + self.invalidator.debug_assert_paint(); + + let element_opacity = self.element_opacity(); + let scale_factor = self.scale_factor(); + let glyph_origin = origin.scale(scale_factor); + + if !raster_bounds.is_zero() { + let tile = self + .sprite_atlas + .get_or_insert_with(¶ms.clone().into(), &mut || { + let (size, bytes) = self.text_system().rasterize_glyph(params)?; + Ok(Some((size, Cow::Owned(bytes)))) + })? + .expect("Callback above only errors or returns Some"); + let bounds = Bounds { + origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), + size: tile.bounds.size.map(Into::into), + }; + let content_mask = self.content_mask().scale(scale_factor); + self.next_frame.scene.insert_primitive(MonochromeSprite { + order: 0, + pad: 0, + bounds, + content_mask, + color: color.opacity(element_opacity), + tile, + transformation: TransformationMatrix::unit(), + }); + } + Ok(()) + } + + /// Paints an emoji glyph with pre-computed raster bounds. + /// + /// This is faster than `paint_emoji` because it skips the per-glyph cache lookup. + /// Use `ShapedLine::compute_glyph_raster_data` to batch-compute raster bounds during prepaint. + pub fn paint_emoji_with_raster_bounds( + &mut self, + origin: Point, + _font_id: FontId, + _glyph_id: GlyphId, + _font_size: Pixels, + raster_bounds: Bounds, + params: &RenderGlyphParams, + ) -> Result<()> { + self.invalidator.debug_assert_paint(); + + let scale_factor = self.scale_factor(); + let glyph_origin = origin.scale(scale_factor); + + if !raster_bounds.is_zero() { + let tile = self + .sprite_atlas + .get_or_insert_with(¶ms.clone().into(), &mut || { + let (size, bytes) = self.text_system().rasterize_glyph(params)?; + Ok(Some((size, Cow::Owned(bytes)))) + })? + .expect("Callback above only errors or returns Some"); + + let bounds = Bounds { + origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), + size: tile.bounds.size.map(Into::into), + }; + let content_mask = self.content_mask().scale(scale_factor); + let opacity = self.element_opacity(); + + self.next_frame.scene.insert_primitive(PolychromeSprite { + order: 0, + pad: 0, + grayscale: false, + bounds, + corner_radii: Default::default(), + content_mask, + tile, + opacity, + }); + } + Ok(()) + } + fn should_use_subpixel_rendering(&self, font_id: FontId, font_size: Pixels) -> bool { if self.platform_window.background_appearance() != WindowBackgroundAppearance::Opaque { return false; @@ -4063,6 +4195,11 @@ impl Window { self.refresh(); } } + + // Auto-release pointer capture on mouse up + if event.is::() && self.captured_hitbox.is_some() { + self.captured_hitbox = None; + } } fn dispatch_key_event(&mut self, event: &dyn Any, cx: &mut App) { diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs index 93e039019b1ca639118b5453ff8f9de0d30e4f99..e96d14b15691bec1da54aa9d46e3e765218292b2 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -110,10 +110,12 @@ impl InstanceBufferPool { pub(crate) struct MetalRenderer { device: metal::Device, - layer: metal::MetalLayer, + layer: Option, is_apple_gpu: bool, is_unified_memory: bool, presents_with_transaction: bool, + /// For headless rendering, tracks whether output should be opaque + opaque: bool, command_queue: CommandQueue, paths_rasterization_pipeline_state: metal::RenderPipelineState, path_sprites_pipeline_state: metal::RenderPipelineState, @@ -142,26 +144,9 @@ pub struct PathRasterizationVertex { } impl MetalRenderer { + /// Creates a new MetalRenderer with a CAMetalLayer for window-based rendering. pub fn new(instance_buffer_pool: Arc>, transparent: bool) -> Self { - // Prefer low‐power integrated GPUs on Intel Mac. On Apple - // Silicon, there is only ever one GPU, so this is equivalent to - // `metal::Device::system_default()`. - let device = if let Some(d) = metal::Device::all() - .into_iter() - .min_by_key(|d| (d.is_removable(), !d.is_low_power())) - { - d - } else { - // For some reason `all()` can return an empty list, see https://github.com/zed-industries/zed/issues/37689 - // In that case, we fall back to the system default device. - log::error!( - "Unable to enumerate Metal devices; attempting to use system default device" - ); - metal::Device::system_default().unwrap_or_else(|| { - log::error!("unable to access a compatible graphics device"); - std::process::exit(1); - }) - }; + let device = Self::create_device(); let layer = metal::MetalLayer::new(); layer.set_device(&device); @@ -182,6 +167,48 @@ impl MetalRenderer { | AutoresizingMask::HEIGHT_SIZABLE ]; } + + Self::new_internal(device, Some(layer), !transparent, instance_buffer_pool) + } + + /// Creates a new headless MetalRenderer for offscreen rendering without a window. + /// + /// This renderer can render scenes to images without requiring a CAMetalLayer, + /// window, or AppKit. Use `render_scene_to_image()` to render scenes. + #[cfg(any(test, feature = "test-support"))] + pub fn new_headless(instance_buffer_pool: Arc>) -> Self { + let device = Self::create_device(); + Self::new_internal(device, None, true, instance_buffer_pool) + } + + fn create_device() -> metal::Device { + // Prefer low‐power integrated GPUs on Intel Mac. On Apple + // Silicon, there is only ever one GPU, so this is equivalent to + // `metal::Device::system_default()`. + if let Some(d) = metal::Device::all() + .into_iter() + .min_by_key(|d| (d.is_removable(), !d.is_low_power())) + { + d + } else { + // For some reason `all()` can return an empty list, see https://github.com/zed-industries/zed/issues/37689 + // In that case, we fall back to the system default device. + log::error!( + "Unable to enumerate Metal devices; attempting to use system default device" + ); + metal::Device::system_default().unwrap_or_else(|| { + log::error!("unable to access a compatible graphics device"); + std::process::exit(1); + }) + } + } + + fn new_internal( + device: metal::Device, + layer: Option, + opaque: bool, + instance_buffer_pool: Arc>, + ) -> Self { #[cfg(feature = "runtime_shaders")] let library = device .new_library_with_source(&SHADERS_SOURCE_FILE, &metal::CompileOptions::new()) @@ -303,6 +330,7 @@ impl MetalRenderer { presents_with_transaction: false, is_apple_gpu, is_unified_memory, + opaque, command_queue, paths_rasterization_pipeline_state, path_sprites_pipeline_state, @@ -322,12 +350,15 @@ impl MetalRenderer { } } - pub fn layer(&self) -> &metal::MetalLayerRef { - &self.layer + pub fn layer(&self) -> Option<&metal::MetalLayerRef> { + self.layer.as_ref().map(|l| l.as_ref()) } pub fn layer_ptr(&self) -> *mut CAMetalLayer { - self.layer.as_ptr() + self.layer + .as_ref() + .map(|l| l.as_ptr()) + .unwrap_or(ptr::null_mut()) } pub fn sprite_atlas(&self) -> &Arc { @@ -336,26 +367,25 @@ impl MetalRenderer { pub fn set_presents_with_transaction(&mut self, presents_with_transaction: bool) { self.presents_with_transaction = presents_with_transaction; - self.layer - .set_presents_with_transaction(presents_with_transaction); + if let Some(layer) = &self.layer { + layer.set_presents_with_transaction(presents_with_transaction); + } } pub fn update_drawable_size(&mut self, size: Size) { - let size = NSSize { - width: size.width.0 as f64, - height: size.height.0 as f64, - }; - unsafe { - let _: () = msg_send![ - self.layer(), - setDrawableSize: size - ]; + if let Some(layer) = &self.layer { + let ns_size = NSSize { + width: size.width.0 as f64, + height: size.height.0 as f64, + }; + unsafe { + let _: () = msg_send![ + layer.as_ref(), + setDrawableSize: ns_size + ]; + } } - let device_pixels_size = Size { - width: DevicePixels(size.width as i32), - height: DevicePixels(size.height as i32), - }; - self.update_path_intermediate_textures(device_pixels_size); + self.update_path_intermediate_textures(size); } fn update_path_intermediate_textures(&mut self, size: Size) { @@ -396,8 +426,11 @@ impl MetalRenderer { } } - pub fn update_transparency(&self, transparent: bool) { - self.layer.set_opaque(!transparent); + pub fn update_transparency(&mut self, transparent: bool) { + self.opaque = !transparent; + if let Some(layer) = &self.layer { + layer.set_opaque(!transparent); + } } pub fn destroy(&self) { @@ -405,7 +438,15 @@ impl MetalRenderer { } pub fn draw(&mut self, scene: &Scene) { - let layer = self.layer.clone(); + let layer = match &self.layer { + Some(l) => l.clone(), + None => { + log::error!( + "draw() called on headless renderer - use render_scene_to_image() instead" + ); + return; + } + }; let viewport_size = layer.drawable_size(); let viewport_size: Size = size( (viewport_size.width.ceil() as i32).into(), @@ -476,9 +517,15 @@ impl MetalRenderer { /// Renders the scene to a texture and returns the pixel data as an RGBA image. /// This does not present the frame to screen - useful for visual testing /// where we want to capture what would be rendered without displaying it. + /// + /// Note: This requires a layer-backed renderer. For headless rendering, + /// use `render_scene_to_image()` instead. #[cfg(any(test, feature = "test-support"))] pub fn render_to_image(&mut self, scene: &Scene) -> Result { - let layer = self.layer.clone(); + let layer = self + .layer + .clone() + .ok_or_else(|| anyhow::anyhow!("render_to_image requires a layer-backed renderer"))?; let viewport_size = layer.drawable_size(); let viewport_size: Size = size( (viewport_size.width.ceil() as i32).into(), @@ -567,21 +614,146 @@ impl MetalRenderer { } } + /// Renders a scene to an image without requiring a window or CAMetalLayer. + /// + /// This is the primary method for headless rendering. It creates an offscreen + /// texture, renders the scene to it, and returns the pixel data as an RGBA image. + #[cfg(any(test, feature = "test-support"))] + pub fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> Result { + if size.width.0 <= 0 || size.height.0 <= 0 { + anyhow::bail!("Invalid size for render_scene_to_image: {:?}", size); + } + + // Update path intermediate textures for this size + self.update_path_intermediate_textures(size); + + // Create an offscreen texture as render target + let texture_descriptor = metal::TextureDescriptor::new(); + texture_descriptor.set_width(size.width.0 as u64); + texture_descriptor.set_height(size.height.0 as u64); + texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm); + texture_descriptor + .set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); + texture_descriptor.set_storage_mode(metal::MTLStorageMode::Managed); + let target_texture = self.device.new_texture(&texture_descriptor); + + loop { + let mut instance_buffer = self + .instance_buffer_pool + .lock() + .acquire(&self.device, self.is_unified_memory); + + let command_buffer = + self.draw_primitives_to_texture(scene, &mut instance_buffer, &target_texture, size); + + match command_buffer { + Ok(command_buffer) => { + let instance_buffer_pool = self.instance_buffer_pool.clone(); + let instance_buffer = Cell::new(Some(instance_buffer)); + let block = ConcreteBlock::new(move |_| { + if let Some(instance_buffer) = instance_buffer.take() { + instance_buffer_pool.lock().release(instance_buffer); + } + }); + let block = block.copy(); + command_buffer.add_completed_handler(&block); + + // On discrete GPUs (non-unified memory), Managed textures + // require an explicit blit synchronize before the CPU can + // read back the rendered data. Without this, get_bytes + // returns stale zeros. + if !self.is_unified_memory { + let blit = command_buffer.new_blit_command_encoder(); + blit.synchronize_resource(&target_texture); + blit.end_encoding(); + } + + // Commit and wait for completion + command_buffer.commit(); + command_buffer.wait_until_completed(); + + // Read pixels from the texture + let width = size.width.0 as u32; + let height = size.height.0 as u32; + let bytes_per_row = width as usize * 4; + let buffer_size = height as usize * bytes_per_row; + + let mut pixels = vec![0u8; buffer_size]; + + let region = metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { + width: width as u64, + height: height as u64, + depth: 1, + }, + }; + + target_texture.get_bytes( + pixels.as_mut_ptr() as *mut std::ffi::c_void, + bytes_per_row as u64, + region, + 0, + ); + + // Convert BGRA to RGBA (swap B and R channels) + for chunk in pixels.chunks_exact_mut(4) { + chunk.swap(0, 2); + } + + return RgbaImage::from_raw(width, height, pixels).ok_or_else(|| { + anyhow::anyhow!("Failed to create RgbaImage from pixel data") + }); + } + Err(err) => { + log::error!( + "failed to render: {}. retrying with larger instance buffer size", + err + ); + let mut instance_buffer_pool = self.instance_buffer_pool.lock(); + let buffer_size = instance_buffer_pool.buffer_size; + if buffer_size >= 256 * 1024 * 1024 { + anyhow::bail!("instance buffer size grew too large: {}", buffer_size); + } + instance_buffer_pool.reset(buffer_size * 2); + log::info!( + "increased instance buffer size to {}", + instance_buffer_pool.buffer_size + ); + } + } + } + } + fn draw_primitives( &mut self, scene: &Scene, instance_buffer: &mut InstanceBuffer, drawable: &metal::MetalDrawableRef, viewport_size: Size, + ) -> Result { + self.draw_primitives_to_texture(scene, instance_buffer, drawable.texture(), viewport_size) + } + + fn draw_primitives_to_texture( + &mut self, + scene: &Scene, + instance_buffer: &mut InstanceBuffer, + texture: &metal::TextureRef, + viewport_size: Size, ) -> Result { let command_queue = self.command_queue.clone(); let command_buffer = command_queue.new_command_buffer(); - let alpha = if self.layer.is_opaque() { 1. } else { 0. }; + let alpha = if self.opaque { 1. } else { 0. }; let mut instance_offset = 0; - let mut command_encoder = new_command_encoder( + let mut command_encoder = new_command_encoder_for_texture( command_buffer, - drawable, + texture, viewport_size, |color_attachment| { color_attachment.set_load_action(metal::MTLLoadAction::Clear); @@ -617,9 +789,9 @@ impl MetalRenderer { command_buffer, ); - command_encoder = new_command_encoder( + command_encoder = new_command_encoder_for_texture( command_buffer, - drawable, + texture, viewport_size, |color_attachment| { color_attachment.set_load_action(metal::MTLLoadAction::Load); @@ -1309,9 +1481,9 @@ impl MetalRenderer { } } -fn new_command_encoder<'a>( +fn new_command_encoder_for_texture<'a>( command_buffer: &'a metal::CommandBufferRef, - drawable: &'a metal::MetalDrawableRef, + texture: &'a metal::TextureRef, viewport_size: Size, configure_color_attachment: impl Fn(&RenderPassColorAttachmentDescriptorRef), ) -> &'a metal::RenderCommandEncoderRef { @@ -1320,7 +1492,7 @@ fn new_command_encoder<'a>( .color_attachments() .object_at(0) .unwrap(); - color_attachment.set_texture(Some(drawable.texture())); + color_attachment.set_texture(Some(texture)); color_attachment.set_store_action(metal::MTLStoreAction::Store); configure_color_attachment(color_attachment); @@ -1506,3 +1678,32 @@ pub struct SurfaceBounds { pub bounds: Bounds, pub content_mask: ContentMask, } + +#[cfg(any(test, feature = "test-support"))] +pub struct MetalHeadlessRenderer { + renderer: MetalRenderer, +} + +#[cfg(any(test, feature = "test-support"))] +impl MetalHeadlessRenderer { + pub fn new() -> Self { + let instance_buffer_pool = Arc::new(Mutex::new(InstanceBufferPool::default())); + let renderer = MetalRenderer::new_headless(instance_buffer_pool); + Self { renderer } + } +} + +#[cfg(any(test, feature = "test-support"))] +impl gpui::PlatformHeadlessRenderer for MetalHeadlessRenderer { + fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> anyhow::Result { + self.renderer.render_scene_to_image(scene, size) + } + + fn sprite_atlas(&self) -> Arc { + self.renderer.sprite_atlas().clone() + } +} diff --git a/crates/gpui_macos/src/text_system.rs b/crates/gpui_macos/src/text_system.rs index 2511bcf12dc240bf11d2c050579a6c06ebb155ed..e0f8a010eadf422ce588d8a7d30b3db6f9a4dcee 100644 --- a/crates/gpui_macos/src/text_system.rs +++ b/crates/gpui_macos/src/text_system.rs @@ -53,7 +53,8 @@ use crate::open_type::apply_features_and_fallbacks; #[allow(non_upper_case_globals)] const kCGImageAlphaOnly: u32 = 7; -pub(crate) struct MacTextSystem(RwLock); +/// macOS text system using CoreText for font shaping. +pub struct MacTextSystem(RwLock); #[derive(Clone, PartialEq, Eq, Hash)] struct FontKey { @@ -73,7 +74,8 @@ struct MacTextSystemState { } impl MacTextSystem { - pub(crate) fn new() -> Self { + /// Create a new MacTextSystem. + pub fn new() -> Self { Self(RwLock::new(MacTextSystemState { memory_source: MemSource::empty(), system_source: SystemSource::new(), diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index c20c86026a102464343fc7c8cfb03b69b19b7641..290b2b704672028c79d99ef7eddad7ce37ed230e 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -2067,11 +2067,13 @@ fn update_window_scale_factor(window_state: &Arc>) { let scale_factor = lock.scale_factor(); let size = lock.content_size(); let drawable_size = size.to_device_pixels(scale_factor); - unsafe { - let _: () = msg_send![ - lock.renderer.layer(), - setContentsScale: scale_factor as f64 - ]; + if let Some(layer) = lock.renderer.layer() { + unsafe { + let _: () = msg_send![ + layer, + setContentsScale: scale_factor as f64 + ]; + } } lock.renderer.update_drawable_size(drawable_size); diff --git a/crates/gpui_platform/src/gpui_platform.rs b/crates/gpui_platform/src/gpui_platform.rs index 7dac5498a652f7a7fe68b9f6d7ea23dffabdfb22..1d2fea90b477542031dfbf591f458b2427ec6e01 100644 --- a/crates/gpui_platform/src/gpui_platform.rs +++ b/crates/gpui_platform/src/gpui_platform.rs @@ -59,6 +59,22 @@ pub fn current_platform(headless: bool) -> Rc { } } +/// Returns a new [`HeadlessRenderer`] for the current platform, if available. +#[cfg(feature = "test-support")] +pub fn current_headless_renderer() -> Option> { + #[cfg(target_os = "macos")] + { + Some(Box::new( + gpui_macos::metal_renderer::MetalHeadlessRenderer::new(), + )) + } + + #[cfg(not(target_os = "macos"))] + { + None + } +} + #[cfg(all(test, target_os = "macos"))] mod tests { use super::*; From dd0e51ecb8706d918b0dfd7eac509c9c637a55fd Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 12 Mar 2026 23:24:38 +0100 Subject: [PATCH 183/219] agent_ui: Disable pickers while thread is generating (#50519) It does not make sense to enable them during the running turn and it can lead to more confusing states if subagents are used. Release Notes: - N/A --------- Co-authored-by: Danilo Leal --- .../src/connection_view/thread_view.rs | 48 +++++++++++++++++-- crates/agent_ui/src/model_selector_popover.rs | 48 +++++++++++++++---- crates/agent_ui/src/profile_selector.rs | 37 +++++++++++--- 3 files changed, 114 insertions(+), 19 deletions(-) diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 44f9e78a2bb47af6cb171194fbd5a34de7383f1b..030f6c5431eb79258be60f9d0139b8757611aa71 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -2674,6 +2674,14 @@ impl ThreadView { return div().into_any_element(); } + let is_generating = self.thread.read(cx).status() != ThreadStatus::Idle; + if let Some(model_selector) = &self.model_selector { + model_selector.update(cx, |selector, _| selector.set_disabled(is_generating)); + } + if let Some(profile_selector) = &self.profile_selector { + profile_selector.update(cx, |selector, _| selector.set_disabled(is_generating)); + } + let focus_handle = self.message_editor.focus_handle(cx); let editor_bg_color = cx.theme().colors().editor_background; let editor_expanded = self.editor_expanded; @@ -3223,6 +3231,7 @@ impl ThreadView { return None; } + let is_generating = self.thread.read(cx).status() != ThreadStatus::Idle; let thinking = thread.thinking_enabled(); let (tooltip_label, icon, color) = if thinking { @@ -3244,8 +3253,13 @@ impl ThreadView { let thinking_toggle = IconButton::new("thinking-mode", icon) .icon_size(IconSize::Small) .icon_color(color) - .tooltip(move |_, cx| { - Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx) + .disabled(is_generating) + .tooltip(move |window, cx| { + if is_generating { + Tooltip::text("Disabled until generation is done")(window, cx) + } else { + Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx) + } }) .on_click(cx.listener(move |this, _, _window, cx| { if let Some(thread) = this.as_native_thread(cx) { @@ -3277,6 +3291,7 @@ impl ThreadView { let right_btn = self.render_effort_selector( model.supported_effort_levels(), thread.thinking_effort().cloned(), + is_generating, cx, ); @@ -3291,6 +3306,7 @@ impl ThreadView { &self, supported_effort_levels: Vec, selected_effort: Option, + disabled: bool, cx: &Context, ) -> impl IntoElement { let weak_self = cx.weak_entity(); @@ -3359,6 +3375,7 @@ impl ThreadView { PopoverMenu::new("effort-selector") .trigger_with_tooltip( ButtonLike::new_rounded_right("effort-selector-trigger") + .disabled(disabled) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .child(Label::new(label).size(LabelSize::Small).color(label_color)) .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)), @@ -7722,6 +7739,9 @@ impl Render for ThreadView { this.toggle_fast_mode(cx); })) .on_action(cx.listener(|this, _: &ToggleThinkingMode, _window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, cx| { thread.set_thinking_enabled(!thread.thinking_enabled(), cx); @@ -7729,9 +7749,19 @@ impl Render for ThreadView { } })) .on_action(cx.listener(|this, _: &CycleThinkingEffort, _window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } this.cycle_thinking_effort(cx); })) - .on_action(cx.listener(Self::toggle_thinking_effort_menu)) + .on_action( + cx.listener(|this, action: &ToggleThinkingEffortMenu, window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } + this.toggle_thinking_effort_menu(action, window, cx); + }), + ) .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| { this.send_queued_message_at_index(0, true, window, cx); })) @@ -7749,6 +7779,9 @@ impl Render for ThreadView { cx.notify(); })) .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } if let Some(config_options_view) = this.config_options_view.clone() { let handled = config_options_view.update(cx, |view, cx| { view.toggle_category_picker( @@ -7769,6 +7802,9 @@ impl Render for ThreadView { } })) .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } if let Some(config_options_view) = this.config_options_view.clone() { let handled = config_options_view.update(cx, |view, cx| { view.cycle_category_option( @@ -7793,6 +7829,9 @@ impl Render for ThreadView { } })) .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } if let Some(config_options_view) = this.config_options_view.clone() { let handled = config_options_view.update(cx, |view, cx| { view.toggle_category_picker( @@ -7812,6 +7851,9 @@ impl Render for ThreadView { } })) .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } if let Some(config_options_view) = this.config_options_view.clone() { let handled = config_options_view.update(cx, |view, cx| { view.cycle_category_option( diff --git a/crates/agent_ui/src/model_selector_popover.rs b/crates/agent_ui/src/model_selector_popover.rs index 257337b6b0b8a39645bc38b4d814b250d7b5e1f9..7a4e9dbf8633680fe9c6ee3bda4acdb0ff5b1478 100644 --- a/crates/agent_ui/src/model_selector_popover.rs +++ b/crates/agent_ui/src/model_selector_popover.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector}; use fs::Fs; -use gpui::{Entity, FocusHandle}; +use gpui::{AnyView, Entity, FocusHandle}; use picker::popover_menu::PickerPopoverMenu; use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*}; @@ -13,6 +13,7 @@ use crate::{ModelSelector, model_selector::acp_model_selector}; pub struct ModelSelectorPopover { selector: Entity, menu_handle: PopoverMenuHandle, + disabled: bool, } impl ModelSelectorPopover { @@ -30,10 +31,18 @@ impl ModelSelectorPopover { acp_model_selector(selector, agent_server, fs, focus_handle.clone(), window, cx) }), menu_handle, + disabled: false, } } + pub fn set_disabled(&mut self, disabled: bool) { + self.disabled = disabled; + } + pub fn toggle(&self, window: &mut Window, cx: &mut Context) { + if self.disabled { + return; + } self.menu_handle.toggle(window, cx); } @@ -42,6 +51,9 @@ impl ModelSelectorPopover { } pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context) { + if self.disabled { + return; + } self.selector.update(cx, |selector, cx| { selector.delegate.cycle_favorite_models(window, cx); }); @@ -61,23 +73,31 @@ impl Render for ModelSelectorPopover { let (color, icon) = if self.menu_handle.is_deployed() { (Color::Accent, IconName::ChevronUp) + } else if self.disabled { + (Color::Disabled, IconName::ChevronDown) } else { (Color::Muted, IconName::ChevronDown) }; let show_cycle_row = selector.delegate.favorites_count() > 1; + let disabled = self.disabled; - let tooltip = Tooltip::element({ - move |_, _cx| { - ModelSelectorTooltip::new() - .show_cycle_row(show_cycle_row) - .into_any_element() - } - }); + let tooltip: Box AnyView> = if disabled { + Box::new(Tooltip::text("Disabled until generation is done")) + } else { + Box::new(Tooltip::element({ + move |_, _cx| { + ModelSelectorTooltip::new() + .show_cycle_row(show_cycle_row) + .into_any_element() + } + })) + }; PickerPopoverMenu::new( self.selector.clone(), ButtonLike::new("active-model") + .disabled(self.disabled) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .when_some(model_icon, |this, icon| { this.child( @@ -95,7 +115,17 @@ impl Render for ModelSelectorPopover { .size(LabelSize::Small) .ml_0p5(), ) - .child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)), + .child( + Icon::new(icon) + .map(|this| { + if self.disabled { + this.color(Color::Disabled) + } else { + this.color(Color::Muted) + } + }) + .size(IconSize::XSmall), + ), tooltip, gpui::Corner::BottomRight, cx, diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 926549c22f88bcb0937dddf7c3ff1b32060ed297..f785c936a643f4280121d083831eba4c909bc0f5 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -5,8 +5,8 @@ use agent_settings::{ use fs::Fs; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ - Action, AnyElement, App, BackgroundExecutor, Context, DismissEvent, Entity, FocusHandle, - Focusable, ForegroundExecutor, SharedString, Subscription, Task, Window, + Action, AnyElement, AnyView, App, BackgroundExecutor, Context, DismissEvent, Entity, + FocusHandle, Focusable, ForegroundExecutor, SharedString, Subscription, Task, Window, }; use picker::{Picker, PickerDelegate, popover_menu::PickerPopoverMenu}; use settings::{Settings as _, SettingsStore, update_settings_file}; @@ -34,6 +34,7 @@ pub trait ProfileProvider { pub struct ProfileSelector { profiles: AvailableProfiles, pending_refresh: bool, + disabled: bool, fs: Arc, provider: Arc, picker: Option>>, @@ -57,6 +58,7 @@ impl ProfileSelector { Self { profiles: AgentProfile::available_profiles(cx), pending_refresh: false, + disabled: false, fs, provider, picker: None, @@ -70,7 +72,19 @@ impl ProfileSelector { self.picker_handle.clone() } + pub fn set_disabled(&mut self, disabled: bool) { + self.disabled = disabled; + } + + pub fn is_disabled(&self) -> bool { + self.disabled + } + pub fn cycle_profile(&mut self, cx: &mut Context) { + if self.disabled { + return; + } + if !self.provider.profiles_supported(cx) { return; } @@ -175,6 +189,7 @@ impl Render for ProfileSelector { }; let trigger_button = Button::new("profile-selector", selected_profile) + .disabled(self.disabled) .label_size(LabelSize::Small) .color(Color::Muted) .icon(icon) @@ -183,10 +198,12 @@ impl Render for ProfileSelector { .icon_color(Color::Muted) .selected_style(ButtonStyle::Tinted(TintColor::Accent)); - PickerPopoverMenu::new( - picker, - trigger_button, - Tooltip::element({ + let disabled = self.disabled; + + let tooltip: Box AnyView> = if disabled { + Box::new(Tooltip::text("Disabled until generation is done")) + } else { + Box::new(Tooltip::element({ move |_window, cx| { let container = || h_flex().gap_1().justify_between(); v_flex() @@ -206,7 +223,13 @@ impl Render for ProfileSelector { ) .into_any() } - }), + })) + }; + + PickerPopoverMenu::new( + picker, + trigger_button, + tooltip, gpui::Corner::BottomRight, cx, ) From 5e60aa9872607a6caa2e4e6a6ce69ce12128f380 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 12 Mar 2026 15:34:09 -0700 Subject: [PATCH 184/219] Implement worktree interactions for the sidebar (#51421) 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: - N/A --- crates/agent_ui/src/agent_panel.rs | 17 +- crates/agent_ui/src/sidebar.rs | 322 +++++++++++++++++- crates/journal/src/journal.rs | 7 +- crates/recent_projects/src/recent_projects.rs | 21 +- crates/workspace/src/multi_workspace.rs | 4 +- crates/workspace/src/workspace.rs | 88 ++--- crates/zed/src/zed.rs | 14 +- crates/zed/src/zed/open_listener.rs | 8 +- 8 files changed, 399 insertions(+), 82 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index f9a136c10fe26ce1763fbde52c532f065e097463..23dc1dfcbc086f4b145bb5372929d9aa32f30fc5 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -87,7 +87,7 @@ use ui::{ use util::{ResultExt as _, debug_panic}; use workspace::{ CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar, - MultiWorkspace, SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, ToggleZoom, + MultiWorkspace, OpenResult, SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, multi_workspace_enabled, @@ -3025,21 +3025,16 @@ impl AgentPanel { workspace.set_dock_structure(dock_structure, window, cx); })); - let (new_window_handle, _) = cx + let OpenResult { + window: new_window_handle, + workspace: new_workspace, + .. + } = cx .update(|_window, cx| { Workspace::new_local(all_paths, app_state, window_handle, None, init, false, cx) })? .await?; - let new_workspace = new_window_handle.update(cx, |multi_workspace, _window, _cx| { - let workspaces = multi_workspace.workspaces(); - workspaces.last().cloned() - })?; - - let Some(new_workspace) = new_workspace else { - anyhow::bail!("New workspace was not added to MultiWorkspace"); - }; - let panels_task = new_window_handle.update(cx, |_, _, cx| { new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task()) })?; diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 7d7779e75504a93c7923ba26ec87e4fce4bbceb9..6dc684b3d30737dbce1b7d1c9c706341cf4ef11f 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -99,13 +99,19 @@ impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo { } } +#[derive(Clone)] +enum ThreadEntryWorkspace { + Open(Entity), + Closed(PathList), +} + #[derive(Clone)] struct ThreadEntry { session_info: acp_thread::AgentSessionInfo, icon: IconName, icon_from_external_svg: Option, status: AgentThreadStatus, - workspace: Entity, + workspace: ThreadEntryWorkspace, is_live: bool, is_background: bool, highlight_positions: Vec, @@ -528,7 +534,8 @@ impl Sidebar { // 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)>> = HashMap::new(); + let mut pending: HashMap, Vec<(usize, SharedString, Arc)>> = HashMap::new(); + let mut absorbed_workspace_by_path: HashMap, usize> = HashMap::new(); for (i, workspace) in workspaces.iter().enumerate() { for snapshot in root_repository_snapshots(workspace, cx) { @@ -537,8 +544,9 @@ impl Sidebar { .entry(snapshot.work_directory_abs_path.clone()) .or_insert(i); if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) { - for (ws_idx, name) in waiting { + for (ws_idx, name, ws_path) in waiting { absorbed.insert(ws_idx, (i, name)); + absorbed_workspace_by_path.insert(ws_path, ws_idx); } } } else { @@ -553,11 +561,13 @@ impl Sidebar { 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)); + .push((i, name, snapshot.work_directory_abs_path.clone())); } } } @@ -586,7 +596,7 @@ impl Sidebar { icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::default(), - workspace: workspace.clone(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, is_background: false, highlight_positions: Vec::new(), @@ -599,7 +609,8 @@ impl Sidebar { // Load threads from linked git worktrees of this workspace's repos. if let Some(ref thread_store) = thread_store { - let mut linked_worktree_queries: Vec<(PathList, SharedString)> = Vec::new(); + let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc)> = + Vec::new(); for snapshot in root_repository_snapshots(workspace, cx) { if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path { continue; @@ -614,11 +625,20 @@ impl Sidebar { linked_worktree_queries.push(( PathList::new(std::slice::from_ref(&git_worktree.path)), name.into(), + Arc::from(git_worktree.path.as_path()), )); } } - for (worktree_path_list, worktree_name) in &linked_worktree_queries { + 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) => ThreadEntryWorkspace::Open(workspaces[idx].clone()), + None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()), + }; + for meta in thread_store.read(cx).threads_for_paths(worktree_path_list) { if !seen_session_ids.insert(meta.id.clone()) { continue; @@ -628,7 +648,7 @@ impl Sidebar { icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::default(), - workspace: workspace.clone(), + workspace: target_workspace.clone(), is_live: false, is_background: false, highlight_positions: Vec::new(), @@ -1347,8 +1367,20 @@ impl Sidebar { } ListEntry::Thread(thread) => { let session_info = thread.session_info.clone(); - let workspace = thread.workspace.clone(); - self.activate_thread(session_info, &workspace, window, cx); + match &thread.workspace { + ThreadEntryWorkspace::Open(workspace) => { + let workspace = workspace.clone(); + self.activate_thread(session_info, &workspace, window, cx); + } + ThreadEntryWorkspace::Closed(path_list) => { + self.open_workspace_and_activate_thread( + session_info, + path_list.clone(), + window, + cx, + ); + } + } } ListEntry::ViewMore { path_list, @@ -1403,6 +1435,32 @@ impl Sidebar { } } + fn open_workspace_and_activate_thread( + &mut self, + session_info: acp_thread::AgentSessionInfo, + path_list: PathList, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + let paths: Vec = + path_list.paths().iter().map(|p| p.to_path_buf()).collect(); + + let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx)); + + cx.spawn_in(window, async move |this, cx| { + let workspace = open_task.await?; + this.update_in(cx, |this, window, cx| { + this.activate_thread(session_info, &workspace, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + fn expand_selected_entry( &mut self, _: &ExpandSelectedEntry, @@ -1480,7 +1538,7 @@ impl Sidebar { .clone() .unwrap_or_else(|| "Untitled".into()); let session_info = thread.session_info.clone(); - let workspace = thread.workspace.clone(); + let thread_workspace = thread.workspace.clone(); let id = SharedString::from(format!("thread-entry-{}", ix)); @@ -1533,7 +1591,19 @@ impl Sidebar { .docked_right(docked_right) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; - this.activate_thread(session_info.clone(), &workspace, window, cx); + match &thread_workspace { + ThreadEntryWorkspace::Open(workspace) => { + this.activate_thread(session_info.clone(), workspace, window, cx); + } + ThreadEntryWorkspace::Closed(path_list) => { + this.open_workspace_and_activate_thread( + session_info.clone(), + path_list.clone(), + window, + cx, + ); + } + } })) .into_any_element() } @@ -2447,7 +2517,7 @@ mod tests { icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::Completed, - workspace: workspace.clone(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, is_background: false, highlight_positions: Vec::new(), @@ -2468,7 +2538,7 @@ mod tests { icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::Running, - workspace: workspace.clone(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: true, is_background: false, highlight_positions: Vec::new(), @@ -2489,7 +2559,7 @@ mod tests { icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::Error, - workspace: workspace.clone(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: true, is_background: false, highlight_positions: Vec::new(), @@ -2510,7 +2580,7 @@ mod tests { icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::WaitingForConfirmation, - workspace: workspace.clone(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, is_background: false, highlight_positions: Vec::new(), @@ -2531,7 +2601,7 @@ mod tests { icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::Completed, - workspace: workspace.clone(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: true, is_background: true, highlight_positions: Vec::new(), @@ -4305,4 +4375,222 @@ mod tests { vec!["v [project]", " Thread A {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: "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("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_and_label(&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_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: "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("thread-main", "Main Thread", &paths_main, cx).await; + save_named_thread("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" + ); + } } diff --git a/crates/journal/src/journal.rs b/crates/journal/src/journal.rs index ba97bcf66a77659fb3196ba45ebb3f831452e008..b8028c79b3d5da415a52d946d7601d8cbb40f738 100644 --- a/crates/journal/src/journal.rs +++ b/crates/journal/src/journal.rs @@ -9,7 +9,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use workspace::{AppState, OpenVisible, Workspace}; +use workspace::{AppState, OpenResult, OpenVisible, Workspace}; actions!( journal, @@ -107,7 +107,10 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap .spawn(cx, async move |cx| { let (journal_dir, entry_path) = create_entry.await?; let opened = if open_new_workspace { - let (new_workspace, _) = cx + let OpenResult { + window: new_workspace, + .. + } = cx .update(|_window, cx| { workspace::open_paths( &[journal_dir], diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index b5ae7b048276f671da48beaa52b0db5fbcdda61a..c9720af2aba7f4a27adf8e40745bb05012c4dafd 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -935,7 +935,14 @@ impl PickerDelegate for RecentProjectsDelegate { } return; } else { - workspace.open_workspace_for_paths(false, paths, window, cx) + workspace + .open_workspace_for_paths(false, paths, window, cx) + .detach_and_prompt_err( + "Failed to open project", + window, + cx, + |_, _, _| None, + ); } } SerializedWorkspaceLocation::Remote(mut connection) => { @@ -964,14 +971,14 @@ impl PickerDelegate for RecentProjectsDelegate { ) .await }) + .detach_and_prompt_err( + "Failed to open project", + window, + cx, + |_, _, _| None, + ); } } - .detach_and_prompt_err( - "Failed to open project", - window, - cx, - |_, _, _| None, - ); }); cx.emit(DismissEvent); } diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index adfc62a2bd210b4da24202d734ba9f9eedd17aef..cb60978d85220baa8519a7a1816434b4c06eb0c3 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -498,7 +498,7 @@ impl MultiWorkspace { paths: Vec, window: &mut Window, cx: &mut Context, - ) -> Task> { + ) -> Task>> { let workspace = self.workspace().clone(); if multi_workspace_enabled(cx) { @@ -519,7 +519,7 @@ impl MultiWorkspace { })? .await } else { - Ok(()) + Ok(workspace) } }) } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 949dc127a7465c4cf3941ee4c4982fad37d06281..19d02e9a8a6742ba04bc52a68568cb2bf994608a 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -659,7 +659,7 @@ fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, c } else { let task = Workspace::new_local(Vec::new(), app_state.clone(), None, None, None, true, cx); cx.spawn(async move |cx| { - let (window, _) = task.await?; + let OpenResult { window, .. } = task.await?; window.update(cx, |multi_workspace, window, cx| { window.activate_window(); let workspace = multi_workspace.workspace().clone(); @@ -1752,12 +1752,7 @@ impl Workspace { init: Option) + Send>>, activate: bool, cx: &mut App, - ) -> Task< - anyhow::Result<( - WindowHandle, - Vec>>>, - )>, - > { + ) -> Task> { let project_handle = Project::local( app_state.client.clone(), app_state.node_runtime.clone(), @@ -1997,7 +1992,11 @@ impl Workspace { }); }) .log_err(); - Ok((window, opened_items)) + Ok(OpenResult { + window, + workspace, + opened_items, + }) }) } @@ -2685,7 +2684,10 @@ impl Workspace { cx, ); cx.spawn_in(window, async move |_vh, cx| { - let (multi_workspace_window, _) = task.await?; + let OpenResult { + window: multi_workspace_window, + .. + } = task.await?; multi_workspace_window.update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| callback(workspace, window, cx)) @@ -2723,7 +2725,10 @@ impl Workspace { cx, ); cx.spawn_in(window, async move |_vh, cx| { - let (multi_workspace_window, _) = task.await?; + let OpenResult { + window: multi_workspace_window, + .. + } = task.await?; multi_workspace_window.update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| callback(workspace, window, cx)) @@ -3102,7 +3107,7 @@ impl Workspace { paths: Vec, window: &mut Window, cx: &mut Context, - ) -> Task> { + ) -> Task>> { let window_handle = window.window_handle().downcast::(); let is_remote = self.project.read(cx).is_via_collab(); let has_worktree = self.project.read(cx).worktrees(cx).next().is_some(); @@ -3118,19 +3123,20 @@ impl Workspace { let app_state = self.app_state.clone(); cx.spawn(async move |_, cx| { - cx.update(|cx| { - open_paths( - &paths, - app_state, - OpenOptions { - replace_window: window_to_replace, - ..Default::default() - }, - cx, - ) - }) - .await?; - Ok(()) + let OpenResult { workspace, .. } = cx + .update(|cx| { + open_paths( + &paths, + app_state, + OpenOptions { + replace_window: window_to_replace, + ..Default::default() + }, + cx, + ) + }) + .await?; + Ok(workspace) }) } @@ -8210,7 +8216,7 @@ pub async fn restore_multiworkspace( cx.update(|cx| open_workspace_by_id(first.workspace_id, app_state.clone(), None, cx)) .await? } else { - let (window, _items) = cx + let OpenResult { window, .. } = cx .update(|cx| { Workspace::new_local( first.paths.paths().to_vec(), @@ -8503,7 +8509,10 @@ pub fn join_channel( let mut active_window = requesting_window.or_else(|| activate_any_workspace_window(cx)); if active_window.is_none() { // no open workspaces, make one to show the error in (blergh) - let (window_handle, _) = cx + let OpenResult { + window: window_handle, + .. + } = cx .update(|cx| { Workspace::new_local( vec![], @@ -8759,6 +8768,14 @@ pub struct OpenOptions { pub env: Option>, } +/// The result of opening a workspace via [`open_paths`], [`Workspace::new_local`], +/// or [`Workspace::open_workspace_for_paths`]. +pub struct OpenResult { + pub window: WindowHandle, + pub workspace: Entity, + pub opened_items: Vec>>>, +} + /// Opens a workspace by its database ID, used for restoring empty workspaces with unsaved content. pub fn open_workspace_by_id( workspace_id: WorkspaceId, @@ -8878,12 +8895,7 @@ pub fn open_paths( app_state: Arc, open_options: OpenOptions, cx: &mut App, -) -> Task< - anyhow::Result<( - WindowHandle, - Vec>>>, - )>, -> { +) -> Task> { let abs_paths = abs_paths.to_vec(); #[cfg(target_os = "windows")] let wsl_path = abs_paths @@ -8962,7 +8974,7 @@ pub fn open_paths( }); }); - Ok((existing, open_task)) + Ok(OpenResult { window: existing, workspace: target_workspace, opened_items: open_task }) } else { let result = cx .update(move |cx| { @@ -8978,8 +8990,8 @@ pub fn open_paths( }) .await; - if let Ok((ref window_handle, _)) = result { - window_handle + if let Ok(ref result) = result { + result.window .update(cx, |_, window, _cx| { window.activate_window(); }) @@ -8991,9 +9003,9 @@ pub fn open_paths( #[cfg(target_os = "windows")] if let Some(util::paths::WslPath{distro, path}) = wsl_path - && let Ok((multi_workspace_window, _)) = &result + && let Ok(ref result) = result { - multi_workspace_window + result.window .update(cx, move |multi_workspace, _window, cx| { struct OpenInWsl; let workspace = multi_workspace.workspace().clone(); @@ -9040,7 +9052,7 @@ pub fn open_new( cx, ); cx.spawn(async move |cx| { - let (window, _opened_paths) = task.await?; + let OpenResult { window, .. } = task.await?; window .update(cx, |_, window, _cx| { window.activate_window(); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 511b0edc6ac168fa47b52e66c9632487de86acf4..76930f627ecd6b8bf37729d9b48c0bacb300ecfb 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -3442,7 +3442,11 @@ mod tests { PathBuf::from(path!("/root/.git/HEAD")), PathBuf::from(path!("/root/excluded_dir/ignored_subdir")), ]; - let (opened_workspace, new_items) = cx + let workspace::OpenResult { + window: opened_workspace, + opened_items: new_items, + .. + } = cx .update(|cx| { workspace::open_paths( &paths_to_open, @@ -5866,7 +5870,9 @@ mod tests { // // Window A: workspace for dir1, workspace for dir2 // Window B: workspace for dir3 - let (window_a, _) = cx + let workspace::OpenResult { + window: window_a, .. + } = cx .update(|cx| { Workspace::new_local( vec![dir1.into()], @@ -5890,7 +5896,9 @@ mod tests { .expect("failed to open second workspace into window A"); cx.run_until_parked(); - let (window_b, _) = cx + let workspace::OpenResult { + window: window_b, .. + } = cx .update(|cx| { Workspace::new_local( vec![dir3.into()], diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index e8f8554482680c4a51fc182c58369de19184bcb0..ca376f300d97de83d0b4a9af7620ee98ba5b4215 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -29,7 +29,7 @@ use util::ResultExt; use util::paths::PathWithPosition; use workspace::PathList; use workspace::item::ItemHandle; -use workspace::{AppState, MultiWorkspace, OpenOptions, SerializedWorkspaceLocation}; +use workspace::{AppState, MultiWorkspace, OpenOptions, OpenResult, SerializedWorkspaceLocation}; #[derive(Default, Debug)] pub struct OpenRequest { @@ -345,7 +345,11 @@ pub async fn open_paths_with_positions( .map(|path_with_position| path_with_position.path.clone()) .collect::>(); - let (multi_workspace, mut items) = cx + let OpenResult { + window: multi_workspace, + opened_items: mut items, + .. + } = cx .update(|cx| workspace::open_paths(&paths, app_state, open_options, cx)) .await?; From cc09611d0b058f2c814b8d9e290bdd902e8a6ebf Mon Sep 17 00:00:00 2001 From: Amaan <121273095+AmaanBilwar@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:13:34 +0530 Subject: [PATCH 185/219] workspace: Fix opening closed projects randomly when Zed restarts (#50961) Closes #49854 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] No UI changes Video for the fix: in the video i open a project -> close the project -> quit out of zed -> reopen zed -> zed opens to an empty workspace which was not the case before https://github.com/user-attachments/assets/1afb44a1-932b-4dab-8228-9d9d65750b6e Release Notes: - Fixed closed projects re-opening erroneously --- crates/zed/src/zed.rs | 71 +++++++++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 76930f627ecd6b8bf37729d9b48c0bacb300ecfb..2b515786d5dc503564607ffc1bc881a3077819a8 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1066,37 +1066,54 @@ fn register_actions( }) .register_action({ let app_state = Arc::downgrade(&app_state); - move |_, _: &CloseProject, window, cx| { + move |_workspace, _: &CloseProject, window, cx| { let Some(window_handle) = window.window_handle().downcast::() else { return; }; if let Some(app_state) = app_state.upgrade() { - open_new( - workspace::OpenOptions { - replace_window: Some(window_handle), - ..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, - ); - }, - ) + 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); } } From 94248361be53c511a19ffefc14a3c55c040a639c Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 12 Mar 2026 16:45:41 -0600 Subject: [PATCH 186/219] Make dispatcher on TestApp public again (#51431) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/gpui/src/app/test_context.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 7fa47191404fd28baf11f27d055e5ac7b85a747d..d8f459df3c54200f07b4584eeb8e1ffa8415554b 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -22,7 +22,8 @@ pub struct TestAppContext { pub background_executor: BackgroundExecutor, #[doc(hidden)] pub foreground_executor: ForegroundExecutor, - dispatcher: TestDispatcher, + #[doc(hidden)] + pub dispatcher: TestDispatcher, test_platform: Rc, text_system: Arc, fn_name: Option<&'static str>, From a07d0f4d2140b2bdf1da2d1721413077bc5c64e1 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 12 Mar 2026 18:49:17 -0400 Subject: [PATCH 187/219] Assign meaningful names to some single-letter bindings (#51432) This PR assigns meaningful names to some single-letter bindings we were using to refer to the organization. Release Notes: - N/A --- crates/language_model/src/model/cloud_model.rs | 2 +- crates/language_models/src/provider/cloud.rs | 6 +++--- crates/web_search_providers/src/cloud.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index e384ce05fa390677529235442c4cb91186520a02..527d24ec18c0f9ef08576a71fe92562dd94d4afd 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -159,7 +159,7 @@ impl RefreshLlmTokenListener { .user_store .read(cx) .current_organization() - .map(|o| o.id.clone()); + .map(|organization| organization.id.clone()); cx.spawn(async move |this, cx| { llm_api_token.refresh(&client, organization_id).await?; this.update(cx, |_this, cx| cx.emit(LlmTokenRefreshedEvent)) diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 610b0167b86f8bf4426b671cedad45a28c3fdc6d..4fdf06cc959ccc853f92f4e150978cd15c8e70d3 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -157,7 +157,7 @@ impl State { .user_store .read(cx) .current_organization() - .map(|o| o.id.clone()); + .map(|organization| organization.id.clone()); cx.spawn(async move |this, cx| { let response = Self::fetch_models(client, llm_api_token, organization_id).await?; @@ -705,7 +705,7 @@ impl LanguageModel for CloudLanguageModel { .user_store .read(cx) .current_organization() - .map(|o| o.id.clone()); + .map(|organization| organization.id.clone()); let model_id = self.model.id.to_string(); let generate_content_request = into_google(request, model_id.clone(), GoogleModelMode::Default); @@ -777,7 +777,7 @@ impl LanguageModel for CloudLanguageModel { user_store .read(cx) .current_organization() - .map(|o| o.id.clone()) + .map(|organization| organization.id.clone()) }); let thinking_allowed = request.thinking_allowed; let enable_thinking = thinking_allowed && self.model.supports_thinking; diff --git a/crates/web_search_providers/src/cloud.rs b/crates/web_search_providers/src/cloud.rs index 51be6c9ddff01a956eebabe3e44166ae15de4515..17addd24d445a666138a1b37fef872beedd07aed 100644 --- a/crates/web_search_providers/src/cloud.rs +++ b/crates/web_search_providers/src/cloud.rs @@ -55,7 +55,7 @@ impl WebSearchProvider for CloudWebSearchProvider { .user_store .read(cx) .current_organization() - .map(|o| o.id.clone()); + .map(|organization| organization.id.clone()); let body = WebSearchBody { query }; cx.background_spawn(async move { perform_web_search(client, llm_api_token, organization_id, body).await From b15a8c1e5e214f7288259d98eab87892a6997ed7 Mon Sep 17 00:00:00 2001 From: Justin Su Date: Thu, 12 Mar 2026 20:05:27 -0400 Subject: [PATCH 188/219] docs: Clarify that `"..."` enables all other registered language servers (#51427) Closes #51416 Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --- docs/src/languages/python.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/languages/python.md b/docs/src/languages/python.md index fdeabec5069ed20a9b168ab19129dde0cc6280ba..0f34fdb752143b30eb1f42a836482bd4ea1d1188 100644 --- a/docs/src/languages/python.md +++ b/docs/src/languages/python.md @@ -89,7 +89,7 @@ Configure language servers in Settings ({#kb zed::OpenSettings}) under Languages "languages": { "Python": { "language_servers": [ - // Disable basedpyright and enable ty, and include all + // Enable ty, disable basedpyright, and enable all // other registered language servers (ruff, pylsp, pyright). "ty", "!basedpyright", From d4bb640555e8035b45d6a56db206048ac1b35a0f Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:09:53 -0300 Subject: [PATCH 189/219] git_ui: Remove unused ProjectDiffEmptyState component (#51436) Just cleaning up our component set a bit. This one wasn't used at all. Release Notes: - N/A --- crates/git_ui/src/project_diff.rs | 252 +----------------------------- 1 file changed, 1 insertion(+), 251 deletions(-) diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index ad7d6b86befd0b0f4a1ecf6386c030d4294cdf5e..3af77b8fb680abbca2688410b783007af573578d 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -2,7 +2,6 @@ use crate::{ conflict_view::ConflictAddon, git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, git_panel_settings::GitPanelSettings, - remote_button::{render_publish_button, render_push_button}, resolve_active_repository, }; use agent_settings::AgentSettings; @@ -18,8 +17,7 @@ use editor::{ use git::repository::DiffType; use git::{ - Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext, - repository::{Branch, RepoPath, Upstream, UpstreamTracking, UpstreamTrackingStatus}, + Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext, repository::RepoPath, status::FileStatus, }; use gpui::{ @@ -1719,254 +1717,6 @@ impl Render for BranchDiffToolbar { } } -#[derive(IntoElement, RegisterComponent)] -pub struct ProjectDiffEmptyState { - pub no_repo: bool, - pub can_push_and_pull: bool, - pub focus_handle: Option, - pub current_branch: Option, - // has_pending_commits: bool, - // ahead_of_remote: bool, - // no_git_repository: bool, -} - -impl RenderOnce for ProjectDiffEmptyState { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool { - matches!(self.current_branch, Some(Branch { - upstream: - Some(Upstream { - tracking: - UpstreamTracking::Tracked(UpstreamTrackingStatus { - ahead, behind, .. - }), - .. - }), - .. - }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0)) - }; - - let change_count = |current_branch: &Branch| -> (usize, usize) { - match current_branch { - Branch { - upstream: - Some(Upstream { - tracking: - UpstreamTracking::Tracked(UpstreamTrackingStatus { - ahead, behind, .. - }), - .. - }), - .. - } => (*ahead as usize, *behind as usize), - _ => (0, 0), - } - }; - - let not_ahead_or_behind = status_against_remote(0, 0); - let ahead_of_remote = status_against_remote(1, 0); - let branch_not_on_remote = if let Some(branch) = self.current_branch.as_ref() { - branch.upstream.is_none() - } else { - false - }; - - let has_branch_container = |branch: &Branch| { - h_flex() - .max_w(px(420.)) - .bg(cx.theme().colors().text.opacity(0.05)) - .border_1() - .border_color(cx.theme().colors().border) - .rounded_sm() - .gap_8() - .px_6() - .py_4() - .map(|this| { - if ahead_of_remote { - let ahead_count = change_count(branch).0; - let ahead_string = format!("{} Commits Ahead", ahead_count); - this.child( - v_flex() - .child(Headline::new(ahead_string).size(HeadlineSize::Small)) - .child( - Label::new(format!("Push your changes to {}", branch.name())) - .color(Color::Muted), - ), - ) - .child(div().child(render_push_button( - self.focus_handle, - "push".into(), - ahead_count as u32, - ))) - } else if branch_not_on_remote { - this.child( - v_flex() - .child(Headline::new("Publish Branch").size(HeadlineSize::Small)) - .child( - Label::new(format!("Create {} on remote", branch.name())) - .color(Color::Muted), - ), - ) - .child( - div().child(render_publish_button(self.focus_handle, "publish".into())), - ) - } else { - this.child(Label::new("Remote status unknown").color(Color::Muted)) - } - }) - }; - - v_flex().size_full().items_center().justify_center().child( - v_flex() - .gap_1() - .when(self.no_repo, |this| { - this.text_center() - .child(Label::new("No Repository").color(Color::Muted)) - .child( - Button::new("initialize-repo", "Initialize Repository") - .on_click(move |_, _, cx| cx.dispatch_action(&git::Init)), - ) - }) - .map(|this| { - if not_ahead_or_behind && self.current_branch.is_some() { - this.text_center() - .child(Label::new("No Changes").color(Color::Muted)) - } else { - this.when_some(self.current_branch.as_ref(), |this, branch| { - this.child(has_branch_container(branch)) - }) - } - }), - ) - } -} - -mod preview { - use git::repository::{ - Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus, - }; - use ui::prelude::*; - - use super::ProjectDiffEmptyState; - - // View this component preview using `workspace: open component-preview` - impl Component for ProjectDiffEmptyState { - fn scope() -> ComponentScope { - ComponentScope::VersionControl - } - - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - let unknown_upstream: Option = None; - let ahead_of_upstream: Option = Some( - UpstreamTrackingStatus { - ahead: 2, - behind: 0, - } - .into(), - ); - - let not_ahead_or_behind_upstream: Option = Some( - UpstreamTrackingStatus { - ahead: 0, - behind: 0, - } - .into(), - ); - - fn branch(upstream: Option) -> Branch { - Branch { - is_head: true, - ref_name: "some-branch".into(), - upstream: upstream.map(|tracking| Upstream { - ref_name: "origin/some-branch".into(), - tracking, - }), - most_recent_commit: Some(CommitSummary { - sha: "abc123".into(), - subject: "Modify stuff".into(), - commit_timestamp: 1710932954, - author_name: "John Doe".into(), - has_parent: true, - }), - } - } - - let no_repo_state = ProjectDiffEmptyState { - no_repo: true, - can_push_and_pull: false, - focus_handle: None, - current_branch: None, - }; - - let no_changes_state = ProjectDiffEmptyState { - no_repo: false, - can_push_and_pull: true, - focus_handle: None, - current_branch: Some(branch(not_ahead_or_behind_upstream)), - }; - - let ahead_of_upstream_state = ProjectDiffEmptyState { - no_repo: false, - can_push_and_pull: true, - focus_handle: None, - current_branch: Some(branch(ahead_of_upstream)), - }; - - let unknown_upstream_state = ProjectDiffEmptyState { - no_repo: false, - can_push_and_pull: true, - focus_handle: None, - current_branch: Some(branch(unknown_upstream)), - }; - - let (width, height) = (px(480.), px(320.)); - - Some( - v_flex() - .gap_6() - .children(vec![ - example_group(vec![ - single_example( - "No Repo", - div() - .w(width) - .h(height) - .child(no_repo_state) - .into_any_element(), - ), - single_example( - "No Changes", - div() - .w(width) - .h(height) - .child(no_changes_state) - .into_any_element(), - ), - single_example( - "Unknown Upstream", - div() - .w(width) - .h(height) - .child(unknown_upstream_state) - .into_any_element(), - ), - single_example( - "Ahead of Remote", - div() - .w(width) - .h(height) - .child(ahead_of_upstream_state) - .into_any_element(), - ), - ]) - .vertical(), - ]) - .into_any_element(), - ) - } - } -} - struct BranchDiffAddon { branch_diff: Entity, } From 7b9afc8c454607222eaf751bbc38159ececc1f7a Mon Sep 17 00:00:00 2001 From: Finn Eitreim <48069764+feitreim@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:12:31 -0400 Subject: [PATCH 190/219] gpui: Recalculate list layout after the window has been resized (#51414) Closes #51417 I noticed this bug in the settings menu where when I opened the settings menu, I could not scroll down through all the available options, eg. on the initial page I wasn't able to scroll down to privacy. When I saw that no one else had reported this issue, I figured it may be due to my setup, and it turns out that using Aerospace, the window manager I use, was what made this bug visible to me. Because aerospace resizes the window right after it launches, the originally computed heights for the list are incorrect, meaning the scroll bar is the wrong size as well. in the relevant code there was a comment that says "If the width of the list has changed, invalidate all cached item heights" which wasn't incorrect per-se, but it just invalidated them without triggering any re-computation, causing incorrect scroll bars. My intuition is that window resizes/events that change the width of the list bounds are fairly rare, so there shouldn't be a large performance hit from the change. Also implemented a test that directly showcases the behavior, if you run the test without the change it fails, as the max_offset_for_scrollbar will be wrong. Videos: Before https://github.com/user-attachments/assets/2b680222-7071-4098-863f-519361f0756a After: https://github.com/user-attachments/assets/1222a299-23d7-4007-8e88-55d2daccce64 [x] Tests [x] Video of behavior Release Notes: - gpui: fixed list height re-computation when the list width changes. --- crates/gpui/src/elements/list.rs | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 92b5389fecf219c0c113f682463498902df4c07d..b84241e9e0f79fe5cf8a24514cbf57982247a76b 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -1103,6 +1103,7 @@ impl Element for List { ); state.items = new_items; + state.measuring_behavior.reset(); } let padding = style @@ -1348,6 +1349,41 @@ mod test { assert_eq!(offset.offset_in_item, px(0.)); } + #[gpui::test] + fn test_measure_all_after_width_change(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + 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()))); + + // First draw at width 100: all 10 items measured (total 500px). + // Viewport is 200px, so max scroll offset should be 300px. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert_eq!(state.max_offset_for_scrollbar().y, px(300.)); + + // Second draw at a different width: items get invalidated. + // Without the fix, max_offset would drop because unmeasured items + // contribute 0 height. + cx.draw(point(px(0.), px(0.)), size(px(200.), px(200.)), |_, _| { + view.into_any_element() + }); + assert_eq!(state.max_offset_for_scrollbar().y, px(300.)); + } + #[gpui::test] fn test_remeasure(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); From 7aba1f9691c6b0d08916a2d385d179ba876553a8 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 12 Mar 2026 19:58:57 -0600 Subject: [PATCH 191/219] Fix leak detector on HeadlessAppContext (#51442) Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --- crates/gpui/src/app/headless_app_context.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/gpui/src/app/headless_app_context.rs b/crates/gpui/src/app/headless_app_context.rs index bebade89d9a8417769147e5f64923953e4bc3694..90dc8c8f0c0994e3f118916b2d004f7d90566ea7 100644 --- a/crates/gpui/src/app/headless_app_context.rs +++ b/crates/gpui/src/app/headless_app_context.rs @@ -186,6 +186,14 @@ impl HeadlessAppContext { } } +impl Drop for HeadlessAppContext { + fn drop(&mut self) { + // Shut down the app so windows are closed and entity handles are + // released before the LeakDetector runs. + self.app.borrow_mut().shutdown(); + } +} + impl AppContext for HeadlessAppContext { fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { let mut app = self.app.borrow_mut(); From 8e045237c4104c139e1f996f9f90f33a0697468c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Soares?= <37777652+Dnreikronos@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:03:51 -0300 Subject: [PATCH 192/219] gpui: Hide XF86 keybindings from menus and keybinding hints (#50540) Closes #50436 Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed XF86 multimedia key names ("New", "Save", "Open") being shown as keybinding hints in menus instead of the actual keyboard shortcuts. --- assets/keymaps/default-linux.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 0516221b6e0849ab631c021d020050be99aaf728..56a51843ca9da052e39450ba38d8afcda9d1166d 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -226,8 +226,8 @@ "context": "ContextEditor > Editor", "bindings": { "ctrl-enter": "assistant::Assist", - "ctrl-s": "workspace::Save", "save": "workspace::Save", + "ctrl-s": "workspace::Save", "ctrl-<": "assistant::InsertIntoEditor", "shift-enter": "assistant::Split", "ctrl-r": "assistant::CycleMessageRole", From ea5c58c19a2bcb0a2fc88ebe3258352ed2c586e4 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:33:29 -0300 Subject: [PATCH 193/219] ui: Add new component for thread sidebar panel toggle (#51441) --- assets/icons/thread.svg | 3 +- crates/ui/src/components/ai.rs | 2 + .../src/components/ai/configured_api_card.rs | 55 +++++- .../ai/copilot_configuration_callout.rs | 1 - .../components/ai/thread_sidebar_toggle.rs | 177 ++++++++++++++++++ 5 files changed, 235 insertions(+), 3 deletions(-) delete mode 100644 crates/ui/src/components/ai/copilot_configuration_callout.rs create mode 100644 crates/ui/src/components/ai/thread_sidebar_toggle.rs diff --git a/assets/icons/thread.svg b/assets/icons/thread.svg index 496cf42e3a3ee1439f36b8e2479d05564362e628..569a6f3aec7e3b8742d3d7d23fe11db5aea199ba 100644 --- a/assets/icons/thread.svg +++ b/assets/icons/thread.svg @@ -1,3 +1,4 @@ - + + diff --git a/crates/ui/src/components/ai.rs b/crates/ui/src/components/ai.rs index a31db264e985b3adbca26b9e8d3fb2bdca306dcb..de6b74afb02e23d5fa87a01ae448d63979815870 100644 --- a/crates/ui/src/components/ai.rs +++ b/crates/ui/src/components/ai.rs @@ -1,5 +1,7 @@ mod configured_api_card; mod thread_item; +mod thread_sidebar_toggle; pub use configured_api_card::*; pub use thread_item::*; +pub use thread_sidebar_toggle::*; diff --git a/crates/ui/src/components/ai/configured_api_card.rs b/crates/ui/src/components/ai/configured_api_card.rs index 37f9ac7602d676906565a911f1bbca6d2b40f755..2104e816811a68776f69f3970b53636dbbd63e17 100644 --- a/crates/ui/src/components/ai/configured_api_card.rs +++ b/crates/ui/src/components/ai/configured_api_card.rs @@ -1,7 +1,7 @@ use crate::{Tooltip, prelude::*}; use gpui::{ClickEvent, IntoElement, ParentElement, SharedString}; -#[derive(IntoElement)] +#[derive(IntoElement, RegisterComponent)] pub struct ConfiguredApiCard { label: SharedString, button_label: Option, @@ -52,6 +52,59 @@ impl ConfiguredApiCard { } } +impl Component for ConfiguredApiCard { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let container = || { + v_flex() + .w_72() + .p_2() + .gap_2() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) + }; + + let examples = vec![ + single_example( + "Default", + container() + .child(ConfiguredApiCard::new("API key is configured")) + .into_any_element(), + ), + single_example( + "Custom Button Label", + container() + .child( + ConfiguredApiCard::new("OpenAI API key configured") + .button_label("Remove Key"), + ) + .into_any_element(), + ), + single_example( + "With Tooltip", + container() + .child( + ConfiguredApiCard::new("Anthropic API key configured") + .tooltip_label("Click to reset your API key"), + ) + .into_any_element(), + ), + single_example( + "Disabled", + container() + .child(ConfiguredApiCard::new("API key is configured").disabled(true)) + .into_any_element(), + ), + ]; + + Some(example_group(examples).into_any_element()) + } +} + impl RenderOnce for ConfiguredApiCard { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let button_label = self.button_label.unwrap_or("Reset Key".into()); diff --git a/crates/ui/src/components/ai/copilot_configuration_callout.rs b/crates/ui/src/components/ai/copilot_configuration_callout.rs deleted file mode 100644 index 8b137891791fe96927ad78e64b0aad7bded08bdc..0000000000000000000000000000000000000000 --- a/crates/ui/src/components/ai/copilot_configuration_callout.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/ui/src/components/ai/thread_sidebar_toggle.rs b/crates/ui/src/components/ai/thread_sidebar_toggle.rs new file mode 100644 index 0000000000000000000000000000000000000000..606d7f1eed6852f677b7167e0b868c1c1e3847c2 --- /dev/null +++ b/crates/ui/src/components/ai/thread_sidebar_toggle.rs @@ -0,0 +1,177 @@ +use gpui::{AnyView, ClickEvent}; +use ui_macros::RegisterComponent; + +use crate::prelude::*; +use crate::{IconButton, IconName, Tooltip}; + +#[derive(IntoElement, RegisterComponent)] +pub struct ThreadSidebarToggle { + sidebar_selected: bool, + thread_selected: bool, + flipped: bool, + sidebar_tooltip: Option AnyView + 'static>>, + thread_tooltip: Option AnyView + 'static>>, + on_sidebar_click: Option>, + on_thread_click: Option>, +} + +impl ThreadSidebarToggle { + pub fn new() -> Self { + Self { + sidebar_selected: false, + thread_selected: false, + flipped: false, + sidebar_tooltip: None, + thread_tooltip: None, + on_sidebar_click: None, + on_thread_click: None, + } + } + + pub fn sidebar_selected(mut self, selected: bool) -> Self { + self.sidebar_selected = selected; + self + } + + pub fn thread_selected(mut self, selected: bool) -> Self { + self.thread_selected = selected; + self + } + + pub fn flipped(mut self, flipped: bool) -> Self { + self.flipped = flipped; + self + } + + pub fn sidebar_tooltip( + mut self, + tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, + ) -> Self { + self.sidebar_tooltip = Some(Box::new(tooltip)); + self + } + + pub fn thread_tooltip( + mut self, + tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, + ) -> Self { + self.thread_tooltip = Some(Box::new(tooltip)); + self + } + + pub fn on_sidebar_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_sidebar_click = Some(Box::new(handler)); + self + } + + pub fn on_thread_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_thread_click = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for ThreadSidebarToggle { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let sidebar_icon = match (self.sidebar_selected, self.flipped) { + (true, false) => IconName::ThreadsSidebarLeftOpen, + (false, false) => IconName::ThreadsSidebarLeftClosed, + (true, true) => IconName::ThreadsSidebarRightOpen, + (false, true) => IconName::ThreadsSidebarRightClosed, + }; + + h_flex() + .min_w_0() + .rounded_sm() + .gap_px() + .border_1() + .border_color(cx.theme().colors().border) + .when(self.flipped, |this| this.flex_row_reverse()) + .child( + IconButton::new("sidebar-toggle", sidebar_icon) + .icon_size(IconSize::Small) + .toggle_state(self.sidebar_selected) + .when_some(self.sidebar_tooltip, |this, tooltip| this.tooltip(tooltip)) + .when_some(self.on_sidebar_click, |this, handler| { + this.on_click(handler) + }), + ) + .child(div().h_4().w_px().bg(cx.theme().colors().border)) + .child( + IconButton::new("thread-toggle", IconName::Thread) + .icon_size(IconSize::Small) + .toggle_state(self.thread_selected) + .when_some(self.thread_tooltip, |this, tooltip| this.tooltip(tooltip)) + .when_some(self.on_thread_click, |this, handler| this.on_click(handler)), + ) + } +} + +impl Component for ThreadSidebarToggle { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let container = || div().p_2().bg(cx.theme().colors().status_bar_background); + + let examples = vec![ + single_example( + "Both Unselected", + container() + .child(ThreadSidebarToggle::new()) + .into_any_element(), + ), + single_example( + "Sidebar Selected", + container() + .child(ThreadSidebarToggle::new().sidebar_selected(true)) + .into_any_element(), + ), + single_example( + "Thread Selected", + container() + .child(ThreadSidebarToggle::new().thread_selected(true)) + .into_any_element(), + ), + single_example( + "Both Selected", + container() + .child( + ThreadSidebarToggle::new() + .sidebar_selected(true) + .thread_selected(true), + ) + .into_any_element(), + ), + single_example( + "Flipped", + container() + .child( + ThreadSidebarToggle::new() + .sidebar_selected(true) + .thread_selected(true) + .flipped(true), + ) + .into_any_element(), + ), + single_example( + "With Tooltips", + container() + .child( + ThreadSidebarToggle::new() + .sidebar_tooltip(Tooltip::text("Toggle Sidebar")) + .thread_tooltip(Tooltip::text("Toggle Thread")), + ) + .into_any_element(), + ), + ]; + + Some(example_group(examples).into_any_element()) + } +} From b8eea31a09b4b8cc68ef4dbb68dae72b9d105bc1 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Fri, 13 Mar 2026 03:56:25 +0000 Subject: [PATCH 194/219] agent: Add tooltip to diff stats (#51448) --- crates/ui/src/components/ai/thread_item.rs | 14 ++++++++------ crates/ui/src/components/diff_stat.rs | 12 ++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 13e1db8f483ea251a6f65b61054c205d040a0d53..35aa3487a39c69795545b646666840743cfd8526 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -311,11 +311,10 @@ impl RenderOnce for ThreadItem { this.child(dot_separator()) }) .when(has_diff_stats, |this| { - this.child(DiffStat::new( - diff_stat_id.clone(), - added_count, - removed_count, - )) + this.child( + DiffStat::new(diff_stat_id.clone(), added_count, removed_count) + .tooltip("Unreviewed changes"), + ) }) .when(has_diff_stats && has_timestamp, |this| { this.child(dot_separator()) @@ -336,7 +335,10 @@ impl RenderOnce for ThreadItem { .gap_1p5() .child(icon_container()) // Icon Spacing .when(has_diff_stats, |this| { - this.child(DiffStat::new(diff_stat_id, added_count, removed_count)) + this.child( + DiffStat::new(diff_stat_id, added_count, removed_count) + .tooltip("Unreviewed changes"), + ) }) .when(has_diff_stats && has_timestamp, |this| { this.child(dot_separator()) diff --git a/crates/ui/src/components/diff_stat.rs b/crates/ui/src/components/diff_stat.rs index 45539c62869b8c23cb76671d2a7a862c9592a181..c2e76b171e7e28cc5cb2e2b0c4d776b5bc7e2bfc 100644 --- a/crates/ui/src/components/diff_stat.rs +++ b/crates/ui/src/components/diff_stat.rs @@ -1,3 +1,4 @@ +use crate::Tooltip; use crate::prelude::*; #[derive(IntoElement, RegisterComponent)] @@ -6,6 +7,7 @@ pub struct DiffStat { added: usize, removed: usize, label_size: LabelSize, + tooltip: Option, } impl DiffStat { @@ -15,6 +17,7 @@ impl DiffStat { added, removed, label_size: LabelSize::Small, + tooltip: None, } } @@ -22,10 +25,16 @@ impl DiffStat { self.label_size = label_size; self } + + pub fn tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip = Some(tooltip.into()); + self + } } impl RenderOnce for DiffStat { fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement { + let tooltip = self.tooltip; h_flex() .id(self.id) .gap_1() @@ -39,6 +48,9 @@ impl RenderOnce for DiffStat { .color(Color::Error) .size(self.label_size), ) + .when_some(tooltip, |this, tooltip| { + this.tooltip(Tooltip::text(tooltip)) + }) } } From 7eb009e259a3879a3c7016cc373477bba7a4ed65 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 12 Mar 2026 23:16:02 -0600 Subject: [PATCH 195/219] editor: Make underscores and newlines subword boundaries (#50552) Updates #21054 Authored-By: @ngauder Release Notes: - Added _ and newline to subword boundaries --------- Co-authored-by: Nikolas Gauder --- crates/editor/src/movement.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 01f7d0064e6f5ecd0d4d9c1760386102e9ce16e0..6bf6449506f1c1eb2a71270546ad3b063f7e9022 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -408,7 +408,7 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis let classifier = map.buffer_snapshot().char_classifier_at(raw_point); find_preceding_boundary_display_point(map, point, FindRange::MultiLine, &mut |left, right| { - is_subword_start(left, right, &classifier) || left == '\n' + is_subword_start(left, right, &classifier) || left == '\n' || right == '\n' }) } @@ -431,6 +431,7 @@ pub fn is_subword_start(left: char, right: char, classifier: &CharClassifier) -> let is_word_start = classifier.kind(left) != classifier.kind(right) && !right.is_whitespace(); let is_subword_start = classifier.is_word('-') && left == '-' && right != '-' || left == '_' && right != '_' + || left != '_' && right == '_' || left.is_lowercase() && right.is_uppercase(); is_word_start || is_subword_start } @@ -484,7 +485,7 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo let classifier = map.buffer_snapshot().char_classifier_at(raw_point); find_boundary(map, point, FindRange::MultiLine, &mut |left, right| { - is_subword_end(left, right, &classifier) || right == '\n' + is_subword_end(left, right, &classifier) || left == '\n' || right == '\n' }) } @@ -519,6 +520,7 @@ pub fn is_subword_end(left: char, right: char, classifier: &CharClassifier) -> b fn is_subword_boundary_end(left: char, right: char, classifier: &CharClassifier) -> bool { classifier.is_word('-') && left != '-' && right == '-' || left != '_' && right == '_' + || left == '_' && right != '_' || left.is_lowercase() && right.is_uppercase() } @@ -973,10 +975,10 @@ mod tests { } // Subword boundaries are respected - assert("lorem_ˇipˇsum", cx); + assert("loremˇ_ˇipsum", cx); assert("lorem_ˇipsumˇ", cx); - assert("ˇlorem_ˇipsum", cx); - assert("lorem_ˇipsum_ˇdolor", cx); + assert("ˇloremˇ_ipsum", cx); + assert("lorem_ˇipsumˇ_dolor", cx); assert("loremˇIpˇsum", cx); assert("loremˇIpsumˇ", cx); @@ -1156,10 +1158,10 @@ mod tests { } // Subword boundaries are respected - assert("loˇremˇ_ipsum", cx); + assert("loremˇ_ˇipsum", cx); assert("ˇloremˇ_ipsum", cx); - assert("loremˇ_ipsumˇ", cx); - assert("loremˇ_ipsumˇ_dolor", cx); + assert("loremˇ_ˇipsum", cx); + assert("lorem_ˇipsumˇ_dolor", cx); assert("loˇremˇIpsum", cx); assert("loremˇIpsumˇDolor", cx); @@ -1172,7 +1174,7 @@ mod tests { assert("loremˇ ipsumˇ ", cx); assert("loremˇ-ˇipsum", cx); assert("loremˇ#$@-ˇipsum", cx); - assert("loremˇ_ipsumˇ", cx); + assert("loremˇ_ˇipsum", cx); assert(" ˇbcˇΔ", cx); assert(" abˇ——ˇcd", cx); } From 07cfa81f09520c691715c40acff84994a55acaf3 Mon Sep 17 00:00:00 2001 From: Oussama ELJabbari Date: Fri, 13 Mar 2026 05:17:08 +0000 Subject: [PATCH 196/219] Grace period for inaccessible workspaces (#50829) Closes #49603 Release Notes: - Added a 7-day grace period to prevent recently used local workspaces from being deleted when their paths are temporarily unavailable. Session workspaces are always preserved on restart. --- crates/workspace/src/persistence.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 9f0b035049ebb5bfbeef7211acee9ced5288bb47..89ce7dade6e17d5b422dceb46cd9b0a6107eaa46 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -1784,11 +1784,17 @@ impl WorkspaceDb { } } - async fn all_paths_exist_with_a_directory(paths: &[PathBuf], fs: &dyn Fs) -> bool { + async fn all_paths_exist_with_a_directory( + paths: &[PathBuf], + fs: &dyn Fs, + timestamp: Option>, + ) -> bool { let mut any_dir = false; for path in paths { match fs.metadata(path).await.ok().flatten() { - None => return false, + None => { + return timestamp.is_some_and(|t| Utc::now() - t < chrono::Duration::days(7)); + } Some(meta) => { if meta.is_dir { any_dir = true; @@ -1844,7 +1850,9 @@ impl WorkspaceDb { // If a local workspace points to WSL, this check will cause us to wait for the // WSL VM and file server to boot up. This can block for many seconds. // Supported scenarios use remote workspaces. - if !has_wsl_path && Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { + if !has_wsl_path + && Self::all_paths_exist_with_a_directory(paths.paths(), fs, Some(timestamp)).await + { result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp)); } else { delete_tasks.push(self.delete_workspace_by_id(id)); @@ -1904,7 +1912,7 @@ impl WorkspaceDb { window_id, }); } else { - if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { + if Self::all_paths_exist_with_a_directory(paths.paths(), fs, None).await { workspaces.push(SessionWorkspace { workspace_id, location: SerializedWorkspaceLocation::Local, From 95a9340952e74a64b447a363d65387fc5fb3c636 Mon Sep 17 00:00:00 2001 From: Finn Eitreim <48069764+feitreim@users.noreply.github.com> Date: Fri, 13 Mar 2026 02:59:07 -0400 Subject: [PATCH 197/219] lsp: Fix LSP restart breaking semantic token highlighting (#51452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #51450 When you restart the lsp, it does not sufficiently clear cached data regarding the semantic tokens, if using semantic_tokens = "full", this would mean that you would have no syntax highlighting. also, toggling on and off semantic tokens in the menu would have no effect. this change properly clears the cached state and things work again! Before: https://github.com/user-attachments/assets/67ac1be1-ae3d-4c84-afbc-056fd81f63f0 After: https://github.com/user-attachments/assets/644f8297-8003-4d74-b962-81ba9bb8274c You might notice that the syntax highlighting is quite spare in the videos, especially compared to the non semantic token based highlighting, and you would be correct! but thats just how it is with `semantic_tokens: "full"`, other editors, like neovim, provide basic syntax highlighting that zed doesn't (because it doesn't need to with treesitter usually, but here treesitter is disabled), however if we turn off that syntax highlighting we can see that neovim actually matches zed here: Screenshot 2026-03-12 at 11 33 19 PM 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: - lsp: Fixed restarting the LSP breaking semantic token highlighting. --- crates/project/src/lsp_store.rs | 5 +---- crates/project/src/lsp_store/semantic_tokens.rs | 8 ++++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index ff272cb10a662f7e69d1789d9afd719cb9e73005..8b4f3d7e8e1a6f68a1263fc11dc2e61c4a4890aa 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -3963,10 +3963,7 @@ impl BufferLspData { self.inlay_hints.remove_server_data(for_server); if let Some(semantic_tokens) = &mut self.semantic_tokens { - semantic_tokens.raw_tokens.servers.remove(&for_server); - semantic_tokens - .latest_invalidation_requests - .remove(&for_server); + semantic_tokens.remove_server_data(for_server); } if let Some(folding_ranges) = &mut self.folding_ranges { diff --git a/crates/project/src/lsp_store/semantic_tokens.rs b/crates/project/src/lsp_store/semantic_tokens.rs index 2927e5c0af77c50420462e95c271e61828b020e5..7865e8f20ca0e4dbc9d06c2ffd808fe4090634ed 100644 --- a/crates/project/src/lsp_store/semantic_tokens.rs +++ b/crates/project/src/lsp_store/semantic_tokens.rs @@ -610,6 +610,14 @@ pub struct SemanticTokensData { update: Option<(Global, SemanticTokensTask)>, } +impl SemanticTokensData { + pub(super) fn remove_server_data(&mut self, server_id: LanguageServerId) { + self.raw_tokens.servers.remove(&server_id); + self.latest_invalidation_requests.remove(&server_id); + self.update = None; + } +} + /// All the semantic token tokens for a buffer. /// /// This aggregates semantic tokens from multiple language servers in a specific order. From 12852537f195c1a3f27ca1e97efe5599e5858a83 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 13 Mar 2026 08:47:48 +0100 Subject: [PATCH 198/219] project: Support resolving paths with worktree names prefixed (#50692) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/editor/src/hover_links.rs | 63 ++++++++++++++++++++++++++++++++ crates/project/src/project.rs | 33 ++++++++++++++--- crates/worktree/src/worktree.rs | 4 ++ 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 3a6ff4ec0e4fc53d19bfb51a10b1f7790933b175..4cbd3d77cf09ccfebd48f50b6b26413837b24b2c 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -1889,6 +1889,69 @@ mod tests { }); } + #[gpui::test] + async fn test_hover_filenames_with_worktree_prefix(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + ..Default::default() + }, + cx, + ) + .await; + + let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file( + path!("/root/dir/file2.rs"), + "This is file2.rs".as_bytes().to_vec(), + ) + .await; + + #[cfg(not(target_os = "windows"))] + cx.set_state(indoc! {" + Go to root/dir/file2.rs if you want.ˇ + "}); + #[cfg(target_os = "windows")] + cx.set_state(indoc! {" + Go to root/dir/file2.rs if you want.ˇ + "}); + + let screen_coord = cx.pixel_position(indoc! {" + Go to root/diˇr/file2.rs if you want. + "}); + + cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key()); + cx.assert_editor_text_highlights( + HighlightKey::HoveredLinkState, + indoc! {" + Go to «root/dir/file2.rsˇ» if you want. + "}, + ); + + cx.simulate_click(screen_coord, Modifiers::secondary_key()); + + cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2)); + cx.update_workspace(|workspace, _, cx| { + let active_editor = workspace.active_item_as::(cx).unwrap(); + + let buffer = active_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .unwrap(); + + let file = buffer.read(cx).file().unwrap(); + let file_path = file.as_local().unwrap().abs_path(cx); + + assert_eq!( + file_path, + std::path::PathBuf::from(path!("/root/dir/file2.rs")) + ); + }); + } + #[gpui::test] async fn test_hover_directories(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index ed8884cd68c6df32375686dd5ceb41b21cbb5cdd..14379e20fd45c0460f54ea3d33fbfe8a04917c7a 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -4590,24 +4590,38 @@ impl Project { let worktrees_with_ids: Vec<_> = self .worktrees(cx) .map(|worktree| { - let id = worktree.read(cx).id(); - (worktree, id) + let read = worktree.read(cx); + let id = read.id(); + ( + worktree, + id, + read.is_visible().then(|| read.root_name_arc()), + ) }) .collect(); cx.spawn(async move |_, cx| { if let Some(buffer_worktree_id) = buffer_worktree_id - && let Some((worktree, _)) = worktrees_with_ids + && let Some((worktree, _, root_name)) = worktrees_with_ids .iter() - .find(|(_, id)| *id == buffer_worktree_id) + .find(|(_, id, _)| *id == buffer_worktree_id) { for candidate in candidates.iter() { if let Some(path) = Self::resolve_path_in_worktree(worktree, candidate, cx) { return Some(path); } + if let Some(root_name) = root_name { + if let Ok(candidate) = candidate.strip_prefix(root_name) { + if let Some(path) = + Self::resolve_path_in_worktree(worktree, candidate, cx) + { + return Some(path); + } + } + } } } - for (worktree, id) in worktrees_with_ids { + for (worktree, id, root_name) in worktrees_with_ids { if Some(id) == buffer_worktree_id { continue; } @@ -4615,6 +4629,15 @@ impl Project { if let Some(path) = Self::resolve_path_in_worktree(&worktree, candidate, cx) { return Some(path); } + if let Some(root_name) = &root_name { + if let Ok(candidate) = candidate.strip_prefix(root_name) { + if let Some(path) = + Self::resolve_path_in_worktree(&worktree, candidate, cx) + { + return Some(path); + } + } + } } } None diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 44ba4e752cff778b7918b9a29935d0f0e1ebb614..518bf5b4620fdf1f65793ca912bba21f614c67ee 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -2466,6 +2466,10 @@ impl Snapshot { &self.root_name } + pub fn root_name_arc(&self) -> Arc { + self.root_name.clone() + } + pub fn root_name_str(&self) -> &str { self.root_name.as_unix_str() } From e4b6286a63143f21ed3e825126afa9193d8b12a6 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 13 Mar 2026 08:48:35 +0100 Subject: [PATCH 199/219] file_finder: Show collab channels in file search (#51120) Release Notes: - N/A *or* Added/Fixed/Improved ... --- Cargo.lock | 3 + crates/channel/src/channel_store.rs | 4 + crates/collab_ui/src/collab_panel.rs | 11 +- crates/file_finder/Cargo.toml | 3 + crates/file_finder/src/file_finder.rs | 185 +++++++++++++++----- crates/file_finder/src/file_finder_tests.rs | 4 +- crates/workspace/src/workspace.rs | 9 + 7 files changed, 175 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6570398f5b22f2248a9cd59f84d2cf70080c3591..4e347d40f3f0e0f23f48770537e7df92d8bd862a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6242,6 +6242,8 @@ name = "file_finder" version = "0.1.0" dependencies = [ "anyhow", + "channel", + "client", "collections", "ctor", "editor", @@ -6255,6 +6257,7 @@ dependencies = [ "pretty_assertions", "project", "project_panel", + "remote_connection", "serde", "serde_json", "settings", diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index a9357a765a75443e18efb1e6f31cdfab313ebcce..f8d28ac96d7c140141ac520b1c38a10c82dd75a9 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -156,6 +156,10 @@ impl ChannelStore { cx.global::().0.clone() } + pub fn try_global(cx: &App) -> Option> { + cx.try_global::().map(|g| g.0.clone()) + } + pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { let rpc_subscriptions = [ client.add_message_handler(cx.weak_entity(), Self::handle_update_channels), diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 0ec5d03a478ba42d438f57ae2f4fdea9f34d1b50..d0cac2e69f8d8c5b3fde588cc4ceee92d64962d7 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -36,8 +36,8 @@ use ui::{ }; use util::{ResultExt, TryFutureExt, maybe}; use workspace::{ - CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, ScreenShare, - ShareProject, Workspace, + CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, OpenChannelNotesById, + ScreenShare, ShareProject, Workspace, dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotifyResultExt}, }; @@ -114,6 +114,13 @@ pub fn init(cx: &mut App) { }); } }); + workspace.register_action(|_, action: &OpenChannelNotesById, window, cx| { + let channel_id = client::ChannelId(action.channel_id); + let workspace = cx.entity(); + window.defer(cx, move |window, cx| { + ChannelView::open(channel_id, None, workspace, window, cx).detach_and_log_err(cx) + }); + }); // TODO: make it possible to bind this one to a held key for push to talk? // how to make "toggle_on_modifiers_press" contextual? workspace.register_action(|_, _: &Mute, _, cx| title_bar::collab::toggle_mute(cx)); diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 113bf68d34f778f8fba9fdc62b586c31e689a380..0876d95a7b044d2a4ce5bf8be964c4057725f827 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -14,6 +14,8 @@ doctest = false [dependencies] anyhow.workspace = true +channel.workspace = true +client.workspace = true collections.workspace = true editor.workspace = true file_icons.workspace = true @@ -45,3 +47,4 @@ serde_json.workspace = true theme = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true +remote_connection = { workspace = true, features = ["test-support"] } diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index a1e64964ff578ed263e9e89a610997423f33f7c0..cd0c4dbdb922c6d8251225c696b60e27eb5951cf 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -4,10 +4,12 @@ mod file_finder_tests; use futures::future::join_all; pub use open_path_prompt::OpenPathDelegate; +use channel::ChannelStore; +use client::ChannelId; use collections::HashMap; use editor::Editor; use file_icons::FileIcons; -use fuzzy::{CharBag, PathMatch, PathMatchCandidate}; +use fuzzy::{CharBag, PathMatch, PathMatchCandidate, StringMatch, StringMatchCandidate}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity, @@ -45,8 +47,8 @@ use util::{ rel_path::RelPath, }; use workspace::{ - ModalView, OpenOptions, OpenVisible, SplitDirection, Workspace, item::PreviewTabsSettings, - notifications::NotifyResultExt, pane, + ModalView, OpenChannelNotesById, OpenOptions, OpenVisible, SplitDirection, Workspace, + item::PreviewTabsSettings, notifications::NotifyResultExt, pane, }; use zed_actions::search::ToggleIncludeIgnored; @@ -321,7 +323,7 @@ impl FileFinder { if let Some(workspace) = delegate.workspace.upgrade() && let Some(m) = delegate.matches.get(delegate.selected_index()) { - let path = match &m { + let path = match m { Match::History { path, .. } => { let worktree_id = path.project.worktree_id; ProjectPath { @@ -334,6 +336,7 @@ impl FileFinder { path: m.0.path.clone(), }, Match::CreateNew(p) => p.clone(), + Match::Channel { .. } => return, }; let open_task = workspace.update(cx, move |workspace, cx| { workspace.split_path_preview(path, false, Some(split_direction), window, cx) @@ -392,6 +395,7 @@ pub struct FileFinderDelegate { file_finder: WeakEntity, workspace: WeakEntity, project: Entity, + channel_store: Option>, search_count: usize, latest_search_id: usize, latest_search_did_cancel: bool, @@ -450,13 +454,18 @@ struct Matches { matches: Vec, } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[derive(Debug, Clone)] enum Match { History { path: FoundPath, panel_match: Option, }, Search(ProjectPanelOrdMatch), + Channel { + channel_id: ChannelId, + channel_name: SharedString, + string_match: StringMatch, + }, CreateNew(ProjectPath), } @@ -465,7 +474,7 @@ impl Match { match self { Match::History { path, .. } => Some(&path.project.path), Match::Search(panel_match) => Some(&panel_match.0.path), - Match::CreateNew(_) => None, + Match::Channel { .. } | Match::CreateNew(_) => None, } } @@ -479,7 +488,7 @@ impl Match { .read(cx) .absolutize(&path_match.path), ), - Match::CreateNew(_) => None, + Match::Channel { .. } | Match::CreateNew(_) => None, } } @@ -487,7 +496,7 @@ impl Match { match self { Match::History { panel_match, .. } => panel_match.as_ref(), Match::Search(panel_match) => Some(panel_match), - Match::CreateNew(_) => None, + Match::Channel { .. } | Match::CreateNew(_) => None, } } } @@ -628,7 +637,6 @@ impl Matches { (_, Match::CreateNew(_)) => return cmp::Ordering::Greater, _ => {} } - debug_assert!(a.panel_match().is_some() && b.panel_match().is_some()); match (&a, &b) { // bubble currently opened files to the top @@ -651,32 +659,35 @@ impl Matches { } } - let a_panel_match = match a.panel_match() { - Some(pm) => pm, - None => { - return if b.panel_match().is_some() { - cmp::Ordering::Less - } else { - cmp::Ordering::Equal - }; + // For file-vs-file matches, use the existing detailed comparison. + if let (Some(a_panel), Some(b_panel)) = (a.panel_match(), b.panel_match()) { + let a_in_filename = Self::is_filename_match(a_panel); + let b_in_filename = Self::is_filename_match(b_panel); + + match (a_in_filename, b_in_filename) { + (true, false) => return cmp::Ordering::Greater, + (false, true) => return cmp::Ordering::Less, + _ => {} } - }; - let b_panel_match = match b.panel_match() { - Some(pm) => pm, - None => return cmp::Ordering::Greater, - }; + return a_panel.cmp(b_panel); + } - let a_in_filename = Self::is_filename_match(a_panel_match); - let b_in_filename = Self::is_filename_match(b_panel_match); + let a_score = Self::match_score(a); + let b_score = Self::match_score(b); + // When at least one side is a channel, compare by raw score. + a_score + .partial_cmp(&b_score) + .unwrap_or(cmp::Ordering::Equal) + } - match (a_in_filename, b_in_filename) { - (true, false) => return cmp::Ordering::Greater, - (false, true) => return cmp::Ordering::Less, - _ => {} // Both are filename matches or both are path matches + fn match_score(m: &Match) -> f64 { + match m { + Match::History { panel_match, .. } => panel_match.as_ref().map_or(0.0, |pm| pm.0.score), + Match::Search(pm) => pm.0.score, + Match::Channel { string_match, .. } => string_match.score, + Match::CreateNew(_) => 0.0, } - - a_panel_match.cmp(b_panel_match) } /// Determines if the match occurred within the filename rather than in the path @@ -833,10 +844,12 @@ impl FileFinderDelegate { cx: &mut Context, ) -> Self { Self::subscribe_to_updates(&project, window, cx); + let channel_store = ChannelStore::try_global(cx); Self { file_finder, workspace, project, + channel_store, search_count: 0, latest_search_id: 0, latest_search_did_cancel: false, @@ -971,6 +984,68 @@ impl FileFinderDelegate { path_style, ); + // Add channel matches + if let Some(channel_store) = &self.channel_store { + let channel_store = channel_store.read(cx); + let channels: Vec<_> = channel_store.channels().cloned().collect(); + if !channels.is_empty() { + let candidates = channels + .iter() + .enumerate() + .map(|(id, channel)| StringMatchCandidate::new(id, &channel.name)); + let channel_query = query.path_query(); + let query_lower = channel_query.to_lowercase(); + let mut channel_matches = Vec::new(); + for candidate in candidates { + let channel_name = candidate.string; + let name_lower = channel_name.to_lowercase(); + + let mut positions = Vec::new(); + let mut query_idx = 0; + for (name_idx, name_char) in name_lower.char_indices() { + if query_idx < query_lower.len() { + let query_char = + query_lower[query_idx..].chars().next().unwrap_or_default(); + if name_char == query_char { + positions.push(name_idx); + query_idx += query_char.len_utf8(); + } + } + } + + if query_idx == query_lower.len() { + let channel = &channels[candidate.id]; + let score = if name_lower == query_lower { + 1.0 + } else if name_lower.starts_with(&query_lower) { + 0.8 + } else { + 0.5 * (query_lower.len() as f64 / name_lower.len() as f64) + }; + channel_matches.push(Match::Channel { + channel_id: channel.id, + channel_name: channel.name.clone(), + string_match: StringMatch { + candidate_id: candidate.id, + score, + positions, + string: channel_name, + }, + }); + } + } + for channel_match in channel_matches { + match self + .matches + .position(&channel_match, self.currently_opened_path.as_ref()) + { + Ok(_duplicate) => {} + Err(ix) => self.matches.matches.insert(ix, channel_match), + } + } + } + } + let query_path = query.raw_query.as_str(); if let Ok(mut query_path) = RelPath::new(Path::new(query_path), path_style) { let available_worktree = self @@ -1095,6 +1170,16 @@ impl FileFinderDelegate { } } Match::Search(path_match) => self.labels_for_path_match(&path_match.0, path_style), + Match::Channel { + channel_name, + string_match, + .. + } => ( + channel_name.to_string(), + string_match.positions.clone(), + "Channel Notes".to_string(), + vec![], + ), Match::CreateNew(project_path) => ( format!("Create file: {}", project_path.path.display(path_style)), vec![], @@ -1479,6 +1564,16 @@ impl PickerDelegate for FileFinderDelegate { if let Some(m) = self.matches.get(self.selected_index()) && let Some(workspace) = self.workspace.upgrade() { + // Channel matches are handled separately since they dispatch an action + // rather than directly opening a file path. + if let Match::Channel { channel_id, .. } = m { + let channel_id = channel_id.0; + let finder = self.file_finder.clone(); + window.dispatch_action(OpenChannelNotesById { channel_id }.boxed_clone(), cx); + finder.update(cx, |_, cx| cx.emit(DismissEvent)).log_err(); + return; + } + let open_task = workspace.update(cx, |workspace, cx| { let split_or_open = |workspace: &mut Workspace, @@ -1571,6 +1666,7 @@ impl PickerDelegate for FileFinderDelegate { window, cx, ), + Match::Channel { .. } => unreachable!("handled above"), } }); @@ -1627,7 +1723,7 @@ impl PickerDelegate for FileFinderDelegate { let path_match = self.matches.get(ix)?; - let history_icon = match &path_match { + let end_icon = match path_match { Match::History { .. } => Icon::new(IconName::HistoryRerun) .color(Color::Muted) .size(IconSize::Small) @@ -1636,6 +1732,10 @@ impl PickerDelegate for FileFinderDelegate { .flex_none() .size(IconSize::Small.rems()) .into_any_element(), + Match::Channel { .. } => v_flex() + .flex_none() + .size(IconSize::Small.rems()) + .into_any_element(), Match::CreateNew(_) => Icon::new(IconName::Plus) .color(Color::Muted) .size(IconSize::Small) @@ -1643,21 +1743,24 @@ impl PickerDelegate for FileFinderDelegate { }; let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx); - let file_icon = maybe!({ - if !settings.file_icons { - return None; - } - let abs_path = path_match.abs_path(&self.project, cx)?; - let file_name = abs_path.file_name()?; - let icon = FileIcons::get_icon(file_name.as_ref(), cx)?; - Some(Icon::from_path(icon).color(Color::Muted)) - }); + let file_icon = match path_match { + Match::Channel { .. } => Some(Icon::new(IconName::Hash).color(Color::Muted)), + _ => maybe!({ + if !settings.file_icons { + return None; + } + let abs_path = path_match.abs_path(&self.project, cx)?; + let file_name = abs_path.file_name()?; + let icon = FileIcons::get_icon(file_name.as_ref(), cx)?; + Some(Icon::from_path(icon).color(Color::Muted)) + }), + }; Some( ListItem::new(ix) .spacing(ListItemSpacing::Sparse) .start_slot::(file_icon) - .end_slot::(history_icon) + .end_slot::(end_icon) .inset(true) .toggle_state(selected) .child( diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index c81d13420b179cc7ce0d8afd2aee26673673f09e..da9fd4b87b045a6321a291cb7128a051d977815b 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -3709,7 +3709,7 @@ impl SearchEntries { fn collect_search_matches(picker: &Picker) -> SearchEntries { let mut search_entries = SearchEntries::default(); for m in &picker.delegate.matches.matches { - match &m { + match m { Match::History { path: history_path, panel_match: path_match, @@ -3734,6 +3734,7 @@ fn collect_search_matches(picker: &Picker) -> SearchEntries search_entries.search_matches.push(path_match.0.clone()); } Match::CreateNew(_) => {} + Match::Channel { .. } => {} } } search_entries @@ -3768,6 +3769,7 @@ fn assert_match_at_position( Match::History { path, .. } => path.absolute.file_name().and_then(|s| s.to_str()), Match::Search(path_match) => path_match.0.path.file_name(), Match::CreateNew(project_path) => project_path.path.file_name(), + Match::Channel { channel_name, .. } => Some(channel_name.as_str()), } .unwrap(); assert_eq!(match_file_name, expected_file_name); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 19d02e9a8a6742ba04bc52a68568cb2bf994608a..7696af97996a83db0aab05dc11d03f6ac0a77513 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -8330,6 +8330,15 @@ actions!( CopyRoomId, ] ); + +/// Opens the channel notes for a specific channel by its ID. +#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] +#[action(namespace = collab)] +#[serde(deny_unknown_fields)] +pub struct OpenChannelNotesById { + pub channel_id: u64, +} + actions!( zed, [ From 3bc4b584b17b1b2858021ceef52dd27cfeb9cd83 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 13 Mar 2026 09:00:22 +0100 Subject: [PATCH 200/219] editor: Replace `BreadcrumbText` with `HighlightedText` (#51083) Remove the BreadcrumbText struct from workspace and use the existing HighlightedText struct from the language crate instead. The per-segment font field is replaced by returning an optional Font alongside the segments from the breadcrumbs() method, since the font was always uniform across all segments of a given item. Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/breadcrumbs/src/breadcrumbs.rs | 10 +++--- .../collab/tests/integration/editor_tests.rs | 3 +- crates/editor/src/editor.rs | 19 +++++----- crates/editor/src/element.rs | 36 ++++++++++--------- crates/editor/src/items.rs | 15 ++++---- crates/editor/src/split.rs | 10 +++--- crates/git_ui/src/file_diff_view.rs | 8 ++--- crates/git_ui/src/file_history_view.rs | 5 ++- crates/git_ui/src/multi_diff_view.rs | 8 ++--- crates/image_viewer/src/image_viewer.rs | 28 ++++++++------- crates/terminal_view/src/terminal_view.rs | 18 +++++----- crates/workspace/src/item.rs | 20 ++++------- 12 files changed, 93 insertions(+), 87 deletions(-) diff --git a/crates/breadcrumbs/src/breadcrumbs.rs b/crates/breadcrumbs/src/breadcrumbs.rs index 54a5e40337dc4b41ddd668783656498e9be841b9..a63a332e4a0e38e4b65020bf77f94f78600594d3 100644 --- a/crates/breadcrumbs/src/breadcrumbs.rs +++ b/crates/breadcrumbs/src/breadcrumbs.rs @@ -1,14 +1,15 @@ use gpui::{ - AnyElement, App, Context, EventEmitter, Global, IntoElement, Render, Subscription, Window, + AnyElement, App, Context, EventEmitter, Font, Global, IntoElement, Render, Subscription, Window, }; use ui::prelude::*; use workspace::{ ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, - item::{BreadcrumbText, ItemEvent, ItemHandle}, + item::{HighlightedText, ItemEvent, ItemHandle}, }; type RenderBreadcrumbTextFn = fn( - Vec, + Vec, + Option, Option, &dyn ItemHandle, bool, @@ -57,7 +58,7 @@ impl Render for Breadcrumbs { return element.into_any_element(); }; - let Some(segments) = active_item.breadcrumbs(cx) else { + let Some((segments, breadcrumb_font)) = active_item.breadcrumbs(cx) else { return element.into_any_element(); }; @@ -66,6 +67,7 @@ impl Render for Breadcrumbs { if let Some(render_fn) = cx.try_global::() { (render_fn.0)( segments, + breadcrumb_font, prefix_element, active_item.as_ref(), false, diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 6b23780156e03d62543cf597e82959083685f0c0..1590f498308c74125c7672595cb7510b6653e9b1 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -5691,7 +5691,7 @@ async fn test_document_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont executor.run_until_parked(); editor_a.update(cx_a, |editor, cx| { - let breadcrumbs = editor + let (breadcrumbs, _) = editor .breadcrumbs(cx) .expect("Host should have breadcrumbs"); let texts: Vec<_> = breadcrumbs.iter().map(|b| b.text.as_str()).collect(); @@ -5727,6 +5727,7 @@ async fn test_document_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont editor .breadcrumbs(cx) .expect("Client B should have breadcrumbs") + .0 .iter() .map(|b| b.text.as_str()) .collect::>(), diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 20d976ad6c0e0a9c82fbaa681efea80f2873d375..7536e58d2f0dbfd58f738bdb8bed3b3c2a65a25e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -217,7 +217,7 @@ use workspace::{ CollaboratorId, Item as WorkspaceItem, ItemId, ItemNavHistory, NavigationEntry, OpenInTerminal, OpenTerminal, Pane, RestoreOnStartupBehavior, SERIALIZATION_THROTTLE_TIME, SplitDirection, TabBarSettings, Toast, ViewId, Workspace, WorkspaceId, WorkspaceSettings, - item::{BreadcrumbText, ItemBufferKind, ItemHandle, PreviewTabsSettings, SaveOptions}, + item::{ItemBufferKind, ItemHandle, PreviewTabsSettings, SaveOptions}, notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt}, searchable::SearchEvent, }; @@ -25323,14 +25323,13 @@ impl Editor { } } - fn breadcrumbs_inner(&self, cx: &App) -> Option> { + fn breadcrumbs_inner(&self, cx: &App) -> Option> { let multibuffer = self.buffer().read(cx); let is_singleton = multibuffer.is_singleton(); let (buffer_id, symbols) = self.outline_symbols_at_cursor.as_ref()?; let buffer = multibuffer.buffer(*buffer_id)?; let buffer = buffer.read(cx); - let settings = ThemeSettings::get_global(cx); // In a multi-buffer layout, we don't want to include the filename in the breadcrumbs let mut breadcrumbs = if is_singleton { let text = self.breadcrumb_header.clone().unwrap_or_else(|| { @@ -25351,19 +25350,17 @@ impl Editor { } }) }); - vec![BreadcrumbText { - text, - highlights: None, - font: Some(settings.buffer_font.clone()), + vec![HighlightedText { + text: text.into(), + highlights: vec![], }] } else { vec![] }; - breadcrumbs.extend(symbols.iter().map(|symbol| BreadcrumbText { - text: symbol.text.clone(), - highlights: Some(symbol.highlight_ranges.clone()), - font: Some(settings.buffer_font.clone()), + breadcrumbs.extend(symbols.iter().map(|symbol| HighlightedText { + text: symbol.text.clone().into(), + highlights: symbol.highlight_ranges.clone(), })); Some(breadcrumbs) } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index dcbd00ef8c89de8c4a3e3334ae1804ebe9e7b042..ab00de0df25ca209604c7052367f0ac6ce2142ae 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -41,18 +41,18 @@ use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatu use gpui::{ Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, - DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, FontWeight, - GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, - KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent, - MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement, - Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, - Size, StatefulInteractiveElement, Style, Styled, StyledText, TextAlign, TextRun, + DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, Font, FontId, + FontWeight, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, + IsZero, KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, + MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, + ParentElement, Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, + SharedString, Size, StatefulInteractiveElement, Style, Styled, StyledText, TextAlign, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, pattern_slash, point, px, quad, relative, size, solid_background, transparent_black, }; use itertools::Itertools; -use language::{IndentGuideSettings, language_settings::ShowWhitespaceSetting}; +use language::{HighlightedText, IndentGuideSettings, language_settings::ShowWhitespaceSetting}; use markdown::Markdown; use multi_buffer::{ Anchor, ExcerptId, ExcerptInfo, ExpandExcerptDirection, ExpandInfo, MultiBufferPoint, @@ -98,7 +98,7 @@ use util::{RangeExt, ResultExt, debug_panic}; use workspace::{ CollaboratorId, ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel, Workspace, - item::{BreadcrumbText, Item, ItemBufferKind}, + item::{Item, ItemBufferKind}, }; /// Determines what kinds of highlights should be applied to a lines background. @@ -7913,7 +7913,8 @@ impl EditorElement { } pub fn render_breadcrumb_text( - mut segments: Vec, + mut segments: Vec, + breadcrumb_font: Option, prefix: Option, active_item: &dyn ItemHandle, multibuffer_header: bool, @@ -7933,17 +7934,16 @@ pub fn render_breadcrumb_text( if suffix_start_ix > prefix_end_ix { segments.splice( prefix_end_ix..suffix_start_ix, - Some(BreadcrumbText { + Some(HighlightedText { text: "⋯".into(), - highlights: None, - font: None, + highlights: vec![], }), ); } let highlighted_segments = segments.into_iter().enumerate().map(|(index, segment)| { let mut text_style = window.text_style(); - if let Some(ref font) = segment.font { + if let Some(font) = &breadcrumb_font { text_style.font_family = font.family.clone(); text_style.font_features = font.features.clone(); text_style.font_style = font.style; @@ -7960,7 +7960,7 @@ pub fn render_breadcrumb_text( } StyledText::new(segment.text.replace('\n', " ")) - .with_default_highlights(&text_style, segment.highlights.unwrap_or_default()) + .with_default_highlights(&text_style, segment.highlights) .into_any() }); @@ -8070,13 +8070,13 @@ pub fn render_breadcrumb_text( } fn apply_dirty_filename_style( - segment: &BreadcrumbText, + segment: &HighlightedText, text_style: &gpui::TextStyle, cx: &App, ) -> Option { let text = segment.text.replace('\n', " "); - let filename_position = std::path::Path::new(&segment.text) + let filename_position = std::path::Path::new(segment.text.as_ref()) .file_name() .and_then(|f| { let filename_str = f.to_string_lossy(); @@ -8446,8 +8446,12 @@ 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) + .buffer_font + .clone(); then.child(render_breadcrumb_text( breadcrumbs, + Some(font), None, editor_handle, true, diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 1a79414ddc3aa57397d964d4e0af0d87bedc9c3b..e0502e4d9987bef512506ef927ff5384be5f0c30 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -14,12 +14,12 @@ use fs::MTime; use futures::future::try_join_all; use git::status::GitSummary; use gpui::{ - AnyElement, App, AsyncWindowContext, Context, Entity, EntityId, EventEmitter, IntoElement, - ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point, + AnyElement, App, AsyncWindowContext, Context, Entity, EntityId, EventEmitter, Font, + IntoElement, ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point, }; use language::{ - Bias, Buffer, BufferRow, CharKind, CharScopeContext, LocalFile, Point, SelectionGoal, - proto::serialize_anchor as serialize_text_anchor, + Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, Point, + SelectionGoal, proto::serialize_anchor as serialize_text_anchor, }; use lsp::DiagnosticSeverity; use multi_buffer::MultiBufferOffset; @@ -56,7 +56,7 @@ use workspace::{ }; use workspace::{ OpenVisible, Pane, WorkspaceSettings, - item::{BreadcrumbText, FollowEvent, ProjectItemKind}, + item::{FollowEvent, ProjectItemKind}, searchable::SearchOptions, }; use zed_actions::preview::{ @@ -981,9 +981,10 @@ 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> { + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { if self.buffer.read(cx).is_singleton() { - self.breadcrumbs_inner(cx) + let font = theme::ThemeSettings::get_global(cx).buffer_font.clone(); + Some((self.breadcrumbs_inner(cx)?, Some(font))) } else { None } diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index 877f388fc3b783202cb29f8ca063446635e4277a..c9668bc35655dfcda62e71884a782b4edecae093 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -6,9 +6,11 @@ use std::{ use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use collections::HashMap; -use gpui::{Action, AppContext as _, Entity, EventEmitter, Focusable, Subscription, WeakEntity}; +use gpui::{ + Action, AppContext as _, Entity, EventEmitter, Focusable, Font, Subscription, WeakEntity, +}; use itertools::Itertools; -use language::{Buffer, Capability}; +use language::{Buffer, Capability, HighlightedText}; use multi_buffer::{ Anchor, BufferOffset, ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferSnapshot, PathKey, @@ -29,7 +31,7 @@ use crate::{ }; use workspace::{ ActivatePaneLeft, ActivatePaneRight, Item, ToolbarItemLocation, Workspace, - item::{BreadcrumbText, ItemBufferKind, ItemEvent, SaveOptions, TabContentParams}, + item::{ItemBufferKind, ItemEvent, SaveOptions, TabContentParams}, searchable::{SearchEvent, SearchToken, SearchableItem, SearchableItemHandle}, }; @@ -1853,7 +1855,7 @@ impl Item for SplittableEditor { self.rhs_editor.read(cx).breadcrumb_location(cx) } - fn breadcrumbs(&self, cx: &App) -> Option> { + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { self.rhs_editor.read(cx).breadcrumbs(cx) } diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index c684c230cf54cdbe89f13d9126c142e2dece3558..bdd5dee36e2d54888d081cfefed21602ecb8fa1b 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -6,9 +6,9 @@ use editor::{Editor, EditorEvent, MultiBuffer}; use futures::{FutureExt, select_biased}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, Render, Task, WeakEntity, Window, + Focusable, Font, IntoElement, Render, Task, WeakEntity, Window, }; -use language::{Buffer, LanguageRegistry}; +use language::{Buffer, HighlightedText, LanguageRegistry}; use project::Project; use std::{ any::{Any, TypeId}, @@ -21,7 +21,7 @@ use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString}; use util::paths::PathExt as _; use workspace::{ Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace, - item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams}, + item::{ItemEvent, SaveOptions, TabContentParams}, searchable::SearchableItemHandle, }; @@ -324,7 +324,7 @@ impl Item for FileDiffView { ToolbarItemLocation::PrimaryLeft } - fn breadcrumbs(&self, cx: &App) -> Option> { + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { self.editor.breadcrumbs(cx) } diff --git a/crates/git_ui/src/file_history_view.rs b/crates/git_ui/src/file_history_view.rs index ffd600c32af5be8fe9f390b93b6f96911bfecb07..03cf6671a23524a0e514ee5c11f55d5eba666796 100644 --- a/crates/git_ui/src/file_history_view.rs +++ b/crates/git_ui/src/file_history_view.rs @@ -565,7 +565,10 @@ impl Item for FileHistoryView { false } - fn breadcrumbs(&self, _cx: &App) -> Option> { + fn breadcrumbs( + &self, + _cx: &App, + ) -> Option<(Vec, Option)> { None } diff --git a/crates/git_ui/src/multi_diff_view.rs b/crates/git_ui/src/multi_diff_view.rs index 6c4c236da869e479cd042e4ed4cf12c98d861a84..c5e456a1e43584fd6ec5da98b9f5134e9801ef5c 100644 --- a/crates/git_ui/src/multi_diff_view.rs +++ b/crates/git_ui/src/multi_diff_view.rs @@ -3,9 +3,9 @@ use buffer_diff::BufferDiff; use editor::{Editor, EditorEvent, MultiBuffer, multibuffer_context_lines}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, Render, SharedString, Task, Window, + Focusable, Font, IntoElement, Render, SharedString, Task, Window, }; -use language::{Buffer, Capability, OffsetRangeExt}; +use language::{Buffer, Capability, HighlightedText, OffsetRangeExt}; use multi_buffer::PathKey; use project::Project; use std::{ @@ -18,7 +18,7 @@ use util::paths::PathStyle; use util::rel_path::RelPath; use workspace::{ Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace, - item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams}, + item::{ItemEvent, SaveOptions, TabContentParams}, searchable::SearchableItemHandle, }; @@ -338,7 +338,7 @@ impl Item for MultiDiffView { ToolbarItemLocation::PrimaryLeft } - fn breadcrumbs(&self, cx: &App) -> Option> { + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { self.editor.breadcrumbs(cx) } diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 291603b2b3f1544f6c60f9c3bdbbb87d3f77c424..729a2d9ce31cbe2165f0f66c15921e566d6878b4 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -10,10 +10,10 @@ use file_icons::FileIcons; use gpui::PinchEvent; use gpui::{ AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter, - FocusHandle, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, - LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, - Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task, WeakEntity, Window, actions, - checkerboard, div, img, point, px, size, + FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, InteractiveElement, + IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + ParentElement, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task, + WeakEntity, Window, actions, checkerboard, div, img, point, px, size, }; use language::File as _; use persistence::IMAGE_VIEWER; @@ -26,7 +26,7 @@ use workspace::{ ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId, delete_unloaded_items, invalid_item_view::InvalidItemView, - item::{BreadcrumbText, Item, ItemHandle, ProjectItem, SerializableItem, TabContentParams}, + item::{HighlightedText, Item, ItemHandle, ProjectItem, SerializableItem, TabContentParams}, }; pub use crate::image_info::*; @@ -530,15 +530,17 @@ impl Item for ImageView { } } - fn breadcrumbs(&self, cx: &App) -> Option> { + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx); - let settings = ThemeSettings::get_global(cx); - - Some(vec![BreadcrumbText { - text, - highlights: None, - font: Some(settings.buffer_font.clone()), - }]) + let font = ThemeSettings::get_global(cx).buffer_font.clone(); + + Some(( + vec![HighlightedText { + text: text.into(), + highlights: vec![], + }], + Some(font), + )) } fn can_split(&self) -> bool { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index e4ed410ef79897770d2a27aaef10017b1d284390..c1a6542fbc17526eed4914815738212cf74eca8f 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -9,7 +9,7 @@ use assistant_slash_command::SlashCommandRegistry; use editor::{Editor, EditorSettings, actions::SelectAll, blink_manager::BlinkManager}; use gpui::{ Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, ExternalPaths, - FocusHandle, Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, + FocusHandle, Focusable, Font, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Point, Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div, }; @@ -55,7 +55,7 @@ use workspace::{ CloseActiveItem, DraggedSelection, DraggedTab, NewCenterTerminal, NewTerminal, Pane, ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items, item::{ - BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent, + HighlightedText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent, }, register_serializable_item, searchable::{ @@ -1655,12 +1655,14 @@ impl Item for TerminalView { } } - fn breadcrumbs(&self, cx: &App) -> Option> { - Some(vec![BreadcrumbText { - text: self.terminal().read(cx).breadcrumb_text.clone(), - highlights: None, - font: None, - }]) + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { + Some(( + vec![HighlightedText { + text: self.terminal().read(cx).breadcrumb_text.clone().into(), + highlights: vec![], + }], + None, + )) } fn added_to_workspace( diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 09c99c230a0c7a9710e2976ac0673b639d8e36c4..d4d31739779e7872e29005b180f2e4682ef808af 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -12,10 +12,11 @@ use client::{Client, proto}; use futures::{StreamExt, channel::mpsc}; use gpui::{ Action, AnyElement, AnyEntity, AnyView, App, AppContext, Context, Entity, EntityId, - EventEmitter, FocusHandle, Focusable, Font, HighlightStyle, Pixels, Point, Render, - SharedString, Task, WeakEntity, Window, + EventEmitter, FocusHandle, Focusable, Font, Pixels, Point, Render, SharedString, Task, + WeakEntity, Window, }; use language::Capability; +pub use language::HighlightedText; use project::{Project, ProjectEntryId, ProjectPath}; pub use settings::{ ActivateOnClose, ClosePosition, RegisterSetting, Settings, SettingsLocation, ShowCloseButton, @@ -25,7 +26,6 @@ use smallvec::SmallVec; use std::{ any::{Any, TypeId}, cell::RefCell, - ops::Range, path::Path, rc::Rc, sync::Arc, @@ -124,14 +124,6 @@ pub enum ItemEvent { Edit, } -// TODO: Combine this with existing HighlightedText struct? -#[derive(Debug)] -pub struct BreadcrumbText { - pub text: String, - pub highlights: Option, HighlightStyle)>>, - pub font: Option, -} - #[derive(Clone, Copy, Default, Debug)] pub struct TabContentParams { pub detail: Option, @@ -329,7 +321,7 @@ pub trait Item: Focusable + EventEmitter + Render + Sized { ToolbarItemLocation::Hidden } - fn breadcrumbs(&self, _cx: &App) -> Option> { + fn breadcrumbs(&self, _cx: &App) -> Option<(Vec, Option)> { None } @@ -548,7 +540,7 @@ pub trait ItemHandle: 'static + Send { ) -> gpui::Subscription; fn to_searchable_item_handle(&self, cx: &App) -> Option>; fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation; - fn breadcrumbs(&self, cx: &App) -> Option>; + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)>; fn breadcrumb_prefix(&self, window: &mut Window, cx: &mut App) -> Option; fn show_toolbar(&self, cx: &App) -> bool; fn pixel_position_of_cursor(&self, cx: &App) -> Option>; @@ -1090,7 +1082,7 @@ impl ItemHandle for Entity { self.read(cx).breadcrumb_location(cx) } - fn breadcrumbs(&self, cx: &App) -> Option> { + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { self.read(cx).breadcrumbs(cx) } From 5ab2d97a390fdb8bdb22050a308daa55dff84f22 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 13 Mar 2026 12:09:37 +0100 Subject: [PATCH 201/219] Revert "project: Support resolving paths with worktree names prefixed" (#51474) Reverts zed-industries/zed#50692 The test here doesn't pass, unsure how this managed to even get merged --- crates/editor/src/hover_links.rs | 63 -------------------------------- crates/project/src/project.rs | 33 +++-------------- crates/worktree/src/worktree.rs | 4 -- 3 files changed, 5 insertions(+), 95 deletions(-) diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 4cbd3d77cf09ccfebd48f50b6b26413837b24b2c..3a6ff4ec0e4fc53d19bfb51a10b1f7790933b175 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -1889,69 +1889,6 @@ mod tests { }); } - #[gpui::test] - async fn test_hover_filenames_with_worktree_prefix(cx: &mut gpui::TestAppContext) { - init_test(cx, |_| {}); - let mut cx = EditorLspTestContext::new_rust( - lsp::ServerCapabilities { - ..Default::default() - }, - cx, - ) - .await; - - let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); - fs.as_fake() - .insert_file( - path!("/root/dir/file2.rs"), - "This is file2.rs".as_bytes().to_vec(), - ) - .await; - - #[cfg(not(target_os = "windows"))] - cx.set_state(indoc! {" - Go to root/dir/file2.rs if you want.ˇ - "}); - #[cfg(target_os = "windows")] - cx.set_state(indoc! {" - Go to root/dir/file2.rs if you want.ˇ - "}); - - let screen_coord = cx.pixel_position(indoc! {" - Go to root/diˇr/file2.rs if you want. - "}); - - cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key()); - cx.assert_editor_text_highlights( - HighlightKey::HoveredLinkState, - indoc! {" - Go to «root/dir/file2.rsˇ» if you want. - "}, - ); - - cx.simulate_click(screen_coord, Modifiers::secondary_key()); - - cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2)); - cx.update_workspace(|workspace, _, cx| { - let active_editor = workspace.active_item_as::(cx).unwrap(); - - let buffer = active_editor - .read(cx) - .buffer() - .read(cx) - .as_singleton() - .unwrap(); - - let file = buffer.read(cx).file().unwrap(); - let file_path = file.as_local().unwrap().abs_path(cx); - - assert_eq!( - file_path, - std::path::PathBuf::from(path!("/root/dir/file2.rs")) - ); - }); - } - #[gpui::test] async fn test_hover_directories(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 14379e20fd45c0460f54ea3d33fbfe8a04917c7a..ed8884cd68c6df32375686dd5ceb41b21cbb5cdd 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -4590,38 +4590,24 @@ impl Project { let worktrees_with_ids: Vec<_> = self .worktrees(cx) .map(|worktree| { - let read = worktree.read(cx); - let id = read.id(); - ( - worktree, - id, - read.is_visible().then(|| read.root_name_arc()), - ) + let id = worktree.read(cx).id(); + (worktree, id) }) .collect(); cx.spawn(async move |_, cx| { if let Some(buffer_worktree_id) = buffer_worktree_id - && let Some((worktree, _, root_name)) = worktrees_with_ids + && let Some((worktree, _)) = worktrees_with_ids .iter() - .find(|(_, id, _)| *id == buffer_worktree_id) + .find(|(_, id)| *id == buffer_worktree_id) { for candidate in candidates.iter() { if let Some(path) = Self::resolve_path_in_worktree(worktree, candidate, cx) { return Some(path); } - if let Some(root_name) = root_name { - if let Ok(candidate) = candidate.strip_prefix(root_name) { - if let Some(path) = - Self::resolve_path_in_worktree(worktree, candidate, cx) - { - return Some(path); - } - } - } } } - for (worktree, id, root_name) in worktrees_with_ids { + for (worktree, id) in worktrees_with_ids { if Some(id) == buffer_worktree_id { continue; } @@ -4629,15 +4615,6 @@ impl Project { if let Some(path) = Self::resolve_path_in_worktree(&worktree, candidate, cx) { return Some(path); } - if let Some(root_name) = &root_name { - if let Ok(candidate) = candidate.strip_prefix(root_name) { - if let Some(path) = - Self::resolve_path_in_worktree(&worktree, candidate, cx) - { - return Some(path); - } - } - } } } None diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 518bf5b4620fdf1f65793ca912bba21f614c67ee..44ba4e752cff778b7918b9a29935d0f0e1ebb614 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -2466,10 +2466,6 @@ impl Snapshot { &self.root_name } - pub fn root_name_arc(&self) -> Arc { - self.root_name.clone() - } - pub fn root_name_str(&self) -> &str { self.root_name.as_unix_str() } From b77c4441112aac6db7e53f0aea9728a66f229f28 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 13 Mar 2026 13:12:42 +0100 Subject: [PATCH 202/219] editor: Remove unnecessary clone (#51470) Release Notes: - N/A --- crates/editor/src/hover_popover.rs | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index ad54d6105ca3896d21857d548d80f991a1a76ecc..99069cac6ceeec3983d6713777007876c74c8d19 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -8,10 +8,10 @@ use crate::{ }; use anyhow::Context as _; use gpui::{ - AnyElement, App, AsyncApp, AsyncWindowContext, Bounds, Context, Entity, Focusable as _, - FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, - ScrollHandle, Size, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, - TextStyleRefinement, WeakEntity, Window, canvas, div, px, + AnyElement, App, AsyncWindowContext, Bounds, Context, Entity, Focusable as _, FontWeight, Hsla, + InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size, + StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TextStyleRefinement, + Window, canvas, div, px, }; use itertools::Itertools; use language::{DiagnosticEntry, Language, LanguageRegistry}; @@ -73,18 +73,13 @@ pub fn hover_at( } // If we are moving closer, or if no timer is running at all, start/restart the 300ms timer. - let delay = 300u64; - let task = cx.spawn(move |this: WeakEntity, cx: &mut AsyncApp| { - let mut cx = cx.clone(); - async move { - cx.background_executor() - .timer(Duration::from_millis(delay)) - .await; - this.update(&mut cx, |editor, cx| { - hide_hover(editor, cx); - }) - .ok(); - } + let delay = Duration::from_millis(300u64); + let task = cx.spawn(async move |this, cx| { + cx.background_executor().timer(delay).await; + this.update(cx, |editor, cx| { + hide_hover(editor, cx); + }) + .ok(); }); editor.hover_state.hiding_delay_task = Some(task); } From 6fb9680bf63bf2b991f36266bb56f1001c1e33a3 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 13 Mar 2026 13:16:59 +0100 Subject: [PATCH 203/219] agent_ui: Wire up archive entry loading (#51475) Release Notes: - N/A --------- Co-authored-by: cameron Co-authored-by: Bennet Bo Fenner --- crates/agent_ui/src/agent_panel.rs | 162 ++++---- crates/agent_ui/src/sidebar.rs | 420 +++++++++++++++++--- crates/agent_ui/src/thread_history_view.rs | 18 +- crates/agent_ui/src/threads_archive_view.rs | 11 +- crates/agent_ui/src/ui/mention_crease.rs | 13 +- 5 files changed, 492 insertions(+), 132 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 23dc1dfcbc086f4b145bb5372929d9aa32f30fc5..e69b6a9f164a07d17c01057ea8a57c287ab6f938 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -888,7 +888,7 @@ pub struct AgentPanel { zoomed: bool, pending_serialization: Option>>, onboarding: Entity, - selected_agent: AgentType, + selected_agent_type: AgentType, start_thread_in: StartThreadIn, worktree_creation_status: Option, _thread_view_subscription: Option, @@ -908,7 +908,7 @@ impl AgentPanel { }; let width = self.width; - let selected_agent = self.selected_agent.clone(); + let selected_agent_type = self.selected_agent_type.clone(); let start_thread_in = Some(self.start_thread_in); let last_active_thread = self.active_agent_thread(cx).map(|thread| { @@ -916,7 +916,7 @@ impl AgentPanel { let title = thread.title(); SerializedActiveThread { session_id: thread.session_id().0.to_string(), - agent_type: self.selected_agent.clone(), + agent_type: self.selected_agent_type.clone(), title: if title.as_ref() != DEFAULT_THREAD_TITLE { Some(title.to_string()) } else { @@ -931,7 +931,7 @@ impl AgentPanel { workspace_id, SerializedAgentPanel { width, - selected_agent: Some(selected_agent), + selected_agent: Some(selected_agent_type), last_active_thread, start_thread_in, }, @@ -1017,7 +1017,7 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width.map(|w| w.round()); if let Some(selected_agent) = serialized_panel.selected_agent.clone() { - panel.selected_agent = selected_agent; + panel.selected_agent_type = selected_agent; } if let Some(start_thread_in) = serialized_panel.start_thread_in { let is_worktree_flag_enabled = @@ -1045,8 +1045,18 @@ impl AgentPanel { if let Some(thread_info) = last_active_thread { let agent_type = thread_info.agent_type.clone(); panel.update(cx, |panel, cx| { - panel.selected_agent = agent_type; - panel.load_agent_thread_inner(thread_info.session_id.into(), thread_info.cwd, thread_info.title.map(SharedString::from), false, window, cx); + panel.selected_agent_type = agent_type; + if let Some(agent) = panel.selected_agent() { + panel.load_agent_thread( + agent, + thread_info.session_id.into(), + thread_info.cwd, + thread_info.title.map(SharedString::from), + false, + window, + cx, + ); + } }); } panel @@ -1214,7 +1224,7 @@ impl AgentPanel { onboarding, text_thread_history, thread_store, - selected_agent: AgentType::default(), + selected_agent_type: AgentType::default(), start_thread_in: StartThreadIn::default(), worktree_creation_status: None, _thread_view_subscription: None, @@ -1403,8 +1413,8 @@ impl AgentPanel { editor }); - if self.selected_agent != AgentType::TextThread { - self.selected_agent = AgentType::TextThread; + if self.selected_agent_type != AgentType::TextThread { + self.selected_agent_type = AgentType::TextThread; self.serialize(cx); } @@ -1464,7 +1474,7 @@ impl AgentPanel { .detach(); let server = agent.server(fs, thread_store); - self.create_external_thread( + self.create_agent_thread( server, resume_session_id, cwd, @@ -1497,7 +1507,7 @@ impl AgentPanel { let server = ext_agent.server(fs, thread_store); this.update_in(cx, |agent_panel, window, cx| { - agent_panel.create_external_thread( + agent_panel.create_agent_thread( server, resume_session_id, cwd, @@ -1558,7 +1568,7 @@ impl AgentPanel { } fn has_history_for_selected_agent(&self, cx: &App) -> bool { - match &self.selected_agent { + match &self.selected_agent_type { AgentType::TextThread | AgentType::NativeAgent => true, AgentType::Custom { name } => { let agent = Agent::Custom { name: name.clone() }; @@ -1575,7 +1585,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) -> Option { - match &self.selected_agent { + match &self.selected_agent_type { AgentType::TextThread => Some(History::TextThreads), AgentType::NativeAgent => { let history = self @@ -1587,7 +1597,7 @@ impl AgentPanel { .clone(); Some(History::AgentThreads { - view: self.create_thread_history_view(history, window, cx), + view: self.create_thread_history_view(Agent::NativeAgent, history, window, cx), }) } AgentType::Custom { name } => { @@ -1601,7 +1611,7 @@ impl AgentPanel { .clone(); if history.read(cx).has_session_list() { Some(History::AgentThreads { - view: self.create_thread_history_view(history, window, cx), + view: self.create_thread_history_view(agent, history, window, cx), }) } else { None @@ -1612,22 +1622,29 @@ impl AgentPanel { fn create_thread_history_view( &self, + agent: Agent, history: Entity, window: &mut Window, cx: &mut Context, ) -> Entity { let view = cx.new(|cx| ThreadHistoryView::new(history.clone(), window, cx)); - cx.subscribe_in(&view, window, |this, _, event, window, cx| match event { - ThreadHistoryViewEvent::Open(thread) => { - this.load_agent_thread( - thread.session_id.clone(), - thread.cwd.clone(), - thread.title.clone(), - window, - cx, - ); - } - }) + cx.subscribe_in( + &view, + window, + move |this, _, event, window, cx| match event { + ThreadHistoryViewEvent::Open(thread) => { + this.load_agent_thread( + agent.clone(), + thread.session_id.clone(), + thread.cwd.clone(), + thread.title.clone(), + true, + window, + cx, + ); + } + }, + ) .detach(); view } @@ -1691,8 +1708,8 @@ impl AgentPanel { ) }); - if self.selected_agent != AgentType::TextThread { - self.selected_agent = AgentType::TextThread; + if self.selected_agent_type != AgentType::TextThread { + self.selected_agent_type = AgentType::TextThread; self.serialize(cx); } @@ -2266,13 +2283,17 @@ impl AgentPanel { let entry = entry.clone(); panel .update(cx, move |this, cx| { - this.load_agent_thread( - entry.session_id.clone(), - entry.cwd.clone(), - entry.title.clone(), - window, - cx, - ); + if let Some(agent) = this.selected_agent() { + this.load_agent_thread( + agent, + entry.session_id.clone(), + entry.cwd.clone(), + entry.title.clone(), + true, + window, + cx, + ); + } }) .ok(); } @@ -2322,10 +2343,6 @@ impl AgentPanel { menu.separator() } - pub fn selected_agent(&self) -> AgentType { - self.selected_agent.clone() - } - fn subscribe_to_active_thread_view( server_view: &Entity, window: &mut Window, @@ -2396,8 +2413,8 @@ impl AgentPanel { } } - fn selected_external_agent(&self) -> Option { - match &self.selected_agent { + pub(crate) fn selected_agent(&self) -> Option { + match &self.selected_agent_type { AgentType::NativeAgent => Some(Agent::NativeAgent), AgentType::Custom { name } => Some(Agent::Custom { name: name.clone() }), AgentType::TextThread => None, @@ -2493,17 +2510,7 @@ impl AgentPanel { pub fn load_agent_thread( &mut self, - session_id: acp::SessionId, - cwd: Option, - title: Option, - window: &mut Window, - cx: &mut Context, - ) { - self.load_agent_thread_inner(session_id, cwd, title, true, window, cx); - } - - fn load_agent_thread_inner( - &mut self, + agent: Agent, session_id: acp::SessionId, cwd: Option, title: Option, @@ -2541,9 +2548,6 @@ impl AgentPanel { } } - let Some(agent) = self.selected_external_agent() else { - return; - }; self.external_thread( Some(agent), Some(session_id), @@ -2556,7 +2560,7 @@ impl AgentPanel { ); } - pub(crate) fn create_external_thread( + pub(crate) fn create_agent_thread( &mut self, server: Rc, resume_session_id: Option, @@ -2571,8 +2575,8 @@ impl AgentPanel { cx: &mut Context, ) { let selected_agent = AgentType::from(ext_agent.clone()); - if self.selected_agent != selected_agent { - self.selected_agent = selected_agent; + if self.selected_agent_type != selected_agent { + self.selected_agent_type = selected_agent; self.serialize(cx); } let thread_store = server @@ -2825,8 +2829,8 @@ impl AgentPanel { ) { self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message)); if matches!(self.active_view, ActiveView::Uninitialized) { - let selected_agent = self.selected_agent.clone(); - self.new_agent_thread(selected_agent, window, cx); + let selected_agent_type = self.selected_agent_type.clone(); + self.new_agent_thread(selected_agent_type, window, cx); } cx.notify(); } @@ -3218,8 +3222,8 @@ impl Panel for AgentPanel { Some(WorktreeCreationStatus::Creating) ) { - let selected_agent = self.selected_agent.clone(); - self.new_agent_thread_inner(selected_agent, false, window, cx); + let selected_agent_type = self.selected_agent_type.clone(); + self.new_agent_thread_inner(selected_agent_type, false, window, cx); } } @@ -3871,16 +3875,16 @@ impl AgentPanel { let docked_right = agent_panel_dock_position(cx) == DockPosition::Right; let (selected_agent_custom_icon, selected_agent_label) = - if let AgentType::Custom { name, .. } = &self.selected_agent { + if let AgentType::Custom { name, .. } = &self.selected_agent_type { let store = agent_server_store.read(cx); let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); let label = store .agent_display_name(&ExternalAgentServerName(name.clone())) - .unwrap_or_else(|| self.selected_agent.label()); + .unwrap_or_else(|| self.selected_agent_type.label()); (icon, label) } else { - (None, self.selected_agent.label()) + (None, self.selected_agent_type.label()) }; let active_thread = match &self.active_view { @@ -3894,7 +3898,7 @@ impl AgentPanel { let new_thread_menu_builder: Rc< dyn Fn(&mut Window, &mut App) -> Option>, > = { - let selected_agent = self.selected_agent.clone(); + let selected_agent = self.selected_agent_type.clone(); let is_agent_selected = move |agent_type: AgentType| selected_agent == agent_type; let workspace = self.workspace.clone(); @@ -4210,7 +4214,7 @@ impl AgentPanel { let has_custom_icon = selected_agent_custom_icon.is_some(); let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone(); - let selected_agent_builtin_icon = self.selected_agent.icon(); + let selected_agent_builtin_icon = self.selected_agent_type.icon(); let selected_agent_label_for_tooltip = selected_agent_label.clone(); let selected_agent = div() @@ -4220,7 +4224,7 @@ impl AgentPanel { .child(Icon::from_external_svg(icon_path).color(Color::Muted)) }) .when(!has_custom_icon, |this| { - this.when_some(self.selected_agent.icon(), |this, icon| { + this.when_some(self.selected_agent_type.icon(), |this, icon| { this.px_1().child(Icon::new(icon).color(Color::Muted)) }) }) @@ -5230,7 +5234,7 @@ impl AgentPanel { name: server.name(), }; - self.create_external_thread( + self.create_agent_thread( server, None, None, None, None, workspace, project, ext_agent, true, window, cx, ); } @@ -5378,7 +5382,7 @@ mod tests { ); }); - let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone()); + let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent_type.clone()); // --- Set up workspace B: ClaudeCode, width=400, no active thread --- let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { @@ -5388,7 +5392,7 @@ mod tests { panel_b.update(cx, |panel, _cx| { panel.width = Some(px(400.0)); - panel.selected_agent = AgentType::Custom { + panel.selected_agent_type = AgentType::Custom { name: "claude-acp".into(), }; }); @@ -5421,7 +5425,7 @@ mod tests { "workspace A width should be restored" ); assert_eq!( - panel.selected_agent, agent_type_a, + panel.selected_agent_type, agent_type_a, "workspace A agent type should be restored" ); assert!( @@ -5438,7 +5442,7 @@ mod tests { "workspace B width should be restored" ); assert_eq!( - panel.selected_agent, + panel.selected_agent_type, AgentType::Custom { name: "claude-acp".into() }, @@ -5922,7 +5926,15 @@ mod tests { // Load thread A back via load_agent_thread — should promote from background. panel.update_in(&mut cx, |panel, window, cx| { - panel.load_agent_thread(session_id_a.clone(), None, None, window, cx); + panel.load_agent_thread( + panel.selected_agent().expect("selected agent must be set"), + session_id_a.clone(), + None, + None, + true, + window, + cx, + ); }); // Thread A should now be the active view, promoted from background. diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 6dc684b3d30737dbce1b7d1c9c706341cf4ef11f..0bc0968ea44c25ec9cfd3d68d8600814f922fc12 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1,5 +1,5 @@ use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent}; -use crate::{AgentPanel, AgentPanelEvent, NewThread}; +use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread}; use acp_thread::ThreadStatus; use action_log::DiffStats; use agent::ThreadStore; @@ -107,6 +107,7 @@ enum ThreadEntryWorkspace { #[derive(Clone)] struct ThreadEntry { + agent: Agent, session_info: acp_thread::AgentSessionInfo, icon: IconName, icon_from_external_svg: Option, @@ -192,7 +193,7 @@ fn root_repository_snapshots( workspace: &Entity, cx: &App, ) -> Vec { - let (path_list, _) = workspace_path_list_and_label(workspace, cx); + let path_list = workspace_path_list(workspace, cx); let project = workspace.read(cx).project().read(cx); project .repositories(cx) @@ -208,34 +209,23 @@ fn root_repository_snapshots( .collect() } -fn workspace_path_list_and_label( - workspace: &Entity, - cx: &App, -) -> (PathList, SharedString) { - let workspace_ref = workspace.read(cx); - let mut paths = Vec::new(); - let mut names = Vec::new(); - - for worktree in workspace_ref.worktrees(cx) { - let worktree_ref = worktree.read(cx); - if !worktree_ref.is_visible() { - continue; - } - let abs_path = worktree_ref.abs_path(); - paths.push(abs_path.to_path_buf()); +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()); } } - - let label: SharedString = if names.is_empty() { + if names.is_empty() { // TODO: Can we do something better in this case? "Empty Workspace".into() } else { names.join(", ").into() - }; - - (PathList::new(&paths), label) + } } pub struct Sidebar { @@ -578,7 +568,8 @@ impl Sidebar { continue; } - let (path_list, label) = workspace_path_list_and_label(workspace, cx); + let path_list = workspace_path_list(workspace, cx); + let label = workspace_label_from_path_list(&path_list); let is_collapsed = self.collapsed_groups.contains(&path_list); let should_load_threads = !is_collapsed || !query.is_empty(); @@ -592,6 +583,7 @@ impl Sidebar { for meta in thread_store.read(cx).threads_for_paths(&path_list) { seen_session_ids.insert(meta.id.clone()); threads.push(ThreadEntry { + agent: Agent::NativeAgent, session_info: meta.into(), icon: IconName::ZedAgent, icon_from_external_svg: None, @@ -644,6 +636,7 @@ impl Sidebar { continue; } threads.push(ThreadEntry { + agent: Agent::NativeAgent, session_info: meta.into(), icon: IconName::ZedAgent, icon_from_external_svg: None, @@ -1222,7 +1215,7 @@ impl Sidebar { // contains other folders. let mut to_remove: Vec> = Vec::new(); for workspace in &workspaces { - let (path_list, _) = workspace_path_list_and_label(workspace, cx); + let path_list = workspace_path_list(workspace, cx); if path_list.paths().len() != 1 { continue; } @@ -1370,10 +1363,17 @@ impl Sidebar { match &thread.workspace { ThreadEntryWorkspace::Open(workspace) => { let workspace = workspace.clone(); - self.activate_thread(session_info, &workspace, window, cx); + self.activate_thread( + thread.agent.clone(), + session_info, + &workspace, + window, + cx, + ); } ThreadEntryWorkspace::Closed(path_list) => { self.open_workspace_and_activate_thread( + thread.agent.clone(), session_info, path_list.clone(), window, @@ -1405,6 +1405,7 @@ impl Sidebar { fn activate_thread( &mut self, + agent: Agent, session_info: acp_thread::AgentSessionInfo, workspace: &Entity, window: &mut Window, @@ -1425,18 +1426,23 @@ impl Sidebar { if let Some(agent_panel) = workspace.read(cx).panel::(cx) { agent_panel.update(cx, |panel, cx| { panel.load_agent_thread( + agent, session_info.session_id, session_info.cwd, session_info.title, + true, window, cx, ); }); } + + self.update_entries(cx); } fn open_workspace_and_activate_thread( &mut self, + agent: Agent, session_info: acp_thread::AgentSessionInfo, path_list: PathList, window: &mut Window, @@ -1454,13 +1460,69 @@ impl Sidebar { cx.spawn_in(window, async move |this, cx| { let workspace = open_task.await?; this.update_in(cx, |this, window, cx| { - this.activate_thread(session_info, &workspace, window, cx); + this.activate_thread(agent, session_info, &workspace, window, cx); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } + fn find_open_workspace_for_path_list( + &self, + path_list: &PathList, + cx: &App, + ) -> Option> { + let multi_workspace = self.multi_workspace.upgrade()?; + multi_workspace + .read(cx) + .workspaces() + .iter() + .find(|workspace| workspace_path_list(workspace, cx).paths() == path_list.paths()) + .cloned() + } + + fn activate_archived_thread( + &mut self, + agent: Agent, + session_info: acp_thread::AgentSessionInfo, + window: &mut Window, + cx: &mut Context, + ) { + let saved_path_list = ThreadStore::try_global(cx).and_then(|thread_store| { + thread_store + .read(cx) + .thread_from_session_id(&session_info.session_id) + .map(|thread| thread.folder_paths.clone()) + }); + let path_list = saved_path_list.or_else(|| { + // we don't have saved metadata, so create path list based on the cwd + session_info + .cwd + .as_ref() + .map(|cwd| PathList::new(&[cwd.to_path_buf()])) + }); + + if let Some(path_list) = path_list { + if let Some(workspace) = self.find_open_workspace_for_path_list(&path_list, cx) { + self.activate_thread(agent, session_info, &workspace, window, cx); + } else { + self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx); + } + return; + } + + let active_workspace = self.multi_workspace.upgrade().and_then(|w| { + w.read(cx) + .workspaces() + .get(w.read(cx).active_workspace_index()) + .cloned() + }); + + if let Some(workspace) = active_workspace { + self.activate_thread(agent, session_info, &workspace, window, cx); + } + } + fn expand_selected_entry( &mut self, _: &ExpandSelectedEntry, @@ -1589,22 +1651,32 @@ impl Sidebar { .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) .focused(is_selected) .docked_right(docked_right) - .on_click(cx.listener(move |this, _, window, cx| { - this.selection = None; - match &thread_workspace { - ThreadEntryWorkspace::Open(workspace) => { - this.activate_thread(session_info.clone(), workspace, window, cx); - } - ThreadEntryWorkspace::Closed(path_list) => { - this.open_workspace_and_activate_thread( - session_info.clone(), - path_list.clone(), - window, - cx, - ); + .on_click({ + let agent = thread.agent.clone(); + cx.listener(move |this, _, window, cx| { + this.selection = None; + match &thread_workspace { + ThreadEntryWorkspace::Open(workspace) => { + this.activate_thread( + agent.clone(), + session_info.clone(), + workspace, + window, + cx, + ); + } + ThreadEntryWorkspace::Closed(path_list) => { + this.open_workspace_and_activate_thread( + agent.clone(), + session_info.clone(), + path_list.clone(), + window, + cx, + ); + } } - } - })) + }) + }) .into_any_element() } @@ -1852,8 +1924,12 @@ impl Sidebar { ThreadsArchiveViewEvent::Close => { this.show_thread_list(window, cx); } - ThreadsArchiveViewEvent::OpenThread(_session_info) => { - //TODO: Actually open thread once we support it + ThreadsArchiveViewEvent::OpenThread { + agent, + session_info, + } => { + this.show_thread_list(window, cx); + this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx); } }, ); @@ -2506,6 +2582,7 @@ mod tests { }, // Thread with default (Completed) status, not active ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-1")), cwd: None, @@ -2527,6 +2604,7 @@ mod tests { }), // 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")), cwd: None, @@ -2548,6 +2626,7 @@ mod tests { }), // 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")), cwd: None, @@ -2569,6 +2648,7 @@ mod tests { }), // 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")), cwd: None, @@ -2590,6 +2670,7 @@ mod tests { }), // 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")), cwd: None, @@ -3940,6 +4021,7 @@ mod tests { // ── 2. Click thread in workspace A via sidebar ─────────────────────── sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_thread( + Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id_a.clone(), cwd: None, @@ -4007,6 +4089,7 @@ mod tests { // 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(), cwd: None, @@ -4469,9 +4552,8 @@ mod tests { mw.workspaces()[1].clone() }); - let (new_path_list, _) = new_workspace.read_with(cx, |_, cx| { - workspace_path_list_and_label(&new_workspace, cx) - }); + 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")]), @@ -4593,4 +4675,250 @@ mod tests { "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_thread_to_store(&session_id, &path_list_b, 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(), + cwd: Some("/project-b".into()), + 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")), + cwd: Some(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")), + cwd: 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")); + save_thread_to_store(&session_id, &path_list_b, cx).await; + + 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(), + cwd: None, + 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" + ); + } } diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs index 4e43748911ba0559485e7a4d991e5dc9d2d4c524..092169efbf57f2947f2532e4a599e7b4935dc539 100644 --- a/crates/agent_ui/src/thread_history_view.rs +++ b/crates/agent_ui/src/thread_history_view.rs @@ -751,13 +751,17 @@ impl RenderOnce for HistoryEntryElement { { if let Some(panel) = workspace.read(cx).panel::(cx) { panel.update(cx, |panel, cx| { - panel.load_agent_thread( - entry.session_id.clone(), - entry.cwd.clone(), - entry.title.clone(), - window, - cx, - ); + if let Some(agent) = panel.selected_agent() { + panel.load_agent_thread( + agent, + entry.session_id.clone(), + entry.cwd.clone(), + entry.title.clone(), + true, + window, + cx, + ); + } }); } } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 3d7dba591dfa60f7408f9710561863791bcd802b..e1fd44b4d81280037404fa3f2415b39bdc2aade7 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -89,7 +89,10 @@ fn fuzzy_match_positions(query: &str, text: &str) -> Option> { pub enum ThreadsArchiveViewEvent { Close, - OpenThread(AgentSessionInfo), + OpenThread { + agent: Agent, + session_info: AgentSessionInfo, + }, } impl EventEmitter for ThreadsArchiveView {} @@ -263,7 +266,10 @@ impl ThreadsArchiveView { ) { self.selection = None; self.reset_filter_editor_text(window, cx); - cx.emit(ThreadsArchiveViewEvent::OpenThread(session_info)); + cx.emit(ThreadsArchiveViewEvent::OpenThread { + agent: self.selected_agent.clone(), + session_info, + }); } fn is_selectable_item(&self, ix: usize) -> bool { @@ -413,7 +419,6 @@ impl ThreadsArchiveView { ListItem::new(id) .toggle_state(is_selected) - .disabled(true) .child( h_flex() .min_w_0() diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 0f0b8ecc1d7d66a6025bcfed772c7ead7061fe20..b70b77e6ca603aba8fd55706918ffb3543e2a734 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -13,6 +13,8 @@ use theme::ThemeSettings; use ui::{ButtonLike, TintColor, Tooltip, prelude::*}; use workspace::{OpenOptions, Workspace}; +use crate::Agent; + #[derive(IntoElement)] pub struct MentionCrease { id: ElementId, @@ -275,8 +277,17 @@ fn open_thread( return; }; + // Right now we only support loading threads in the native agent panel.update(cx, |panel, cx| { - panel.load_agent_thread(id, None, Some(name.into()), window, cx) + panel.load_agent_thread( + Agent::NativeAgent, + id, + None, + Some(name.into()), + true, + window, + cx, + ) }); } From 0674324fe51afb6440dcf1b26bedd8feca18433b Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 13 Mar 2026 13:27:13 +0100 Subject: [PATCH 204/219] agent: Fix session close capability check (#51479) Release Notes: - agent: Fixed an issue where external agents would return an error because unsupported ACP method was called --- crates/agent_servers/src/acp.rs | 2 +- crates/agent_ui/src/connection_view.rs | 235 ++++++++++++++++++++++++- 2 files changed, 232 insertions(+), 5 deletions(-) diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index a661289f6221818c6f63c799b0593907bb665eb9..ba0851565e4ee84e1eb4360a6391a1ad442602cf 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -753,7 +753,7 @@ impl AgentConnection for AcpConnection { session_id: &acp::SessionId, cx: &mut App, ) -> Task> { - if !self.agent_capabilities.session_capabilities.close.is_none() { + if !self.supports_close_session() { return Task::ready(Err(anyhow!(LoadError::Other( "Closing sessions is not supported by this agent.".into() )))); diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index e84e18e645ed4a84bd667564416682298b35ce17..d2226e675a6a242588074dd2e7b646a7376c8c37 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -462,10 +462,13 @@ impl ConnectedServerState { } pub fn close_all_sessions(&self, cx: &mut App) -> Task<()> { - let tasks = self - .threads - .keys() - .map(|id| self.connection.clone().close_session(id, cx)); + let tasks = self.threads.keys().filter_map(|id| { + if self.connection.supports_close_session() { + Some(self.connection.clone().close_session(id, cx)) + } else { + None + } + }); let task = futures::future::join_all(tasks); cx.background_spawn(async move { task.await; @@ -6536,4 +6539,228 @@ pub(crate) mod tests { "Main editor should have existing content and queued message separated by two newlines" ); } + + #[gpui::test] + async fn test_close_all_sessions_skips_when_unsupported(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); + + // StubAgentConnection defaults to supports_close_session() -> false + let thread_view = cx.update(|window, cx| { + cx.new(|cx| { + ConnectionView::new( + Rc::new(StubAgentServer::default_response()), + connection_store, + Agent::Custom { + name: "Test".into(), + }, + None, + None, + None, + None, + workspace.downgrade(), + project, + Some(thread_store), + None, + window, + cx, + ) + }) + }); + + cx.run_until_parked(); + + thread_view.read_with(cx, |view, _cx| { + let connected = view.as_connected().expect("Should be connected"); + assert!( + !connected.threads.is_empty(), + "There should be at least one thread" + ); + assert!( + !connected.connection.supports_close_session(), + "StubAgentConnection should not support close" + ); + }); + + thread_view + .update(cx, |view, cx| { + view.as_connected() + .expect("Should be connected") + .close_all_sessions(cx) + }) + .await; + } + + #[gpui::test] + async fn test_close_all_sessions_calls_close_when_supported(cx: &mut TestAppContext) { + init_test(cx); + + let (thread_view, cx) = + setup_thread_view(StubAgentServer::new(CloseCapableConnection::new()), cx).await; + + cx.run_until_parked(); + + let close_capable = thread_view.read_with(cx, |view, _cx| { + let connected = view.as_connected().expect("Should be connected"); + assert!( + !connected.threads.is_empty(), + "There should be at least one thread" + ); + assert!( + connected.connection.supports_close_session(), + "CloseCapableConnection should support close" + ); + connected + .connection + .clone() + .into_any() + .downcast::() + .expect("Should be CloseCapableConnection") + }); + + thread_view + .update(cx, |view, cx| { + view.as_connected() + .expect("Should be connected") + .close_all_sessions(cx) + }) + .await; + + let closed_count = close_capable.closed_sessions.lock().len(); + assert!( + closed_count > 0, + "close_session should have been called for each thread" + ); + } + + #[gpui::test] + async fn test_close_session_returns_error_when_unsupported(cx: &mut TestAppContext) { + init_test(cx); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + + cx.run_until_parked(); + + let result = thread_view + .update(cx, |view, cx| { + let connected = view.as_connected().expect("Should be connected"); + assert!( + !connected.connection.supports_close_session(), + "StubAgentConnection should not support close" + ); + let session_id = connected + .threads + .keys() + .next() + .expect("Should have at least one thread") + .clone(); + connected.connection.clone().close_session(&session_id, cx) + }) + .await; + + assert!( + result.is_err(), + "close_session should return an error when close is not supported" + ); + assert!( + result.unwrap_err().to_string().contains("not supported"), + "Error message should indicate that closing is not supported" + ); + } + + #[derive(Clone)] + struct CloseCapableConnection { + closed_sessions: Arc>>, + } + + impl CloseCapableConnection { + fn new() -> Self { + Self { + closed_sessions: Arc::new(Mutex::new(Vec::new())), + } + } + } + + impl AgentConnection for CloseCapableConnection { + fn telemetry_id(&self) -> SharedString { + "close-capable".into() + } + + fn new_session( + self: Rc, + project: Entity, + cwd: &Path, + cx: &mut gpui::App, + ) -> Task>> { + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let thread = cx.new(|cx| { + AcpThread::new( + None, + "CloseCapableConnection", + Some(cwd.to_path_buf()), + self, + project, + action_log, + SessionId::new("close-capable-session"), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), + cx, + ) + }); + Task::ready(Ok(thread)) + } + + fn supports_close_session(&self) -> bool { + true + } + + fn close_session( + self: Rc, + session_id: &acp::SessionId, + _cx: &mut App, + ) -> Task> { + self.closed_sessions.lock().push(session_id.clone()); + Task::ready(Ok(())) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &[] + } + + fn authenticate( + &self, + _method_id: acp::AuthMethodId, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn prompt( + &self, + _id: Option, + _params: acp::PromptRequest, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + } + + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} + + fn into_any(self: Rc) -> Rc { + self + } + } } From 46f16c750284533965519acf4dc6df12a283ba60 Mon Sep 17 00:00:00 2001 From: LBF38 Date: Fri, 13 Mar 2026 13:38:25 +0100 Subject: [PATCH 205/219] docs: Introduce fresh documentation for snippets in extensions (#50874) Add documentation for snippets in extensions. Feel free to change the wording or add more content. Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --------- Co-authored-by: Finn Evers --- docs/src/SUMMARY.md | 1 + docs/src/extensions.md | 1 + docs/src/extensions/developing-extensions.md | 6 ++++- docs/src/extensions/snippets.md | 27 ++++++++++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 docs/src/extensions/snippets.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 2b45c581685e9ecb63888edd256ec14b0da94a30..7fae303160702216a8c75095597293c375751c82 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -161,6 +161,7 @@ - [Debugger Extensions](./extensions/debugger-extensions.md) - [Theme Extensions](./extensions/themes.md) - [Icon Theme Extensions](./extensions/icon-themes.md) +- [Snippets Extensions](./extensions/snippets.md) - [Slash Command Extensions](./extensions/slash-commands.md) - [Agent Server Extensions](./extensions/agent-servers.md) - [MCP Server Extensions](./extensions/mcp-extensions.md) diff --git a/docs/src/extensions.md b/docs/src/extensions.md index 01636894a11781717a837a0f0784d6221ded1c3c..af44d981fd9e911235d5a70a1b0266037ed30ddc 100644 --- a/docs/src/extensions.md +++ b/docs/src/extensions.md @@ -14,6 +14,7 @@ Zed lets you add new functionality using user-defined extensions. - [Developing Debugger Extensions](./extensions/debugger-extensions.md) - [Developing Themes](./extensions/themes.md) - [Developing Icon Themes](./extensions/icon-themes.md) + - [Developing Snippets](./extensions/snippets.md) - [Developing Slash Commands](./extensions/slash-commands.md) - [Developing Agent Servers](./extensions/agent-servers.md) - [Developing MCP Servers](./extensions/mcp-extensions.md) diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index c5b4b1079066ba3f7b5e4149778c8e369d03d9cd..c1d593628d9e1b7775aa5ce743351c59ad0ce70e 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -5,7 +5,7 @@ description: "Create Zed extensions: languages, themes, debuggers, slash command # Developing Extensions {#developing-extensions} -Zed extensions are Git repositories containing an `extension.toml` manifest. They can provide languages, themes, debuggers, slash commands, and MCP servers. +Zed extensions are Git repositories containing an `extension.toml` manifest. They can provide languages, themes, debuggers, snippets, slash commands, and MCP servers. ## Extension Features {#extension-features} @@ -15,6 +15,7 @@ Extensions can provide: - [Debuggers](./debugger-extensions.md) - [Themes](./themes.md) - [Icon Themes](./icon-themes.md) +- [Snippets](./snippets.md) - [Slash Commands](./slash-commands.md) - [MCP Servers](./mcp-extensions.md) @@ -63,6 +64,9 @@ my-extension/ highlights.scm themes/ my-theme.json + snippets/ + snippets.json + rust.json ``` ## WebAssembly diff --git a/docs/src/extensions/snippets.md b/docs/src/extensions/snippets.md new file mode 100644 index 0000000000000000000000000000000000000000..1fa83b07b78403346608494b3932b58e37f8688e --- /dev/null +++ b/docs/src/extensions/snippets.md @@ -0,0 +1,27 @@ +--- +title: Snippets +description: "Snippets for Zed extensions." +--- + +# Snippets + +Extensions may provide snippets for one or more languages. + +Each file containing snippets can be specified in the `snippets` field of the `extensions.toml` file. + +The referenced path must be relative to the `extension.toml`. + +## Defining Snippets + +A given extension may provide one or more snippets. Each snippet must be registered in the `extension.toml`. + +Zed matches snippet files based on the lowercase name of the language (e.g. `rust.json` for Rust). +You can use `snippets.json` as a file name to define snippets that will be available regardless of the current buffer language. + +For example, here is an extension that provides snippets for Rust and TypeScript: + +```toml +snippets = ["./snippets/rust.json", "./snippets/typescript.json"] +``` + +For more information on how to create snippets, see the [Snippets documentation](../snippets.md). From 7d566e0600b04ac7da4f6c60edebd80df1c19fde Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 13 Mar 2026 13:40:45 +0100 Subject: [PATCH 206/219] extension_ci: Add initial support for extensions in a subdirectory (#51173) This wil help with releases for extensions living this repository, which will become more relevant once agent provider extensions are back on the table. Release Notes: - N/A --- .github/workflows/extension_bump.yml | 47 ++++++++-- .github/workflows/extension_tests.yml | 63 ++++++++++--- .github/workflows/run_tests.yml | 4 +- .../src/tasks/workflows/extension_bump.rs | 82 +++++++++++----- .../src/tasks/workflows/extension_tests.rs | 93 ++++++++++++++----- .../workflows/extension_workflow_rollout.rs | 5 +- .../xtask/src/tasks/workflows/run_tests.rs | 32 +++++-- tooling/xtask/src/tasks/workflows/steps.rs | 9 +- tooling/xtask/src/tasks/workflows/vars.rs | 33 +++++-- 9 files changed, 278 insertions(+), 90 deletions(-) diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 9cc53741e8007a1b3ddd02ad07b191b3ce171cc8..e61e98f4042826858e54c6f5565c5fd62f280553 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -17,6 +17,10 @@ on: description: force-bump required: true type: boolean + working-directory: + description: working-directory + type: string + default: . secrets: app-id: description: The app ID used to create the PR @@ -42,8 +46,6 @@ jobs: if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then PR_FORK_POINT="$(git merge-base origin/main HEAD)" git checkout "$PR_FORK_POINT" - elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then - git checkout "$BRANCH_PARENT_SHA" else git checkout "$(git log -1 --format=%H)"~1 fi @@ -59,6 +61,10 @@ jobs: version_changed: ${{ steps.compare-versions-check.outputs.version_changed }} current_version: ${{ steps.compare-versions-check.outputs.current_version }} timeout-minutes: 1 + defaults: + run: + shell: bash -euxo pipefail {0} + working-directory: ${{ inputs.working-directory }} bump_extension_version: needs: - check_version_changed @@ -98,18 +104,35 @@ jobs: fi NEW_VERSION="$(sed -n 's/^version = \"\(.*\)\"/\1/p' < extension.toml | tr -d '[:space:]')" + EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')" + EXTENSION_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')" + + if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then + { + echo "title=Bump version to ${NEW_VERSION}"; + echo "body=This PR bumps the version of this extension to v${NEW_VERSION}"; + echo "branch_name=zed-zippy-autobump"; + } >> "$GITHUB_OUTPUT" + else + { + echo "title=${EXTENSION_ID}: Bump to v${NEW_VERSION}"; + echo "body=This PR bumps the version of the ${EXTENSION_NAME} extension to v${NEW_VERSION}"; + echo "branch_name=zed-zippy-${EXTENSION_ID}-autobump"; + } >> "$GITHUB_OUTPUT" + fi echo "new_version=${NEW_VERSION}" >> "$GITHUB_OUTPUT" env: OLD_VERSION: ${{ needs.check_version_changed.outputs.current_version }} BUMP_TYPE: ${{ inputs.bump-type }} + WORKING_DIR: ${{ inputs.working-directory }} - name: extension_bump::create_pull_request uses: peter-evans/create-pull-request@v7 with: - title: Bump version to ${{ steps.bump-version.outputs.new_version }} - body: This PR bumps the version of this extension to v${{ steps.bump-version.outputs.new_version }} - commit-message: Bump version to v${{ steps.bump-version.outputs.new_version }} - branch: zed-zippy-autobump + title: ${{ steps.bump-version.outputs.title }} + body: ${{ steps.bump-version.outputs.body }} + commit-message: ${{ steps.bump-version.outputs.title }} + branch: ${{ steps.bump-version.outputs.branch_name }} committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> base: main delete-branch: true @@ -117,6 +140,10 @@ jobs: sign-commits: true assignees: ${{ github.actor }} timeout-minutes: 3 + defaults: + run: + shell: bash -euxo pipefail {0} + working-directory: ${{ inputs.working-directory }} create_version_label: needs: - check_version_changed @@ -145,6 +172,10 @@ jobs: }) github-token: ${{ steps.generate-token.outputs.token }} timeout-minutes: 1 + defaults: + run: + shell: bash -euxo pipefail {0} + working-directory: ${{ inputs.working-directory }} trigger_release: needs: - check_version_changed @@ -178,6 +209,10 @@ jobs: tag: v${{ needs.check_version_changed.outputs.current_version }} env: COMMITTER_TOKEN: ${{ steps.generate-token.outputs.token }} + defaults: + run: + shell: bash -euxo pipefail {0} + working-directory: ${{ inputs.working-directory }} concurrency: group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} cancel-in-progress: true diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 53de373c1b79dc3ca9a3637642e10998c781580a..de9b4dc047a039c0f6af063c2a95fdecd70e8cba 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -9,7 +9,12 @@ env: RUSTUP_TOOLCHAIN: stable CARGO_BUILD_TARGET: wasm32-wasip2 on: - workflow_call: {} + workflow_call: + inputs: + working-directory: + description: working-directory + type: string + default: . jobs: orchestrate: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -34,6 +39,14 @@ jobs: fi CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")" + # When running from a subdirectory, git diff returns repo-root-relative paths. + # Filter to only files within the current working directory and strip the prefix. + REPO_SUBDIR="$(git rev-parse --show-prefix)" + REPO_SUBDIR="${REPO_SUBDIR%/}" + if [ -n "$REPO_SUBDIR" ]; then + CHANGED_FILES="$(echo "$CHANGED_FILES" | grep "^${REPO_SUBDIR}/" | sed "s|^${REPO_SUBDIR}/||" || true)" + fi + check_pattern() { local output_name="$1" local pattern="$2" @@ -49,6 +62,10 @@ jobs: outputs: check_rust: ${{ steps.filter.outputs.check_rust }} check_extension: ${{ steps.filter.outputs.check_extension }} + defaults: + run: + shell: bash -euxo pipefail {0} + working-directory: ${{ inputs.working-directory }} check_rust: needs: - orchestrate @@ -66,17 +83,31 @@ jobs: path: ~/.rustup - name: extension_tests::install_rust_target run: rustup target add wasm32-wasip2 - - name: steps::cargo_fmt - run: cargo fmt --all -- --check + - id: get-package-name + name: extension_tests::get_package_name + run: | + PACKAGE_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < Cargo.toml | head -1 | tr -d '[:space:]')" + echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT" + - name: extension_tests::cargo_fmt_package + run: cargo fmt -p "$PACKAGE_NAME" -- --check + env: + PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }} - name: extension_tests::run_clippy - run: cargo clippy --release --all-features -- --deny warnings + run: cargo clippy -p "$PACKAGE_NAME" --release --all-features -- --deny warnings + env: + PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }} - name: steps::cargo_install_nextest uses: taiki-e/install-action@nextest - - name: steps::cargo_nextest - run: 'cargo nextest run --workspace --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n ''s|host: ||p'')"' + - 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: + PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }} NEXTEST_NO_TESTS: warn timeout-minutes: 6 + defaults: + run: + shell: bash -euxo pipefail {0} + working-directory: ${{ inputs.working-directory }} check_extension: needs: - orchestrate @@ -97,8 +128,8 @@ jobs: - name: extension_tests::download_zed_extension_cli if: steps.cache-zed-extension-cli.outputs.cache-hit != 'true' run: | - wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" - chmod +x zed-extension + 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 with: @@ -108,7 +139,7 @@ jobs: run: | mkdir -p /tmp/ext-scratch mkdir -p /tmp/ext-output - ./zed-extension --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output + "$GITHUB_WORKSPACE/zed-extension" --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output - name: run_tests::fetch_ts_query_ls uses: dsaltares/fetch-gh-release-asset@aa37ae5c44d3c9820bc12fe675e8670ecd93bd1c with: @@ -117,8 +148,8 @@ jobs: file: ts_query_ls-x86_64-unknown-linux-gnu.tar.gz - name: run_tests::run_ts_query_ls run: |- - tar -xf ts_query_ls-x86_64-unknown-linux-gnu.tar.gz - ./ts_query_ls format --check . || { + tar -xf "$GITHUB_WORKSPACE/ts_query_ls-x86_64-unknown-linux-gnu.tar.gz" -C "$GITHUB_WORKSPACE" + "$GITHUB_WORKSPACE/ts_query_ls" format --check . || { echo "Found unformatted queries, please format them with ts_query_ls." echo "For easy use, install the Tree-sitter query extension:" echo "zed://extension/tree-sitter-query" @@ -132,8 +163,6 @@ jobs: if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then PR_FORK_POINT="$(git merge-base origin/main HEAD)" git checkout "$PR_FORK_POINT" - elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then - git checkout "$BRANCH_PARENT_SHA" else git checkout "$(git log -1 --format=%H)"~1 fi @@ -156,6 +185,10 @@ jobs: VERSION_CHANGED: ${{ steps.compare-versions-check.outputs.version_changed }} PR_USER_LOGIN: ${{ github.event.pull_request.user.login }} timeout-minutes: 6 + defaults: + run: + shell: bash -euxo pipefail {0} + working-directory: ${{ inputs.working-directory }} tests_pass: needs: - orchestrate @@ -183,6 +216,10 @@ jobs: RESULT_ORCHESTRATE: ${{ needs.orchestrate.result }} RESULT_CHECK_RUST: ${{ needs.check_rust.result }} RESULT_CHECK_EXTENSION: ${{ needs.check_extension.result }} + defaults: + run: + shell: bash -euxo pipefail {0} + working-directory: ${{ inputs.working-directory }} concurrency: group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} cancel-in-progress: true diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 00d69639a53868386157e67aeab5ce7383d32426..b1d8c1fff3c9f48e62f42fab05473d5f38aad2ce 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -147,8 +147,8 @@ jobs: file: ts_query_ls-x86_64-unknown-linux-gnu.tar.gz - name: run_tests::run_ts_query_ls run: |- - tar -xf ts_query_ls-x86_64-unknown-linux-gnu.tar.gz - ./ts_query_ls format --check . || { + tar -xf "$GITHUB_WORKSPACE/ts_query_ls-x86_64-unknown-linux-gnu.tar.gz" -C "$GITHUB_WORKSPACE" + "$GITHUB_WORKSPACE/ts_query_ls" format --check . || { echo "Found unformatted queries, please format them with ts_query_ls." echo "For easy use, install the Tree-sitter query extension:" echo "zed://extension/tree-sitter-query" diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index 8c31de202ee7ac81b5f5e95fb26ec89452fd077c..e31800e3ecd4a1039e7a1a191fffa735f64f84f2 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -5,8 +5,8 @@ use crate::tasks::workflows::{ extension_tests::{self}, runners, steps::{ - self, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, NamedJob, - checkout_repo, dependant_job, named, + self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, + NamedJob, checkout_repo, dependant_job, named, }, vars::{ JobOutput, StepOutput, WorkflowInput, WorkflowSecret, one_workflow_per_non_main_branch, @@ -22,6 +22,7 @@ pub(crate) fn extension_bump() -> Workflow { // TODO: Ideally, this would have a default of `false`, but this is currently not // supported in gh-workflows let force_bump = WorkflowInput::bool("force-bump", None); + let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned())); let (app_id, app_secret) = extension_workflow_secrets(); let (check_version_changed, version_changed, current_version) = check_version_changed(); @@ -59,6 +60,7 @@ pub(crate) fn extension_bump() -> Workflow { WorkflowCall::default() .add_input(bump_type.name, bump_type.call_input()) .add_input(force_bump.name, force_bump.call_input()) + .add_input(working_directory.name, working_directory.call_input()) .secrets([ (app_id.name.to_owned(), app_id.secret_configuration()), ( @@ -82,10 +84,19 @@ pub(crate) fn extension_bump() -> Workflow { .add_job(trigger_release.name, trigger_release.job) } +fn extension_job_defaults() -> Defaults { + Defaults::default().run( + RunDefaults::default() + .shell(BASH_SHELL) + .working_directory("${{ inputs.working-directory }}"), + ) +} + fn check_version_changed() -> (NamedJob, StepOutput, StepOutput) { let (compare_versions, version_changed, current_version) = compare_versions(); let job = Job::default() + .defaults(extension_job_defaults()) .with_repository_owner_guard() .outputs([ (version_changed.name.to_owned(), version_changed.to_string()), @@ -112,6 +123,7 @@ fn create_version_label( let (generate_token, generated_token) = generate_token(&app_id.to_string(), &app_secret.to_string(), None); let job = steps::dependant_job(dependencies) + .defaults(extension_job_defaults()) .cond(Expression::new(format!( "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.event_name == 'push' && \ github.ref == 'refs/heads/main' && {version_changed} == 'true'", @@ -153,8 +165,6 @@ pub(crate) fn compare_versions() -> (Step, StepOutput, StepOutput) { if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then PR_FORK_POINT="$(git merge-base origin/main HEAD)" git checkout "$PR_FORK_POINT" - elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then - git checkout "$BRANCH_PARENT_SHA" else git checkout "$(git log -1 --format=%H)"~1 fi @@ -187,9 +197,11 @@ fn bump_extension_version( ) -> NamedJob { let (generate_token, generated_token) = generate_token(&app_id.to_string(), &app_secret.to_string(), None); - let (bump_version, new_version) = bump_version(current_version, bump_type); + let (bump_version, _new_version, title, body, branch_name) = + bump_version(current_version, bump_type); let job = steps::dependant_job(dependencies) + .defaults(extension_job_defaults()) .cond(Expression::new(format!( "{DEFAULT_REPOSITORY_OWNER_GUARD} &&\n({force_bump} == true || {version_changed} == 'false')", force_bump = force_bump_output.expr(), @@ -201,7 +213,12 @@ fn bump_extension_version( .add_step(steps::checkout_repo()) .add_step(install_bump_2_version()) .add_step(bump_version) - .add_step(create_pull_request(new_version, generated_token)); + .add_step(create_pull_request( + title, + body, + generated_token, + branch_name, + )); named::job(job) } @@ -256,7 +273,10 @@ fn install_bump_2_version() -> Step { ) } -fn bump_version(current_version: &JobOutput, bump_type: &WorkflowInput) -> (Step, StepOutput) { +fn bump_version( + current_version: &JobOutput, + bump_type: &WorkflowInput, +) -> (Step, StepOutput, StepOutput, StepOutput, StepOutput) { let step = named::bash(formatdoc! {r#" BUMP_FILES=("extension.toml") if [[ -f "Cargo.toml" ]]; then @@ -274,33 +294,50 @@ fn bump_version(current_version: &JobOutput, bump_type: &WorkflowInput) -> (Step fi NEW_VERSION="$({VERSION_CHECK})" + EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')" + EXTENSION_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')" + + if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then + {{ + echo "title=Bump version to ${{NEW_VERSION}}"; + echo "body=This PR bumps the version of this extension to v${{NEW_VERSION}}"; + echo "branch_name=zed-zippy-autobump"; + }} >> "$GITHUB_OUTPUT" + else + {{ + echo "title=${{EXTENSION_ID}}: Bump to v${{NEW_VERSION}}"; + echo "body=This PR bumps the version of the ${{EXTENSION_NAME}} extension to v${{NEW_VERSION}}"; + echo "branch_name=zed-zippy-${{EXTENSION_ID}}-autobump"; + }} >> "$GITHUB_OUTPUT" + fi echo "new_version=${{NEW_VERSION}}" >> "$GITHUB_OUTPUT" "# }) .id("bump-version") .add_env(("OLD_VERSION", current_version.to_string())) - .add_env(("BUMP_TYPE", bump_type.to_string())); + .add_env(("BUMP_TYPE", bump_type.to_string())) + .add_env(("WORKING_DIR", "${{ inputs.working-directory }}")); let new_version = StepOutput::new(&step, "new_version"); - (step, new_version) + let title = StepOutput::new(&step, "title"); + let body = StepOutput::new(&step, "body"); + let branch_name = StepOutput::new(&step, "branch_name"); + (step, new_version, title, body, branch_name) } -fn create_pull_request(new_version: StepOutput, generated_token: StepOutput) -> Step { - let formatted_version = format!("v{new_version}"); - +fn create_pull_request( + title: StepOutput, + body: StepOutput, + generated_token: StepOutput, + branch_name: StepOutput, +) -> Step { named::uses("peter-evans", "create-pull-request", "v7").with( Input::default() - .add("title", format!("Bump version to {new_version}")) - .add( - "body", - format!("This PR bumps the version of this extension to {formatted_version}",), - ) - .add( - "commit-message", - format!("Bump version to {formatted_version}"), - ) - .add("branch", "zed-zippy-autobump") + .add("title", title.to_string()) + .add("body", body.to_string()) + .add("commit-message", title.to_string()) + .add("branch", branch_name.to_string()) .add( "committer", "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", @@ -328,6 +365,7 @@ fn trigger_release( let (get_extension_id, extension_id) = get_extension_id(); let job = dependant_job(dependencies) + .defaults(extension_job_defaults()) .with_repository_owner_guard() .runs_on(runners::LINUX_SMALL) .add_step(generate_token) diff --git a/tooling/xtask/src/tasks/workflows/extension_tests.rs b/tooling/xtask/src/tasks/workflows/extension_tests.rs index 09f0cadf1c8731f8eed4ef1197a7edd05e0d1558..a50db3f98bf7bec887ea69f841f547ad717976f9 100644 --- a/tooling/xtask/src/tasks/workflows/extension_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extension_tests.rs @@ -3,15 +3,13 @@ use indoc::indoc; use crate::tasks::workflows::{ extension_bump::compare_versions, - run_tests::{ - fetch_ts_query_ls, orchestrate_without_package_filter, run_ts_query_ls, tests_pass, - }, + run_tests::{fetch_ts_query_ls, orchestrate_for_extension, run_ts_query_ls, tests_pass}, runners, steps::{ - self, CommonJobConditions, FluentBuilder, NamedJob, cache_rust_dependencies_namespace, - named, + self, BASH_SHELL, CommonJobConditions, FluentBuilder, NamedJob, + cache_rust_dependencies_namespace, named, }, - vars::{PathCondition, StepOutput, one_workflow_per_non_main_branch}, + vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch}, }; pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "03d8e9aee95ea6117d75a48bcac2e19241f6e667"; @@ -25,8 +23,10 @@ pub(crate) fn extension_tests() -> Workflow { let should_check_extension = PathCondition::new("check_extension", r"^(extension\.toml|.*\.scm)$"); - let orchestrate = - orchestrate_without_package_filter(&[&should_check_rust, &should_check_extension]); + let orchestrate = with_extension_defaults(orchestrate_for_extension(&[ + &should_check_rust, + &should_check_extension, + ])); let jobs = [ orchestrate, @@ -34,10 +34,17 @@ pub(crate) fn extension_tests() -> Workflow { should_check_extension.guard(check_extension()), ]; - let tests_pass = tests_pass(&jobs); + let tests_pass = with_extension_defaults(tests_pass(&jobs)); + + let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned())); named::workflow() - .add_event(Event::default().workflow_call(WorkflowCall::default())) + .add_event( + Event::default().workflow_call( + WorkflowCall::default() + .add_input(working_directory.name, working_directory.call_input()), + ), + ) .concurrency(one_workflow_per_non_main_branch()) .add_env(("CARGO_TERM_COLOR", "always")) .add_env(("RUST_BACKTRACE", 1)) @@ -58,27 +65,66 @@ fn install_rust_target() -> Step { named::bash(format!("rustup target add {EXTENSION_RUST_TARGET}",)) } -fn run_clippy() -> Step { - named::bash("cargo clippy --release --all-features -- --deny warnings") +fn get_package_name() -> (Step, StepOutput) { + let step = named::bash(indoc! {r#" + PACKAGE_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < Cargo.toml | head -1 | tr -d '[:space:]')" + echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT" + "#}) + .id("get-package-name"); + + let output = StepOutput::new(&step, "package_name"); + (step, output) +} + +fn cargo_fmt_package(package_name: &StepOutput) -> Step { + named::bash(r#"cargo fmt -p "$PACKAGE_NAME" -- --check"#) + .add_env(("PACKAGE_NAME", package_name.to_string())) +} + +fn run_clippy(package_name: &StepOutput) -> Step { + named::bash(r#"cargo clippy -p "$PACKAGE_NAME" --release --all-features -- --deny warnings"#) + .add_env(("PACKAGE_NAME", package_name.to_string())) +} + +fn run_nextest(package_name: &StepOutput) -> Step { + named::bash( + r#"cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n 's|host: ||p')""#, + ) + .add_env(("PACKAGE_NAME", package_name.to_string())) + .add_env(("NEXTEST_NO_TESTS", "warn")) +} + +fn extension_job_defaults() -> Defaults { + Defaults::default().run( + RunDefaults::default() + .shell(BASH_SHELL) + .working_directory("${{ inputs.working-directory }}"), + ) +} + +fn with_extension_defaults(named_job: NamedJob) -> NamedJob { + NamedJob { + name: named_job.name, + job: named_job.job.defaults(extension_job_defaults()), + } } fn check_rust() -> NamedJob { + let (get_package, package_name) = get_package_name(); + let job = Job::default() + .defaults(extension_job_defaults()) .with_repository_owner_guard() .runs_on(runners::LINUX_LARGE_RAM) .timeout_minutes(6u32) .add_step(steps::checkout_repo()) .add_step(steps::cache_rust_dependencies_namespace()) .add_step(install_rust_target()) - .add_step(steps::cargo_fmt()) - .add_step(run_clippy()) + .add_step(get_package) + .add_step(cargo_fmt_package(&package_name)) + .add_step(run_clippy(&package_name)) .add_step(steps::cargo_install_nextest()) - .add_step( - steps::cargo_nextest(runners::Platform::Linux) - // Set the target to the current platform again - .with_target("$(rustc -vV | sed -n 's|host: ||p')") - .add_env(("NEXTEST_NO_TESTS", "warn")), - ); + .add_step(run_nextest(&package_name)); named::job(job) } @@ -88,6 +134,7 @@ pub(crate) fn check_extension() -> NamedJob { let (check_version_job, version_changed, _) = compare_versions(); let job = Job::default() + .defaults(extension_job_defaults()) .with_repository_owner_guard() .runs_on(runners::LINUX_LARGE_RAM) .timeout_minutes(6u32) @@ -124,8 +171,8 @@ pub fn download_zed_extension_cli(cache_hit: StepOutput) -> Step { named::bash( indoc! { r#" - wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" - chmod +x zed-extension + 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" "#, } ).if_condition(Expression::new(format!("{} != 'true'", cache_hit.expr()))) @@ -136,7 +183,7 @@ pub fn check() -> Step { r#" mkdir -p /tmp/ext-scratch mkdir -p /tmp/ext-output - ./zed-extension --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output + "$GITHUB_WORKSPACE/zed-extension" --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output "# }) } diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs index 4e247fe16ca7b97638488c218684889c39cfcfa8..a62bb107da5228cd3ba620e47ab77dc673974696 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -127,8 +127,9 @@ fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOu .id("calc-changes") .add_env(("PREV_COMMIT", prev_commit.to_string())); - let removed_ci = StepOutput::new(&step, "removed_ci"); - let removed_shared = StepOutput::new(&step, "removed_shared"); + // These are created in the for-loop above and thus do exist + let removed_ci = StepOutput::new_unchecked(&step, "removed_ci"); + let removed_shared = StepOutput::new_unchecked(&step, "removed_shared"); (step, removed_ci, removed_shared) } diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index 38ba1bd32945f9ba8ee1e08ebc994a1132fb07f2..f134fa166d6dfe2ef00e47516e33d658a71badd9 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -97,14 +97,18 @@ pub(crate) fn run_tests() -> Workflow { // Generates a bash script that checks changed files against regex patterns // and sets GitHub output variables accordingly pub fn orchestrate(rules: &[&PathCondition]) -> NamedJob { - orchestrate_impl(rules, true) + orchestrate_impl(rules, true, false) } -pub fn orchestrate_without_package_filter(rules: &[&PathCondition]) -> NamedJob { - orchestrate_impl(rules, false) +pub fn orchestrate_for_extension(rules: &[&PathCondition]) -> NamedJob { + orchestrate_impl(rules, false, true) } -fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> NamedJob { +fn orchestrate_impl( + rules: &[&PathCondition], + include_package_filter: bool, + filter_by_working_directory: bool, +) -> NamedJob { let name = "orchestrate".to_owned(); let step_name = "filter".to_owned(); let mut script = String::new(); @@ -121,6 +125,22 @@ fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> N fi CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")" + "#}); + + if filter_by_working_directory { + script.push_str(indoc::indoc! {r#" + # When running from a subdirectory, git diff returns repo-root-relative paths. + # Filter to only files within the current working directory and strip the prefix. + REPO_SUBDIR="$(git rev-parse --show-prefix)" + REPO_SUBDIR="${REPO_SUBDIR%/}" + if [ -n "$REPO_SUBDIR" ]; then + CHANGED_FILES="$(echo "$CHANGED_FILES" | grep "^${REPO_SUBDIR}/" | sed "s|^${REPO_SUBDIR}/||" || true)" + fi + + "#}); + } + + script.push_str(indoc::indoc! {r#" check_pattern() { local output_name="$1" local pattern="$2" @@ -298,8 +318,8 @@ pub(crate) fn fetch_ts_query_ls() -> Step { pub(crate) fn run_ts_query_ls() -> Step { named::bash(formatdoc!( - r#"tar -xf {TS_QUERY_LS_FILE} - ./ts_query_ls format --check . || {{ + r#"tar -xf "$GITHUB_WORKSPACE/{TS_QUERY_LS_FILE}" -C "$GITHUB_WORKSPACE" + "$GITHUB_WORKSPACE/ts_query_ls" format --check . || {{ echo "Found unformatted queries, please format them with ts_query_ls." echo "For easy use, install the Tree-sitter query extension:" echo "zed://extension/tree-sitter-query" diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 6bede217b74a1172db712b92ed3d50cd2af603b2..fbe7ef66a331e2e7b84c1b4be7af3482f2b1ce95 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -10,7 +10,7 @@ pub(crate) fn use_clang(job: Job) -> Job { const SCCACHE_R2_BUCKET: &str = "sccache-zed"; -const BASH_SHELL: &str = "bash -euxo pipefail {0}"; +pub(crate) const BASH_SHELL: &str = "bash -euxo pipefail {0}"; // https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsshell pub const PWSH_SHELL: &str = "pwsh"; @@ -24,13 +24,6 @@ pub(crate) fn cargo_nextest(platform: Platform) -> Nextest { } impl Nextest { - pub(crate) fn with_target(mut self, target: &str) -> Step { - if let Some(nextest_command) = self.0.value.run.as_mut() { - nextest_command.push_str(&format!(r#" --target "{target}""#)); - } - self.into() - } - #[allow(dead_code)] pub(crate) fn with_filter_expr(mut self, filter_expr: &str) -> Self { if let Some(nextest_command) = self.0.value.run.as_mut() { diff --git a/tooling/xtask/src/tasks/workflows/vars.rs b/tooling/xtask/src/tasks/workflows/vars.rs index aa8fb0a4056a53807cd4b2f12f331cb9d4d0a235..b3f8bdf56e9bb0f93f81992fbc61dab2b9754e63 100644 --- a/tooling/xtask/src/tasks/workflows/vars.rs +++ b/tooling/xtask/src/tasks/workflows/vars.rs @@ -156,14 +156,31 @@ pub(crate) struct StepOutput { impl StepOutput { pub fn new(step: &Step, name: &'static str) -> Self { - Self { - name, - step_id: step - .value - .id - .clone() - .expect("Steps that produce outputs must have an ID"), - } + let step_id = step + .value + .id + .clone() + .expect("Steps that produce outputs must have an ID"); + + assert!( + step.value + .run + .as_ref() + .is_none_or(|run_command| run_command.contains(name)), + "Step Output name {name} must occur at least once in run command with ID {step_id}!" + ); + + Self { name, step_id } + } + + pub fn new_unchecked(step: &Step, name: &'static str) -> Self { + let step_id = step + .value + .id + .clone() + .expect("Steps that produce outputs must have an ID"); + + Self { name, step_id } } pub fn expr(&self) -> String { From b0cc006400ead61df61e9968d415870f3d385980 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Fri, 13 Mar 2026 08:01:42 -0500 Subject: [PATCH 207/219] ep: Error indication when Mercury free tier limit reached (#51447) Release Notes: - Added an error indicator in the edit prediction menu with an error message when the free tier limit is exceeded --- crates/edit_prediction/src/edit_prediction.rs | 4 + crates/edit_prediction/src/mercury.rs | 74 ++++++- .../src/edit_prediction_button.rs | 195 ++++++++++-------- 3 files changed, 182 insertions(+), 91 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 2347a731cb5b5f3590dafcf0a57dc0bab88c380c..0dd387e627a29fcd48b0523dd72990bbc05a5311 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -967,6 +967,10 @@ impl EditPredictionStore { self.mercury.api_token.read(cx).has_key() } + pub fn mercury_has_payment_required_error(&self) -> bool { + self.mercury.has_payment_required_error() + } + pub fn clear_history(&mut self) { for project_state in self.projects.values_mut() { project_state.events.clear(); diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index 0a952f0869b46f626c231e11f8a61370c50490fa..b80498c4ddccfffab02e77ceb20e6e9cf68851f4 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -1,19 +1,19 @@ use crate::{ DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, - EditPredictionStartedDebugEvent, open_ai_response::text_from_response, + EditPredictionStartedDebugEvent, EditPredictionStore, open_ai_response::text_from_response, prediction::EditPredictionResult, zeta::compute_edits, }; use anyhow::{Context as _, Result}; use cloud_llm_client::EditPredictionRejectReason; use futures::AsyncReadExt as _; use gpui::{ - App, AppContext as _, Entity, Global, SharedString, Task, - http_client::{self, AsyncBody, HttpClient, Method}, + App, AppContext as _, Context, Entity, Global, SharedString, Task, + http_client::{self, AsyncBody, HttpClient, Method, StatusCode}, }; use language::{ToOffset, ToPoint as _}; use language_model::{ApiKeyState, EnvVar, env_var}; use release_channel::AppVersion; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant}; use zeta_prompt::ZetaPromptInput; @@ -21,17 +21,27 @@ const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions" pub struct Mercury { pub api_token: Entity, + payment_required_error: bool, } impl Mercury { pub fn new(cx: &mut App) -> Self { Mercury { api_token: mercury_api_token(cx), + payment_required_error: false, } } + pub fn has_payment_required_error(&self) -> bool { + self.payment_required_error + } + + pub fn set_payment_required_error(&mut self, payment_required_error: bool) { + self.payment_required_error = payment_required_error; + } + pub(crate) fn request_prediction( - &self, + &mut self, EditPredictionModelInput { buffer, snapshot, @@ -41,7 +51,7 @@ impl Mercury { debug_tx, .. }: EditPredictionModelInput, - cx: &mut App, + cx: &mut Context, ) -> Task>> { self.api_token.update(cx, |key_state, cx| { _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx); @@ -163,6 +173,12 @@ impl Mercury { let response_received_at = Instant::now(); if !response.status().is_success() { + if response.status() == StatusCode::PAYMENT_REQUIRED { + anyhow::bail!(MercuryPaymentRequiredError( + mercury_payment_required_message(&body), + )); + } + anyhow::bail!( "Request failed with status: {:?}\nBody: {}", response.status(), @@ -209,9 +225,22 @@ impl Mercury { anyhow::Ok((id, edits, snapshot, response_received_at, inputs)) }); - cx.spawn(async move |cx| { - let (id, edits, old_snapshot, response_received_at, inputs) = - result.await.context("Mercury edit prediction failed")?; + cx.spawn(async move |ep_store, cx| { + let result = result.await.context("Mercury edit prediction failed"); + + let has_payment_required_error = result + .as_ref() + .err() + .is_some_and(is_mercury_payment_required_error); + + ep_store.update(cx, |store, cx| { + store + .mercury + .set_payment_required_error(has_payment_required_error); + cx.notify(); + })?; + + let (id, edits, old_snapshot, response_received_at, inputs) = result?; anyhow::Ok(Some( EditPredictionResult::new( EditPredictionId(id.into()), @@ -315,6 +344,33 @@ fn push_delimited(prompt: &mut String, delimiters: Range<&str>, cb: impl FnOnce( pub const MERCURY_CREDENTIALS_URL: SharedString = SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions"); pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token"; + +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +struct MercuryPaymentRequiredError(SharedString); + +#[derive(Deserialize)] +struct MercuryErrorResponse { + error: MercuryErrorMessage, +} + +#[derive(Deserialize)] +struct MercuryErrorMessage { + message: String, +} + +fn is_mercury_payment_required_error(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some() +} + +fn mercury_payment_required_message(body: &[u8]) -> SharedString { + serde_json::from_slice::(body) + .map(|response| response.error.message.into()) + .unwrap_or_else(|_| String::from_utf8_lossy(body).trim().to_string().into()) +} + pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock = env_var!("MERCURY_AI_TOKEN"); struct GlobalMercuryApiKey(Entity); diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index dac4c812f8ac1377423f7044c1c250b5a5333f64..1a5e60ca8b27f31d26c6389bbd39a516164f3bf6 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -359,10 +359,16 @@ impl Render for EditPredictionButton { } EditPredictionProvider::Mercury => { ep_icon = if enabled { icons.base } else { icons.disabled }; + let mercury_has_error = + edit_prediction::EditPredictionStore::try_global(cx).is_some_and( + |ep_store| ep_store.read(cx).mercury_has_payment_required_error(), + ); missing_token = edit_prediction::EditPredictionStore::try_global(cx) .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx)); tooltip_meta = if missing_token { "Missing API key for Mercury" + } else if mercury_has_error { + "Mercury free tier limit reached" } else { "Powered by Mercury" }; @@ -414,7 +420,12 @@ impl Render for EditPredictionButton { let show_editor_predictions = self.editor_show_predictions; let user = self.user_store.read(cx).current_user(); - let indicator_color = if missing_token { + let mercury_has_error = matches!(provider, EditPredictionProvider::Mercury) + && edit_prediction::EditPredictionStore::try_global(cx).is_some_and( + |ep_store| ep_store.read(cx).mercury_has_payment_required_error(), + ); + + let indicator_color = if missing_token || mercury_has_error { Some(Color::Error) } else if enabled && (!show_editor_predictions || over_limit) { Some(if over_limit { @@ -1096,96 +1107,116 @@ impl EditPredictionButton { }, ) .separator(); - } else if let Some(usage) = self - .edit_prediction_provider - .as_ref() - .and_then(|provider| provider.usage(cx)) - { - menu = menu.header("Usage"); - menu = menu - .custom_entry( - move |_window, cx| { - let used_percentage = match usage.limit { - UsageLimit::Limited(limit) => { - Some((usage.amount as f32 / limit as f32) * 100.) - } - UsageLimit::Unlimited => None, - }; + } else { + let mercury_payment_required = matches!(provider, EditPredictionProvider::Mercury) + && edit_prediction::EditPredictionStore::try_global(cx).is_some_and( + |ep_store| ep_store.read(cx).mercury_has_payment_required_error(), + ); + + if mercury_payment_required { + menu = menu + .header("Mercury") + .item(ContextMenuEntry::new("Free tier limit reached").disabled(true)) + .item( + ContextMenuEntry::new( + "Upgrade to a paid plan to continue using the service", + ) + .disabled(true), + ) + .separator(); + } + + if let Some(usage) = self + .edit_prediction_provider + .as_ref() + .and_then(|provider| provider.usage(cx)) + { + menu = menu.header("Usage"); + menu = menu + .custom_entry( + move |_window, cx| { + let used_percentage = match usage.limit { + UsageLimit::Limited(limit) => { + Some((usage.amount as f32 / limit as f32) * 100.) + } + UsageLimit::Unlimited => None, + }; - h_flex() - .flex_1() - .gap_1p5() - .children( - used_percentage.map(|percent| { + h_flex() + .flex_1() + .gap_1p5() + .children(used_percentage.map(|percent| { ProgressBar::new("usage", percent, 100., cx) - }), - ) - .child( - Label::new(match usage.limit { - UsageLimit::Limited(limit) => { - format!("{} / {limit}", usage.amount) - } - UsageLimit::Unlimited => format!("{} / ∞", usage.amount), - }) + })) + .child( + Label::new(match usage.limit { + UsageLimit::Limited(limit) => { + format!("{} / {limit}", usage.amount) + } + UsageLimit::Unlimited => { + format!("{} / ∞", usage.amount) + } + }) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element() + }, + move |_, cx| cx.open_url(&zed_urls::account_url(cx)), + ) + .when(usage.over_limit(), |menu| -> ContextMenu { + menu.entry("Subscribe to increase your limit", None, |_window, cx| { + telemetry::event!( + "Edit Prediction Menu Action", + action = "upsell_clicked", + reason = "usage_limit", + ); + cx.open_url(&zed_urls::account_url(cx)) + }) + }) + .separator(); + } else if self.user_store.read(cx).account_too_young() { + menu = menu + .custom_entry( + |_window, _cx| { + Label::new("Your GitHub account is less than 30 days old.") .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() - }, - move |_, cx| cx.open_url(&zed_urls::account_url(cx)), - ) - .when(usage.over_limit(), |menu| -> ContextMenu { - menu.entry("Subscribe to increase your limit", None, |_window, cx| { + .color(Color::Warning) + .into_any_element() + }, + |_window, cx| cx.open_url(&zed_urls::account_url(cx)), + ) + .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| { telemetry::event!( "Edit Prediction Menu Action", action = "upsell_clicked", - reason = "usage_limit", + reason = "account_age", ); cx.open_url(&zed_urls::account_url(cx)) }) - }) - .separator(); - } else if self.user_store.read(cx).account_too_young() { - menu = menu - .custom_entry( - |_window, _cx| { - Label::new("Your GitHub account is less than 30 days old.") - .size(LabelSize::Small) - .color(Color::Warning) - .into_any_element() - }, - |_window, cx| cx.open_url(&zed_urls::account_url(cx)), - ) - .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| { - telemetry::event!( - "Edit Prediction Menu Action", - action = "upsell_clicked", - reason = "account_age", - ); - cx.open_url(&zed_urls::account_url(cx)) - }) - .separator(); - } else if self.user_store.read(cx).has_overdue_invoices() { - menu = menu - .custom_entry( - |_window, _cx| { - Label::new("You have an outstanding invoice") - .size(LabelSize::Small) - .color(Color::Warning) - .into_any_element() - }, - |_window, cx| { - cx.open_url(&zed_urls::account_url(cx)) - }, - ) - .entry( - "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.", - None, - |_window, cx| { - cx.open_url(&zed_urls::account_url(cx)) - }, - ) - .separator(); + .separator(); + } else if self.user_store.read(cx).has_overdue_invoices() { + menu = menu + .custom_entry( + |_window, _cx| { + Label::new("You have an outstanding invoice") + .size(LabelSize::Small) + .color(Color::Warning) + .into_any_element() + }, + |_window, cx| { + cx.open_url(&zed_urls::account_url(cx)) + }, + ) + .entry( + "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.", + None, + |_window, cx| { + cx.open_url(&zed_urls::account_url(cx)) + }, + ) + .separator(); + } } if !needs_sign_in { From 3e7f2e3f9a576c4704c2f71497f6ba3516d9339b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:23:09 -0300 Subject: [PATCH 208/219] agent_ui: Add branch diff menu item to context menu (#51487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds the recently introduced "branch diff" mention option to the "Add Context" menu in the message editor: Screenshot 2026-03-13 at 9  58@2x Release Notes: - N/A --- .../agent_ui/src/connection_view/thread_view.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 030f6c5431eb79258be60f9d0139b8757611aa71..f50f5eee302bca163954d5ae0ff06345d0caa5b0 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -3557,6 +3557,7 @@ impl ThreadView { let message_editor = self.message_editor.clone(); let workspace = self.workspace.clone(); let supports_images = self.prompt_capabilities.borrow().image; + let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context; let has_editor_selection = workspace .upgrade() @@ -3672,6 +3673,21 @@ impl ThreadView { } }), ) + .item( + ContextMenuEntry::new("Branch Diff") + .icon(IconName::GitBranch) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .disabled(!supports_embedded_context) + .handler({ + move |window, cx| { + message_editor.focus_handle(cx).focus(window, cx); + message_editor.update(cx, |editor, cx| { + editor.insert_context_type("diff", window, cx); + }); + } + }), + ) }) } From 697e5be795ba5b2949f2087016e005f11073108a Mon Sep 17 00:00:00 2001 From: Dino Date: Fri, 13 Mar 2026 14:08:12 +0000 Subject: [PATCH 209/219] git: Fix commit message generation in untrusted projects and block external diff (#51323) When on a untrusted project, if one was to try and use the commit generation functionality, the command would fail because of the `-c diff.external` configuration provided in `GitBinary::build_command`, as git would interpret this as `""` and try to run that command. This `-c diff.external` is a good safeguard to have on untrusted repositories because it prevents random commands, configured in `.git/config` from being run. For example, if one uses `git config diff.external "touch bananas.txt"` and then run `git diff`, a new `bananas.txt` file would be created. However, it was still possible to bypass this safeguard using the following strategy: 1. Specify a custom diff for a specific file format. For example, for markdown files, with `printf '*.md diff=pwned\n' > .gitattributes` 2. Update the command run by the `pwned` diff, for example, `git config diff.pwned.command "touch bananas.txt"` 3. Open Zed and attempt to generate a commit message in an untrusted repository and check that a new `bananas.txt` file was created This is only prevented by using the `--no-ext-diff` flag on the `diff` command, so a new `GitBinary::build_diff_command` has been introduced which simply wraps `GitBinary::build_command` and adds the `--no-ext-diff` flag, if necessary. As a side-effect, this also makes it so that generating a commit message in an untrusted repository works again, which was accidentally broken on https://github.com/zed-industries/zed/pull/50649 . 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 commit message generation in untrusted repositories --- crates/git/src/blame.rs | 2 +- crates/git/src/commit.rs | 2 +- crates/git/src/repository.rs | 98 +++++++++++++++++++----------------- 3 files changed, 55 insertions(+), 47 deletions(-) diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index c44aea74051bb7c190a091703d6c60807fc4e27e..76e622fd6d7ae490c2c869c5ed02f02a48b45cab 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -58,7 +58,7 @@ async fn run_git_blame( let mut child = { let span = ztracing::debug_span!("spawning git-blame command", path = path.as_unix_str()); let _enter = span.enter(); - git.build_command(["blame", "--incremental", "--contents", "-"]) + git.build_command(&["blame", "--incremental", "--contents", "-"]) .arg(path.as_unix_str()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs index 46e050ce155fc049a670fdfa26101eb729b34352..a9c9ee633b1892fa4b7fd8d80f3ede44178aa0b2 100644 --- a/crates/git/src/commit.rs +++ b/crates/git/src/commit.rs @@ -81,7 +81,7 @@ pub(crate) async fn get_messages(git: &GitBinary, shas: &[Oid]) -> Result Result> { const MARKER: &str = ""; let output = git - .build_command(["show"]) + .build_command(&["show"]) .arg("-s") .arg(format!("--format=%B{}", MARKER)) .args(shas.iter().map(ToString::to_string)) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 45e719fb6d5a586074de523b5974ee11bf225453..37523672e382d7b2bb6e1da25f1c40fc2d01c0b1 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1039,7 +1039,7 @@ impl RealGitRepository { let git_binary = self.git_binary(); let output: SharedString = self .executor - .spawn(async move { git_binary?.run(["help", "-a"]).await }) + .spawn(async move { git_binary?.run(&["help", "-a"]).await }) .await .unwrap_or_default() .into(); @@ -1086,9 +1086,12 @@ pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter { ); cx.background_spawn(async move { - let name = git.run(["config", "--global", "user.name"]).await.log_err(); + let name = git + .run(&["config", "--global", "user.name"]) + .await + .log_err(); let email = git - .run(["config", "--global", "user.email"]) + .run(&["config", "--global", "user.email"]) .await .log_err(); GitCommitter { name, email } @@ -1119,7 +1122,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let output = git - .build_command([ + .build_command(&[ "--no-optional-locks", "show", "--no-patch", @@ -1157,7 +1160,7 @@ impl GitRepository for RealGitRepository { cx.background_spawn(async move { let git = git_binary?; let show_output = git - .build_command([ + .build_command(&[ "--no-optional-locks", "show", "--format=", @@ -1179,7 +1182,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(&["--no-optional-locks", "cat-file", "--batch=%(objectsize)"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1295,7 +1298,7 @@ impl GitRepository for RealGitRepository { let git = git_binary?; let output = git - .build_command(["reset", mode_flag, &commit]) + .build_command(&["reset", mode_flag, &commit]) .envs(env.iter()) .output() .await?; @@ -1323,7 +1326,7 @@ impl GitRepository for RealGitRepository { let git = git_binary?; let output = git - .build_command(["checkout", &commit, "--"]) + .build_command(&["checkout", &commit, "--"]) .envs(env.iter()) .args(paths.iter().map(|path| path.as_unix_str())) .output() @@ -1427,7 +1430,7 @@ impl GitRepository for RealGitRepository { if let Some(content) = content { let mut child = git - .build_command(["hash-object", "-w", "--stdin"]) + .build_command(&["hash-object", "-w", "--stdin"]) .envs(env.iter()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -1442,7 +1445,7 @@ impl GitRepository for RealGitRepository { log::debug!("indexing SHA: {sha}, path {path:?}"); let output = git - .build_command(["update-index", "--add", "--cacheinfo", mode, sha]) + .build_command(&["update-index", "--add", "--cacheinfo", mode, sha]) .envs(env.iter()) .arg(path.as_unix_str()) .output() @@ -1456,7 +1459,7 @@ impl GitRepository for RealGitRepository { } else { log::debug!("removing path {path:?} from the index"); let output = git - .build_command(["update-index", "--force-remove"]) + .build_command(&["update-index", "--force-remove"]) .envs(env.iter()) .arg(path.as_unix_str()) .output() @@ -1491,7 +1494,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let mut process = git - .build_command([ + .build_command(&[ "--no-optional-locks", "cat-file", "--batch-check=%(objectname)", @@ -1551,7 +1554,7 @@ impl GitRepository for RealGitRepository { let args = git_status_args(path_prefixes); log::debug!("Checking for git status in {path_prefixes:?}"); self.executor.spawn(async move { - let output = git.build_command(args).output().await?; + let output = git.build_command(&args).output().await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); stdout.parse() @@ -1589,7 +1592,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - let output = git.build_command(args).output().await?; + let output = git.build_command(&args).output().await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); stdout.parse() @@ -1645,7 +1648,7 @@ impl GitRepository for RealGitRepository { &fields, ]; let git = git_binary?; - let output = git.build_command(args).output().await?; + let output = git.build_command(&args).output().await?; anyhow::ensure!( output.status.success(), @@ -1659,7 +1662,7 @@ impl GitRepository for RealGitRepository { if branches.is_empty() { let args = vec!["symbolic-ref", "--quiet", "HEAD"]; - let output = git.build_command(args).output().await?; + let output = git.build_command(&args).output().await?; // git symbolic-ref returns a non-0 exit code if HEAD points // to something other than a branch @@ -1727,7 +1730,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { std::fs::create_dir_all(final_path.parent().unwrap_or(&final_path))?; let git = git_binary?; - let output = git.build_command(args).output().await?; + let output = git.build_command(&args).output().await?; if output.status.success() { Ok(()) } else { @@ -1753,7 +1756,7 @@ impl GitRepository for RealGitRepository { } args.push("--".into()); args.push(path.as_os_str().into()); - git_binary?.run(args).await?; + git_binary?.run(&args).await?; anyhow::Ok(()) }) .boxed() @@ -1772,7 +1775,7 @@ impl GitRepository for RealGitRepository { old_path.as_os_str().into(), new_path.as_os_str().into(), ]; - git_binary?.run(args).await?; + git_binary?.run(&args).await?; anyhow::Ok(()) }) .boxed() @@ -1975,11 +1978,11 @@ impl GitRepository for RealGitRepository { let git = git_binary?; let output = match diff { DiffType::HeadToIndex => { - git.build_command(["diff", "--staged"]).output().await? + git.build_command(&["diff", "--staged"]).output().await? } - DiffType::HeadToWorktree => git.build_command(["diff"]).output().await?, + DiffType::HeadToWorktree => git.build_command(&["diff"]).output().await?, DiffType::MergeBase { base_ref } => { - git.build_command(["diff", "--merge-base", base_ref.as_ref()]) + git.build_command(&["diff", "--merge-base", base_ref.as_ref()]) .output() .await? } @@ -2036,7 +2039,7 @@ impl GitRepository for RealGitRepository { if !paths.is_empty() { let git = git_binary?; let output = git - .build_command(["update-index", "--add", "--remove", "--"]) + .build_command(&["update-index", "--add", "--remove", "--"]) .envs(env.iter()) .args(paths.iter().map(|p| p.as_unix_str())) .output() @@ -2064,7 +2067,7 @@ impl GitRepository for RealGitRepository { if !paths.is_empty() { let git = git_binary?; let output = git - .build_command(["reset", "--quiet", "--"]) + .build_command(&["reset", "--quiet", "--"]) .envs(env.iter()) .args(paths.iter().map(|p| p.as_std_path())) .output() @@ -2091,7 +2094,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let output = git - .build_command(["stash", "push", "--quiet", "--include-untracked"]) + .build_command(&["stash", "push", "--quiet", "--include-untracked"]) .envs(env.iter()) .args(paths.iter().map(|p| p.as_unix_str())) .output() @@ -2196,7 +2199,7 @@ impl GitRepository for RealGitRepository { // which we want to block on. async move { let git = git_binary?; - let mut cmd = git.build_command(["commit", "--quiet", "-m"]); + let mut cmd = git.build_command(&["commit", "--quiet", "-m"]); cmd.envs(env.iter()) .arg(&message.to_string()) .arg("--cleanup=strip") @@ -2248,7 +2251,7 @@ impl GitRepository for RealGitRepository { executor.clone(), is_trusted, ); - let mut command = git.build_command(["push"]); + let mut command = git.build_command(&["push"]); command .envs(env.iter()) .args(options.map(|option| match option { @@ -2290,7 +2293,7 @@ impl GitRepository for RealGitRepository { executor.clone(), is_trusted, ); - let mut command = git.build_command(["pull"]); + let mut command = git.build_command(&["pull"]); command.envs(env.iter()); if rebase { @@ -2331,7 +2334,7 @@ impl GitRepository for RealGitRepository { executor.clone(), is_trusted, ); - let mut command = git.build_command(["fetch", &remote_name]); + let mut command = git.build_command(&["fetch", &remote_name]); command .envs(env.iter()) .stdout(Stdio::piped()) @@ -2348,7 +2351,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let output = git - .build_command(["rev-parse", "--abbrev-ref"]) + .build_command(&["rev-parse", "--abbrev-ref"]) .arg(format!("{branch}@{{push}}")) .output() .await?; @@ -2373,7 +2376,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let output = git - .build_command(["config", "--get"]) + .build_command(&["config", "--get"]) .arg(format!("branch.{branch}.remote")) .output() .await?; @@ -2394,7 +2397,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let git = git_binary?; - let output = git.build_command(["remote", "-v"]).output().await?; + let output = git.build_command(&["remote", "-v"]).output().await?; anyhow::ensure!( output.status.success(), @@ -2725,7 +2728,7 @@ impl GitRepository for RealGitRepository { async move { let git = git_binary?; - let mut command = git.build_command([ + let mut command = git.build_command(&[ "log", GRAPH_COMMIT_FORMAT, log_order.as_arg(), @@ -2808,7 +2811,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(&["--no-optional-locks", "cat-file", "--batch"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -3075,7 +3078,7 @@ impl GitBinary { .join(format!("index-{}.tmp", id)) } - pub async fn run(&self, args: impl IntoIterator) -> Result + pub async fn run(&self, args: &[S]) -> Result where S: AsRef, { @@ -3087,7 +3090,7 @@ impl GitBinary { } /// Returns the result of the command without trimming the trailing newline. - pub async fn run_raw(&self, args: impl IntoIterator) -> Result + pub async fn run_raw(&self, args: &[S]) -> Result where S: AsRef, { @@ -3105,10 +3108,7 @@ impl GitBinary { } #[allow(clippy::disallowed_methods)] - pub(crate) fn build_command( - &self, - args: impl IntoIterator, - ) -> util::command::Command + pub(crate) fn build_command(&self, args: &[S]) -> util::command::Command where S: AsRef, { @@ -3125,6 +3125,14 @@ impl GitBinary { command.args(["-c", "diff.external="]); } command.args(args); + + // If the `diff` command is being used, we'll want to add the + // `--no-ext-diff` flag when working on an untrusted repository, + // preventing any external diff programs from being invoked. + if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") { + command.arg("--no-ext-diff"); + } + if let Some(index_file_path) = self.index_file_path.as_ref() { command.env("GIT_INDEX_FILE", index_file_path); } @@ -3394,7 +3402,7 @@ mod tests { false, ); let output = git - .build_command(["version"]) + .build_command(&["version"]) .output() .await .expect("git version should succeed"); @@ -3407,7 +3415,7 @@ mod tests { false, ); let output = git - .build_command(["config", "--get", "core.fsmonitor"]) + .build_command(&["config", "--get", "core.fsmonitor"]) .output() .await .expect("git config should run"); @@ -3426,7 +3434,7 @@ mod tests { false, ); let output = git - .build_command(["config", "--get", "core.hooksPath"]) + .build_command(&["config", "--get", "core.hooksPath"]) .output() .await .expect("git config should run"); @@ -3451,7 +3459,7 @@ mod tests { true, ); let output = git - .build_command(["config", "--get", "core.fsmonitor"]) + .build_command(&["config", "--get", "core.fsmonitor"]) .output() .await .expect("git config should run"); @@ -3469,7 +3477,7 @@ mod tests { true, ); let output = git - .build_command(["config", "--get", "core.hooksPath"]) + .build_command(&["config", "--get", "core.hooksPath"]) .output() .await .expect("git config should run"); From bde0834c6c2479933a91ca94555a332341bbd8e8 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 13 Mar 2026 10:53:14 -0400 Subject: [PATCH 210/219] git: Log some more information when opening a git repository and when `git show` fails (#51495) Release Notes: - N/A --- crates/fs/src/fs.rs | 11 ++++------- crates/git/src/commit.rs | 2 +- crates/git/src/repository.rs | 17 ++++++++++++----- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 6c7074d2139068d2ea581ea6343de4d4c1f09030..311992d20d9947d189ff5026a73620090a8579c4 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -147,7 +147,7 @@ pub trait Fs: Send + Sync { &self, abs_dot_git: &Path, system_git_binary_path: Option<&Path>, - ) -> Option>; + ) -> Result>; async fn git_init(&self, abs_work_directory: &Path, fallback_branch_name: String) -> Result<()>; async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()>; @@ -1149,8 +1149,8 @@ impl Fs for RealFs { &self, dotgit_path: &Path, system_git_binary_path: Option<&Path>, - ) -> Option> { - Some(Arc::new(RealGitRepository::new( + ) -> Result> { + Ok(Arc::new(RealGitRepository::new( dotgit_path, self.bundled_git_binary_path.clone(), system_git_binary_path.map(|path| path.to_path_buf()), @@ -2866,9 +2866,7 @@ impl Fs for FakeFs { &self, abs_dot_git: &Path, _system_git_binary: Option<&Path>, - ) -> Option> { - use util::ResultExt as _; - + ) -> Result> { self.with_git_state_and_paths( abs_dot_git, false, @@ -2884,7 +2882,6 @@ impl Fs for FakeFs { }) as _ }, ) - .log_err() } async fn git_init( diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs index a9c9ee633b1892fa4b7fd8d80f3ede44178aa0b2..50b62fa506bc31c0f4e2b3bedefc46cef415143b 100644 --- a/crates/git/src/commit.rs +++ b/crates/git/src/commit.rs @@ -91,7 +91,7 @@ async fn get_messages_impl(git: &GitBinary, shas: &[Oid]) -> Result> anyhow::ensure!( output.status.success(), "'git show' failed with error {:?}", - output.status + String::from_utf8_lossy(&output.stderr) ); Ok(String::from_utf8_lossy(&output.stdout) .trim() diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 37523672e382d7b2bb6e1da25f1c40fc2d01c0b1..094e634c7ff9265ef60ad0a3b892ef1eebdbad4e 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1000,11 +1000,18 @@ impl RealGitRepository { bundled_git_binary_path: Option, system_git_binary_path: Option, executor: BackgroundExecutor, - ) -> Option { - let any_git_binary_path = system_git_binary_path.clone().or(bundled_git_binary_path)?; - let workdir_root = dotgit_path.parent()?; - let repository = git2::Repository::open(workdir_root).log_err()?; - Some(Self { + ) -> Result { + let any_git_binary_path = system_git_binary_path + .clone() + .or(bundled_git_binary_path) + .context("no git binary available")?; + log::info!( + "opening git repository at {dotgit_path:?} using git binary {any_git_binary_path:?}" + ); + let workdir_root = dotgit_path.parent().context(".git has no parent")?; + let repository = + git2::Repository::open(workdir_root).context("creating libgit2 repository")?; + Ok(Self { repository: Arc::new(Mutex::new(repository)), system_git_binary_path, any_git_binary_path, From 2c0d6c067d874a450c02ce614a3dfce5f3ea12d1 Mon Sep 17 00:00:00 2001 From: K4YT3X Date: Fri, 13 Mar 2026 15:25:54 +0000 Subject: [PATCH 211/219] project_panel: Add horizontal scroll setting (#51143) This PR introduces the `project_panel.scrollbar.horizontal_scroll` setting to allow users to toggle the horizontal scroll bar in the project panel. This was Zed's design before PR #18513, and the default behavior of VSCode (`workbench.list.horizontalScrolling`). https://github.com/user-attachments/assets/f633f4e4-a585-4494-8f48-df77c6aca418 ## Rationale Zed's design used to be the same as the default behavior of VSCode. I.e., no horizontal scrolling, and the view is always snapped to the left, with long file names clipped of. If you want to see the content that is out-of-frame, you'll need to drag the handle and expand the project panel. This could be problematic, especially for large repos with multiple levels of nested directories, as pointed out by issues #5550 and #7001. image\ *VSCode's default setup, for reference.* Then came PR #18513, which added horizontal scroll and addressed this pain point, but users didn't have a choice. They're stuck with horizontal scrolling always turned on. I, for instance, personally prefer the old, VSCode-default behavior, for most projects I open are small and don't need horizontal scrolling in the project panel. With horizontal scrolling always turned on, I find it annoying to have my project panel view accidentally scrolled to the middle, and I'll have to grab my mouse and scroll it back. It's also visually redundant. Thus, why not add an option like VSCode's `workbench.list.horizontalScrolling` and let users choose? I'd love to be able to, say, set a per-project override for the projects that need horizontal scrolling, while having it disabled by default. ## Extra Notes - I was originally thinking about using `ScrollbarAxes` from `src/editor_settings.rs` and make the option `project_panel.scrollbar.axes.horizontal` similar to the global editor scrollbar settings, but this option is specific to the project panel and it doesn't quite make sense to allow disabling vertical scrolling on the project panel, so I added a standalone option for it instead, similar to VSCode's `workbench.list.horizontalScrolling`. - I went the conservative route and set horizontal scrolling to enabled (current behavior) by default. Imo it might make more sense to disable it by default instead, similar to VSCode, but I'll leave this for the Zed team to decide. - I named it `horizontal_scroll` instead of `horizontal_scrolling` to be consistent with the adjacent setting `sticky_scroll`. - As for tests, I don't see tests for the scrollbar, so I didn't add any. I'd be glad to update the PR if anything is not inline with the project's requirements or conventions. --- Release Notes: - Added `project_panel.scrollbar.horizontal_scroll` setting to allow toggling horizontal scrolling in the project panel Signed-off-by: k4yt3x --- assets/settings/default.json | 3 ++ crates/project_panel/src/project_panel.rs | 31 ++++++++++------ .../src/project_panel_settings.rs | 13 +++++-- crates/settings/src/vscode_import.rs | 7 +++- crates/settings_content/src/workspace.rs | 23 ++++++++++-- crates/settings_ui/src/page_data.rs | 28 ++++++++++++++- docs/src/reference/all-settings.md | 35 +++++-------------- 7 files changed, 95 insertions(+), 45 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index d812673d9dac997df570625be3ea07cf1cb831dc..946e88c7237a747c5b24de7b6818e9ed0de614aa 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -768,6 +768,9 @@ // 5. Never show the scrollbar: // "never" "show": null, + // Whether to allow horizontal scrolling in the project panel. + // When false, the view is locked to the leftmost position and long file names are clipped. + "horizontal_scroll": true, }, // Which files containing diagnostic errors/warnings to mark in the project panel. // This setting can take the following three values: diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 2984bb49c6a961c77adc1b82c806f7ec57d54a3e..96e680c0d1648bd4cf337cbc55e321e3948c217a 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -6341,6 +6341,7 @@ impl Render for ProjectPanel { let panel_settings = ProjectPanelSettings::get_global(cx); let indent_size = panel_settings.indent_size; let show_indent_guides = panel_settings.indent_guides.show == ShowIndentGuides::Always; + let horizontal_scroll = panel_settings.scrollbar.horizontal_scroll; let show_sticky_entries = { if panel_settings.sticky_scroll { let is_scrollable = self.scroll_handle.is_scrollable(); @@ -6713,10 +6714,14 @@ impl Render for ProjectPanel { }) }) .with_sizing_behavior(ListSizingBehavior::Infer) - .with_horizontal_sizing_behavior( - ListHorizontalSizingBehavior::Unconstrained, - ) - .with_width_from_item(self.state.max_width_item_index) + .with_horizontal_sizing_behavior(if horizontal_scroll { + ListHorizontalSizingBehavior::Unconstrained + } else { + ListHorizontalSizingBehavior::FitList + }) + .when(horizontal_scroll, |list| { + list.with_width_from_item(self.state.max_width_item_index) + }) .track_scroll(&self.scroll_handle), ) .child( @@ -6877,13 +6882,17 @@ impl Render for ProjectPanel { .size_full(), ) .custom_scrollbars( - Scrollbars::for_settings::() - .tracked_scroll_handle(&self.scroll_handle) - .with_track_along( - ScrollAxes::Horizontal, - cx.theme().colors().panel_background, - ) - .notify_content(), + { + let mut scrollbars = Scrollbars::for_settings::() + .tracked_scroll_handle(&self.scroll_handle); + if horizontal_scroll { + scrollbars = scrollbars.with_track_along( + ScrollAxes::Horizontal, + cx.theme().colors().panel_background, + ); + } + scrollbars.notify_content() + }, window, cx, ) diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 0d703c55c06dfff2976fe59f6e030ad9eb1d758b..de2ff8e0087b8e7dbe4fcc533e3eea0470553b50 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -49,6 +49,11 @@ pub struct ScrollbarSettings { /// /// Default: inherits editor scrollbar settings pub show: Option, + /// Whether to allow horizontal scrolling in the project panel. + /// When false, the view is locked to the leftmost position and long file names are clipped. + /// + /// Default: true + pub horizontal_scroll: bool, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -111,8 +116,12 @@ impl Settings for ProjectPanelSettings { auto_fold_dirs: project_panel.auto_fold_dirs.unwrap(), bold_folder_labels: project_panel.bold_folder_labels.unwrap(), starts_open: project_panel.starts_open.unwrap(), - scrollbar: ScrollbarSettings { - show: project_panel.scrollbar.unwrap().show.map(Into::into), + scrollbar: { + let scrollbar = project_panel.scrollbar.unwrap(); + ScrollbarSettings { + show: scrollbar.show.map(Into::into), + horizontal_scroll: scrollbar.horizontal_scroll.unwrap(), + } }, show_diagnostics: project_panel.show_diagnostics.unwrap(), hide_root: project_panel.hide_root.unwrap(), diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 8a5a497d265c02787d6944915c0dba56e2381a79..bcc579984bda0268a7405cbd1ea184cafc493aab 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -793,7 +793,12 @@ impl VsCodeSettings { hide_root: None, indent_guides: None, indent_size: None, - scrollbar: None, + scrollbar: self.read_bool("workbench.list.horizontalScrolling").map( + |horizontal_scrolling| ProjectPanelScrollbarSettingsContent { + show: None, + horizontal_scroll: Some(horizontal_scrolling), + }, + ), show_diagnostics: self .read_bool("problems.decorations.enabled") .and_then(|b| if b { Some(ShowDiagnostics::Off) } else { None }), diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 7262a83b384665b0bcd868bf14dbfaa2928a35c1..92dc6679e60fc5d54b24afafa4daa00600c066f2 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize}; use settings_macros::{MergeFrom, with_fallible_options}; use crate::{ - CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity, - ScrollbarSettingsContent, ShowIndentGuides, serialize_optional_f32_with_two_decimal_places, + CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity, ShowIndentGuides, + ShowScrollbar, serialize_optional_f32_with_two_decimal_places, }; #[with_fallible_options] @@ -710,7 +710,7 @@ pub struct ProjectPanelSettingsContent { /// Default: true pub starts_open: Option, /// Scrollbar-related settings - pub scrollbar: Option, + pub scrollbar: Option, /// Which files containing diagnostic errors/warnings to mark in the project panel. /// /// Default: all @@ -793,6 +793,23 @@ pub enum ProjectPanelSortMode { FilesFirst, } +#[with_fallible_options] +#[derive( + Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default, +)] +pub struct ProjectPanelScrollbarSettingsContent { + /// When to show the scrollbar in the project panel. + /// + /// Default: inherits editor scrollbar settings + pub show: Option, + /// Whether to allow horizontal scrolling in the project panel. + /// When false, the view is locked to the leftmost position and + /// long file names are clipped. + /// + /// Default: true + pub horizontal_scroll: Option, +} + #[with_fallible_options] #[derive( Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 708840668d7502ae0c34e9f1751fd7b76da2ca07..9243e14521010c5b3aa2a9092c6e0a687a989306 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -4238,7 +4238,7 @@ fn window_and_layout_page() -> SettingsPage { } fn panels_page() -> SettingsPage { - fn project_panel_section() -> [SettingsPageItem; 22] { + fn project_panel_section() -> [SettingsPageItem; 23] { [ SettingsPageItem::SectionHeader("Project Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -4516,6 +4516,32 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Horizontal Scroll", + description: "Whether to allow horizontal scrolling in the project panel. When disabled, the view is always locked to the leftmost position and long file names are clipped.", + field: Box::new(SettingField { + json_path: Some("project_panel.scrollbar.horizontal_scroll"), + pick: |settings_content| { + settings_content + .project_panel + .as_ref()? + .scrollbar + .as_ref()? + .horizontal_scroll + .as_ref() + }, + write: |settings_content, value| { + settings_content + .project_panel + .get_or_insert_default() + .scrollbar + .get_or_insert_default() + .horizontal_scroll = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Show Diagnostics", description: "Which files containing diagnostic errors/warnings to mark in the project panel.", diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 32fec4a84d56cf996dc85cf112e4daec7893311b..7248a5636a29339ec2ca93481cfa4056b2527d30 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -4695,7 +4695,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a "bold_folder_labels": false, "drag_and_drop": true, "scrollbar": { - "show": null + "show": null, + "horizontal_scroll": true }, "sticky_scroll": true, "show_diagnostics": "all", @@ -4941,9 +4942,9 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a } ``` -### Scrollbar: Show +### Scrollbar -- Description: Whether to show a scrollbar in the project panel. Possible values: null, "auto", "system", "always", "never". Inherits editor settings when absent, see its description for more details. +- Description: Scrollbar-related settings for the project panel. - Setting: `scrollbar` - Default: @@ -4951,7 +4952,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a { "project_panel": { "scrollbar": { - "show": null + "show": null, + "horizontal_scroll": true } } } @@ -4959,29 +4961,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a **Options** -1. Show scrollbar in the project panel - -```json [settings] -{ - "project_panel": { - "scrollbar": { - "show": "always" - } - } -} -``` - -2. Hide scrollbar in the project panel - -```json [settings] -{ - "project_panel": { - "scrollbar": { - "show": "never" - } - } -} -``` +- `show`: Whether to show a scrollbar in the project panel. Possible values: null, "auto", "system", "always", "never". Inherits editor settings when absent, see its description for more details. +- `horizontal_scroll`: Whether to allow horizontal scrolling in the project panel. When `false`, the view is locked to the leftmost position and long file names are clipped. ### Sort Mode From e6f571c1db76966d9d7d896ab632b5d2dd40a9d7 Mon Sep 17 00:00:00 2001 From: kitt <11167504+kitt-cat@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:46:56 -0700 Subject: [PATCH 212/219] gpui: Fix busyloop on X disconnect (#41986) When the connection to X is broken zed will go into an infinite loop and eat up 100% (of one core) of CPU; this change causes it to exit with an error instead. I encountered this behavior while running zed in [Xephyr](https://freedesktop.org/wiki/Software/Xephyr/) for testing, though I do sometimes terminate my X server as a way to log out or attempt to recover from a (very) broken state, and I appreciate a graceful exit in those situations! Exiting in case of X server disconnect is common practice in my observations, likely as the difficulty of recreating state stored server-side outweighs the potential utility in attempting to recover (if "reconnecting" to an X server is ever desired in regular usage, [Xpra](https://xpra.org/index.html) might be able to help!). Release Notes: - N/A --- crates/gpui_linux/src/linux/x11/client.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 1f8db390029d67d8cdc17da7800a0f8e1d5e1af9..77f154201d3af6bb7504349e579a5be6b4edcbb5 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -602,6 +602,9 @@ impl X11Client { Ok(None) => { break; } + Err(err @ ConnectionError::IoError(..)) => { + return Err(EventHandlerError::from(err)); + } Err(err) => { let err = handle_connection_error(err); log::warn!("error while polling for X11 events: {err:?}"); From e29206b56935cc2cf24423bf926b81521fd8467b Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 13 Mar 2026 18:27:01 +0200 Subject: [PATCH 213/219] Do not overly eagerly invalidate the runnables (#51500) Follow-up of https://github.com/zed-industries/zed/pull/51299 Release Notes: - N/A --- crates/editor/src/editor.rs | 19 ++-- crates/editor/src/runnables.rs | 194 +++++++++++++++++++++++++++++++-- 2 files changed, 195 insertions(+), 18 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 7536e58d2f0dbfd58f738bdb8bed3b3c2a65a25e..8c2e03722c345a0f093572c336029a0eaa355537 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2142,7 +2142,7 @@ impl Editor { editor.registered_buffers.clear(); editor.register_visible_buffers(cx); editor.invalidate_semantic_tokens(None); - editor.refresh_runnables(window, cx); + editor.refresh_runnables(None, window, cx); editor.update_lsp_data(None, window, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx); } @@ -2172,7 +2172,7 @@ impl Editor { let buffer_id = *buffer_id; if editor.buffer().read(cx).buffer(buffer_id).is_some() { editor.register_buffer(buffer_id, cx); - editor.refresh_runnables(window, cx); + editor.refresh_runnables(Some(buffer_id), window, cx); editor.update_lsp_data(Some(buffer_id), window, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); refresh_linked_ranges(editor, window, cx); @@ -2251,7 +2251,7 @@ impl Editor { &task_inventory, window, |editor, _, window, cx| { - editor.refresh_runnables(window, cx); + editor.refresh_runnables(None, window, cx); }, )); }; @@ -23789,7 +23789,7 @@ impl Editor { .invalidate_buffer(&buffer.read(cx).remote_id()); self.update_lsp_data(Some(buffer_id), window, cx); self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); - self.refresh_runnables(window, cx); + self.refresh_runnables(None, window, cx); self.colorize_brackets(false, cx); self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx); cx.emit(EditorEvent::ExcerptsAdded { @@ -23850,12 +23850,11 @@ impl Editor { } self.colorize_brackets(false, cx); self.update_lsp_data(None, window, cx); - self.refresh_runnables(window, cx); + self.refresh_runnables(None, window, cx); cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() }) } multi_buffer::Event::Reparsed(buffer_id) => { - self.clear_runnables(Some(*buffer_id)); - self.refresh_runnables(window, cx); + self.refresh_runnables(Some(*buffer_id), window, cx); self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx); self.colorize_brackets(true, cx); jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); @@ -23863,7 +23862,7 @@ impl Editor { cx.emit(EditorEvent::Reparsed(*buffer_id)); } multi_buffer::Event::DiffHunksToggled => { - self.refresh_runnables(window, cx); + self.refresh_runnables(None, window, cx); } multi_buffer::Event::LanguageChanged(buffer_id, is_fresh_language) => { if !is_fresh_language { @@ -23999,7 +23998,7 @@ impl Editor { .unwrap_or(DiagnosticSeverity::Hint); self.set_max_diagnostics_severity(new_severity, cx); } - self.refresh_runnables(window, cx); + self.refresh_runnables(None, window, cx); self.update_edit_prediction_settings(cx); self.refresh_edit_prediction(true, false, window, cx); self.refresh_inline_values(cx); @@ -25379,7 +25378,7 @@ impl Editor { self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); if !self.buffer().read(cx).is_singleton() { self.update_lsp_data(None, window, cx); - self.refresh_runnables(window, cx); + self.refresh_runnables(None, window, cx); } } } diff --git a/crates/editor/src/runnables.rs b/crates/editor/src/runnables.rs index 9fa6b89ec130e74f388c5e82b9b346197bb13abb..e36658cf0b160dc2e340f11abe76efa5e895b4ee 100644 --- a/crates/editor/src/runnables.rs +++ b/crates/editor/src/runnables.rs @@ -1,7 +1,7 @@ use std::{collections::BTreeMap, mem, ops::Range, sync::Arc}; use clock::Global; -use collections::HashMap; +use collections::{HashMap, HashSet}; use gpui::{ App, AppContext as _, AsyncWindowContext, ClickEvent, Context, Entity, Focusable as _, MouseButton, Task, Window, @@ -30,6 +30,7 @@ use crate::{ #[derive(Debug)] pub(super) struct RunnableData { runnables: HashMap)>, + invalidate_buffer_data: HashSet, runnables_update_task: Task<()>, } @@ -37,6 +38,7 @@ impl RunnableData { pub fn new() -> Self { Self { runnables: HashMap::default(), + invalidate_buffer_data: HashSet::default(), runnables_update_task: Task::ready(()), } } @@ -108,7 +110,12 @@ pub struct ResolvedTasks { } impl Editor { - pub fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context) { + pub fn refresh_runnables( + &mut self, + invalidate_buffer_data: Option, + window: &mut Window, + cx: &mut Context, + ) { if !self.mode().is_full() || !EditorSettings::get_global(cx).gutter.runnables || !self.enable_runnables @@ -117,13 +124,18 @@ impl Editor { return; } if let Some(buffer) = self.buffer().read(cx).as_singleton() { - if self - .runnables - .has_cached(buffer.read(cx).remote_id(), &buffer.read(cx).version()) + let buffer_id = buffer.read(cx).remote_id(); + if invalidate_buffer_data != Some(buffer_id) + && self + .runnables + .has_cached(buffer_id, &buffer.read(cx).version()) { return; } } + if let Some(buffer_id) = invalidate_buffer_data { + self.runnables.invalidate_buffer_data.insert(buffer_id); + } let project = self.project().map(Entity::downgrade); let lsp_task_sources = self.lsp_task_sources(true, true, cx); @@ -249,6 +261,10 @@ impl Editor { .await; editor .update(cx, |editor, cx| { + for buffer_id in std::mem::take(&mut editor.runnables.invalidate_buffer_data) { + editor.clear_runnables(Some(buffer_id)); + } + for ((buffer_id, row), mut new_tasks) in rows { let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else { continue; @@ -332,6 +348,7 @@ impl Editor { } else { self.runnables.runnables.clear(); } + self.runnables.invalidate_buffer_data.clear(); self.runnables.runnables_update_task = Task::ready(()); } @@ -697,12 +714,17 @@ impl Editor { mod tests { use std::{sync::Arc, time::Duration}; + use futures::StreamExt as _; use gpui::{AppContext as _, Task, TestAppContext}; use indoc::indoc; - use language::ContextProvider; + use language::{ContextProvider, FakeLspAdapter}; use languages::rust_lang; + use lsp::LanguageServerName; use multi_buffer::{MultiBuffer, PathKey}; - use project::{FakeFs, Project}; + use project::{ + FakeFs, Project, + lsp_store::lsp_ext_command::{CargoRunnableArgs, Runnable, RunnableArgs, RunnableKind}, + }; use serde_json::json; use task::{TaskTemplate, TaskTemplates}; use text::Point; @@ -710,8 +732,11 @@ mod tests { use crate::{ Editor, UPDATE_DEBOUNCE, editor_tests::init_test, scroll::scroll_amount::ScrollAmount, + test::build_editor_with_project, }; + const FAKE_LSP_NAME: &str = "the-fake-language-server"; + struct TestRustContextProvider; impl ContextProvider for TestRustContextProvider { @@ -739,6 +764,28 @@ mod tests { } } + struct TestRustContextProviderWithLsp; + + impl ContextProvider for TestRustContextProviderWithLsp { + fn associated_tasks( + &self, + _: Option>, + _: &gpui::App, + ) -> Task> { + Task::ready(Some(TaskTemplates(vec![TaskTemplate { + label: "Run test".into(), + command: "cargo".into(), + args: vec!["test".into()], + tags: vec!["rust-test".into()], + ..TaskTemplate::default() + }]))) + } + + fn lsp_task_source(&self) -> Option { + Some(LanguageServerName::new_static(FAKE_LSP_NAME)) + } + } + fn rust_lang_with_task_context() -> Arc { Arc::new( Arc::try_unwrap(rust_lang()) @@ -747,6 +794,14 @@ mod tests { ) } + fn rust_lang_with_lsp_task_context() -> Arc { + Arc::new( + Arc::try_unwrap(rust_lang()) + .unwrap() + .with_context_provider(Some(Arc::new(TestRustContextProviderWithLsp))), + ) + } + fn collect_runnable_labels( editor: &Editor, ) -> Vec<(text::BufferId, language::BufferRow, Vec)> { @@ -853,7 +908,7 @@ mod tests { editor .update(cx, |editor, window, cx| { editor.clear_runnables(None); - editor.refresh_runnables(window, cx); + editor.refresh_runnables(None, window, cx); }) .unwrap(); cx.executor().advance_clock(UPDATE_DEBOUNCE); @@ -912,4 +967,127 @@ mod tests { "first.rs runnables should survive an edit to second.rs" ); } + + #[gpui::test] + async fn test_lsp_runnables_removed_after_edit(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "main.rs": indoc! {" + #[test] + fn test_one() { + assert!(true); + } + + fn helper() {} + "}, + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang_with_lsp_task_context()); + + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: FAKE_LSP_NAME, + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/main.rs"), cx) + }) + .await + .unwrap(); + + let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); + + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + let editor = cx.add_window(|window, cx| { + build_editor_with_project(project.clone(), multi_buffer, window, cx) + }); + + let fake_server = fake_servers.next().await.expect("fake LSP server"); + + use project::lsp_store::lsp_ext_command::Runnables; + fake_server.set_request_handler::(move |params, _| async move { + let text = params.text_document.uri.path().to_string(); + if text.contains("main.rs") { + let uri = lsp::Uri::from_file_path(path!("/project/main.rs")).expect("valid uri"); + Ok(vec![Runnable { + label: "LSP test_one".into(), + location: Some(lsp::LocationLink { + origin_selection_range: None, + target_uri: uri, + target_range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(3, 1), + ), + target_selection_range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(3, 1), + ), + }), + kind: RunnableKind::Cargo, + args: RunnableArgs::Cargo(CargoRunnableArgs { + environment: Default::default(), + cwd: path!("/project").into(), + override_cargo: None, + workspace_root: None, + cargo_args: vec!["test".into(), "test_one".into()], + executable_args: Vec::new(), + }), + }]) + } else { + Ok(Vec::new()) + } + }); + + // Trigger a refresh to pick up both tree-sitter and LSP runnables. + editor + .update(cx, |editor, window, cx| { + editor.refresh_runnables(None, window, cx); + }) + .expect("editor update"); + cx.executor().advance_clock(UPDATE_DEBOUNCE); + cx.executor().run_until_parked(); + + let labels = editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .expect("editor update"); + assert_eq!( + labels, + vec![(buffer_id, 0, vec!["LSP test_one".to_string()]),], + "LSP runnables should appear for #[test] fn" + ); + + // Remove `#[test]` attribute so the function is no longer a test. + buffer.update(cx, |buffer, cx| { + let test_attr_end = buffer.text().find("\nfn test_one").expect("find fn"); + buffer.edit([(0..test_attr_end, "")], None, cx); + }); + + // Also update the LSP handler to return no runnables. + fake_server + .set_request_handler::(move |_, _| async move { Ok(Vec::new()) }); + + cx.executor().advance_clock(UPDATE_DEBOUNCE); + cx.executor().run_until_parked(); + + let labels = editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .expect("editor update"); + assert_eq!( + labels, + Vec::<(text::BufferId, language::BufferRow, Vec)>::new(), + "Runnables should be removed after #[test] is deleted and LSP returns empty" + ); + } } From f04b4e089f88feb8a9690117cefebd840f2e05ba Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 13 Mar 2026 17:52:50 +0100 Subject: [PATCH 214/219] file_finder: Put collab channel inclusion behind a setting (#51505) Release Notes: - N/A *or* Added/Fixed/Improved ... --- assets/settings/default.json | 2 ++ crates/file_finder/src/file_finder.rs | 6 +++++- crates/open_path_prompt/src/file_finder_settings.rs | 2 ++ crates/settings_content/src/settings_content.rs | 4 ++++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 946e88c7237a747c5b24de7b6818e9ed0de614aa..7af6ce7e44d9abde7b29c80bb170cd13f3c2e786 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1285,6 +1285,8 @@ // * "indexed": Use only the files Zed had indexed // * "smart": Be smart and search for ignored when called from a gitignored worktree "include_ignored": "smart", + // Whether to include text channels in file finder results. + "include_channels": false, }, // Whether or not to remove any trailing whitespace from lines of a buffer // before saving it. diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index cd0c4dbdb922c6d8251225c696b60e27eb5951cf..7e0c584c739caa9c71f87be9673a04bd9b9b840f 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -844,7 +844,11 @@ impl FileFinderDelegate { cx: &mut Context, ) -> Self { Self::subscribe_to_updates(&project, window, cx); - let channel_store = ChannelStore::try_global(cx); + let channel_store = if FileFinderSettings::get_global(cx).include_channels { + ChannelStore::try_global(cx) + } else { + None + }; Self { file_finder, workspace, diff --git a/crates/open_path_prompt/src/file_finder_settings.rs b/crates/open_path_prompt/src/file_finder_settings.rs index 36f05e89bd7a1c73d849e3d72f05a092d0c8ec34..56ea60c20864fc620b43d2e445a1dd7b92edfa65 100644 --- a/crates/open_path_prompt/src/file_finder_settings.rs +++ b/crates/open_path_prompt/src/file_finder_settings.rs @@ -8,6 +8,7 @@ pub struct FileFinderSettings { pub modal_max_width: FileFinderWidth, pub skip_focus_for_active_in_search: bool, pub include_ignored: Option, + pub include_channels: bool, } impl Settings for FileFinderSettings { @@ -23,6 +24,7 @@ impl Settings for FileFinderSettings { settings::IncludeIgnoredContent::Indexed => Some(false), settings::IncludeIgnoredContent::Smart => None, }, + include_channels: file_finder.include_channels.unwrap(), } } } diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 5b573a0f01dc7980abadeba5576b6e8e3553bfb4..023f4954388c0a5e163fe61aba8d970364d84f43 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -721,6 +721,10 @@ pub struct FileFinderSettingsContent { /// /// Default: Smart pub include_ignored: Option, + /// Whether to include text channels in file finder results. + /// + /// Default: false + pub include_channels: Option, } #[derive( From a623dc3d1a586e8180771e3e8143bacee7addfde Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:13:04 -0300 Subject: [PATCH 215/219] agent_ui: Insert branch diff crease when clicking on menu item (#51509) Follow up to https://github.com/zed-industries/zed/pull/51487 The PR above added the item to the menu, and this one makes the menu item actually insert a mention crease with the branch diff. That was missing in the previous one. Release Notes: - N/A --- .../src/connection_view/thread_view.rs | 3 +- crates/agent_ui/src/mention_set.rs | 2 +- crates/agent_ui/src/message_editor.rs | 82 +++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index f50f5eee302bca163954d5ae0ff06345d0caa5b0..79af34d6da515c5f01764ffda9c72277c783729c 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -3681,9 +3681,8 @@ impl ThreadView { .disabled(!supports_embedded_context) .handler({ move |window, cx| { - message_editor.focus_handle(cx).focus(window, cx); message_editor.update(cx, |editor, cx| { - editor.insert_context_type("diff", window, cx); + editor.insert_branch_diff_crease(window, cx); }); } }), diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 1cb22af6a3fd15df5eeedc5018deaeff77a1dbff..782d2b353c8f3599ba38486a4cf558f448b31bcf 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -604,7 +604,7 @@ impl MentionSet { }) } - fn confirm_mention_for_git_diff( + pub fn confirm_mention_for_git_diff( &self, base_ref: SharedString, cx: &mut Context, diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index c9067d4ec261261e66c7718b36ebcb96b2099fed..89b4caee69f5d26306077388edffa77f50ea7596 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -1041,6 +1041,88 @@ impl MessageEditor { }); } + pub fn insert_branch_diff_crease(&mut self, window: &mut Window, cx: &mut Context) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + + let project = workspace.read(cx).project().clone(); + + let Some(repo) = project.read(cx).active_repository(cx) else { + return; + }; + + let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false)); + let editor = self.editor.clone(); + let mention_set = self.mention_set.clone(); + let weak_workspace = self.workspace.clone(); + + window + .spawn(cx, async move |cx| { + let base_ref: SharedString = default_branch_receiver + .await + .ok() + .and_then(|r| r.ok()) + .flatten() + .ok_or_else(|| anyhow!("Could not determine default branch"))?; + + cx.update(|window, cx| { + let mention_uri = MentionUri::GitDiff { + base_ref: base_ref.to_string(), + }; + let mention_text = mention_uri.as_link().to_string(); + + let (excerpt_id, text_anchor, content_len) = editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx); + let snapshot = buffer.snapshot(cx); + let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap(); + let text_anchor = editor + .selections + .newest_anchor() + .start + .text_anchor + .bias_left(&buffer_snapshot); + + editor.insert(&mention_text, window, cx); + editor.insert(" ", window, cx); + + (excerpt_id, text_anchor, mention_text.len()) + }); + + let Some((crease_id, tx)) = insert_crease_for_mention( + excerpt_id, + text_anchor, + content_len, + mention_uri.name().into(), + mention_uri.icon_path(cx), + mention_uri.tooltip_text(), + Some(mention_uri.clone()), + Some(weak_workspace), + None, + editor, + window, + cx, + ) else { + return; + }; + drop(tx); + + let confirm_task = mention_set.update(cx, |mention_set, cx| { + mention_set.confirm_mention_for_git_diff(base_ref, cx) + }); + + let mention_task = cx + .spawn(async move |_cx| confirm_task.await.map_err(|e| e.to_string())) + .shared(); + + mention_set.update(cx, |mention_set, _| { + mention_set.insert_mention(crease_id, mention_uri, mention_task); + }); + }) + }) + .detach_and_log_err(cx); + } + fn insert_crease_impl( &mut self, text: String, From 4e8937b62d8b14e22d82a0ea4a06f4fc280f1491 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:13:12 -0300 Subject: [PATCH 216/219] ui: Refactor the `Button` component icon methods (#51496) Previously, if you wanted to have a button that contains icons on both edges, you'd need to use a `ButtonLike` component, which takes any children. Meanwhile, the `Button` would only take one icon, where you could control its position through the `IconPosition` enum. This has always felt unnecessarily limiting. So, this PR removes this limitation by adding two new methods to the button: `start_icon` and `end_icon`. In the meantime, I have also been bothered by the unnecessary indirection in the `IconButton` due to the existence of the `ButtonIcon` component. So I figured I could also completely eliminate that by adding some of its methods directly to the `IconButton` and in the Button, just using a regular `Icon` component. --- ## Before ```rust Button::new("id", "Label") .icon(IconName::Plus) .icon_position(IconPosition::Start) .icon_size(IconSize::Small) .icon_color(Color::Muted) ``` ## After ```rust Button::new("id", "Label") .start_icon(Icon::new(IconName::Check)) .end_icon(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)) ``` This should have no visual impact to the UI. Release Notes: - N/A --- crates/agent_ui/src/agent_configuration.rs | 45 ++-- .../add_llm_provider_modal.rs | 18 +- .../configure_context_server_modal.rs | 8 +- crates/agent_ui/src/agent_diff.rs | 9 +- crates/agent_ui/src/agent_model_selector.rs | 17 +- crates/agent_ui/src/agent_panel.rs | 51 ++-- crates/agent_ui/src/agent_registry_ui.rs | 17 +- crates/agent_ui/src/config_options.rs | 5 +- .../src/connection_view/thread_view.rs | 89 +++---- crates/agent_ui/src/inline_prompt_editor.rs | 8 +- crates/agent_ui/src/message_editor.rs | 8 +- crates/agent_ui/src/mode_selector.rs | 5 +- crates/agent_ui/src/model_selector_popover.rs | 17 +- crates/agent_ui/src/profile_selector.rs | 8 +- crates/agent_ui/src/sidebar.rs | 18 +- crates/agent_ui/src/text_thread_editor.rs | 29 +-- .../agent_ui/src/ui/acp_onboarding_modal.rs | 9 +- .../src/ui/claude_agent_onboarding_modal.rs | 9 +- crates/collab_ui/src/collab_panel.rs | 6 +- crates/collab_ui/src/notification_panel.rs | 4 +- crates/copilot_ui/src/sign_in.rs | 27 ++- crates/debugger_ui/src/debugger_panel.rs | 36 +-- .../src/rate_prediction_modal.rs | 8 +- crates/extensions_ui/src/extensions_ui.rs | 31 +-- crates/git_graph/src/git_graph.rs | 21 +- crates/git_ui/src/blame_ui.rs | 18 +- crates/git_ui/src/commit_modal.rs | 9 +- crates/git_ui/src/commit_tooltip.rs | 13 +- crates/git_ui/src/commit_view.rs | 9 +- crates/git_ui/src/conflict_view.rs | 9 +- crates/git_ui/src/file_history_view.rs | 9 +- crates/git_ui/src/git_ui.rs | 3 +- crates/git_ui/src/project_diff.rs | 16 +- crates/keymap_editor/src/keymap_editor.rs | 8 +- .../language_models/src/provider/bedrock.rs | 3 +- crates/language_models/src/provider/cloud.rs | 6 +- .../language_models/src/provider/lmstudio.rs | 34 +-- crates/language_models/src/provider/ollama.rs | 38 +-- .../language_models/src/provider/open_ai.rs | 8 +- .../src/provider/open_ai_compatible.rs | 4 +- crates/language_onboarding/src/python.rs | 4 +- crates/language_tools/src/lsp_log_view.rs | 35 +-- crates/onboarding/src/basics_page.rs | 8 +- crates/onboarding/src/multibuffer_hint.rs | 9 +- crates/panel/src/panel.rs | 1 - .../src/disconnected_overlay.rs | 9 +- crates/recent_projects/src/remote_servers.rs | 6 +- crates/repl/src/components/kernel_options.rs | 9 +- crates/repl/src/notebook/notebook_ui.rs | 9 +- crates/rules_library/src/rules_library.rs | 9 +- crates/search/src/project_search.rs | 20 +- .../src/pages/tool_permissions_setup.rs | 17 +- crates/settings_ui/src/settings_ui.rs | 31 +-- .../theme_selector/src/icon_theme_selector.rs | 9 +- crates/theme_selector/src/theme_selector.rs | 9 +- crates/title_bar/src/title_bar.rs | 25 +- .../src/components/ai/configured_api_card.rs | 9 +- crates/ui/src/components/banner.rs | 10 +- crates/ui/src/components/button.rs | 1 - crates/ui/src/components/button/button.rs | 226 +++++++----------- .../ui/src/components/button/button_icon.rs | 199 --------------- .../ui/src/components/button/icon_button.rs | 49 ++-- crates/ui/src/components/dropdown_menu.rs | 9 +- crates/workspace/src/notifications.rs | 28 ++- 64 files changed, 584 insertions(+), 847 deletions(-) delete mode 100644 crates/ui/src/components/button/button_icon.rs diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index ef3f3fdacc3d155554f3e2576ed1ed27c1d9ff0d..6b7f46d87f2db1e9262eadf9e7064c06245b1e3c 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -332,10 +332,11 @@ impl AgentConfiguration { .full_width() .style(ButtonStyle::Outlined) .layer(ElevationIndex::ModalSurface) - .icon_position(IconPosition::Start) - .icon(IconName::Thread) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::Thread) + .size(IconSize::Small) + .color(Color::Muted), + ) .label_size(LabelSize::Small) .on_click(cx.listener({ let provider = provider.clone(); @@ -357,10 +358,11 @@ impl AgentConfiguration { ) .full_width() .style(ButtonStyle::Outlined) - .icon_position(IconPosition::Start) - .icon(IconName::Trash) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::Trash) + .size(IconSize::Small) + .color(Color::Muted), + ) .label_size(LabelSize::Small) .on_click(cx.listener({ let provider = provider.clone(); @@ -426,10 +428,11 @@ impl AgentConfiguration { .trigger( Button::new("add-provider", "Add Provider") .style(ButtonStyle::Outlined) - .icon_position(IconPosition::Start) - .icon(IconName::Plus) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::Plus) + .size(IconSize::Small) + .color(Color::Muted), + ) .label_size(LabelSize::Small), ) .menu({ @@ -525,10 +528,11 @@ impl AgentConfiguration { .trigger( Button::new("add-server", "Add Server") .style(ButtonStyle::Outlined) - .icon_position(IconPosition::Start) - .icon(IconName::Plus) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::Plus) + .size(IconSize::Small) + .color(Color::Muted), + ) .label_size(LabelSize::Small), ) .menu({ @@ -970,10 +974,11 @@ impl AgentConfiguration { .trigger( Button::new("add-agent", "Add Agent") .style(ButtonStyle::Outlined) - .icon_position(IconPosition::Start) - .icon(IconName::Plus) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::Plus) + .size(IconSize::Small) + .color(Color::Muted), + ) .label_size(LabelSize::Small), ) .menu({ 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 a3a389ac0a068d92112ee98caacb2986c499ad86..3d18d734af4890ef06a67dccec0c0e884a219a79 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 @@ -340,10 +340,11 @@ impl AddLlmProviderModal { .child(Label::new("Models").size(LabelSize::Small)) .child( Button::new("add-model", "Add Model") - .icon(IconName::Plus) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::Plus) + .size(IconSize::XSmall) + .color(Color::Muted), + ) .label_size(LabelSize::Small) .on_click(cx.listener(|this, _, window, cx| { this.input.add_model(window, cx); @@ -446,10 +447,11 @@ impl AddLlmProviderModal { .when(has_more_than_one_model, |this| { this.child( Button::new(("remove-model", ix), "Remove Model") - .icon(IconName::Trash) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::Trash) + .size(IconSize::XSmall) + .color(Color::Muted), + ) .label_size(LabelSize::Small) .style(ButtonStyle::Outlined) .full_width() 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 38805f2c26693f168c7273afddf5aceea44f83e3..857a084b720e732b218f0060f1fbee312f712540 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 @@ -693,9 +693,11 @@ impl ConfigureContextServerModal { { Some( Button::new("open-repository", "Open Repository") - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .tooltip({ let repository_url = repository_url.clone(); move |_window, cx| { diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 13e62eb502de1d4bf454b47b216374a0abf2bc79..bb1367b7da31d7975ab271ec821fb43a5da70605 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -686,10 +686,11 @@ impl Render for AgentDiffPane { .child( Button::new("continue-iterating", "Continue Iterating") .style(ButtonStyle::Filled) - .icon(IconName::ForwardArrow) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::ForwardArrow) + .size(IconSize::Small) + .color(Color::Muted), + ) .full_width() .key_binding(KeyBinding::for_action_in( &ToggleFocus, diff --git a/crates/agent_ui/src/agent_model_selector.rs b/crates/agent_ui/src/agent_model_selector.rs index 465eb808404dd5521ef056b62c813a9566bb7a47..93984121c261034a5cc6198621e79d87d2de1ff4 100644 --- a/crates/agent_ui/src/agent_model_selector.rs +++ b/crates/agent_ui/src/agent_model_selector.rs @@ -9,7 +9,7 @@ use language_model::IconOrSvg; use picker::popover_menu::PickerPopoverMenu; use settings::update_settings_file; use std::sync::Arc; -use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*}; +use ui::{PopoverMenuHandle, Tooltip, prelude::*}; pub struct AgentModelSelector { selector: Entity, @@ -112,9 +112,11 @@ impl Render for AgentModelSelector { PickerPopoverMenu::new( self.selector.clone(), - ButtonLike::new("active-model") + Button::new("active-model", model_name) + .label_size(LabelSize::Small) + .color(color) .when_some(provider_icon, |this, icon| { - this.child( + this.start_icon( match icon { IconOrSvg::Svg(path) => Icon::from_external_svg(path), IconOrSvg::Icon(name) => Icon::new(name), @@ -123,14 +125,7 @@ impl Render for AgentModelSelector { .size(IconSize::XSmall), ) }) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .child( - Label::new(model_name) - .color(color) - .size(LabelSize::Small) - .ml_0p5(), - ) - .child( + .end_icon( Icon::new(IconName::ChevronDown) .color(color) .size(IconSize::XSmall), diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index e69b6a9f164a07d17c01057ea8a57c287ab6f938..d5c2942cf3528b94ad7d93271ef75e976bcbea56 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -80,9 +80,8 @@ use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; use theme::ThemeSettings; use ui::{ - Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, Indicator, - KeyBinding, PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*, - utils::WithRemSize, + Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, Indicator, KeyBinding, + PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize, }; use util::{ResultExt as _, debug_panic}; use workspace::{ @@ -3632,11 +3631,7 @@ impl AgentPanel { }; let trigger_button = Button::new("thread-target-trigger", trigger_label) - .icon(icon) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)) .disabled(is_creating); let dock_position = AgentSettings::get_global(cx).dock; @@ -4290,32 +4285,22 @@ impl AgentPanel { (IconName::ChevronDown, Color::Muted, Color::Default) }; - let agent_icon_element: AnyElement = - if let Some(icon_path) = selected_agent_custom_icon_for_button { - Icon::from_external_svg(icon_path) - .size(IconSize::Small) - .color(icon_color) - .into_any_element() - } else { - let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent); - Icon::new(icon_name) - .size(IconSize::Small) - .color(icon_color) - .into_any_element() - }; + let agent_icon = if let Some(icon_path) = selected_agent_custom_icon_for_button { + Icon::from_external_svg(icon_path) + .size(IconSize::Small) + .color(icon_color) + } else { + let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent); + Icon::new(icon_name).size(IconSize::Small).color(icon_color) + }; - let agent_selector_button = ButtonLike::new("agent-selector-trigger") - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .child( - h_flex() - .gap_1() - .child(agent_icon_element) - .child(Label::new(selected_agent_label).color(label_color).ml_0p5()) - .child( - Icon::new(chevron_icon) - .color(icon_color) - .size(IconSize::XSmall), - ), + let agent_selector_button = Button::new("agent-selector-trigger", selected_agent_label) + .start_icon(agent_icon) + .color(label_color) + .end_icon( + Icon::new(chevron_icon) + .color(icon_color) + .size(IconSize::XSmall), ); let agent_selector_menu = PopoverMenu::new("new_thread_menu") diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs index d003ba958276c8c2370011d83028eda2e9121440..cb99077697a59b4f0c1a50277172ef1eaf0b77aa 100644 --- a/crates/agent_ui/src/agent_registry_ui.rs +++ b/crates/agent_ui/src/agent_registry_ui.rs @@ -467,10 +467,11 @@ impl AgentRegistryPage { let agent_id = agent.id().to_string(); Button::new(button_id, "Install") .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .icon(IconName::Download) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Download) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _, cx| { let agent_id = agent_id.clone(); update_settings_file(fs.clone(), cx, move |settings, _| { @@ -541,9 +542,11 @@ impl Render for AgentRegistryPage { Button::new("learn-more", "Learn More") .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _, cx| { cx.open_url(&zed_urls::acp_registry_blog(cx)) }), diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index 6ec2595202490ca7474717f8985b6e4f6d7ca0b9..b8cf7e5d57921c7710392911829fc2b5045a0f90 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -350,10 +350,7 @@ impl ConfigOptionSelector { ) .label_size(LabelSize::Small) .color(Color::Muted) - .icon(icon) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) + .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)) .disabled(self.setting_value) } } diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 79af34d6da515c5f01764ffda9c72277c783729c..35df60b567de86762a9af330013df0fab35f3f01 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -3826,11 +3826,8 @@ impl ThreadView { .child(Divider::horizontal()) .child( Button::new("restore-checkpoint", "Restore Checkpoint") - .icon(IconName::Undo) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::Undo).size(IconSize::XSmall).color(Color::Muted)) .label_size(LabelSize::XSmall) - .icon_color(Color::Muted) .color(Color::Muted) .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation.")) .on_click(cx.listener(move |this, _, _window, cx| { @@ -5783,10 +5780,11 @@ impl ThreadView { .gap_0p5() .child( Button::new(("allow-btn", entry_ix), "Allow") - .icon(IconName::Check) - .icon_color(Color::Success) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) + .start_icon( + Icon::new(IconName::Check) + .size(IconSize::XSmall) + .color(Color::Success), + ) .label_size(LabelSize::Small) .when(is_first, |this| { this.key_binding( @@ -5817,10 +5815,11 @@ impl ThreadView { ) .child( Button::new(("deny-btn", entry_ix), "Deny") - .icon(IconName::Close) - .icon_color(Color::Error) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) + .start_icon( + Icon::new(IconName::Close) + .size(IconSize::XSmall) + .color(Color::Error), + ) .label_size(LabelSize::Small) .when(is_first, |this| { this.key_binding( @@ -5887,9 +5886,11 @@ impl ThreadView { .with_handle(permission_dropdown_handle) .trigger( Button::new(("granularity-trigger", entry_ix), current_label) - .icon(IconName::ChevronDown) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ) .label_size(LabelSize::Small) .when(is_first, |this| { this.key_binding( @@ -5962,24 +5963,35 @@ impl ThreadView { let option_id = SharedString::from(option.option_id.0.clone()); Button::new((option_id, entry_ix), option.name.clone()) .map(|this| { - let (this, action) = match option.kind { + let (icon, action) = match option.kind { acp::PermissionOptionKind::AllowOnce => ( - this.icon(IconName::Check).icon_color(Color::Success), + Icon::new(IconName::Check) + .size(IconSize::XSmall) + .color(Color::Success), Some(&AllowOnce as &dyn Action), ), acp::PermissionOptionKind::AllowAlways => ( - this.icon(IconName::CheckDouble).icon_color(Color::Success), + Icon::new(IconName::CheckDouble) + .size(IconSize::XSmall) + .color(Color::Success), Some(&AllowAlways as &dyn Action), ), acp::PermissionOptionKind::RejectOnce => ( - this.icon(IconName::Close).icon_color(Color::Error), + Icon::new(IconName::Close) + .size(IconSize::XSmall) + .color(Color::Error), Some(&RejectOnce as &dyn Action), ), - acp::PermissionOptionKind::RejectAlways | _ => { - (this.icon(IconName::Close).icon_color(Color::Error), None) - } + acp::PermissionOptionKind::RejectAlways | _ => ( + Icon::new(IconName::Close) + .size(IconSize::XSmall) + .color(Color::Error), + None, + ), }; + let this = this.start_icon(icon); + let Some(action) = action else { return this; }; @@ -5995,8 +6007,6 @@ impl ThreadView { .map(|kb| kb.size(rems_from_px(10.))), ) }) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) .label_size(LabelSize::Small) .on_click(cx.listener({ let session_id = session_id.clone(); @@ -6373,9 +6383,11 @@ impl ThreadView { .color(Color::Muted) .truncate(true) .when(is_file.is_none(), |this| { - this.icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + this.end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::XSmall) + .color(Color::Muted), + ) }) .on_click(cx.listener({ let workspace = self.workspace.clone(); @@ -7470,19 +7482,16 @@ impl ThreadView { .title("Codex on Windows") .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)") .actions_slot( - Button::new("open-wsl-modal", "Open in WSL") - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .on_click(cx.listener({ - move |_, _, _window, cx| { - #[cfg(windows)] - _window.dispatch_action( - zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), - cx, - ); - cx.notify(); - } - })), + Button::new("open-wsl-modal", "Open in WSL").on_click(cx.listener({ + move |_, _, _window, cx| { + #[cfg(windows)] + _window.dispatch_action( + zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), + cx, + ); + cx.notify(); + } + })), ) .dismiss_action( IconButton::new("dismiss", IconName::Close) diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 0450efc4b7ebf466d0b9b13f516249a2cba0ecfa..fa68c86fe9fa39319b7d6adb1c7ae50544ae4f00 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -796,9 +796,11 @@ impl PromptEditor { vec![ Button::new("start", mode.start_label()) .label_size(LabelSize::Small) - .icon(IconName::Return) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::Return) + .size(IconSize::XSmall) + .color(Color::Muted), + ) .on_click( cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::StartRequested)), ) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 89b4caee69f5d26306077388edffa77f50ea7596..4170417df0c5fdfcdb86f2e4c0478c0ef59cefa9 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -33,7 +33,7 @@ use rope::Point; use settings::Settings; use std::{cell::RefCell, fmt::Write, ops::Range, rc::Rc, sync::Arc}; use theme::ThemeSettings; -use ui::{ButtonLike, ButtonStyle, ContextMenu, Disclosure, ElevationIndex, prelude::*}; +use ui::{ContextMenu, Disclosure, ElevationIndex, prelude::*}; use util::paths::PathStyle; use util::{ResultExt, debug_panic}; use workspace::{CollaboratorId, Workspace}; @@ -1161,11 +1161,9 @@ impl MessageEditor { render: Arc::new({ let title = title.clone(); move |_fold_id, _fold_range, _cx| { - ButtonLike::new("crease") - .style(ButtonStyle::Filled) + Button::new("crease", title.clone()) .layer(ElevationIndex::ElevatedSurface) - .child(Icon::new(icon)) - .child(Label::new(title.clone()).single_line()) + .start_icon(Icon::new(icon)) .into_any_element() } }), diff --git a/crates/agent_ui/src/mode_selector.rs b/crates/agent_ui/src/mode_selector.rs index 9ec25d6d2a1e11a12ef8f05061f143fec5fe53bb..60c9b8787092388ad2b3e2d5817834018dc7ea25 100644 --- a/crates/agent_ui/src/mode_selector.rs +++ b/crates/agent_ui/src/mode_selector.rs @@ -169,10 +169,7 @@ impl Render for ModeSelector { let trigger_button = Button::new("mode-selector-trigger", current_mode_name) .label_size(LabelSize::Small) .color(Color::Muted) - .icon(icon) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) + .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)) .disabled(self.setting_mode); PopoverMenu::new("mode-selector") diff --git a/crates/agent_ui/src/model_selector_popover.rs b/crates/agent_ui/src/model_selector_popover.rs index 7a4e9dbf8633680fe9c6ee3bda4acdb0ff5b1478..74ebd78ba61681325cc4905be8d577b225e50e92 100644 --- a/crates/agent_ui/src/model_selector_popover.rs +++ b/crates/agent_ui/src/model_selector_popover.rs @@ -5,7 +5,7 @@ use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector}; use fs::Fs; use gpui::{AnyView, Entity, FocusHandle}; use picker::popover_menu::PickerPopoverMenu; -use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*}; +use ui::{PopoverMenuHandle, Tooltip, prelude::*}; use crate::ui::ModelSelectorTooltip; use crate::{ModelSelector, model_selector::acp_model_selector}; @@ -96,11 +96,12 @@ impl Render for ModelSelectorPopover { PickerPopoverMenu::new( self.selector.clone(), - ButtonLike::new("active-model") + Button::new("active-model", model_name) + .label_size(LabelSize::Small) + .color(color) .disabled(self.disabled) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .when_some(model_icon, |this, icon| { - this.child( + this.start_icon( match icon { AgentModelIcon::Path(path) => Icon::from_external_svg(path), AgentModelIcon::Named(icon_name) => Icon::new(icon_name), @@ -109,13 +110,7 @@ impl Render for ModelSelectorPopover { .size(IconSize::XSmall), ) }) - .child( - Label::new(model_name) - .color(color) - .size(LabelSize::Small) - .ml_0p5(), - ) - .child( + .end_icon( Icon::new(icon) .map(|this| { if self.disabled { diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index f785c936a643f4280121d083831eba4c909bc0f5..661f887b53116094b5a8694bf93b21389bd9f58b 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -16,7 +16,7 @@ use std::{ }; use ui::{ DocumentationAside, DocumentationSide, HighlightedLabel, KeyBinding, LabelSize, ListItem, - ListItemSpacing, PopoverMenuHandle, TintColor, Tooltip, prelude::*, + ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*, }; /// Trait for types that can provide and manage agent profiles @@ -192,11 +192,7 @@ impl Render for ProfileSelector { .disabled(self.disabled) .label_size(LabelSize::Small) .color(Color::Muted) - .icon(icon) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)); + .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)); let disabled = self.disabled; diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 0bc0968ea44c25ec9cfd3d68d8600814f922fc12..aed642ccc9987569fb3681ab93bb2c8fe6de2674 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1775,10 +1775,11 @@ impl Sidebar { ) .full_width() .style(ButtonStyle::Outlined) - .icon(IconName::Plus) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Plus) + .size(IconSize::Small) + .color(Color::Muted), + ) .toggle_state(is_selected) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; @@ -1833,10 +1834,11 @@ impl Sidebar { .full_width() .label_size(LabelSize::Small) .style(ButtonStyle::Outlined) - .icon(IconName::Archive) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Archive) + .size(IconSize::XSmall) + .color(Color::Muted), + ) .on_click(cx.listener(|this, _, window, cx| { this.show_archive(window, cx); })), diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 13764bd655c23176b3aa016f36eae193e16f92de..118de80af215d5ede10b125af1fe154461c3f80d 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -1191,11 +1191,11 @@ impl TextThreadEditor { Button::new("show-error", "Error") .color(Color::Error) .selected_label_color(Color::Error) - .selected_icon_color(Color::Error) - .icon(IconName::XCircle) - .icon_color(Color::Error) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::XCircle) + .size(IconSize::XSmall) + .color(Color::Error), + ) .tooltip(Tooltip::text("View Details")) .on_click({ let text_thread = text_thread.clone(); @@ -2287,20 +2287,11 @@ impl TextThreadEditor { PickerPopoverMenu::new( self.language_model_selector.clone(), - ButtonLike::new("active-model") - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .child( - h_flex() - .gap_0p5() - .child(provider_icon_element) - .child( - Label::new(model_name) - .color(color) - .size(LabelSize::Small) - .ml_0p5(), - ) - .child(Icon::new(icon).color(color).size(IconSize::XSmall)), - ), + Button::new("active-model", model_name) + .color(color) + .label_size(LabelSize::Small) + .start_icon(provider_icon_element) + .end_icon(Icon::new(icon).color(color).size(IconSize::XSmall)), tooltip, gpui::Corner::BottomRight, cx, diff --git a/crates/agent_ui/src/ui/acp_onboarding_modal.rs b/crates/agent_ui/src/ui/acp_onboarding_modal.rs index 23f3eadc4b259aa854f6c2cbb6bb3a68ec46deb5..ee214e07ffb526f1c4ef89cc9301b4ea7e8d6ebf 100644 --- a/crates/agent_ui/src/ui/acp_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/acp_onboarding_modal.rs @@ -193,15 +193,16 @@ impl Render for AcpOnboardingModal { let copy = "Bring the agent of your choice to Zed via our new Agent Client Protocol (ACP), starting with Google's Gemini CLI integration."; let open_panel_button = Button::new("open-panel", "Start with Gemini CLI") - .icon_size(IconSize::Indicator) .style(ButtonStyle::Tinted(TintColor::Accent)) .full_width() .on_click(cx.listener(Self::open_panel)); let docs_button = Button::new("add-other-agents", "Add Other Agents") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Indicator) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Indicator) + .color(Color::Muted), + ) .full_width() .on_click(cx.listener(Self::open_agent_registry)); diff --git a/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs b/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs index 9e499690efcb797e28f32ca8b3bd0f2c2f0da9db..3a9010b0a155873e658946b4155f09f8867e498a 100644 --- a/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs @@ -201,15 +201,16 @@ impl Render for ClaudeCodeOnboardingModal { let copy = "Powered by the Agent Client Protocol, you can now run Claude Agent as\na first-class citizen in Zed's agent panel."; let open_panel_button = Button::new("open-panel", "Start with Claude Agent") - .icon_size(IconSize::Indicator) .style(ButtonStyle::Tinted(TintColor::Accent)) .full_width() .on_click(cx.listener(Self::open_panel)); let docs_button = Button::new("add-other-agents", "Add Other Agents") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Indicator) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Indicator) + .color(Color::Muted), + ) .full_width() .on_click(cx.listener(Self::view_docs)); diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index d0cac2e69f8d8c5b3fde588cc4ceee92d64962d7..9aeeeeb4233a7e5486ef49da8b0aeaaddd846d17 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2347,9 +2347,7 @@ impl CollabPanel { .gap_2() .child( Button::new("sign_in", button_label) - .icon_color(Color::Muted) - .icon(IconName::Github) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::Github).color(Color::Muted)) .style(ButtonStyle::Filled) .full_width() .disabled(is_signing_in) @@ -2597,9 +2595,9 @@ impl CollabPanel { Section::Channels => { Some( h_flex() - .gap_1() .child( 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") diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index f9ce68a6afe8497c50096b153847070b3eca35a2..fd70163896113f0a20b66c5181749d58385b4c34 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -544,9 +544,7 @@ impl Render for NotificationPanel { .p_4() .child( Button::new("connect_prompt_button", "Connect") - .icon_color(Color::Muted) - .icon(IconName::Github) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::Github).color(Color::Muted)) .style(ButtonStyle::Filled) .full_width() .on_click({ diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index 24b1218305474a29ac2d2e7c8e0a212d6d757522..033effd230d65fee7594d0241b2828a41908a432 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -387,10 +387,11 @@ impl CopilotCodeVerification { .full_width() .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) - .icon(IconName::Download) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon( + Icon::new(IconName::Download) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, window, cx| { reinstall_and_sign_in(copilot.clone(), window, cx) }), @@ -570,10 +571,11 @@ impl ConfigurationView { } }) .style(ButtonStyle::Outlined) - .icon(IconName::Github) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon( + Icon::new(IconName::Github) + .size(IconSize::Small) + .color(Color::Muted), + ) .when(edit_prediction, |this| this.tab_index(0isize)) .on_click(|_, window, cx| { if let Some(app_state) = AppState::global(cx).upgrade() @@ -600,10 +602,11 @@ impl ConfigurationView { } }) .style(ButtonStyle::Outlined) - .icon(IconName::Download) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon( + Icon::new(IconName::Download) + .size(IconSize::Small) + .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) diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index cac96918e32cde4770bedac69fb92a08825e3b25..7e11fe4e19f9acafdb9e2d0be30069f3d5457e5c 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -1821,20 +1821,22 @@ impl Render for DebugPanel { .gap_2() .child( Button::new("spawn-new-session-empty-state", "New Session") - .icon(IconName::Plus) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Plus) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(|_, window, cx| { window.dispatch_action(crate::Start.boxed_clone(), cx); }), ) .child( Button::new("edit-debug-settings", "Edit debug.json") - .icon(IconName::Code) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Code) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(|_, window, cx| { window.dispatch_action( zed_actions::OpenProjectDebugTasks.boxed_clone(), @@ -1844,10 +1846,11 @@ impl Render for DebugPanel { ) .child( Button::new("open-debugger-docs", "Debugger Docs") - .icon(IconName::Book) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Book) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(|_, _, cx| cx.open_url("https://zed.dev/docs/debugger")), ) .child( @@ -1855,10 +1858,11 @@ impl Render for DebugPanel { "spawn-new-session-install-extensions", "Debugger Extensions", ) - .icon(IconName::Blocks) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Blocks) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(|_, window, cx| { window.dispatch_action( zed_actions::Extensions { diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index 1c4328d8a1d301b7cc01aa520c166bda4b40e32d..b2e7209c1a7e9dd403ed0ee70336119ef0f1bdc9 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -765,9 +765,7 @@ impl RatePredictionsModal { .gap_1() .child( Button::new("bad", "Bad Prediction") - .icon(IconName::ThumbsDown) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::ThumbsDown).size(IconSize::Small)) .disabled(rated || feedback_empty) .when(feedback_empty, |this| { this.tooltip(Tooltip::text( @@ -791,9 +789,7 @@ impl RatePredictionsModal { ) .child( Button::new("good", "Good Prediction") - .icon(IconName::ThumbsUp) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::ThumbsUp).size(IconSize::Small)) .disabled(rated) .key_binding(KeyBinding::for_action_in( &ThumbsUpActivePrediction, diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 7343edcdef3851bfeb7a3aa80f3449ff06f55d9f..2d0b151a107000e913ba4772d7d3d2bf50474fc1 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -1056,10 +1056,11 @@ impl ExtensionsPage { "Install", ) .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .icon(IconName::Download) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Download) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click({ let extension_id = extension.id.clone(); move |_, _, cx| { @@ -1078,10 +1079,11 @@ impl ExtensionsPage { "Install", ) .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .icon(IconName::Download) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Download) + .size(IconSize::Small) + .color(Color::Muted), + ) .disabled(true), configure: None, upgrade: None, @@ -1479,10 +1481,11 @@ impl ExtensionsPage { } }); let open_registry_button = Button::new("open_registry", "Learn More") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click({ move |_event, _window, cx| { telemetry::event!( @@ -1520,9 +1523,7 @@ impl ExtensionsPage { cx: &mut Context, ) -> impl IntoElement { let docs_url_button = Button::new("open_docs", "View Documentation") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End) + .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)) .on_click({ move |_event, _window, cx| { telemetry::event!( diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 12ed44cd7ec2de0e68d56642b756e1be824e19fe..b0a4701cd25021e2725ff28b7cc45d1b4f203c8d 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -1494,10 +1494,9 @@ impl GitGraph { this.child( Button::new("author-email-copy", author_email.clone()) - .icon(icon) - .icon_size(IconSize::Small) - .icon_color(icon_color) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(icon).size(IconSize::Small).color(icon_color), + ) .label_size(LabelSize::Small) .truncate(true) .color(Color::Muted) @@ -1542,10 +1541,9 @@ impl GitGraph { }; Button::new("sha-button", &full_sha) - .icon(icon) - .icon_size(IconSize::Small) - .icon_color(icon_color) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(icon).size(IconSize::Small).color(icon_color), + ) .label_size(LabelSize::Small) .truncate(true) .color(Color::Muted) @@ -1602,10 +1600,9 @@ impl GitGraph { "view-on-provider", format!("View on {}", provider_name), ) - .icon(icon) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(icon).size(IconSize::Small).color(Color::Muted), + ) .label_size(LabelSize::Small) .truncate(true) .color(Color::Muted) diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index e91d98038818224594c1f139f70d7c3d11f2a78b..c2d7333484224bbfbc248e25fb2ac51a19f428e2 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -322,10 +322,11 @@ impl BlameRenderer for GitBlameRenderer { format!("#{}", pr.number), ) .color(Color::Muted) - .icon(IconName::PullRequest) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon( + Icon::new(IconName::PullRequest) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _, cx| { cx.stop_propagation(); cx.open_url(pr.url.as_str()) @@ -339,10 +340,11 @@ impl BlameRenderer for GitBlameRenderer { short_commit_id.clone(), ) .color(Color::Muted) - .icon(IconName::FileGit) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon( + Icon::new(IconName::FileGit) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, window, cx| { CommitView::open( commit_summary.sha.clone().into(), diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 57c25681439f9bb8ea7e5761c01d4c1a9defd427..432da803e6eedfec304836198f6111f5418084cc 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -366,11 +366,12 @@ impl CommitModal { .unwrap_or_else(|| "".to_owned()); let branch_picker_button = panel_button(branch) - .icon(IconName::GitBranch) - .icon_size(IconSize::Small) - .icon_color(Color::Placeholder) + .start_icon( + Icon::new(IconName::GitBranch) + .size(IconSize::Small) + .color(Color::Placeholder), + ) .color(Color::Muted) - .icon_position(IconPosition::Start) .on_click(cx.listener(|_, _, window, cx| { window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); })) diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 21e7d8a5d1f8e3f5c5b124fe8b276028df91b752..4740e148099980a7510a1f551d0d3f51c08892a1 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -336,9 +336,10 @@ impl Render for CommitTooltip { format!("#{}", pr.number), ) .color(Color::Muted) - .icon(IconName::PullRequest) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::PullRequest) + .color(Color::Muted), + ) .style(ButtonStyle::Subtle) .on_click(move |_, _, cx| { cx.stop_propagation(); @@ -354,9 +355,9 @@ impl Render for CommitTooltip { ) .style(ButtonStyle::Subtle) .color(Color::Muted) - .icon(IconName::FileGit) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::FileGit).color(Color::Muted), + ) .on_click( move |_, window, cx| { CommitView::open( diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 8f2a019fddf0513c100a53956c81012d11c2ca30..b7f7b526ca16ed6686965f82180d0dcbb63f994a 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -524,10 +524,11 @@ impl CommitView { .when(self.stash.is_none(), |this| { this.child( Button::new("sha", "Commit SHA") - .icon(copy_icon) - .icon_color(copy_icon_color) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon( + Icon::new(copy_icon) + .size(IconSize::Small) + .color(copy_icon_color), + ) .tooltip({ let commit_sha = commit_sha.clone(); move |_, cx| { diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 7bb880abe6d1209aaf6b15d78979cc388bf37a36..d3bb5213a5c5c94171d48d324c7ce05e6399399f 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -453,10 +453,11 @@ fn render_conflict_buttons( this.child(Divider::vertical()).child( Button::new("resolve-with-agent", "Resolve with Agent") .label_size(LabelSize::Small) - .icon(IconName::ZedAssistant) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::ZedAssistant) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click({ let conflict = conflict.clone(); move |_, window, cx| { diff --git a/crates/git_ui/src/file_history_view.rs b/crates/git_ui/src/file_history_view.rs index 03cf6671a23524a0e514ee5c11f55d5eba666796..e0cee4ef1d66b7c09ff249d2323fc9fa72abbd7c 100644 --- a/crates/git_ui/src/file_history_view.rs +++ b/crates/git_ui/src/file_history_view.rs @@ -429,10 +429,11 @@ impl Render for FileHistoryView { Button::new("load-more", "Load More") .disabled(self.loading_more) .label_size(LabelSize::Small) - .icon(IconName::ArrowCircle) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(cx.listener(|this, _, window, cx| { this.load_more(window, cx); })), diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 1a9866fcc6e7ef420742620dab3faa2f38bfa5f5..01375e600392d2b18b34ec3241aff45c5fad6e67 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -872,8 +872,7 @@ impl Render for GitCloneModal { .child( Button::new("learn-more", "Learn More") .label_size(LabelSize::Small) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall)) .on_click(|_, _, cx| { cx.open_url("https://github.com/git-guides/git-clone"); }), diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 3af77b8fb680abbca2688410b783007af573578d..41eff7b23a95ca2d4112d4b95aef67ff7d4a765f 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -1592,8 +1592,11 @@ fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusH "send-review", format!("Send Review to Agent ({})", review_count), ) - .icon(IconName::ZedAssistant) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::ZedAssistant) + .size(IconSize::Small) + .color(Color::Muted), + ) .tooltip(Tooltip::for_action_title_in( "Send all review comments to the Agent panel", &SendReviewToAgent, @@ -1686,10 +1689,11 @@ impl Render for BranchDiffToolbar { let focus_handle = focus_handle.clone(); this.child(Divider::vertical()).child( Button::new("review-diff", "Review Diff") - .icon(IconName::ZedAssistant) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::ZedAssistant) + .size(IconSize::Small) + .color(Color::Muted), + ) .key_binding(KeyBinding::for_action_in(&ReviewDiff, &focus_handle, cx)) .tooltip(move |_, cx| { Tooltip::with_meta_in( diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index ff3389a4d4a10bc8472d0931d18ffa5be839c631..c8df5c1d8cf60ed07d6013cfb088bf8d362cf330 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -2928,9 +2928,11 @@ impl Render for KeybindingEditorModal { .child( Button::new("show_matching", "View") .label_size(LabelSize::Small) - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(cx.listener( |this, _, window, cx| { this.show_matching_bindings( diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 5b493fdf1087911372d8796cc88f4ad14eef8df0..0df2f0856c36053367172dd3a0412a0cb6cf4e6f 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -1574,7 +1574,8 @@ impl Render for ConfigurationView { } v_flex() - .size_full() + .min_w_0() + .w_full() .track_focus(&self.focus_handle) .on_action(cx.listener(Self::on_tab)) .on_action(cx.listener(Self::on_tab_prev)) diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 4fdf06cc959ccc853f92f4e150978cd15c8e70d3..b871015826f36fb3dc9b727fb8b194c46c0ec05c 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1126,6 +1126,7 @@ impl RenderOnce for ZedAiConfiguration { let manage_subscription_buttons = if is_pro { Button::new("manage_settings", "Manage Subscription") .full_width() + .label_size(LabelSize::Small) .style(ButtonStyle::Tinted(TintColor::Accent)) .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))) .into_any_element() @@ -1149,10 +1150,7 @@ impl RenderOnce for ZedAiConfiguration { .child(Label::new("Sign in to have access to Zed's complete agentic experience with hosted models.")) .child( Button::new("sign_in", "Sign In to use Zed AI") - .icon_color(Color::Muted) - .icon(IconName::Github) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::Github).size(IconSize::Small).color(Color::Muted)) .full_width() .on_click({ let callback = self.sign_in_callback.clone(); diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index ee08f1689aeea9cfa18346108cd2d314b2259583..6c8d3c6e1c50185a4b09e9afc80c688f4c8d1381 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -820,9 +820,7 @@ impl ConfigurationView { .child( Button::new("reset-api-url", "Reset API URL") .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, _window, cx| this.reset_api_url(_window, cx)), @@ -918,9 +916,11 @@ impl Render for ConfigurationView { this.child( Button::new("lmstudio-site", "LM Studio") .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _window, cx| { cx.open_url(LMSTUDIO_SITE) }) @@ -933,9 +933,11 @@ impl Render for ConfigurationView { "Download LM Studio", ) .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _window, cx| { cx.open_url(LMSTUDIO_DOWNLOAD_URL) }) @@ -946,9 +948,11 @@ impl Render for ConfigurationView { .child( Button::new("view-models", "Model Catalog") .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _window, cx| { cx.open_url(LMSTUDIO_CATALOG_URL) }), @@ -981,9 +985,9 @@ impl Render for ConfigurationView { } else { this.child( Button::new("retry_lmstudio_models", "Connect") - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .icon(IconName::PlayFilled) + .start_icon( + Icon::new(IconName::PlayFilled).size(IconSize::XSmall), + ) .on_click(cx.listener(move |this, _, _window, cx| { this.retry_connection(_window, cx) })), diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 96343ec060e13ff4e63bbdf96db3b2501e32a461..30234687633215ec6a1da6f9d63ea136d08254b8 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -858,9 +858,7 @@ impl ConfigurationView { .child( Button::new("reset-context-window", "Reset") .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, window, cx| { @@ -905,9 +903,7 @@ impl ConfigurationView { .child( Button::new("reset-api-url", "Reset API URL") .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, window, cx| this.reset_api_url(window, cx)), @@ -949,9 +945,11 @@ impl Render for ConfigurationView { this.child( Button::new("ollama-site", "Ollama") .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::XSmall) + .color(Color::Muted), + ) .on_click(move |_, _, cx| cx.open_url(OLLAMA_SITE)) .into_any_element(), ) @@ -959,9 +957,11 @@ impl Render for ConfigurationView { this.child( Button::new("download_ollama_button", "Download Ollama") .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::XSmall) + .color(Color::Muted), + ) .on_click(move |_, _, cx| { cx.open_url(OLLAMA_DOWNLOAD_URL) }) @@ -972,9 +972,11 @@ impl Render for ConfigurationView { .child( Button::new("view-models", "View All Models") .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::XSmall) + .color(Color::Muted), + ) .on_click(move |_, _, cx| cx.open_url(OLLAMA_LIBRARY_URL)), ), ) @@ -1005,9 +1007,9 @@ impl Render for ConfigurationView { } else { this.child( Button::new("retry_ollama_models", "Connect") - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .icon(IconName::PlayOutlined) + .start_icon( + Icon::new(IconName::PlayOutlined).size(IconSize::XSmall), + ) .on_click(cx.listener(move |this, _, window, cx| { this.retry_connection(window, cx) })), diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index ce79de7cb2df22847a2666d7b4847e2c696fb12e..c1ebf76e0b0678d35a5e013e87f9efd9488a4e8d 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -1415,9 +1415,11 @@ impl Render for ConfigurationView { ) .child( Button::new("docs", "Learn More") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _window, cx| { cx.open_url("https://zed.dev/docs/ai/llm-providers#openai-api-compatible") }), diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index b478bc843c05e01d428561d9c255ef0d2ca97148..87a08097782198238a5d2467af32cc66b3183664 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -545,9 +545,7 @@ impl Render for ConfigurationView { .child( Button::new("reset-api-key", "Reset API Key") .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) .layer(ElevationIndex::ModalSurface) .when(env_var_set, |this| { this.tooltip(Tooltip::text(format!("To reset your API key, unset the {env_var_name} environment variable."))) diff --git a/crates/language_onboarding/src/python.rs b/crates/language_onboarding/src/python.rs index e715cb7c806f417980a93a62210c72ca8529fcb5..751980fd57af5d2bd28ca17f38b88aa09741e482 100644 --- a/crates/language_onboarding/src/python.rs +++ b/crates/language_onboarding/src/python.rs @@ -56,10 +56,8 @@ impl Render for BasedPyrightBanner { .gap_0p5() .child( Button::new("learn-more", "Learn More") - .icon(IconName::ArrowUpRight) .label_size(LabelSize::Small) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall).color(Color::Muted)) .on_click(|_, _, cx| { cx.open_url("https://zed.dev/docs/languages/python") }), diff --git a/crates/language_tools/src/lsp_log_view.rs b/crates/language_tools/src/lsp_log_view.rs index a4b8977da7661b09b85fff3cbb86c2a3ff1647aa..47c840ea4e2f22e1b64cfc5b78bb7f983255dcba 100644 --- a/crates/language_tools/src/lsp_log_view.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -18,7 +18,7 @@ use project::{ }; use proto::toggle_lsp_logs::LogType; use std::{any::TypeId, borrow::Cow, sync::Arc}; -use ui::{Button, Checkbox, ContextMenu, Label, PopoverMenu, ToggleState, prelude::*}; +use ui::{Checkbox, ContextMenu, PopoverMenu, ToggleState, prelude::*}; use util::ResultExt as _; use workspace::{ SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId, @@ -969,9 +969,11 @@ impl Render for LspLogToolbarItemView { }) .unwrap_or_else(|| "No server selected".into()), ) - .icon(IconName::ChevronDown) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), + .end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::Small) + .color(Color::Muted), + ), ) .menu({ let log_view = log_view.clone(); @@ -1030,10 +1032,11 @@ impl Render for LspLogToolbarItemView { PopoverMenu::new("LspViewSelector") .anchor(Corner::TopLeft) .trigger( - Button::new("language_server_menu_header", label) - .icon(IconName::ChevronDown) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), + Button::new("language_server_menu_header", label).end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::Small) + .color(Color::Muted), + ), ) .menu(move |window, cx| { let log_toolbar_view = log_toolbar_view.upgrade()?; @@ -1125,9 +1128,11 @@ impl Render for LspLogToolbarItemView { "language_server_trace_level_selector", "Trace level", ) - .icon(IconName::ChevronDown) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), + .end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::Small) + .color(Color::Muted), + ), ) .menu({ let log_view = log_view; @@ -1193,9 +1198,11 @@ impl Render for LspLogToolbarItemView { "language_server_log_level_selector", "Log level", ) - .icon(IconName::ChevronDown) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), + .end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::Small) + .color(Color::Muted), + ), ) .menu({ let log_view = log_view; diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs index b683b13743819bbba692a99a7c559cfd9823a4b4..7221d8104cbff2e1e0a8ebe265b419b1c725472d 100644 --- a/crates/onboarding/src/basics_page.rs +++ b/crates/onboarding/src/basics_page.rs @@ -10,9 +10,8 @@ use theme::{ ThemeSettings, }; use ui::{ - Divider, ParentElement as _, StatefulInteractiveElement, SwitchField, TintColor, - ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip, - prelude::*, rems_from_px, + Divider, StatefulInteractiveElement, SwitchField, TintColor, ToggleButtonGroup, + ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip, prelude::*, }; use vim_mode_setting::VimModeSetting; @@ -477,8 +476,7 @@ fn render_setting_import_button( .toggle_state(imported) .tab_index(tab_index) .when(imported, |this| { - this.icon(IconName::Check) - .icon_size(IconSize::Small) + this.end_icon(Icon::new(IconName::Check).size(IconSize::Small)) .color(Color::Success) }) .on_click(move |_, window, cx| { diff --git a/crates/onboarding/src/multibuffer_hint.rs b/crates/onboarding/src/multibuffer_hint.rs index 26ab409fbad6333f2e56ee4a274a43806adce676..1f710318a64760faeecb31c8a6a368a0e11537a4 100644 --- a/crates/onboarding/src/multibuffer_hint.rs +++ b/crates/onboarding/src/multibuffer_hint.rs @@ -158,10 +158,11 @@ impl Render for MultibufferHint { ) .child( Button::new("open_docs", "Learn More") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::End) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_event, _, cx| { cx.open_url("https://zed.dev/docs/multibuffers") }), diff --git a/crates/panel/src/panel.rs b/crates/panel/src/panel.rs index 133efa9cb61c122af79a228cdfb74f86e22792b4..cf6465f3f5973bf24429f010dadf369346123b8f 100644 --- a/crates/panel/src/panel.rs +++ b/crates/panel/src/panel.rs @@ -52,7 +52,6 @@ pub fn panel_button(label: impl Into) -> ui::Button { let id = ElementId::Name(label.to_lowercase().replace(' ', "_").into()); ui::Button::new(id, label) .label_size(ui::LabelSize::Small) - .icon_size(ui::IconSize::Small) // TODO: Change this once we use on_surface_bg in button_like .layer(ui::ElevationIndex::ModalSurface) .size(ui::ButtonSize::Compact) diff --git a/crates/recent_projects/src/disconnected_overlay.rs b/crates/recent_projects/src/disconnected_overlay.rs index 82ff0699054e5614b8078d3223d5e9282e5034b5..732b50c123d9d61750781df81ce00b392997af3c 100644 --- a/crates/recent_projects/src/disconnected_overlay.rs +++ b/crates/recent_projects/src/disconnected_overlay.rs @@ -2,11 +2,7 @@ use gpui::{ClickEvent, DismissEvent, EventEmitter, FocusHandle, Focusable, Rende use project::project_settings::ProjectSettings; use remote::RemoteConnectionOptions; use settings::Settings; -use ui::{ - Button, ButtonCommon, ButtonStyle, Clickable, Context, ElevationIndex, FluentBuilder, Headline, - HeadlineSize, IconName, IconPosition, InteractiveElement, IntoElement, Label, Modal, - ModalFooter, ModalHeader, ParentElement, Section, Styled, StyledExt, Window, div, h_flex, rems, -}; +use ui::{ElevationIndex, Modal, ModalFooter, ModalHeader, Section, prelude::*}; use workspace::{ ModalView, MultiWorkspace, OpenOptions, Workspace, notifications::DetachAndPromptErr, }; @@ -207,8 +203,7 @@ impl Render for DisconnectedOverlay { Button::new("reconnect", "Reconnect") .style(ButtonStyle::Filled) .layer(ElevationIndex::ModalSurface) - .icon(IconName::ArrowCircle) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::ArrowCircle)) .on_click(cx.listener(Self::handle_reconnect)), ) }), diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 60ebf85dd23460a8a0ce0c70da2d7b69761690db..d4cfb6520e6f73592ede5abcacb558967d10dbc7 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -2117,8 +2117,10 @@ impl RemoteServerProjects { .child( Button::new("learn-more", "Learn More") .label_size(LabelSize::Small) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::XSmall), + ) .on_click(|_, _, cx| { cx.open_url( "https://zed.dev/docs/remote-development", diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index b6d4f39c0ccb75619a7e4efd6a532202893c8722..ce68a4d30285fe04427c54aa8d5fbdc3aa059648 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -431,10 +431,11 @@ impl PickerDelegate for KernelPickerDelegate { .gap_4() .child( Button::new("kernel-docs", "Kernel Docs") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::End) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _, cx| cx.open_url(KERNEL_DOCS_URL)), ) .into_any(), diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs index 87f18708a1988c70d66dc4cef5355d4cbcb11dba..76a0d2a47037f0ccd48fcfe9cb088ceb9e37aeaa 100644 --- a/crates/repl/src/notebook/notebook_ui.rs +++ b/crates/repl/src/notebook/notebook_ui.rs @@ -1117,10 +1117,11 @@ impl NotebookEditor { worktree_id, Button::new("kernel-selector", kernel_name.clone()) .label_size(LabelSize::Small) - .icon(status_icon) - .icon_size(IconSize::Small) - .icon_color(status_color) - .icon_position(IconPosition::Start), + .start_icon( + Icon::new(status_icon) + .size(IconSize::Small) + .color(status_color), + ), Tooltip::text(format!( "Kernel: {} ({}). Click to change.", kernel_name, diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index dd4bbcfaeb7a14ea4bda8c546f5cf2539734eb73..cb568c95627bd6a32dcd89b7cfd645f4dac65f59 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -1170,10 +1170,11 @@ impl RulesLibrary { Button::new("new-rule", "New Rule") .full_width() .style(ButtonStyle::Outlined) - .icon(IconName::Plus) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::Plus) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(|_, window, cx| { window.dispatch_action(Box::new(NewRule), cx); }), diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 9b23c96259e4933bc1660af960b508c0678fe767..292dfd7e5fad4174ecd7dbe51bb28f3a1df98827 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -1583,9 +1583,7 @@ impl ProjectSearchView { ) .child( Button::new("filter-paths", "Include/exclude specific paths") - .icon(IconName::Filter) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon(Icon::new(IconName::Filter).size(IconSize::Small)) .key_binding(KeyBinding::for_action_in(&ToggleFilters, &focus_handle, cx)) .on_click(|_event, window, cx| { window.dispatch_action(ToggleFilters.boxed_clone(), cx) @@ -1593,9 +1591,7 @@ impl ProjectSearchView { ) .child( Button::new("find-replace", "Find and replace") - .icon(IconName::Replace) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon(Icon::new(IconName::Replace).size(IconSize::Small)) .key_binding(KeyBinding::for_action_in(&ToggleReplace, &focus_handle, cx)) .on_click(|_event, window, cx| { window.dispatch_action(ToggleReplace.boxed_clone(), cx) @@ -1603,9 +1599,7 @@ impl ProjectSearchView { ) .child( Button::new("regex", "Match with regex") - .icon(IconName::Regex) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon(Icon::new(IconName::Regex).size(IconSize::Small)) .key_binding(KeyBinding::for_action_in(&ToggleRegex, &focus_handle, cx)) .on_click(|_event, window, cx| { window.dispatch_action(ToggleRegex.boxed_clone(), cx) @@ -1613,9 +1607,7 @@ impl ProjectSearchView { ) .child( Button::new("match-case", "Match case") - .icon(IconName::CaseSensitive) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon(Icon::new(IconName::CaseSensitive).size(IconSize::Small)) .key_binding(KeyBinding::for_action_in( &ToggleCaseSensitive, &focus_handle, @@ -1627,9 +1619,7 @@ impl ProjectSearchView { ) .child( Button::new("match-whole-words", "Match whole words") - .icon(IconName::WholeWord) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon(Icon::new(IconName::WholeWord).size(IconSize::Small)) .key_binding(KeyBinding::for_action_in( &ToggleWholeWord, &focus_handle, diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index c1c978efbb3da5dc57c8d40a45370a908698bd40..f5f1f0ea7eb71c7af41ba2c60a30b2ec5cb01a4d 100644 --- a/crates/settings_ui/src/pages/tool_permissions_setup.rs +++ b/crates/settings_ui/src/pages/tool_permissions_setup.rs @@ -275,10 +275,11 @@ fn render_tool_list_item( .tab_index(tool_index as isize) .style(ButtonStyle::OutlinedGhost) .size(ButtonSize::Medium) - .icon(IconName::ChevronRight) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) + .end_icon( + Icon::new(IconName::ChevronRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(cx.listener(move |this, _, window, cx| { this.push_dynamic_sub_page( tool_name, @@ -1090,9 +1091,7 @@ fn render_global_default_mode_section(current_mode: ToolPermissionMode) -> AnyEl .tab_index(0_isize) .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) - .icon(IconName::ChevronDown) - .icon_position(IconPosition::End) - .icon_size(IconSize::Small), + .end_icon(Icon::new(IconName::ChevronDown).size(IconSize::Small)), ) .menu(move |window, cx| { Some(ContextMenu::build(window, cx, move |menu, _, _| { @@ -1141,9 +1140,7 @@ fn render_default_mode_section( .tab_index(0_isize) .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) - .icon(IconName::ChevronDown) - .icon_position(IconPosition::End) - .icon_size(IconSize::Small), + .end_icon(Icon::new(IconName::ChevronDown).size(IconSize::Small)), ) .menu(move |window, cx| { let tool_id = tool_id_owned.clone(); diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 9d7fe83736be8d1d9ed79d85708c5ed0574b7e3a..26417a5469955cd89a12564248e36be288004a15 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -925,9 +925,7 @@ impl SettingsPageItem { Button::new("error-warning", warning) .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) - .icon(Some(IconName::Debug)) - .icon_position(IconPosition::Start) - .icon_color(Color::Error) + .start_icon(Icon::new(IconName::Debug).color(Color::Error)) .tab_index(0_isize) .tooltip(Tooltip::text(setting_item.field.type_name())) .into_any_element(), @@ -992,11 +990,12 @@ impl SettingsPageItem { ("sub-page".into(), sub_page_link.title.clone()), "Configure", ) - .icon(IconName::ChevronRight) .tab_index(0_isize) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) + .end_icon( + Icon::new(IconName::ChevronRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .style(ButtonStyle::OutlinedGhost) .size(ButtonSize::Medium) .on_click({ @@ -1125,11 +1124,12 @@ impl SettingsPageItem { ("action-link".into(), action_link.title.clone()), action_link.button_text.clone(), ) - .icon(IconName::ArrowUpRight) .tab_index(0_isize) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .style(ButtonStyle::OutlinedGhost) .size(ButtonSize::Medium) .on_click({ @@ -4174,10 +4174,11 @@ fn render_picker_trigger_button(id: SharedString, label: SharedString) -> Button .tab_index(0_isize) .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) - .icon(IconName::ChevronUpDown) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End) + .end_icon( + Icon::new(IconName::ChevronUpDown) + .size(IconSize::Small) + .color(Color::Muted), + ) } fn render_font_picker( diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index 2ea3436d43cd2d2a4bda392384ff51f962824143..1ddd6879405ad69a75e038da608d034f58bb5eff 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -311,10 +311,11 @@ impl PickerDelegate for IconThemeSelectorDelegate { .border_color(cx.theme().colors().border_variant) .child( Button::new("docs", "View Icon Theme Docs") - .icon(IconName::ArrowUpRight) - .icon_position(IconPosition::End) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(|_event, _window, cx| { cx.open_url("https://zed.dev/docs/icon-themes"); }), diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 74b242dd0b7c3a3ddbe6ca76d34a59f03560f14a..f3c32c8f2f50cbec820e043a701f382e6ac22d0a 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -497,10 +497,11 @@ impl PickerDelegate for ThemeSelectorDelegate { .border_color(cx.theme().colors().border_variant) .child( Button::new("docs", "View Theme Docs") - .icon(IconName::ArrowUpRight) - .icon_position(IconPosition::End) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(cx.listener(|_, _, _, cx| { cx.open_url("https://zed.dev/docs/themes"); })), diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 916d58426b76f020bce8a9bf69971f34bc3803a4..7fc86706a3eb0971b1f8539d76b8daf3b709537e 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -583,10 +583,11 @@ impl TitleBar { .style(ButtonStyle::Tinted(TintColor::Warning)) .label_size(LabelSize::Small) .color(Color::Warning) - .icon(IconName::Warning) - .icon_color(Color::Warning) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) .tooltip(|_, cx| { Tooltip::with_meta( "You're in Restricted Mode", @@ -697,9 +698,11 @@ impl TitleBar { Button::new("project_name_trigger", display_name) .label_size(LabelSize::Small) .when(self.worktree_count(cx) > 1, |this| { - this.icon(IconName::ChevronDown) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) + this.end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ) }) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .when(!is_project_selected, |s| s.color(Color::Muted)), @@ -779,11 +782,9 @@ impl TitleBar { .color(Color::Muted) .when(settings.show_branch_icon, |branch_button| { let (icon, icon_color) = icon_info; - branch_button - .icon(icon) - .icon_position(IconPosition::Start) - .icon_color(icon_color) - .icon_size(IconSize::Indicator) + branch_button.start_icon( + Icon::new(icon).size(IconSize::Indicator).color(icon_color), + ) }), move |_window, cx| { Tooltip::with_meta( diff --git a/crates/ui/src/components/ai/configured_api_card.rs b/crates/ui/src/components/ai/configured_api_card.rs index 2104e816811a68776f69f3970b53636dbbd63e17..c9fd129a678d008d2ff0d6833e1497f61c73d989 100644 --- a/crates/ui/src/components/ai/configured_api_card.rs +++ b/crates/ui/src/components/ai/configured_api_card.rs @@ -133,10 +133,11 @@ impl RenderOnce for ConfiguredApiCard { elem.tab_index(tab_index) }) .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Undo) + .size(IconSize::Small) + .color(Color::Muted), + ) .disabled(self.disabled) .when_some(self.tooltip_label, |this, label| { this.tooltip(Tooltip::text(label)) diff --git a/crates/ui/src/components/banner.rs b/crates/ui/src/components/banner.rs index 199c72113afae37ab97c96932f5b9e805c5628bd..19795c2c7c86045572ac4a031276a6552a1d68ee 100644 --- a/crates/ui/src/components/banner.rs +++ b/crates/ui/src/components/banner.rs @@ -8,16 +8,14 @@ use gpui::{AnyElement, IntoElement, ParentElement, Styled}; /// /// ``` /// use ui::prelude::*; -/// use ui::{Banner, Button, IconName, IconPosition, IconSize, Label, Severity}; +/// use ui::{Banner, Button, Icon, IconName, IconSize, Label, Severity}; /// /// Banner::new() /// .severity(Severity::Success) /// .children([Label::new("This is a success message")]) /// .action_slot( /// Button::new("learn-more", "Learn More") -/// .icon(IconName::ArrowUpRight) -/// .icon_size(IconSize::Small) -/// .icon_position(IconPosition::End) +/// .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)), /// ); /// ``` #[derive(IntoElement, RegisterComponent)] @@ -151,9 +149,7 @@ impl Component for Banner { .child(Label::new("This is an informational message")) .action_slot( Button::new("learn-more", "Learn More") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End), + .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)), ) .into_any_element(), ), diff --git a/crates/ui/src/components/button.rs b/crates/ui/src/components/button.rs index 17c216ec7b000bd9b563b3e00d4ee9979ca5287f..bcec46e59ce66a242cbd96d840e4323751541f92 100644 --- a/crates/ui/src/components/button.rs +++ b/crates/ui/src/components/button.rs @@ -1,5 +1,4 @@ mod button; -mod button_icon; mod button_like; mod button_link; mod copy_button; diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index 2ac3b9ca13123a0d9330d71e8b73d034d65faf89..52ea9df14293e5aa25ab8de4487975019a6481ff 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -2,15 +2,12 @@ use crate::component_prelude::*; use gpui::{AnyElement, AnyView, DefiniteLength}; use ui_macros::RegisterComponent; -use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize, Label}; +use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, Label}; use crate::{ - Color, DynamicSpacing, ElevationIndex, IconPosition, KeyBinding, KeybindingPosition, TintColor, - prelude::*, + Color, DynamicSpacing, ElevationIndex, KeyBinding, KeybindingPosition, TintColor, prelude::*, }; -use super::button_icon::ButtonIcon; - -/// An element that creates a button with a label and an optional icon. +/// An element that creates a button with a label and optional icons. /// /// Common buttons: /// - Label, Icon + Label: [`Button`] (this component) @@ -42,7 +39,7 @@ use super::button_icon::ButtonIcon; /// use ui::prelude::*; /// /// Button::new("button_id", "Click me!") -/// .icon(IconName::Check) +/// .start_icon(Icon::new(IconName::Check)) /// .toggle_state(true) /// .on_click(|event, window, cx| { /// // Handle click event @@ -85,12 +82,8 @@ pub struct Button { label_size: Option, selected_label: Option, selected_label_color: Option, - icon: Option, - icon_position: Option, - icon_size: Option, - icon_color: Option, - selected_icon: Option, - selected_icon_color: Option, + start_icon: Option, + end_icon: Option, key_binding: Option, key_binding_position: KeybindingPosition, alpha: Option, @@ -112,12 +105,8 @@ impl Button { label_size: None, selected_label: None, selected_label_color: None, - icon: None, - icon_position: None, - icon_size: None, - icon_color: None, - selected_icon: None, - selected_icon_color: None, + start_icon: None, + end_icon: None, key_binding: None, key_binding_position: KeybindingPosition::default(), alpha: None, @@ -149,39 +138,19 @@ impl Button { self } - /// Assigns an icon to the button. - pub fn icon(mut self, icon: impl Into>) -> Self { - self.icon = icon.into(); - self - } - - /// Sets the position of the icon relative to the label. - pub fn icon_position(mut self, icon_position: impl Into>) -> Self { - self.icon_position = icon_position.into(); - self - } - - /// Specifies the size of the button's icon. - pub fn icon_size(mut self, icon_size: impl Into>) -> Self { - self.icon_size = icon_size.into(); - self - } - - /// Sets the color of the button's icon. - pub fn icon_color(mut self, icon_color: impl Into>) -> Self { - self.icon_color = icon_color.into(); - self - } - - /// Chooses an icon to display when the button is in a selected state. - pub fn selected_icon(mut self, icon: impl Into>) -> Self { - self.selected_icon = icon.into(); + /// Sets an icon to display at the start (left) of the button label. + /// + /// The icon's color will be overridden to `Color::Disabled` when the button is disabled. + pub fn start_icon(mut self, icon: impl Into>) -> Self { + self.start_icon = icon.into(); self } - /// Sets the icon color used when the button is in a selected state. - pub fn selected_icon_color(mut self, color: impl Into>) -> Self { - self.selected_icon_color = color.into(); + /// Sets an icon to display at the end (right) of the button label. + /// + /// The icon's color will be overridden to `Color::Disabled` when the button is disabled. + pub fn end_icon(mut self, icon: impl Into>) -> Self { + self.end_icon = icon.into(); self } @@ -219,22 +188,24 @@ impl Button { impl Toggleable for Button { /// Sets the selected state of the button. /// - /// This method allows the selection state of the button to be specified. - /// It modifies the button's appearance to reflect its selected state. - /// /// # Examples /// + /// Create a toggleable button that changes appearance when selected: + /// /// ``` /// use ui::prelude::*; + /// use ui::TintColor; /// - /// Button::new("button_id", "Click me!") - /// .toggle_state(true) + /// let selected = true; + /// + /// Button::new("toggle_button", "Toggle Me") + /// .start_icon(Icon::new(IconName::Check)) + /// .toggle_state(selected) + /// .selected_style(ButtonStyle::Tinted(TintColor::Accent)) /// .on_click(|event, window, cx| { - /// // Handle click event + /// // Toggle the selected state /// }); /// ``` - /// - /// Use [`selected_style`](Button::selected_style) to change the style of the button when it is selected. fn toggle_state(mut self, selected: bool) -> Self { self.base = self.base.toggle_state(selected); self @@ -242,22 +213,20 @@ impl Toggleable for Button { } impl SelectableButton for Button { - /// Sets the style for the button when selected. + /// Sets the style for the button in a selected state. /// /// # Examples /// + /// Customize the selected appearance of a button: + /// /// ``` /// use ui::prelude::*; /// use ui::TintColor; /// - /// Button::new("button_id", "Click me!") + /// Button::new("styled_button", "Styled Button") /// .toggle_state(true) - /// .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - /// .on_click(|event, window, cx| { - /// // Handle click event - /// }); + /// .selected_style(ButtonStyle::Tinted(TintColor::Accent)); /// ``` - /// This results in a button with a blue tinted background when selected. fn selected_style(mut self, style: ButtonStyle) -> Self { self.base = self.base.selected_style(style); self @@ -265,36 +234,27 @@ impl SelectableButton for Button { } impl Disableable for Button { - /// Disables the button. + /// Disables the button, preventing interaction and changing its appearance. /// - /// This method allows the button to be disabled. When a button is disabled, - /// it doesn't react to user interactions and its appearance is updated to reflect this. + /// When disabled, the button's icon and label will use `Color::Disabled`. /// /// # Examples /// + /// Create a disabled button: + /// /// ``` /// use ui::prelude::*; /// - /// Button::new("button_id", "Click me!") - /// .disabled(true) - /// .on_click(|event, window, cx| { - /// // Handle click event - /// }); + /// Button::new("disabled_button", "Can't Click Me") + /// .disabled(true); /// ``` - /// - /// This results in a button that is disabled and does not respond to click events. fn disabled(mut self, disabled: bool) -> Self { self.base = self.base.disabled(disabled); - self.key_binding = self - .key_binding - .take() - .map(|binding| binding.disabled(disabled)); self } } impl Clickable for Button { - /// Sets the click event handler for the button. fn on_click( mut self, handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, @@ -310,44 +270,35 @@ impl Clickable for Button { } impl FixedWidth for Button { - /// Sets a fixed width for the button. - /// - /// This function allows a button to have a fixed width instead of automatically growing or shrinking. /// Sets a fixed width for the button. /// /// # Examples /// + /// Create a button with a fixed width of 100 pixels: + /// /// ``` /// use ui::prelude::*; /// - /// Button::new("button_id", "Click me!") - /// .width(px(100.)) - /// .on_click(|event, window, cx| { - /// // Handle click event - /// }); + /// Button::new("fixed_width_button", "Fixed Width") + /// .width(px(100.0)); /// ``` - /// - /// This sets the button's width to be exactly 100 pixels. fn width(mut self, width: impl Into) -> Self { self.base = self.base.width(width); self } - /// Sets the button to occupy the full width of its container. + /// Makes the button take up the full width of its container. /// /// # Examples /// + /// Create a button that takes up the full width of its container: + /// /// ``` /// use ui::prelude::*; /// - /// Button::new("button_id", "Click me!") - /// .full_width() - /// .on_click(|event, window, cx| { - /// // Handle click event - /// }); + /// Button::new("full_width_button", "Full Width") + /// .full_width(); /// ``` - /// - /// This stretches the button to the full width of its container. fn full_width(mut self) -> Self { self.base = self.base.full_width(); self @@ -355,43 +306,34 @@ impl FixedWidth for Button { } impl ButtonCommon for Button { - /// Sets the button's id. fn id(&self) -> &ElementId { self.base.id() } - /// Sets the visual style of the button using a [`ButtonStyle`]. + /// Sets the visual style of the button. fn style(mut self, style: ButtonStyle) -> Self { self.base = self.base.style(style); self } - /// Sets the button's size using a [`ButtonSize`]. + /// Sets the size of the button. fn size(mut self, size: ButtonSize) -> Self { self.base = self.base.size(size); self } - /// Sets a tooltip for the button. - /// - /// This method allows a tooltip to be set for the button. The tooltip is a function that - /// takes a mutable references to [`Window`] and [`App`], and returns an [`AnyView`]. The - /// tooltip is displayed when the user hovers over the button. + /// Sets a tooltip that appears on hover. /// /// # Examples /// - /// ``` - /// use ui::prelude::*; - /// use ui::Tooltip; + /// Add a tooltip to a button: /// - /// Button::new("button_id", "Click me!") - /// .tooltip(Tooltip::text("This is a tooltip")) - /// .on_click(|event, window, cx| { - /// // Handle click event - /// }); /// ``` + /// use ui::{Tooltip, prelude::*}; /// - /// This will create a button with a tooltip that displays "This is a tooltip" when hovered over. + /// Button::new("tooltip_button", "Hover Me") + /// .tooltip(Tooltip::text("This is a tooltip")); + /// ``` fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self { self.base = self.base.tooltip(tooltip); self @@ -436,16 +378,12 @@ impl RenderOnce for Button { h_flex() .when(self.truncate, |this| this.min_w_0().overflow_hidden()) .gap(DynamicSpacing::Base04.rems(cx)) - .when(self.icon_position == Some(IconPosition::Start), |this| { - this.children(self.icon.map(|icon| { - ButtonIcon::new(icon) - .disabled(is_disabled) - .toggle_state(is_selected) - .selected_icon(self.selected_icon) - .selected_icon_color(self.selected_icon_color) - .size(self.icon_size) - .color(self.icon_color) - })) + .when_some(self.start_icon, |this, icon| { + this.child(if is_disabled { + icon.color(Color::Disabled) + } else { + icon + }) }) .child( h_flex() @@ -465,16 +403,12 @@ impl RenderOnce for Button { ) .children(self.key_binding), ) - .when(self.icon_position != Some(IconPosition::Start), |this| { - this.children(self.icon.map(|icon| { - ButtonIcon::new(icon) - .disabled(is_disabled) - .toggle_state(is_selected) - .selected_icon(self.selected_icon) - .selected_icon_color(self.selected_icon_color) - .size(self.icon_size) - .color(self.icon_color) - })) + .when_some(self.end_icon, |this, icon| { + this.child(if is_disabled { + icon.color(Color::Disabled) + } else { + icon + }) }), ) } @@ -585,24 +519,28 @@ impl Component for Button { "Buttons with Icons", vec![ single_example( - "Icon Start", - Button::new("icon_start", "Icon Start") - .icon(IconName::Check) - .icon_position(IconPosition::Start) + "Start Icon", + Button::new("icon_start", "Start Icon") + .start_icon(Icon::new(IconName::Check)) + .into_any_element(), + ), + single_example( + "End Icon", + Button::new("icon_end", "End Icon") + .end_icon(Icon::new(IconName::Check)) .into_any_element(), ), single_example( - "Icon End", - Button::new("icon_end", "Icon End") - .icon(IconName::Check) - .icon_position(IconPosition::End) + "Both Icons", + Button::new("both_icons", "Both Icons") + .start_icon(Icon::new(IconName::Check)) + .end_icon(Icon::new(IconName::ChevronDown)) .into_any_element(), ), single_example( "Icon Color", Button::new("icon_color", "Icon Color") - .icon(IconName::Check) - .icon_color(Color::Accent) + .start_icon(Icon::new(IconName::Check).color(Color::Accent)) .into_any_element(), ), ], diff --git a/crates/ui/src/components/button/button_icon.rs b/crates/ui/src/components/button/button_icon.rs deleted file mode 100644 index 510c418714575112070e64e945da3e185f37ee3e..0000000000000000000000000000000000000000 --- a/crates/ui/src/components/button/button_icon.rs +++ /dev/null @@ -1,199 +0,0 @@ -use crate::{Icon, IconName, IconSize, IconWithIndicator, Indicator, prelude::*}; -use gpui::Hsla; - -/// An icon that appears within a button. -/// -/// Can be used as either an icon alongside a label, like in [`Button`](crate::Button), -/// or as a standalone icon, like in [`IconButton`](crate::IconButton). -#[derive(IntoElement, RegisterComponent)] -pub(super) struct ButtonIcon { - icon: IconName, - size: IconSize, - color: Color, - disabled: bool, - selected: bool, - selected_icon: Option, - selected_icon_color: Option, - selected_style: Option, - indicator: Option, - indicator_border_color: Option, -} - -impl ButtonIcon { - pub fn new(icon: IconName) -> Self { - Self { - icon, - size: IconSize::default(), - color: Color::default(), - disabled: false, - selected: false, - selected_icon: None, - selected_icon_color: None, - selected_style: None, - indicator: None, - indicator_border_color: None, - } - } - - pub fn size(mut self, size: impl Into>) -> Self { - if let Some(size) = size.into() { - self.size = size; - } - self - } - - pub fn color(mut self, color: impl Into>) -> Self { - if let Some(color) = color.into() { - self.color = color; - } - self - } - - pub fn selected_icon(mut self, icon: impl Into>) -> Self { - self.selected_icon = icon.into(); - self - } - - pub fn selected_icon_color(mut self, color: impl Into>) -> Self { - self.selected_icon_color = color.into(); - self - } - - pub fn indicator(mut self, indicator: Indicator) -> Self { - self.indicator = Some(indicator); - self - } - - pub fn indicator_border_color(mut self, color: Option) -> Self { - self.indicator_border_color = color; - self - } -} - -impl Disableable for ButtonIcon { - fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } -} - -impl Toggleable for ButtonIcon { - fn toggle_state(mut self, selected: bool) -> Self { - self.selected = selected; - self - } -} - -impl SelectableButton for ButtonIcon { - fn selected_style(mut self, style: ButtonStyle) -> Self { - self.selected_style = Some(style); - self - } -} - -impl RenderOnce for ButtonIcon { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let icon = self - .selected_icon - .filter(|_| self.selected) - .unwrap_or(self.icon); - - let icon_color = if self.disabled { - Color::Disabled - } else if self.selected_style.is_some() && self.selected { - self.selected_style.unwrap().into() - } else if self.selected { - self.selected_icon_color.unwrap_or(Color::Selected) - } else { - self.color - }; - - let icon = Icon::new(icon).size(self.size).color(icon_color); - - match self.indicator { - Some(indicator) => IconWithIndicator::new(icon, Some(indicator)) - .indicator_border_color(self.indicator_border_color) - .into_any_element(), - None => icon.into_any_element(), - } - } -} - -impl Component for ButtonIcon { - fn scope() -> ComponentScope { - ComponentScope::Input - } - - fn name() -> &'static str { - "ButtonIcon" - } - - fn description() -> Option<&'static str> { - Some("An icon component specifically designed for use within buttons.") - } - - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Usage", - vec![ - single_example( - "Default", - ButtonIcon::new(IconName::Star).into_any_element(), - ), - single_example( - "Custom Size", - ButtonIcon::new(IconName::Star) - .size(IconSize::Medium) - .into_any_element(), - ), - single_example( - "Custom Color", - ButtonIcon::new(IconName::Star) - .color(Color::Accent) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "States", - vec![ - single_example( - "Selected", - ButtonIcon::new(IconName::Star) - .toggle_state(true) - .into_any_element(), - ), - single_example( - "Disabled", - ButtonIcon::new(IconName::Star) - .disabled(true) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "With Indicator", - vec![ - single_example( - "Default Indicator", - ButtonIcon::new(IconName::Star) - .indicator(Indicator::dot()) - .into_any_element(), - ), - single_example( - "Custom Indicator", - ButtonIcon::new(IconName::Star) - .indicator(Indicator::dot().color(Color::Error)) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) - } -} diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index 961176ed6cee7e55c7a51cd52719c0eef8a8f181..a103ddf169a8ba3ed9d1b6bf6055ff84858aef7d 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -1,11 +1,11 @@ use gpui::{AnyView, DefiniteLength, Hsla}; use super::button_like::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle}; -use crate::{ElevationIndex, Indicator, SelectableButton, TintColor, prelude::*}; +use crate::{ + ElevationIndex, Icon, IconWithIndicator, Indicator, SelectableButton, TintColor, prelude::*, +}; use crate::{IconName, IconSize}; -use super::button_icon::ButtonIcon; - /// The shape of an [`IconButton`]. #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] pub enum IconButtonShape { @@ -22,6 +22,7 @@ pub struct IconButton { icon_color: Color, selected_icon: Option, selected_icon_color: Option, + selected_style: Option, indicator: Option, indicator_border_color: Option, alpha: Option, @@ -37,6 +38,7 @@ impl IconButton { icon_color: Color::Default, selected_icon: None, selected_icon_color: None, + selected_style: None, indicator: None, indicator_border_color: None, alpha: None, @@ -112,6 +114,7 @@ impl Toggleable for IconButton { impl SelectableButton for IconButton { fn selected_style(mut self, style: ButtonStyle) -> Self { + self.selected_style = Some(style); self.base = self.base.selected_style(style); self } @@ -192,9 +195,25 @@ impl RenderOnce for IconButton { fn render(self, window: &mut Window, cx: &mut App) -> ButtonLike { let is_disabled = self.base.disabled; let is_selected = self.base.selected; - let selected_style = self.base.selected_style; - let color = self.icon_color.color(cx).opacity(self.alpha.unwrap_or(1.0)); + let icon = self + .selected_icon + .filter(|_| is_selected) + .unwrap_or(self.icon); + + let icon_color = if is_disabled { + Color::Disabled + } else if self.selected_style.is_some() && is_selected { + self.selected_style.unwrap().into() + } else if is_selected { + self.selected_icon_color.unwrap_or(Color::Selected) + } else { + let base_color = self.icon_color.color(cx); + Color::Custom(base_color.opacity(self.alpha.unwrap_or(1.0))) + }; + + let icon_element = Icon::new(icon).size(self.icon_size).color(icon_color); + self.base .map(|this| match self.shape { IconButtonShape::Square => { @@ -203,20 +222,12 @@ impl RenderOnce for IconButton { } IconButtonShape::Wide => this, }) - .child( - ButtonIcon::new(self.icon) - .disabled(is_disabled) - .toggle_state(is_selected) - .selected_icon(self.selected_icon) - .selected_icon_color(self.selected_icon_color) - .when_some(selected_style, |this, style| this.selected_style(style)) - .when_some(self.indicator, |this, indicator| { - this.indicator(indicator) - .indicator_border_color(self.indicator_border_color) - }) - .size(self.icon_size) - .color(Color::Custom(color)), - ) + .child(match self.indicator { + Some(indicator) => IconWithIndicator::new(icon_element, Some(indicator)) + .indicator_border_color(self.indicator_border_color) + .into_any_element(), + None => icon_element.into_any_element(), + }) } } diff --git a/crates/ui/src/components/dropdown_menu.rs b/crates/ui/src/components/dropdown_menu.rs index 7a1d3c7dfd77306b2d7b3b6786dae04d6eaee6b2..961608461c04971cda81cfdd64d9eb62577f07ed 100644 --- a/crates/ui/src/components/dropdown_menu.rs +++ b/crates/ui/src/components/dropdown_menu.rs @@ -163,11 +163,10 @@ impl RenderOnce for DropdownMenu { Some( Button::new(self.id.clone(), text) .style(button_style) - .when(self.chevron, |this| { - this.icon(self.trigger_icon) - .icon_position(IconPosition::End) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .when_some(self.trigger_icon.filter(|_| self.chevron), |this, icon| { + this.end_icon( + Icon::new(icon).size(IconSize::XSmall).color(Color::Muted), + ) }) .when(full_width, |this| this.full_width()) .size(trigger_size) diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 9f4b5538ed67bde3f32969467828296485b7810f..29bb9d7b063ff6e4b9f472d708f354fb50f7a2e8 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -917,11 +917,11 @@ pub mod simple_message_notification { })); if let Some(icon) = self.primary_icon { - button = button - .icon(icon) - .icon_color(self.primary_icon_color.unwrap_or(Color::Muted)) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small); + button = button.start_icon( + Icon::new(icon) + .size(IconSize::Small) + .color(self.primary_icon_color.unwrap_or(Color::Muted)), + ); } button @@ -937,11 +937,11 @@ pub mod simple_message_notification { })); if let Some(icon) = self.secondary_icon { - button = button - .icon(icon) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(self.secondary_icon_color.unwrap_or(Color::Muted)); + button = button.start_icon( + Icon::new(icon) + .size(IconSize::Small) + .color(self.secondary_icon_color.unwrap_or(Color::Muted)), + ); } button @@ -955,9 +955,11 @@ pub mod simple_message_notification { let url = url.clone(); Button::new(message.clone(), message.clone()) .label_size(LabelSize::Small) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Indicator) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Indicator) + .color(Color::Muted), + ) .on_click(cx.listener(move |_, _, _, cx| { cx.open_url(&url); })) From ccb2674a77a73169c9d5d41d4ef993dc5ef5b2cc Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 13 Mar 2026 18:17:29 +0100 Subject: [PATCH 217/219] extension_ci: Add infrastructure for this repository (#51493) This will allow us to also use the workflows for this repository, which will especially come in handy once we revisit provider extensions. Not perfect, as we will trigger some failed workflows for extensions that were just added Release Notes: - N/A --- .github/workflows/extension_auto_bump.yml | 72 +++++++++++ .github/workflows/extension_bump.yml | 2 +- .github/workflows/extension_tests.yml | 6 +- .github/workflows/run_tests.yml | 28 ++++- Cargo.lock | 55 +++++++-- Cargo.toml | 2 +- extensions/html/languages/html/brackets.scm | 4 +- tooling/xtask/src/tasks/workflows.rs | 2 + .../tasks/workflows/extension_auto_bump.rs | 113 ++++++++++++++++++ .../src/tasks/workflows/extension_bump.rs | 5 +- .../src/tasks/workflows/extension_tests.rs | 8 +- .../xtask/src/tasks/workflows/run_tests.rs | 108 +++++++++++++---- 12 files changed, 358 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/extension_auto_bump.yml create mode 100644 tooling/xtask/src/tasks/workflows/extension_auto_bump.rs diff --git a/.github/workflows/extension_auto_bump.yml b/.github/workflows/extension_auto_bump.yml new file mode 100644 index 0000000000000000000000000000000000000000..215cdbe5eec30b1e9212616bcd1e1d89ecf9e564 --- /dev/null +++ b/.github/workflows/extension_auto_bump.yml @@ -0,0 +1,72 @@ +# Generated from xtask::workflows::extension_auto_bump +# Rebuild with `cargo xtask workflows`. +name: extension_auto_bump +on: + push: + branches: + - main + paths: + - extensions/** + - '!extensions/workflows/**' + - '!extensions/*.md' +jobs: + detect_changed_extensions: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: 2 + - id: detect + name: extension_auto_bump::detect_changed_extensions + run: | + COMPARE_REV="$(git rev-parse HEAD~1)" + CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")" + # Detect changed extension directories (excluding extensions/workflows) + CHANGED_EXTENSIONS=$(echo "$CHANGED_FILES" | grep -oP '^extensions/[^/]+(?=/)' | sort -u | grep -v '^extensions/workflows$' || true) + if [ -n "$CHANGED_EXTENSIONS" ]; then + EXTENSIONS_JSON=$(echo "$CHANGED_EXTENSIONS" | jq -R -s -c 'split("\n") | map(select(length > 0))') + else + EXTENSIONS_JSON="[]" + fi + # Filter out newly added or entirely removed extensions + FILTERED="[]" + for ext in $(echo "$EXTENSIONS_JSON" | jq -r '.[]'); do + if git show HEAD~1:"$ext/extension.toml" >/dev/null 2>&1 && \ + [ -f "$ext/extension.toml" ]; then + FILTERED=$(echo "$FILTERED" | jq --arg e "$ext" '. + [$e]') + fi + done + echo "changed_extensions=$FILTERED" >> "$GITHUB_OUTPUT" + outputs: + changed_extensions: ${{ steps.detect.outputs.changed_extensions }} + timeout-minutes: 5 + bump_extension_versions: + needs: + - detect_changed_extensions + if: needs.detect_changed_extensions.outputs.changed_extensions != '[]' + permissions: + actions: write + contents: write + issues: write + pull-requests: write + strategy: + matrix: + extension: ${{ fromJson(needs.detect_changed_extensions.outputs.changed_extensions) }} + fail-fast: false + max-parallel: 1 + uses: ./.github/workflows/extension_bump.yml + secrets: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + app-secret: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + with: + working-directory: ${{ matrix.extension }} + force-bump: false +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + cancel-in-progress: true +defaults: + run: + shell: bash -euxo pipefail {0} diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index e61e98f4042826858e54c6f5565c5fd62f280553..31f34c9299cee8b464162d501aecaa2bb70035d6 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -214,7 +214,7 @@ jobs: shell: bash -euxo pipefail {0} working-directory: ${{ inputs.working-directory }} concurrency: - group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}extension-bump cancel-in-progress: true defaults: run: diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index de9b4dc047a039c0f6af063c2a95fdecd70e8cba..89668c028a6d1fa4baddd417687226dd55a52426 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -216,12 +216,8 @@ jobs: RESULT_ORCHESTRATE: ${{ needs.orchestrate.result }} RESULT_CHECK_RUST: ${{ needs.check_rust.result }} RESULT_CHECK_EXTENSION: ${{ needs.check_extension.result }} - defaults: - run: - shell: bash -euxo pipefail {0} - working-directory: ${{ inputs.working-directory }} concurrency: - group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}extension-tests cancel-in-progress: true defaults: run: diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index b1d8c1fff3c9f48e62f42fab05473d5f38aad2ce..fed05e00459b3c688c4244ddb9ea29ec1dbfd564 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -103,13 +103,22 @@ jobs: check_pattern "run_action_checks" '^\.github/(workflows/|actions/|actionlint.yml)|tooling/xtask|script/' -qP check_pattern "run_docs" '^(docs/|crates/.*\.rs)' -qP check_pattern "run_licenses" '^(Cargo.lock|script/.*licenses)' -qP - check_pattern "run_tests" '^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests)))' -qvP + check_pattern "run_tests" '^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests))|extensions/)' -qvP + # Detect changed extension directories (excluding extensions/workflows) + CHANGED_EXTENSIONS=$(echo "$CHANGED_FILES" | grep -oP '^extensions/[^/]+(?=/)' | sort -u | grep -v '^extensions/workflows$' || true) + if [ -n "$CHANGED_EXTENSIONS" ]; then + EXTENSIONS_JSON=$(echo "$CHANGED_EXTENSIONS" | jq -R -s -c 'split("\n") | map(select(length > 0))') + else + EXTENSIONS_JSON="[]" + fi + echo "changed_extensions=$EXTENSIONS_JSON" >> "$GITHUB_OUTPUT" outputs: changed_packages: ${{ steps.filter.outputs.changed_packages }} run_action_checks: ${{ steps.filter.outputs.run_action_checks }} run_docs: ${{ steps.filter.outputs.run_docs }} run_licenses: ${{ steps.filter.outputs.run_licenses }} run_tests: ${{ steps.filter.outputs.run_tests }} + changed_extensions: ${{ steps.filter.outputs.changed_extensions }} check_style: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') runs-on: namespace-profile-4x8-ubuntu-2204 @@ -711,6 +720,20 @@ jobs: - name: run_tests::check_postgres_and_protobuf_migrations::check_protobuf_formatting run: buf format --diff --exit-code crates/proto/proto timeout-minutes: 60 + extension_tests: + needs: + - orchestrate + if: needs.orchestrate.outputs.changed_extensions != '[]' + permissions: + contents: read + strategy: + matrix: + extension: ${{ fromJson(needs.orchestrate.outputs.changed_extensions) }} + fail-fast: false + max-parallel: 1 + uses: ./.github/workflows/extension_tests.yml + with: + working-directory: ${{ matrix.extension }} tests_pass: needs: - orchestrate @@ -728,6 +751,7 @@ jobs: - check_docs - check_licenses - check_scripts + - extension_tests if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && always() runs-on: namespace-profile-2x4-ubuntu-2404 steps: @@ -756,6 +780,7 @@ jobs: check_result "check_docs" "$RESULT_CHECK_DOCS" check_result "check_licenses" "$RESULT_CHECK_LICENSES" check_result "check_scripts" "$RESULT_CHECK_SCRIPTS" + check_result "extension_tests" "$RESULT_EXTENSION_TESTS" exit $EXIT_CODE env: @@ -774,6 +799,7 @@ jobs: RESULT_CHECK_DOCS: ${{ needs.check_docs.result }} RESULT_CHECK_LICENSES: ${{ needs.check_licenses.result }} RESULT_CHECK_SCRIPTS: ${{ needs.check_scripts.result }} + RESULT_EXTENSION_TESTS: ${{ needs.extension_tests.result }} concurrency: group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} cancel-in-progress: true diff --git a/Cargo.lock b/Cargo.lock index 4e347d40f3f0e0f23f48770537e7df92d8bd862a..65d7f7ccb5ae148e337257d52f71ac2cc4aeebc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2193,7 +2193,7 @@ version = "3.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365" dependencies = [ - "darling", + "darling 0.20.11", "ident_case", "prettyplease", "proc-macro2", @@ -2459,7 +2459,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.117", @@ -4513,8 +4513,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -4531,13 +4541,38 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn 2.0.117", ] @@ -4808,11 +4843,11 @@ dependencies = [ [[package]] name = "derive_setters" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae5c625eda104c228c06ecaf988d1c60e542176bd7a490e60eeda3493244c0c9" +checksum = "b7e6f6fa1f03c14ae082120b84b3c7fbd7b8588d924cf2d7c3daf9afd49df8b9" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.117", @@ -7143,7 +7178,7 @@ dependencies = [ [[package]] name = "gh-workflow" version = "0.8.0" -source = "git+https://github.com/zed-industries/gh-workflow?rev=c9eac0ed361583e1072860d96776fa52775b82ac#c9eac0ed361583e1072860d96776fa52775b82ac" +source = "git+https://github.com/zed-industries/gh-workflow?rev=37f3c0575d379c218a9c455ee67585184e40d43f#37f3c0575d379c218a9c455ee67585184e40d43f" dependencies = [ "async-trait", "derive_more", @@ -7160,7 +7195,7 @@ dependencies = [ [[package]] name = "gh-workflow-macros" version = "0.8.0" -source = "git+https://github.com/zed-industries/gh-workflow?rev=c9eac0ed361583e1072860d96776fa52775b82ac#c9eac0ed361583e1072860d96776fa52775b82ac" +source = "git+https://github.com/zed-industries/gh-workflow?rev=37f3c0575d379c218a9c455ee67585184e40d43f#37f3c0575d379c218a9c455ee67585184e40d43f" dependencies = [ "heck 0.5.0", "quote", diff --git a/Cargo.toml b/Cargo.toml index 36e7ca8cc7129af0ed7ab29dc5db338cdf33f7d4..754860cc43f5b841e45316a0434b37886e901a0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -558,7 +558,7 @@ fork = "0.4.0" futures = "0.3" futures-concurrency = "7.7.1" futures-lite = "1.13" -gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "c9eac0ed361583e1072860d96776fa52775b82ac" } +gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "37f3c0575d379c218a9c455ee67585184e40d43f" } git2 = { version = "0.20.1", default-features = false, features = ["vendored-libgit2"] } globset = "0.4" handlebars = "4.3" diff --git a/extensions/html/languages/html/brackets.scm b/extensions/html/languages/html/brackets.scm index adc11a1d7408ae33b80f0daa78a03d8f3352b745..02619c109f3ff2d830948e8e8c4889e1e733fae9 100644 --- a/extensions/html/languages/html/brackets.scm +++ b/extensions/html/languages/html/brackets.scm @@ -2,11 +2,11 @@ "/>" @close) (#set! rainbow.exclude)) -(("" @close) (#set! rainbow.exclude)) -(("<" @open +(("" @close) (#set! rainbow.exclude)) diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 26596c9401c1d3c500a8c1cb18083d525c934e20..35f053f46666a4d5e81bffe27bc80490c20c166d 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -13,6 +13,7 @@ mod cherry_pick; mod compare_perf; mod danger; mod deploy_collab; +mod extension_auto_bump; mod extension_bump; mod extension_tests; mod extension_workflow_rollout; @@ -199,6 +200,7 @@ pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> { WorkflowFile::zed(danger::danger), WorkflowFile::zed(deploy_collab::deploy_collab), WorkflowFile::zed(extension_bump::extension_bump), + WorkflowFile::zed(extension_auto_bump::extension_auto_bump), WorkflowFile::zed(extension_tests::extension_tests), WorkflowFile::zed(extension_workflow_rollout::extension_workflow_rollout), WorkflowFile::zed(publish_extension_cli::publish_extension_cli), diff --git a/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs b/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs new file mode 100644 index 0000000000000000000000000000000000000000..3201fdb1f65233c096738670e48d1b7def1a8975 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs @@ -0,0 +1,113 @@ +use gh_workflow::{ + Event, Expression, Input, Job, Level, Permissions, Push, Strategy, UsesJob, Workflow, +}; +use indoc::indoc; +use serde_json::json; + +use crate::tasks::workflows::{ + extensions::WithAppSecrets, + run_tests::DETECT_CHANGED_EXTENSIONS_SCRIPT, + runners, + steps::{self, CommonJobConditions, NamedJob, named}, + vars::{StepOutput, one_workflow_per_non_main_branch}, +}; + +/// Generates a workflow that triggers on push to main, detects changed extensions +/// in the `extensions/` directory, and invokes the `extension_bump` reusable workflow +/// for each changed extension via a matrix strategy. +pub(crate) fn extension_auto_bump() -> Workflow { + let detect = detect_changed_extensions(); + let bump = bump_extension_versions(&detect); + + named::workflow() + .add_event( + Event::default().push( + Push::default() + .add_branch("main") + .add_path("extensions/**") + .add_path("!extensions/workflows/**") + .add_path("!extensions/*.md"), + ), + ) + .concurrency(one_workflow_per_non_main_branch()) + .add_job(detect.name, detect.job) + .add_job(bump.name, bump.job) +} + +fn detect_changed_extensions() -> NamedJob { + let preamble = indoc! {r#" + COMPARE_REV="$(git rev-parse HEAD~1)" + CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")" + "#}; + + let filter_new_and_removed = indoc! {r#" + # Filter out newly added or entirely removed extensions + FILTERED="[]" + for ext in $(echo "$EXTENSIONS_JSON" | jq -r '.[]'); do + if git show HEAD~1:"$ext/extension.toml" >/dev/null 2>&1 && \ + [ -f "$ext/extension.toml" ]; then + FILTERED=$(echo "$FILTERED" | jq --arg e "$ext" '. + [$e]') + fi + done + echo "changed_extensions=$FILTERED" >> "$GITHUB_OUTPUT" + "#}; + + let script = format!( + "{preamble}{detect}{filter}", + preamble = preamble, + detect = DETECT_CHANGED_EXTENSIONS_SCRIPT, + filter = filter_new_and_removed, + ); + + let step = named::bash(script).id("detect"); + + let output = StepOutput::new(&step, "changed_extensions"); + + let job = Job::default() + .with_repository_owner_guard() + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(5u32) + .add_step(steps::checkout_repo().with_custom_fetch_depth(2)) + .add_step(step) + .outputs([("changed_extensions".to_owned(), output.to_string())]); + + named::job(job) +} + +fn bump_extension_versions(detect_job: &NamedJob) -> NamedJob { + let job = Job::default() + .needs(vec![detect_job.name.clone()]) + .cond(Expression::new(format!( + "needs.{}.outputs.changed_extensions != '[]'", + detect_job.name + ))) + .permissions( + Permissions::default() + .contents(Level::Write) + .issues(Level::Write) + .pull_requests(Level::Write) + .actions(Level::Write), + ) + .strategy( + Strategy::default() + .fail_fast(false) + // TODO: Remove the limit. We currently need this to workaround the concurrency group issue + // where different matrix jobs would be placed in the same concurrency group and thus cancelled. + .max_parallel(1u32) + .matrix(json!({ + "extension": format!( + "${{{{ fromJson(needs.{}.outputs.changed_extensions) }}}}", + detect_job.name + ) + })), + ) + .uses_local(".github/workflows/extension_bump.yml") + .with( + Input::default() + .add("working-directory", "${{ matrix.extension }}") + .add("force-bump", false), + ) + .with_app_secrets(); + + named::job(job) +} diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index e31800e3ecd4a1039e7a1a191fffa735f64f84f2..91d2e5645f9f5e9fd24dbceaf5e2ad6886e41cb6 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -9,7 +9,8 @@ use crate::tasks::workflows::{ NamedJob, checkout_repo, dependant_job, named, }, vars::{ - JobOutput, StepOutput, WorkflowInput, WorkflowSecret, one_workflow_per_non_main_branch, + JobOutput, StepOutput, WorkflowInput, WorkflowSecret, + one_workflow_per_non_main_branch_and_token, }, }; @@ -70,7 +71,7 @@ pub(crate) fn extension_bump() -> Workflow { ]), ), ) - .concurrency(one_workflow_per_non_main_branch()) + .concurrency(one_workflow_per_non_main_branch_and_token("extension-bump")) .add_env(("CARGO_TERM_COLOR", "always")) .add_env(("RUST_BACKTRACE", 1)) .add_env(("CARGO_INCREMENTAL", 0)) diff --git a/tooling/xtask/src/tasks/workflows/extension_tests.rs b/tooling/xtask/src/tasks/workflows/extension_tests.rs index a50db3f98bf7bec887ea69f841f547ad717976f9..caf57ce130f7d7e9f0018ef20d4cf4892823f4ab 100644 --- a/tooling/xtask/src/tasks/workflows/extension_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extension_tests.rs @@ -9,7 +9,7 @@ use crate::tasks::workflows::{ self, BASH_SHELL, CommonJobConditions, FluentBuilder, NamedJob, cache_rust_dependencies_namespace, named, }, - vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch}, + vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch_and_token}, }; pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "03d8e9aee95ea6117d75a48bcac2e19241f6e667"; @@ -34,7 +34,7 @@ pub(crate) fn extension_tests() -> Workflow { should_check_extension.guard(check_extension()), ]; - let tests_pass = with_extension_defaults(tests_pass(&jobs)); + let tests_pass = tests_pass(&jobs, &[]); let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned())); @@ -45,7 +45,9 @@ pub(crate) fn extension_tests() -> Workflow { .add_input(working_directory.name, working_directory.call_input()), ), ) - .concurrency(one_workflow_per_non_main_branch()) + .concurrency(one_workflow_per_non_main_branch_and_token( + "extension-tests", + )) .add_env(("CARGO_TERM_COLOR", "always")) .add_env(("RUST_BACKTRACE", 1)) .add_env(("CARGO_INCREMENTAL", 0)) diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index f134fa166d6dfe2ef00e47516e33d658a71badd9..3ca8e456346dc5b1bbea89ca40993456e4f1354c 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -1,9 +1,10 @@ use gh_workflow::{ - Concurrency, Container, Event, Expression, Job, Port, PullRequest, Push, Run, Step, Use, - Workflow, + Concurrency, Container, Event, Expression, Input, Job, Level, Permissions, Port, PullRequest, + Push, Run, Step, Strategy, Use, UsesJob, Workflow, }; use indexmap::IndexMap; use indoc::formatdoc; +use serde_json::json; use crate::tasks::workflows::{ steps::{ @@ -24,9 +25,10 @@ pub(crate) fn run_tests() -> Workflow { // - script/update_top_ranking_issues/ // - .github/ISSUE_TEMPLATE/ // - .github/workflows/ (except .github/workflows/ci.yml) + // - extensions/ (these have their own test workflow) let should_run_tests = PathCondition::inverted( "run_tests", - r"^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests)))", + r"^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests))|extensions/)", ); let should_check_docs = PathCondition::new("run_docs", r"^(docs/|crates/.*\.rs)"); let should_check_scripts = PathCondition::new( @@ -60,7 +62,8 @@ pub(crate) fn run_tests() -> Workflow { should_check_licences.guard(check_licenses()), should_check_scripts.guard(check_scripts()), ]; - let tests_pass = tests_pass(&jobs); + let ext_tests = extension_tests(); + let tests_pass = tests_pass(&jobs, &[&ext_tests.name]); jobs.push(should_run_tests.guard(check_postgres_and_protobuf_migrations())); // could be more specific here? @@ -91,24 +94,32 @@ pub(crate) fn run_tests() -> Workflow { } workflow }) + .add_job(ext_tests.name, ext_tests.job) .add_job(tests_pass.name, tests_pass.job) } +/// Controls which features `orchestrate_impl` includes in the generated script. +#[derive(PartialEq, Eq)] +enum OrchestrateTarget { + /// For the main Zed repo: includes the cargo package filter and extension + /// change detection, but no working-directory scoping. + ZedRepo, + /// For individual extension repos: scopes changed-file detection to the + /// working directory, with no package filter or extension detection. + Extension, +} + // Generates a bash script that checks changed files against regex patterns // and sets GitHub output variables accordingly pub fn orchestrate(rules: &[&PathCondition]) -> NamedJob { - orchestrate_impl(rules, true, false) + orchestrate_impl(rules, OrchestrateTarget::ZedRepo) } pub fn orchestrate_for_extension(rules: &[&PathCondition]) -> NamedJob { - orchestrate_impl(rules, false, true) + orchestrate_impl(rules, OrchestrateTarget::Extension) } -fn orchestrate_impl( - rules: &[&PathCondition], - include_package_filter: bool, - filter_by_working_directory: bool, -) -> NamedJob { +fn orchestrate_impl(rules: &[&PathCondition], target: OrchestrateTarget) -> NamedJob { let name = "orchestrate".to_owned(); let step_name = "filter".to_owned(); let mut script = String::new(); @@ -127,7 +138,7 @@ fn orchestrate_impl( "#}); - if filter_by_working_directory { + if target == OrchestrateTarget::Extension { script.push_str(indoc::indoc! {r#" # When running from a subdirectory, git diff returns repo-root-relative paths. # Filter to only files within the current working directory and strip the prefix. @@ -155,7 +166,7 @@ fn orchestrate_impl( let mut outputs = IndexMap::new(); - if include_package_filter { + if target == OrchestrateTarget::ZedRepo { script.push_str(indoc::indoc! {r#" # Check for changes that require full rebuild (no filter) # Direct pushes to main/stable/preview always run full suite @@ -241,6 +252,16 @@ fn orchestrate_impl( )); } + if target == OrchestrateTarget::ZedRepo { + script.push_str(DETECT_CHANGED_EXTENSIONS_SCRIPT); + script.push_str("echo \"changed_extensions=$EXTENSIONS_JSON\" >> \"$GITHUB_OUTPUT\"\n"); + + outputs.insert( + "changed_extensions".to_owned(), + format!("${{{{ steps.{}.outputs.changed_extensions }}}}", step_name), + ); + } + let job = Job::default() .runs_on(runners::LINUX_SMALL) .with_repository_owner_guard() @@ -251,7 +272,7 @@ fn orchestrate_impl( NamedJob { name, job } } -pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob { +pub fn tests_pass(jobs: &[NamedJob], extra_job_names: &[&str]) -> NamedJob { let mut script = String::from(indoc::indoc! {r#" set +x EXIT_CODE=0 @@ -263,20 +284,26 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob { "#}); - let env_entries: Vec<_> = jobs + let all_names: Vec<&str> = jobs + .iter() + .map(|job| job.name.as_str()) + .chain(extra_job_names.iter().copied()) + .collect(); + + let env_entries: Vec<_> = all_names .iter() - .map(|job| { - let env_name = format!("RESULT_{}", job.name.to_uppercase()); - let env_value = format!("${{{{ needs.{}.result }}}}", job.name); + .map(|name| { + let env_name = format!("RESULT_{}", name.to_uppercase()); + let env_value = format!("${{{{ needs.{}.result }}}}", name); (env_name, env_value) }) .collect(); script.push_str( - &jobs + &all_names .iter() .zip(env_entries.iter()) - .map(|(job, (env_name, _))| format!("check_result \"{}\" \"${}\"", job.name, env_name)) + .map(|(name, (env_name, _))| format!("check_result \"{}\" \"${}\"", name, env_name)) .collect::>() .join("\n"), ); @@ -286,8 +313,9 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob { let job = Job::default() .runs_on(runners::LINUX_SMALL) .needs( - jobs.iter() - .map(|j| j.name.to_string()) + all_names + .iter() + .map(|name| name.to_string()) .collect::>(), ) .cond(repository_owner_guard_expression(true)) @@ -302,6 +330,19 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob { named::job(job) } +/// Bash script snippet that detects changed extension directories from `$CHANGED_FILES`. +/// Assumes `$CHANGED_FILES` is already set. Sets `$EXTENSIONS_JSON` to a JSON array of +/// changed extension paths. Callers are responsible for writing the result to `$GITHUB_OUTPUT`. +pub(crate) const DETECT_CHANGED_EXTENSIONS_SCRIPT: &str = indoc::indoc! {r#" + # Detect changed extension directories (excluding extensions/workflows) + CHANGED_EXTENSIONS=$(echo "$CHANGED_FILES" | grep -oP '^extensions/[^/]+(?=/)' | sort -u | grep -v '^extensions/workflows$' || true) + if [ -n "$CHANGED_EXTENSIONS" ]; then + EXTENSIONS_JSON=$(echo "$CHANGED_EXTENSIONS" | jq -R -s -c 'split("\n") | map(select(length > 0))') + else + EXTENSIONS_JSON="[]" + fi +"#}; + const TS_QUERY_LS_FILE: &str = "ts_query_ls-x86_64-unknown-linux-gnu.tar.gz"; const CI_TS_QUERY_RELEASE: &str = "tags/v3.15.1"; @@ -712,3 +753,26 @@ pub(crate) fn check_scripts() -> NamedJob { .add_step(check_xtask_workflows()), ) } + +fn extension_tests() -> NamedJob { + let job = Job::default() + .needs(vec!["orchestrate".to_owned()]) + .cond(Expression::new( + "needs.orchestrate.outputs.changed_extensions != '[]'", + )) + .permissions(Permissions::default().contents(Level::Read)) + .strategy( + Strategy::default() + .fail_fast(false) + // TODO: Remove the limit. We currently need this to workaround the concurrency group issue + // where different matrix jobs would be placed in the same concurrency group and thus cancelled. + .max_parallel(1u32) + .matrix(json!({ + "extension": "${{ fromJson(needs.orchestrate.outputs.changed_extensions) }}" + })), + ) + .uses_local(".github/workflows/extension_tests.yml") + .with(Input::default().add("working-directory", "${{ matrix.extension }}")); + + named::job(job) +} From bb6a6e03052ac4ffe556b3e83bf0018a53f9c486 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 13 Mar 2026 18:30:15 +0100 Subject: [PATCH 218/219] ci: Fix jq command (#51510) Sigh.. The missing flag caused the wrong output to be used, resulting in an error in the process. Release Notes: - N/A --- .github/workflows/extension_auto_bump.yml | 2 +- tooling/xtask/src/tasks/workflows/extension_auto_bump.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/extension_auto_bump.yml b/.github/workflows/extension_auto_bump.yml index 215cdbe5eec30b1e9212616bcd1e1d89ecf9e564..f5203800958c51ee0c6bc0f0ee0fb76da826def5 100644 --- a/.github/workflows/extension_auto_bump.yml +++ b/.github/workflows/extension_auto_bump.yml @@ -36,7 +36,7 @@ jobs: for ext in $(echo "$EXTENSIONS_JSON" | jq -r '.[]'); do if git show HEAD~1:"$ext/extension.toml" >/dev/null 2>&1 && \ [ -f "$ext/extension.toml" ]; then - FILTERED=$(echo "$FILTERED" | jq --arg e "$ext" '. + [$e]') + FILTERED=$(echo "$FILTERED" | jq -c --arg e "$ext" '. + [$e]') fi done echo "changed_extensions=$FILTERED" >> "$GITHUB_OUTPUT" diff --git a/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs b/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs index 3201fdb1f65233c096738670e48d1b7def1a8975..14c15f39ad76b48402609023c604e17ea49bc432 100644 --- a/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs @@ -46,7 +46,7 @@ fn detect_changed_extensions() -> NamedJob { for ext in $(echo "$EXTENSIONS_JSON" | jq -r '.[]'); do if git show HEAD~1:"$ext/extension.toml" >/dev/null 2>&1 && \ [ -f "$ext/extension.toml" ]; then - FILTERED=$(echo "$FILTERED" | jq --arg e "$ext" '. + [$e]') + FILTERED=$(echo "$FILTERED" | jq -c --arg e "$ext" '. + [$e]') fi done echo "changed_extensions=$FILTERED" >> "$GITHUB_OUTPUT" From 231b0ccf82f55b4312fd34b07c3ef9cd7d51664b Mon Sep 17 00:00:00 2001 From: rcmz <40456553+rcmz@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:34:54 +0100 Subject: [PATCH 219/219] glsl: Add `task` and `mesh` path suffixes (#50605) The GLSL language extension was missing the "task" and "mesh" path suffixes for task and mesh shaders. "task" and "mesh" are the official suffixes used in glslang. Release Notes: - N/A Co-authored-by: MrSubidubi --- extensions/glsl/languages/glsl/config.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/glsl/languages/glsl/config.toml b/extensions/glsl/languages/glsl/config.toml index 0c71419c91e40f4b5fc65c10c882ac5c542a080c..ecb1a43f6803e40cd7e2bf003be5c32066dae3fd 100644 --- a/extensions/glsl/languages/glsl/config.toml +++ b/extensions/glsl/languages/glsl/config.toml @@ -5,6 +5,8 @@ path_suffixes = [ "vert", "frag", "tesc", "tese", "geom", # Compute shaders "comp", + # Mesh pipeline shaders + "task", "mesh", # Ray tracing pipeline shaders "rgen", "rint", "rahit", "rchit", "rmiss", "rcall", # Other