From 73cd7ec6248293c6acd8ed6e5fa1cc24b9ef37cf Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:45:29 -0400 Subject: [PATCH 01/15] git_graph: Make the graph canvas resizable (#52953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary This PR integrates the git graph canvas with the `Table` component’s `RedistributableColumnsState`, making the graph column resizable while preserving the table’s existing resize behavior. In particular, column resizing continues to use the same cascading redistribution behavior as the table. This is also the last PR needed to remove the feature flag on the git graph! ### Table API changes I pulled the redistributable column logic out of `Table` into reusable UI helpers so layouts outside of `Table` can participate in the same column resizing behavior. This adds a shared `RedistributableColumnsState` API, along with helpers for binding drag/drop behavior, rendering resize handles, and constructing header resize metadata. I also added `ColumnWidthConfig::explicit` and `TableRenderContext::for_column_widths` so callers can render table like headers and content with externally managed column widths. The reason for this change is that the git graph now renders a custom split layout: a graph canvas on the left and table content on the right. By reusing the same column state and resize machinery, the graph column can resize together with the table columns while preserving the existing table behavior, including cascading column redistribution and double click reset to default sizing. I also adjusted the resize handle interaction styling so the divider stays in its hovered/highlighted state while a drag is active, which makes the drag target feel more stable and visually consistent during resizing. ### Preview https://github.com/user-attachments/assets/347eed71-0cc1-4db4-9dee-a86ee5ab6f91 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A or Added/Fixed/Improved ... --- crates/git_graph/src/git_graph.rs | 402 ++++++++----- crates/ui/src/components.rs | 2 + crates/ui/src/components/data_table.rs | 532 ++---------------- crates/ui/src/components/data_table/tests.rs | 3 +- .../src/components/redistributable_columns.rs | 485 ++++++++++++++++ 5 files changed, 824 insertions(+), 600 deletions(-) create mode 100644 crates/ui/src/components/redistributable_columns.rs diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index d473fbbec618c6e7b309ab2ff9dc9eb5787ddc43..bb1566aa29eeae016d31ac549434e7b92d50eb4d 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -42,8 +42,10 @@ use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, - HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, - TableResizeBehavior, Tooltip, WithScrollbar, prelude::*, + HeaderResizeInfo, HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table, + TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, + bind_redistributable_columns, prelude::*, render_redistributable_columns_resize_handles, + render_table_header, table_row::TableRow, }; use workspace::{ Workspace, @@ -901,9 +903,8 @@ pub struct GitGraph { context_menu: Option<(Entity, Point, Subscription)>, row_height: Pixels, table_interaction_state: Entity, - table_column_widths: Entity, + column_widths: Entity, horizontal_scroll_offset: Pixels, - graph_viewport_width: Pixels, selected_entry_idx: Option, hovered_entry_idx: Option, graph_canvas_bounds: Rc>>>, @@ -933,8 +934,60 @@ impl GitGraph { font_size + px(12.0) } - fn graph_content_width(&self) -> Pixels { - (LANE_WIDTH * self.graph_data.max_lanes.min(8) as f32) + LEFT_PADDING * 2.0 + fn graph_canvas_content_width(&self) -> Pixels { + (LANE_WIDTH * self.graph_data.max_lanes.max(6) as f32) + LEFT_PADDING * 2.0 + } + + fn preview_column_fractions(&self, window: &Window, cx: &App) -> [f32; 5] { + let fractions = self + .column_widths + .read(cx) + .preview_fractions(window.rem_size()); + [ + fractions[0], + fractions[1], + fractions[2], + fractions[3], + fractions[4], + ] + } + + fn table_column_width_config(&self, window: &Window, cx: &App) -> ColumnWidthConfig { + let [_, description, date, author, commit] = self.preview_column_fractions(window, cx); + let table_total = description + date + author + commit; + + let widths = if table_total > 0.0 { + vec![ + DefiniteLength::Fraction(description / table_total), + DefiniteLength::Fraction(date / table_total), + DefiniteLength::Fraction(author / table_total), + DefiniteLength::Fraction(commit / table_total), + ] + } else { + vec![ + DefiniteLength::Fraction(0.25), + DefiniteLength::Fraction(0.25), + DefiniteLength::Fraction(0.25), + DefiniteLength::Fraction(0.25), + ] + }; + + ColumnWidthConfig::explicit(widths) + } + + fn graph_viewport_width(&self, window: &Window, cx: &App) -> Pixels { + self.column_widths + .read(cx) + .preview_column_width(0, window) + .unwrap_or_else(|| self.graph_canvas_content_width()) + } + + fn clamp_horizontal_scroll_offset(&mut self, graph_viewport_width: Pixels) { + let max_horizontal_scroll = + (self.graph_canvas_content_width() - graph_viewport_width).max(px(0.)); + self.horizontal_scroll_offset = self + .horizontal_scroll_offset + .clamp(px(0.), max_horizontal_scroll); } pub fn new( @@ -972,20 +1025,22 @@ impl GitGraph { }); let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx)); - let table_column_widths = cx.new(|_cx| { + let column_widths = cx.new(|_cx| { RedistributableColumnsState::new( - 4, + 5, vec![ - DefiniteLength::Fraction(0.72), - DefiniteLength::Fraction(0.12), - DefiniteLength::Fraction(0.10), - DefiniteLength::Fraction(0.06), + DefiniteLength::Fraction(0.14), + DefiniteLength::Fraction(0.6192), + DefiniteLength::Fraction(0.1032), + DefiniteLength::Fraction(0.086), + DefiniteLength::Fraction(0.0516), ], vec![ TableResizeBehavior::Resizable, TableResizeBehavior::Resizable, TableResizeBehavior::Resizable, TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, ], ) }); @@ -1020,9 +1075,8 @@ impl GitGraph { context_menu: None, row_height, table_interaction_state, - table_column_widths, + column_widths, horizontal_scroll_offset: px(0.), - graph_viewport_width: px(88.), selected_entry_idx: None, hovered_entry_idx: None, graph_canvas_bounds: Rc::new(Cell::new(None)), @@ -2089,8 +2143,12 @@ impl GitGraph { let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height); let horizontal_scroll_offset = self.horizontal_scroll_offset; - let max_lanes = self.graph_data.max_lanes.max(6); - let graph_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0; + let graph_viewport_width = self.graph_viewport_width(window, cx); + let graph_width = if self.graph_canvas_content_width() > graph_viewport_width { + self.graph_canvas_content_width() + } else { + graph_viewport_width + }; let last_visible_row = first_visible_row + (viewport_height / row_height).ceil() as usize + 1; @@ -2414,9 +2472,9 @@ impl GitGraph { let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.)); let new_offset = Point::new(current_offset.x, new_y); - let max_lanes = self.graph_data.max_lanes.max(1); - let graph_content_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0; - let max_horizontal_scroll = (graph_content_width - self.graph_viewport_width).max(px(0.)); + let graph_viewport_width = self.graph_viewport_width(window, cx); + let max_horizontal_scroll = + (self.graph_canvas_content_width() - graph_viewport_width).max(px(0.)); let new_horizontal_offset = (self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll); @@ -2497,6 +2555,8 @@ impl Render for GitGraph { cx, ); self.graph_data.add_commits(&commits); + let graph_viewport_width = self.graph_viewport_width(window, cx); + self.clamp_horizontal_scroll_offset(graph_viewport_width); (commits.len(), is_loading) }) } else { @@ -2527,118 +2587,202 @@ impl Render for GitGraph { this.child(self.render_loading_spinner(cx)) }) } else { - div() + let header_resize_info = HeaderResizeInfo::from_state(&self.column_widths, cx); + let header_context = TableRenderContext::for_column_widths( + Some(self.column_widths.read(cx).widths_to_render()), + true, + ); + let [ + graph_fraction, + description_fraction, + date_fraction, + author_fraction, + commit_fraction, + ] = self.preview_column_fractions(window, cx); + let table_fraction = + description_fraction + date_fraction + author_fraction + commit_fraction; + let table_width_config = self.table_column_width_config(window, cx); + let graph_viewport_width = self.graph_viewport_width(window, cx); + self.clamp_horizontal_scroll_offset(graph_viewport_width); + + h_flex() .size_full() - .flex() - .flex_row() .child( div() - .w(self.graph_content_width()) - .h_full() + .flex_1() + .min_w_0() + .size_full() .flex() .flex_col() - .child( - div() - .flex() - .items_center() - .px_1() - .py_0p5() - .border_b_1() - .whitespace_nowrap() - .border_color(cx.theme().colors().border) - .child(Label::new("Graph").color(Color::Muted)), - ) - .child( - div() - .id("graph-canvas") - .flex_1() - .overflow_hidden() - .child(self.render_graph(window, cx)) - .on_scroll_wheel(cx.listener(Self::handle_graph_scroll)) - .on_mouse_move(cx.listener(Self::handle_graph_mouse_move)) - .on_click(cx.listener(Self::handle_graph_click)) - .on_hover(cx.listener(|this, &is_hovered: &bool, _, cx| { - if !is_hovered && this.hovered_entry_idx.is_some() { - this.hovered_entry_idx = None; - cx.notify(); - } - })), - ), - ) - .child({ - let row_height = self.row_height; - let selected_entry_idx = self.selected_entry_idx; - let hovered_entry_idx = self.hovered_entry_idx; - let weak_self = cx.weak_entity(); - let focus_handle = self.focus_handle.clone(); - div().flex_1().size_full().child( - Table::new(4) - .interactable(&self.table_interaction_state) - .hide_row_borders() - .hide_row_hover() - .header(vec![ - Label::new("Description") - .color(Color::Muted) - .into_any_element(), - Label::new("Date").color(Color::Muted).into_any_element(), - Label::new("Author").color(Color::Muted).into_any_element(), - Label::new("Commit").color(Color::Muted).into_any_element(), - ]) - .width_config(ColumnWidthConfig::redistributable( - self.table_column_widths.clone(), - )) - .map_row(move |(index, row), window, cx| { - let is_selected = selected_entry_idx == Some(index); - let is_hovered = hovered_entry_idx == Some(index); - let is_focused = focus_handle.is_focused(window); - let weak = weak_self.clone(); - let weak_for_hover = weak.clone(); - - let hover_bg = cx.theme().colors().element_hover.opacity(0.6); - let selected_bg = if is_focused { - cx.theme().colors().element_selected - } else { - cx.theme().colors().element_hover - }; - - row.h(row_height) - .when(is_selected, |row| row.bg(selected_bg)) - .when(is_hovered && !is_selected, |row| row.bg(hover_bg)) - .on_hover(move |&is_hovered, _, cx| { - weak_for_hover - .update(cx, |this, cx| { - if is_hovered { - if this.hovered_entry_idx != Some(index) { - this.hovered_entry_idx = Some(index); - cx.notify(); - } - } else if this.hovered_entry_idx == Some(index) { - // Only clear if this row was the hovered one - this.hovered_entry_idx = None; - cx.notify(); - } - }) - .ok(); - }) - .on_click(move |event, window, cx| { - let click_count = event.click_count(); - weak.update(cx, |this, cx| { - this.select_entry(index, ScrollStrategy::Center, cx); - if click_count >= 2 { - this.open_commit_view(index, window, cx); - } - }) - .ok(); - }) - .into_any_element() - }) - .uniform_list( - "git-graph-commits", - commit_count, - cx.processor(Self::render_table_rows), + .child(render_table_header( + TableRow::from_vec( + vec![ + Label::new("Graph") + .color(Color::Muted) + .truncate() + .into_any_element(), + Label::new("Description") + .color(Color::Muted) + .into_any_element(), + Label::new("Date").color(Color::Muted).into_any_element(), + Label::new("Author").color(Color::Muted).into_any_element(), + Label::new("Commit").color(Color::Muted).into_any_element(), + ], + 5, ), - ) - }) + header_context, + Some(header_resize_info), + Some(self.column_widths.entity_id()), + cx, + )) + .child({ + let row_height = self.row_height; + let selected_entry_idx = self.selected_entry_idx; + let hovered_entry_idx = self.hovered_entry_idx; + let weak_self = cx.weak_entity(); + let focus_handle = self.focus_handle.clone(); + + bind_redistributable_columns( + div() + .relative() + .flex_1() + .w_full() + .overflow_hidden() + .child( + h_flex() + .size_full() + .child( + div() + .w(DefiniteLength::Fraction(graph_fraction)) + .h_full() + .min_w_0() + .overflow_hidden() + .child( + div() + .id("graph-canvas") + .size_full() + .overflow_hidden() + .child( + div() + .size_full() + .child(self.render_graph(window, cx)), + ) + .on_scroll_wheel( + cx.listener(Self::handle_graph_scroll), + ) + .on_mouse_move( + cx.listener(Self::handle_graph_mouse_move), + ) + .on_click(cx.listener(Self::handle_graph_click)) + .on_hover(cx.listener( + |this, &is_hovered: &bool, _, cx| { + if !is_hovered + && this.hovered_entry_idx.is_some() + { + this.hovered_entry_idx = None; + cx.notify(); + } + }, + )), + ), + ) + .child( + div() + .w(DefiniteLength::Fraction(table_fraction)) + .h_full() + .min_w_0() + .child( + Table::new(4) + .interactable(&self.table_interaction_state) + .hide_row_borders() + .hide_row_hover() + .width_config(table_width_config) + .map_row(move |(index, row), window, cx| { + let is_selected = + selected_entry_idx == Some(index); + let is_hovered = + hovered_entry_idx == Some(index); + let is_focused = + focus_handle.is_focused(window); + let weak = weak_self.clone(); + let weak_for_hover = weak.clone(); + + let hover_bg = cx + .theme() + .colors() + .element_hover + .opacity(0.6); + let selected_bg = if is_focused { + cx.theme().colors().element_selected + } else { + cx.theme().colors().element_hover + }; + + row.h(row_height) + .when(is_selected, |row| row.bg(selected_bg)) + .when( + is_hovered && !is_selected, + |row| row.bg(hover_bg), + ) + .on_hover(move |&is_hovered, _, cx| { + weak_for_hover + .update(cx, |this, cx| { + if is_hovered { + if this.hovered_entry_idx + != Some(index) + { + this.hovered_entry_idx = + Some(index); + cx.notify(); + } + } else if this + .hovered_entry_idx + == Some(index) + { + this.hovered_entry_idx = + None; + cx.notify(); + } + }) + .ok(); + }) + .on_click(move |event, window, cx| { + let click_count = event.click_count(); + weak.update(cx, |this, cx| { + this.select_entry( + index, + ScrollStrategy::Center, + cx, + ); + if click_count >= 2 { + this.open_commit_view( + index, + window, + cx, + ); + } + }) + .ok(); + }) + .into_any_element() + }) + .uniform_list( + "git-graph-commits", + commit_count, + cx.processor(Self::render_table_rows), + ), + ), + ), + ) + .child(render_redistributable_columns_resize_handles( + &self.column_widths, + window, + cx, + )), + self.column_widths.clone(), + ) + }), + ) .on_drag_move::(cx.listener(|this, event, window, cx| { this.commit_details_split_state.update(cx, |state, cx| { state.on_drag_move(event, window, cx); @@ -3734,9 +3878,11 @@ mod tests { }); cx.run_until_parked(); - git_graph.update_in(&mut *cx, |this, window, cx| { - this.render(window, cx); - }); + cx.draw( + point(px(0.), px(0.)), + gpui::size(px(1200.), px(800.)), + |_, _| git_graph.clone().into_any_element(), + ); cx.run_until_parked(); let commit_count_after_switch_back = diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 68b1ff9beb7a8918ee3f5e1857e3cc68e15a3fc1..367d80d79c9af8722091e36c8e04bafb7ef0d8b5 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -29,6 +29,7 @@ mod notification; mod popover; mod popover_menu; mod progress; +mod redistributable_columns; mod right_click_menu; mod scrollbar; mod stack; @@ -73,6 +74,7 @@ pub use notification::*; pub use popover::*; pub use popover_menu::*; pub use progress::*; +pub use redistributable_columns::*; pub use right_click_menu::*; pub use scrollbar::*; pub use stack::*; diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 2012defc47d9cccea87849fa41470ad1183b552f..e5a14a3ddabc0d918bfe6d6bcb077e32adeb6eb4 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -1,19 +1,19 @@ use std::{ops::Range, rc::Rc}; use gpui::{ - AbsoluteLength, AppContext as _, DefiniteLength, DragMoveEvent, Entity, EntityId, FocusHandle, - Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point, Stateful, - UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list, + DefiniteLength, Entity, EntityId, FocusHandle, Length, ListHorizontalSizingBehavior, + ListSizingBehavior, ListState, Point, Stateful, UniformListScrollHandle, WeakEntity, list, + transparent_black, uniform_list, }; -use itertools::intersperse_with; use crate::{ ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component, - ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator, - InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce, - ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled, - StyledExt as _, StyledTypography, Window, WithScrollbar, div, example_group_with_title, h_flex, - px, single_example, + ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, HeaderResizeInfo, + Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, RedistributableColumnsState, + RegisterComponent, RenderOnce, ScrollAxes, ScrollableHandle, Scrollbars, SharedString, + StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, Window, WithScrollbar, + bind_redistributable_columns, div, example_group_with_title, h_flex, px, + render_redistributable_columns_resize_handles, single_example, table_row::{IntoTableRow as _, TableRow}, v_flex, }; @@ -22,16 +22,10 @@ pub mod table_row; #[cfg(test)] mod tests; -const RESIZE_COLUMN_WIDTH: f32 = 8.0; -const RESIZE_DIVIDER_WIDTH: f32 = 1.0; - /// Represents an unchecked table row, which is a vector of elements. /// Will be converted into `TableRow` internally pub type UncheckedTableRow = Vec; -#[derive(Debug)] -pub(crate) struct DraggedColumn(pub(crate) usize); - struct UniformListData { render_list_of_rows_fn: Box, &mut Window, &mut App) -> Vec>>, @@ -113,124 +107,6 @@ impl TableInteractionState { } } -/// Renders invisible resize handles overlaid on top of table content. -/// -/// - Spacer: invisible element that matches the width of table column content -/// - Divider: contains the actual resize handle that users can drag to resize columns -/// -/// Structure: [spacer] [divider] [spacer] [divider] [spacer] -/// -/// Business logic: -/// 1. Creates spacers matching each column width -/// 2. Intersperses (inserts) resize handles between spacers (interactive only for resizable columns) -/// 3. Each handle supports hover highlighting, double-click to reset, and drag to resize -/// 4. Returns an absolute-positioned overlay that sits on top of table content -fn render_resize_handles( - column_widths: &TableRow, - resizable_columns: &TableRow, - initial_sizes: &TableRow, - columns: Option>, - window: &mut Window, - cx: &mut App, -) -> AnyElement { - let spacers = column_widths - .as_slice() - .iter() - .map(|width| base_cell_style(Some(*width)).into_any_element()); - - let mut column_ix = 0; - let resizable_columns_shared = Rc::new(resizable_columns.clone()); - let initial_sizes_shared = Rc::new(initial_sizes.clone()); - let mut resizable_columns_iter = resizable_columns.as_slice().iter(); - - let dividers = intersperse_with(spacers, || { - let resizable_columns = Rc::clone(&resizable_columns_shared); - let initial_sizes = Rc::clone(&initial_sizes_shared); - window.with_id(column_ix, |window| { - let mut resize_divider = div() - .id(column_ix) - .relative() - .top_0() - .w(px(RESIZE_DIVIDER_WIDTH)) - .h_full() - .bg(cx.theme().colors().border.opacity(0.8)); - - let mut resize_handle = div() - .id("column-resize-handle") - .absolute() - .left_neg_0p5() - .w(px(RESIZE_COLUMN_WIDTH)) - .h_full(); - - if resizable_columns_iter - .next() - .is_some_and(TableResizeBehavior::is_resizable) - { - let hovered = window.use_state(cx, |_window, _cx| false); - - resize_divider = resize_divider.when(*hovered.read(cx), |div| { - div.bg(cx.theme().colors().border_focused) - }); - - resize_handle = resize_handle - .on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered)) - .cursor_col_resize() - .when_some(columns.clone(), |this, columns| { - this.on_click(move |event, window, cx| { - if event.click_count() >= 2 { - columns.update(cx, |columns, _| { - columns.on_double_click( - column_ix, - &initial_sizes, - &resizable_columns, - window, - ); - }) - } - - cx.stop_propagation(); - }) - }) - .on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| { - cx.new(|_cx| gpui::Empty) - }) - } - - column_ix += 1; - resize_divider.child(resize_handle).into_any_element() - }) - }); - - h_flex() - .id("resize-handles") - .absolute() - .inset_0() - .w_full() - .children(dividers) - .into_any_element() -} - -#[derive(Debug, Copy, Clone, PartialEq)] -pub enum TableResizeBehavior { - None, - Resizable, - MinSize(f32), -} - -impl TableResizeBehavior { - pub fn is_resizable(&self) -> bool { - *self != TableResizeBehavior::None - } - - pub fn min_size(&self) -> Option { - match self { - TableResizeBehavior::None => None, - TableResizeBehavior::Resizable => Some(0.05), - TableResizeBehavior::MinSize(min_size) => Some(*min_size), - } - } -} - pub enum ColumnWidthConfig { /// Static column widths (no resize handles). Static { @@ -278,6 +154,21 @@ impl ColumnWidthConfig { } } + /// Explicit column widths with no fixed table width. + pub fn explicit>(widths: Vec) -> Self { + let cols = widths.len(); + ColumnWidthConfig::Static { + widths: StaticColumnWidths::Explicit( + widths + .into_iter() + .map(Into::into) + .collect::>() + .into_table_row(cols), + ), + table_width: None, + } + } + /// Column widths for rendering. pub fn widths_to_render(&self, cx: &App) -> Option> { match self { @@ -292,10 +183,7 @@ impl ColumnWidthConfig { ColumnWidthConfig::Redistributable { columns_state: entity, .. - } => { - let state = entity.read(cx); - Some(state.preview_widths.map_cloned(Length::Definite)) - } + } => Some(entity.read(cx).widths_to_render()), } } @@ -316,296 +204,6 @@ impl ColumnWidthConfig { None => ListHorizontalSizingBehavior::FitList, } } - - /// Render resize handles overlay if applicable. - pub fn render_resize_handles(&self, window: &mut Window, cx: &mut App) -> Option { - match self { - ColumnWidthConfig::Redistributable { - columns_state: entity, - .. - } => { - let (column_widths, resize_behavior, initial_widths) = { - let state = entity.read(cx); - ( - state.preview_widths.map_cloned(Length::Definite), - state.resize_behavior.clone(), - state.initial_widths.clone(), - ) - }; - Some(render_resize_handles( - &column_widths, - &resize_behavior, - &initial_widths, - Some(entity.clone()), - window, - cx, - )) - } - _ => None, - } - } - - /// Returns info needed for header double-click-to-reset, if applicable. - pub fn header_resize_info(&self, cx: &App) -> Option { - match self { - ColumnWidthConfig::Redistributable { columns_state, .. } => { - let state = columns_state.read(cx); - Some(HeaderResizeInfo { - columns_state: columns_state.downgrade(), - resize_behavior: state.resize_behavior.clone(), - initial_widths: state.initial_widths.clone(), - }) - } - _ => None, - } - } -} - -#[derive(Clone)] -pub struct HeaderResizeInfo { - pub columns_state: WeakEntity, - pub resize_behavior: TableRow, - pub initial_widths: TableRow, -} - -pub struct RedistributableColumnsState { - pub(crate) initial_widths: TableRow, - pub(crate) committed_widths: TableRow, - pub(crate) preview_widths: TableRow, - pub(crate) resize_behavior: TableRow, - pub(crate) cached_table_width: Pixels, -} - -impl RedistributableColumnsState { - pub fn new( - cols: usize, - initial_widths: UncheckedTableRow>, - resize_behavior: UncheckedTableRow, - ) -> Self { - let widths: TableRow = initial_widths - .into_iter() - .map(Into::into) - .collect::>() - .into_table_row(cols); - Self { - initial_widths: widths.clone(), - committed_widths: widths.clone(), - preview_widths: widths, - resize_behavior: resize_behavior.into_table_row(cols), - cached_table_width: Default::default(), - } - } - - pub fn cols(&self) -> usize { - self.committed_widths.cols() - } - - pub fn initial_widths(&self) -> &TableRow { - &self.initial_widths - } - - pub fn resize_behavior(&self) -> &TableRow { - &self.resize_behavior - } - - fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 { - match length { - DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width, - DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => { - rems_width.to_pixels(rem_size) / bounds_width - } - DefiniteLength::Fraction(fraction) => *fraction, - } - } - - pub(crate) fn on_double_click( - &mut self, - double_click_position: usize, - initial_sizes: &TableRow, - resize_behavior: &TableRow, - window: &mut Window, - ) { - let bounds_width = self.cached_table_width; - let rem_size = window.rem_size(); - let initial_sizes = - initial_sizes.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); - let widths = self - .committed_widths - .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); - - let updated_widths = Self::reset_to_initial_size( - double_click_position, - widths, - initial_sizes, - resize_behavior, - ); - self.committed_widths = updated_widths.map(DefiniteLength::Fraction); - self.preview_widths = self.committed_widths.clone(); - } - - pub(crate) fn reset_to_initial_size( - col_idx: usize, - mut widths: TableRow, - initial_sizes: TableRow, - resize_behavior: &TableRow, - ) -> TableRow { - let diff = initial_sizes[col_idx] - widths[col_idx]; - - let left_diff = - initial_sizes[..col_idx].iter().sum::() - widths[..col_idx].iter().sum::(); - let right_diff = initial_sizes[col_idx + 1..].iter().sum::() - - widths[col_idx + 1..].iter().sum::(); - - let go_left_first = if diff < 0.0 { - left_diff > right_diff - } else { - left_diff < right_diff - }; - - if !go_left_first { - let diff_remaining = - Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, 1); - - if diff_remaining != 0.0 && col_idx > 0 { - Self::propagate_resize_diff( - diff_remaining, - col_idx, - &mut widths, - resize_behavior, - -1, - ); - } - } else { - let diff_remaining = - Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, -1); - - if diff_remaining != 0.0 { - Self::propagate_resize_diff( - diff_remaining, - col_idx, - &mut widths, - resize_behavior, - 1, - ); - } - } - - widths - } - - pub(crate) fn on_drag_move( - &mut self, - drag_event: &DragMoveEvent, - window: &mut Window, - cx: &mut Context, - ) { - let drag_position = drag_event.event.position; - let bounds = drag_event.bounds; - - let mut col_position = 0.0; - let rem_size = window.rem_size(); - let bounds_width = bounds.right() - bounds.left(); - let col_idx = drag_event.drag(cx).0; - - let divider_width = Self::get_fraction( - &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))), - bounds_width, - rem_size, - ); - - let mut widths = self - .committed_widths - .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); - - for length in widths[0..=col_idx].iter() { - col_position += length + divider_width; - } - - let mut total_length_ratio = col_position; - for length in widths[col_idx + 1..].iter() { - total_length_ratio += length; - } - let cols = self.resize_behavior.cols(); - total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width; - - let drag_fraction = (drag_position.x - bounds.left()) / bounds_width; - let drag_fraction = drag_fraction * total_length_ratio; - let diff = drag_fraction - col_position - divider_width / 2.0; - - Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior); - - self.preview_widths = widths.map(DefiniteLength::Fraction); - } - - pub(crate) fn drag_column_handle( - diff: f32, - col_idx: usize, - widths: &mut TableRow, - resize_behavior: &TableRow, - ) { - if diff > 0.0 { - Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1); - } else { - Self::propagate_resize_diff(-diff, col_idx + 1, widths, resize_behavior, -1); - } - } - - pub(crate) fn propagate_resize_diff( - diff: f32, - col_idx: usize, - widths: &mut TableRow, - resize_behavior: &TableRow, - direction: i8, - ) -> f32 { - let mut diff_remaining = diff; - if resize_behavior[col_idx].min_size().is_none() { - return diff; - } - - let step_right; - let step_left; - if direction < 0 { - step_right = 0; - step_left = 1; - } else { - step_right = 1; - step_left = 0; - } - if col_idx == 0 && direction < 0 { - return diff; - } - let mut curr_column = col_idx + step_right - step_left; - - while diff_remaining != 0.0 && curr_column < widths.cols() { - let Some(min_size) = resize_behavior[curr_column].min_size() else { - if curr_column == 0 { - break; - } - curr_column -= step_left; - curr_column += step_right; - continue; - }; - - let curr_width = widths[curr_column] - diff_remaining; - widths[curr_column] = curr_width; - - if min_size > curr_width { - diff_remaining = min_size - curr_width; - widths[curr_column] = min_size; - } else { - diff_remaining = 0.0; - break; - } - if curr_column == 0 { - break; - } - curr_column -= step_left; - curr_column += step_right; - } - widths[col_idx] = widths[col_idx] + (diff - diff_remaining); - - diff_remaining - } } /// A table component @@ -919,11 +517,8 @@ pub fn render_table_header( if event.click_count() > 1 { info.columns_state .update(cx, |column, _| { - column.on_double_click( - header_idx, - &info.initial_widths, - &info.resize_behavior, - window, + column.reset_column_to_initial_width( + header_idx, window, ); }) .ok(); @@ -962,6 +557,19 @@ impl TableRenderContext { disable_base_cell_style: table.disable_base_cell_style, } } + + pub fn for_column_widths(column_widths: Option>, use_ui_font: bool) -> Self { + Self { + striped: false, + show_row_borders: true, + show_row_hover: true, + total_row_count: 0, + column_widths, + map_row: None, + use_ui_font, + disable_base_cell_style: false, + } + } } impl RenderOnce for Table { @@ -969,9 +577,15 @@ impl RenderOnce for Table { let table_context = TableRenderContext::new(&self, cx); let interaction_state = self.interaction_state.and_then(|state| state.upgrade()); - let header_resize_info = interaction_state - .as_ref() - .and_then(|_| self.column_width_config.header_resize_info(cx)); + let header_resize_info = + interaction_state + .as_ref() + .and_then(|_| match &self.column_width_config { + ColumnWidthConfig::Redistributable { columns_state, .. } => { + Some(HeaderResizeInfo::from_state(columns_state, cx)) + } + _ => None, + }); let table_width = self.column_width_config.table_width(); let horizontal_sizing = self.column_width_config.list_horizontal_sizing(); @@ -985,13 +599,19 @@ impl RenderOnce for Table { ColumnWidthConfig::Redistributable { columns_state: entity, .. - } => Some(entity.downgrade()), + } => Some(entity.clone()), _ => None, }); - let resize_handles = interaction_state - .as_ref() - .and_then(|_| self.column_width_config.render_resize_handles(window, cx)); + let resize_handles = + interaction_state + .as_ref() + .and_then(|_| match &self.column_width_config { + ColumnWidthConfig::Redistributable { columns_state, .. } => Some( + render_redistributable_columns_resize_handles(columns_state, window, cx), + ), + _ => None, + }); let table = div() .when_some(table_width, |this, width| this.w(width)) @@ -1006,38 +626,8 @@ impl RenderOnce for Table { cx, )) }) - .when_some(redistributable_entity, { - |this, widths| { - this.on_drag_move::({ - let widths = widths.clone(); - move |e, window, cx| { - widths - .update(cx, |widths, cx| { - widths.on_drag_move(e, window, cx); - }) - .ok(); - } - }) - .on_children_prepainted({ - let widths = widths.clone(); - move |bounds, _, cx| { - widths - .update(cx, |widths, _| { - // This works because all children x axis bounds are the same - widths.cached_table_width = - bounds[0].right() - bounds[0].left(); - }) - .ok(); - } - }) - .on_drop::(move |_, _, cx| { - widths - .update(cx, |widths, _| { - widths.committed_widths = widths.preview_widths.clone(); - }) - .ok(); - }) - } + .when_some(redistributable_entity, |this, widths| { + bind_redistributable_columns(this, widths) }) .child({ let content = div() diff --git a/crates/ui/src/components/data_table/tests.rs b/crates/ui/src/components/data_table/tests.rs index 0936cd3088cc50bc08bf0a0a09d9a6fa7a2cdaf0..604e8b7cd1aabee85b406ec99d458c949eda599b 100644 --- a/crates/ui/src/components/data_table/tests.rs +++ b/crates/ui/src/components/data_table/tests.rs @@ -1,4 +1,5 @@ -use super::*; +use super::table_row::TableRow; +use crate::{RedistributableColumnsState, TableResizeBehavior}; 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) diff --git a/crates/ui/src/components/redistributable_columns.rs b/crates/ui/src/components/redistributable_columns.rs new file mode 100644 index 0000000000000000000000000000000000000000..cd22c31e19736e72e5d88676178053b49a3e65fd --- /dev/null +++ b/crates/ui/src/components/redistributable_columns.rs @@ -0,0 +1,485 @@ +use std::rc::Rc; + +use gpui::{ + AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity, Length, + WeakEntity, +}; +use itertools::intersperse_with; + +use super::data_table::table_row::{IntoTableRow as _, TableRow}; +use crate::{ + ActiveTheme as _, AnyElement, App, Context, Div, FluentBuilder as _, InteractiveElement, + IntoElement, ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, div, h_flex, + px, +}; + +const RESIZE_COLUMN_WIDTH: f32 = 8.0; +const RESIZE_DIVIDER_WIDTH: f32 = 1.0; + +#[derive(Debug)] +struct DraggedColumn(usize); + +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum TableResizeBehavior { + None, + Resizable, + MinSize(f32), +} + +impl TableResizeBehavior { + pub fn is_resizable(&self) -> bool { + *self != TableResizeBehavior::None + } + + pub fn min_size(&self) -> Option { + match self { + TableResizeBehavior::None => None, + TableResizeBehavior::Resizable => Some(0.05), + TableResizeBehavior::MinSize(min_size) => Some(*min_size), + } + } +} + +#[derive(Clone)] +pub struct HeaderResizeInfo { + pub columns_state: WeakEntity, + pub resize_behavior: TableRow, +} + +impl HeaderResizeInfo { + pub fn from_state(columns_state: &Entity, cx: &App) -> Self { + let resize_behavior = columns_state.read(cx).resize_behavior().clone(); + Self { + columns_state: columns_state.downgrade(), + resize_behavior, + } + } +} + +pub struct RedistributableColumnsState { + pub(crate) initial_widths: TableRow, + pub(crate) committed_widths: TableRow, + pub(crate) preview_widths: TableRow, + pub(crate) resize_behavior: TableRow, + pub(crate) cached_container_width: Pixels, +} + +impl RedistributableColumnsState { + pub fn new( + cols: usize, + initial_widths: Vec>, + resize_behavior: Vec, + ) -> Self { + let widths: TableRow = initial_widths + .into_iter() + .map(Into::into) + .collect::>() + .into_table_row(cols); + Self { + initial_widths: widths.clone(), + committed_widths: widths.clone(), + preview_widths: widths, + resize_behavior: resize_behavior.into_table_row(cols), + cached_container_width: Default::default(), + } + } + + pub fn cols(&self) -> usize { + self.committed_widths.cols() + } + + pub fn initial_widths(&self) -> &TableRow { + &self.initial_widths + } + + pub fn preview_widths(&self) -> &TableRow { + &self.preview_widths + } + + pub fn resize_behavior(&self) -> &TableRow { + &self.resize_behavior + } + + pub fn widths_to_render(&self) -> TableRow { + self.preview_widths.map_cloned(Length::Definite) + } + + pub fn preview_fractions(&self, rem_size: Pixels) -> TableRow { + if self.cached_container_width > px(0.) { + self.preview_widths + .map_ref(|length| Self::get_fraction(length, self.cached_container_width, rem_size)) + } else { + self.preview_widths.map_ref(|length| match length { + DefiniteLength::Fraction(fraction) => *fraction, + DefiniteLength::Absolute(_) => 0.0, + }) + } + } + + pub fn preview_column_width(&self, column_index: usize, window: &Window) -> Option { + let width = self.preview_widths().as_slice().get(column_index)?; + match width { + DefiniteLength::Fraction(fraction) if self.cached_container_width > px(0.) => { + Some(self.cached_container_width * *fraction) + } + DefiniteLength::Fraction(_) => None, + DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => Some(*pixels), + DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => { + Some(rems_width.to_pixels(window.rem_size())) + } + } + } + + pub fn cached_container_width(&self) -> Pixels { + self.cached_container_width + } + + pub fn set_cached_container_width(&mut self, width: Pixels) { + self.cached_container_width = width; + } + + pub fn commit_preview(&mut self) { + self.committed_widths = self.preview_widths.clone(); + } + + pub fn reset_column_to_initial_width(&mut self, column_index: usize, window: &Window) { + let bounds_width = self.cached_container_width; + if bounds_width <= px(0.) { + return; + } + + let rem_size = window.rem_size(); + let initial_sizes = self + .initial_widths + .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); + let widths = self + .committed_widths + .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); + + let updated_widths = + Self::reset_to_initial_size(column_index, widths, initial_sizes, &self.resize_behavior); + self.committed_widths = updated_widths.map(DefiniteLength::Fraction); + self.preview_widths = self.committed_widths.clone(); + } + + fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 { + match length { + DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width, + DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => { + rems_width.to_pixels(rem_size) / bounds_width + } + DefiniteLength::Fraction(fraction) => *fraction, + } + } + + pub(crate) fn reset_to_initial_size( + col_idx: usize, + mut widths: TableRow, + initial_sizes: TableRow, + resize_behavior: &TableRow, + ) -> TableRow { + let diff = initial_sizes[col_idx] - widths[col_idx]; + + let left_diff = + initial_sizes[..col_idx].iter().sum::() - widths[..col_idx].iter().sum::(); + let right_diff = initial_sizes[col_idx + 1..].iter().sum::() + - widths[col_idx + 1..].iter().sum::(); + + let go_left_first = if diff < 0.0 { + left_diff > right_diff + } else { + left_diff < right_diff + }; + + if !go_left_first { + let diff_remaining = + Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, 1); + + if diff_remaining != 0.0 && col_idx > 0 { + Self::propagate_resize_diff( + diff_remaining, + col_idx, + &mut widths, + resize_behavior, + -1, + ); + } + } else { + let diff_remaining = + Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, -1); + + if diff_remaining != 0.0 { + Self::propagate_resize_diff( + diff_remaining, + col_idx, + &mut widths, + resize_behavior, + 1, + ); + } + } + + widths + } + + fn on_drag_move( + &mut self, + drag_event: &DragMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + let drag_position = drag_event.event.position; + let bounds = drag_event.bounds; + let bounds_width = bounds.right() - bounds.left(); + if bounds_width <= px(0.) { + return; + } + + let mut col_position = 0.0; + let rem_size = window.rem_size(); + let col_idx = drag_event.drag(cx).0; + + let divider_width = Self::get_fraction( + &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))), + bounds_width, + rem_size, + ); + + let mut widths = self + .committed_widths + .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); + + for length in widths[0..=col_idx].iter() { + col_position += length + divider_width; + } + + let mut total_length_ratio = col_position; + for length in widths[col_idx + 1..].iter() { + total_length_ratio += length; + } + let cols = self.resize_behavior.cols(); + total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width; + + let drag_fraction = (drag_position.x - bounds.left()) / bounds_width; + let drag_fraction = drag_fraction * total_length_ratio; + let diff = drag_fraction - col_position - divider_width / 2.0; + + Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior); + + self.preview_widths = widths.map(DefiniteLength::Fraction); + } + + pub(crate) fn drag_column_handle( + diff: f32, + col_idx: usize, + widths: &mut TableRow, + resize_behavior: &TableRow, + ) { + if diff > 0.0 { + Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1); + } else { + Self::propagate_resize_diff(-diff, col_idx + 1, widths, resize_behavior, -1); + } + } + + pub(crate) fn propagate_resize_diff( + diff: f32, + col_idx: usize, + widths: &mut TableRow, + resize_behavior: &TableRow, + direction: i8, + ) -> f32 { + let mut diff_remaining = diff; + if resize_behavior[col_idx].min_size().is_none() { + return diff; + } + + let step_right; + let step_left; + if direction < 0 { + step_right = 0; + step_left = 1; + } else { + step_right = 1; + step_left = 0; + } + if col_idx == 0 && direction < 0 { + return diff; + } + let mut curr_column = col_idx + step_right - step_left; + + while diff_remaining != 0.0 && curr_column < widths.cols() { + let Some(min_size) = resize_behavior[curr_column].min_size() else { + if curr_column == 0 { + break; + } + curr_column -= step_left; + curr_column += step_right; + continue; + }; + + let curr_width = widths[curr_column] - diff_remaining; + widths[curr_column] = curr_width; + + if min_size > curr_width { + diff_remaining = min_size - curr_width; + widths[curr_column] = min_size; + } else { + diff_remaining = 0.0; + break; + } + if curr_column == 0 { + break; + } + curr_column -= step_left; + curr_column += step_right; + } + widths[col_idx] = widths[col_idx] + (diff - diff_remaining); + + diff_remaining + } +} + +pub fn bind_redistributable_columns( + container: Div, + columns_state: Entity, +) -> Div { + container + .on_drag_move::({ + let columns_state = columns_state.clone(); + move |event, window, cx| { + columns_state.update(cx, |columns, cx| { + columns.on_drag_move(event, window, cx); + }); + } + }) + .on_children_prepainted({ + let columns_state = columns_state.clone(); + move |bounds, _, cx| { + if let Some(width) = child_bounds_width(&bounds) { + columns_state.update(cx, |columns, _| { + columns.set_cached_container_width(width); + }); + } + } + }) + .on_drop::(move |_, _, cx| { + columns_state.update(cx, |columns, _| { + columns.commit_preview(); + }); + }) +} + +pub fn render_redistributable_columns_resize_handles( + columns_state: &Entity, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let (column_widths, resize_behavior) = { + let state = columns_state.read(cx); + (state.widths_to_render(), state.resize_behavior().clone()) + }; + + let mut column_ix = 0; + let resize_behavior = Rc::new(resize_behavior); + let dividers = intersperse_with( + column_widths + .as_slice() + .iter() + .copied() + .map(|width| resize_spacer(width).into_any_element()), + || { + let current_column_ix = column_ix; + let resize_behavior = Rc::clone(&resize_behavior); + let columns_state = columns_state.clone(); + column_ix += 1; + + window.with_id(current_column_ix, |window| { + let mut resize_divider = div() + .id(current_column_ix) + .relative() + .top_0() + .w(px(RESIZE_DIVIDER_WIDTH)) + .h_full() + .bg(cx.theme().colors().border.opacity(0.8)); + + let mut resize_handle = div() + .id("column-resize-handle") + .absolute() + .left_neg_0p5() + .w(px(RESIZE_COLUMN_WIDTH)) + .h_full(); + + if resize_behavior[current_column_ix].is_resizable() { + let is_highlighted = window.use_state(cx, |_window, _cx| false); + + resize_divider = resize_divider.when(*is_highlighted.read(cx), |div| { + div.bg(cx.theme().colors().border_focused) + }); + + resize_handle = resize_handle + .on_hover({ + let is_highlighted = is_highlighted.clone(); + move |&was_hovered, _, cx| is_highlighted.write(cx, was_hovered) + }) + .cursor_col_resize() + .on_click({ + let columns_state = columns_state.clone(); + move |event, window, cx| { + if event.click_count() >= 2 { + columns_state.update(cx, |columns, _| { + columns.reset_column_to_initial_width( + current_column_ix, + window, + ); + }); + } + + cx.stop_propagation(); + } + }) + .on_drag(DraggedColumn(current_column_ix), { + let is_highlighted = is_highlighted.clone(); + move |_, _offset, _window, cx| { + is_highlighted.write(cx, true); + cx.new(|_cx| Empty) + } + }) + .on_drop::(move |_, _, cx| { + is_highlighted.write(cx, false); + columns_state.update(cx, |state, _| { + state.commit_preview(); + }); + }); + } + + resize_divider.child(resize_handle).into_any_element() + }) + }, + ); + + h_flex() + .id("resize-handles") + .absolute() + .inset_0() + .w_full() + .children(dividers) + .into_any_element() +} + +fn resize_spacer(width: Length) -> Div { + div().w(width).h_full() +} + +fn child_bounds_width(bounds: &[Bounds]) -> Option { + let first_bounds = bounds.first()?; + let mut left = first_bounds.left(); + let mut right = first_bounds.right(); + + for bound in bounds.iter().skip(1) { + left = left.min(bound.left()); + right = right.max(bound.right()); + } + + Some(right - left) +} From c02ea54130f50627dd11d9cd917ae2512f56669a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:47:22 -0300 Subject: [PATCH 02/15] docs: Update typefaces and some other styles (#52992) Update the heading typeface to use IBM Plex Serif, the code typeface to use Lilex (IBM Plex Mono), and pull them from the zed.dev CDN. Also added some stray design adjustments here and there. Release Notes: - N/A --- docs/theme/css/general.css | 33 +++++++++++++----- docs/theme/css/variables.css | 13 +++---- docs/theme/fonts/Lora.var.woff2 | Bin 84124 -> 0 bytes docs/theme/fonts/fonts.css | 32 ++++++++++++++--- .../fonts/iAWriterQuattroS-Regular.woff2 | Bin 44416 -> 0 bytes docs/theme/page-toc.css | 2 +- docs/theme/plugins.css | 6 ++-- 7 files changed, 63 insertions(+), 23 deletions(-) delete mode 100644 docs/theme/fonts/Lora.var.woff2 delete mode 100644 docs/theme/fonts/iAWriterQuattroS-Regular.woff2 diff --git a/docs/theme/css/general.css b/docs/theme/css/general.css index f63fd24d1379aa3f325ba53a92784ba256a0dd97..9c8077bad525da1b7c15572d6fc154b66602e987 100644 --- a/docs/theme/css/general.css +++ b/docs/theme/css/general.css @@ -70,10 +70,21 @@ h5, h6 { position: relative; font-family: var(--title-font); - font-weight: 480; + font-weight: 400; +} + +h1 { color: var(--title-color); } +h2, +h3, +h4, +h5, +h6 { + color: var(--full-contrast); +} + /* Don't change font size in headers. */ h1 code, h2 code, @@ -213,7 +224,7 @@ hr { } .content { - padding: 48px 32px 0 32px; + padding: 32px 32px 0 32px; display: flex; justify-content: space-between; gap: 36px; @@ -272,10 +283,14 @@ hr { border-radius: 8px; overflow: clip; } -.content .header:link, -.content .header:visited { +.content h1 .header:link, +.content h1 .header:visited { color: var(--title-color); } +.content :is(h2, h3, h4, h5, h6) .header:link, +.content :is(h2, h3, h4, h5, h6) .header:visited { + color: var(--full-contrast); +} .content .header:link, .content .header:visited:hover { text-decoration: none; @@ -383,15 +398,17 @@ blockquote .warning:before { } kbd { - background-color: rgba(8, 76, 207, 0.1); + background-color: var(--keybinding-bg); + padding: 4px 4px 6px 4px; border-radius: 4px; + font-family: var(--mono-font); + display: inline-block; + margin: 0 2px; border: solid 1px var(--popover-border); box-shadow: inset 0 -1px 0 var(--theme-hover); - display: inline-block; font-size: var(--code-font-size); - font-family: var(--mono-font); + color: var(--full-contrast); line-height: 10px; - padding: 4px 5px; vertical-align: middle; } diff --git a/docs/theme/css/variables.css b/docs/theme/css/variables.css index 46ea739daf8643db5ad57a239091e557df2a3d0c..ca43e6feb4a17d67ce0a6140ba1459569bb6e33f 100644 --- a/docs/theme/css/variables.css +++ b/docs/theme/css/variables.css @@ -11,11 +11,12 @@ --page-padding: 15px; --content-max-width: 690px; --menu-bar-height: 64px; - --font: "IA Writer Quattro S", sans-serif; - --title-font: "Lora", "Helvetica Neue", Helvetica, Arial, sans-serif; + --font: "iA Writer Quattro S", sans-serif; + --title-font: + "IBM Plex Serif", "Helvetica Neue", Helvetica, Arial, sans-serif; --mono-font: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, - Courier New, monospace; + "Lilex", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + Liberation Mono, Courier New, monospace; --code-font-size: 0.875em /* please adjust the ace font size accordingly in editor.js */; @@ -151,7 +152,7 @@ --inline-code-color: hsl(40, 100%, 80%); --code-text: hsl(220, 13%, 95%); --code-bg: hsl(220, 93%, 50%, 0.2); - --keybinding-bg: hsl(0, 0%, 12%); + --keybinding-bg: hsl(220, 20%, 10%); --pre-bg: hsl(220, 13%, 5%); --pre-border: hsla(220, 93%, 70%, 0.3); @@ -162,7 +163,7 @@ --popover-shadow: 0 10px 15px -3px hsl(0, 0%, 0%, 0.1), 0 4px 6px -4px hsl(0, 0%, 0%, 0.1); - --theme-hover: hsl(220, 13%, 25%); + --theme-hover: hsl(220, 13%, 20%); --hover-section-title: hsl(220, 13%, 11%); --quote-bg: hsl(220, 13%, 25%, 0.4); diff --git a/docs/theme/fonts/Lora.var.woff2 b/docs/theme/fonts/Lora.var.woff2 deleted file mode 100644 index e2d8990a7ee9fe1f2b02c5d9c23b1e8e13e14de9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 84124 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�?IK+@GkKE@5@K>^n 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 diff --git a/docs/theme/fonts/fonts.css b/docs/theme/fonts/fonts.css index f55cb6ee898ad7c346d7e1774323a70e1fda001f..49a3bd666476efc571f483c5170e882e7e2c436c 100644 --- a/docs/theme/fonts/fonts.css +++ b/docs/theme/fonts/fonts.css @@ -3,15 +3,37 @@ /* open-sans-300 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ @font-face { - font-family: "IA Writer Quattro S"; + font-family: "iA Writer Quattro S"; + src: url("https://cdn.zed.dev/fonts/iAWriterQuattroV.woff2") + format("woff2-variations"); + font-weight: 100 900; font-style: normal; - font-weight: 400; - src: url("iAWriterQuattroS-Regular.woff2") format("woff2"); + font-display: swap; } @font-face { - font-family: "Lora"; - src: url("Lora.var.woff2") format("woff2-variations"); + font-family: "iA Writer Quattro S"; + src: url("https://cdn.zed.dev/fonts/iAWriterQuattroV-Italic.woff2") + format("woff2-variations"); font-weight: 100 900; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "IBM Plex Serif"; + src: url("https://cdn.zed.dev/fonts/IBMPlexSerif-Var.woff2") + format("woff2-variations"); + font-weight: 400 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Lilex"; + src: url("https://cdn.zed.dev/fonts/Lilex-Regular.woff2") + format("woff2-variations"); + font-weight: 400; font-style: normal; + font-display: swap; } diff --git a/docs/theme/fonts/iAWriterQuattroS-Regular.woff2 b/docs/theme/fonts/iAWriterQuattroS-Regular.woff2 deleted file mode 100644 index a25cdbcdd3f2127e7c2f6d0fe2832a83ae2fc6e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44416 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

j5OEScBpA$90YThpFBbuNQF{{VKZbOq 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 Date: Thu, 2 Apr 2026 08:06:20 -0700 Subject: [PATCH 03/15] Expose flexible panel settings in settings UI (#52946) Release Notes: - Added controls to the settings UI for whether the terminal and agent panels use flexible width. --- crates/settings_ui/src/page_data.rs | 30 +++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 8496620f9b4db94f93b2ea65952423b73512e724..f0cf87c403b340dacd33e2c04b043ab8085a461a 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -4990,7 +4990,7 @@ fn panels_page() -> SettingsPage { ] } - fn terminal_panel_section() -> [SettingsPageItem; 3] { + fn terminal_panel_section() -> [SettingsPageItem; 4] { [ SettingsPageItem::SectionHeader("Terminal Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5006,6 +5006,19 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Terminal Panel Flexible Sizing", + description: "Whether the terminal panel should use flexible (proportional) sizing when docked to the left or right.", + field: Box::new(SettingField { + json_path: Some("terminal.flexible"), + pick: |settings_content| settings_content.terminal.as_ref()?.flexible.as_ref(), + write: |settings_content, value| { + settings_content.terminal.get_or_insert_default().flexible = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Show Count Badge", description: "Show a badge on the terminal panel icon with the count of open terminals.", @@ -5666,7 +5679,7 @@ fn panels_page() -> SettingsPage { ] } - fn agent_panel_section() -> [SettingsPageItem; 5] { + fn agent_panel_section() -> [SettingsPageItem; 6] { [ SettingsPageItem::SectionHeader("Agent Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5695,6 +5708,19 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Agent Panel Flexible Sizing", + description: "Whether the agent panel should use flexible (proportional) sizing when docked to the left or right.", + field: Box::new(SettingField { + json_path: Some("agent.flexible"), + pick: |settings_content| settings_content.agent.as_ref()?.flexible.as_ref(), + write: |settings_content, value| { + settings_content.agent.get_or_insert_default().flexible = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Agent Panel Default Width", description: "Default width when the agent panel is docked to the left or right.", From 7892b932795911516f26f3c1c1c72249ed181ba8 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:26:37 -0400 Subject: [PATCH 04/15] git_graph: Remove feature flag (#52972) After #52953 gets merged the git graph will be ready for it's preview release, so we can finally remove the feature flag! AKA this PR releases the git graph Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - Add Git Graph. Can be accessed through the button on the bottom of the git panel or the `git graph: Open` action --- Cargo.lock | 2 -- crates/feature_flags/src/flags.rs | 6 ------ crates/git_graph/Cargo.toml | 1 - crates/git_graph/src/git_graph.rs | 4 +--- crates/git_ui/Cargo.toml | 1 - crates/git_ui/src/commit_view.rs | 29 +++++++++++++---------------- crates/git_ui/src/git_panel.rs | 24 ++++++++++-------------- script/docs-suggest-publish | 16 ++++++++-------- 8 files changed, 32 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b428dbcd537e33088f40fdde5e3251a6148672a..ce645cae5bf4bbf76dac037880e9e7038df67df9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7132,7 +7132,6 @@ dependencies = [ "collections", "db", "editor", - "feature_flags", "fs", "git", "git_ui", @@ -7190,7 +7189,6 @@ dependencies = [ "ctor", "db", "editor", - "feature_flags", "file_icons", "futures 0.3.31", "fuzzy", diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 4d477aa4b393ee8b04829833324cd9092c2a04cd..54dc96ad37f8e51a1074a0a32976f8236cb1a0ed 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -47,12 +47,6 @@ impl FeatureFlag for DiffReviewFeatureFlag { } } -pub struct GitGraphFeatureFlag; - -impl FeatureFlag for GitGraphFeatureFlag { - const NAME: &'static str = "git-graph"; -} - pub struct StreamingEditFileToolFeatureFlag; impl FeatureFlag for StreamingEditFileToolFeatureFlag { diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml index cc3374a85932435d010daabdfe0e4b4eef628de6..e9e31a8361e367275c994e125ae6e04cbd652fc3 100644 --- a/crates/git_graph/Cargo.toml +++ b/crates/git_graph/Cargo.toml @@ -24,7 +24,6 @@ anyhow.workspace = true collections.workspace = true db.workspace = true editor.workspace = true -feature_flags.workspace = true git.workspace = true git_ui.workspace = true gpui.workspace = true diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index bb1566aa29eeae016d31ac549434e7b92d50eb4d..c56fb051b896f32ac364cd15e73ae8708498ca5a 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -1,6 +1,5 @@ use collections::{BTreeMap, HashMap, IndexSet}; use editor::Editor; -use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, @@ -732,8 +731,7 @@ pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut workspace::Workspace, _, _| { workspace.register_action_renderer(|div, workspace, _, cx| { div.when( - workspace.project().read(cx).active_repository(cx).is_some() - && cx.has_flag::(), + workspace.project().read(cx).active_repository(cx).is_some(), |div| { let workspace = workspace.weak_handle(); diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index d95e25fbc7821d42fac4386b522c4effb9462715..e06d16708697f721d9377365223dc444ba7b08ae 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -27,7 +27,6 @@ db.workspace = true editor.workspace = true file_icons.workspace = true futures.workspace = true -feature_flags.workspace = true fuzzy.workspace = true git.workspace = true gpui.workspace = true diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index a298380336515aad24e9c55d637d392fa6898b35..aac44c7f9c6eaf6f18c72bea390c0a0b7ad1a4bd 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -3,7 +3,6 @@ use buffer_diff::BufferDiff; use collections::HashMap; use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle}; use editor::{Addon, Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines}; -use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content}; use git::status::{FileStatus, StatusCode, TrackedStatus}; use git::{ @@ -1045,21 +1044,19 @@ impl Render for CommitViewToolbar { }), ) .when(!is_stash, |this| { - this.when(cx.has_flag::(), |this| { - this.child( - IconButton::new("show-in-git-graph", IconName::GitGraph) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Show in Git Graph")) - .on_click(move |_, window, cx| { - window.dispatch_action( - Box::new(crate::git_panel::OpenAtCommit { - sha: sha_for_graph.clone(), - }), - cx, - ); - }), - ) - }) + this.child( + IconButton::new("show-in-git-graph", IconName::GitGraph) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Show in Git Graph")) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(crate::git_panel::OpenAtCommit { + sha: sha_for_graph.clone(), + }), + cx, + ); + }), + ) .children(remote_info.map(|(provider_name, url)| { let icon = match provider_name.as_str() { "GitHub" => IconName::Github, diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 00a3b4041b91454d0587a1503b66dc3fa8629917..5b40c4bffc3a492f0113a8c5e45b2cfc1763d380 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -20,7 +20,6 @@ use editor::{ actions::ExpandAllDiffHunks, }; use editor::{EditorStyle, RewrapOptions}; -use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use file_icons::FileIcons; use futures::StreamExt as _; use git::commit::ParsedCommitMessage; @@ -4529,7 +4528,6 @@ impl GitPanel { let commit = branch.most_recent_commit.as_ref()?.clone(); let workspace = self.workspace.clone(); let this = cx.entity(); - let can_open_git_graph = cx.has_flag::(); Some( h_flex() @@ -4607,18 +4605,16 @@ impl GitPanel { ), ) }) - .when(can_open_git_graph, |this| { - this.child( - panel_icon_button("git-graph-button", IconName::GitGraph) - .icon_size(IconSize::Small) - .tooltip(|_window, cx| { - Tooltip::for_action("Open Git Graph", &Open, cx) - }) - .on_click(|_, window, cx| { - window.dispatch_action(Open.boxed_clone(), cx) - }), - ) - }), + .child( + panel_icon_button("git-graph-button", IconName::GitGraph) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| { + Tooltip::for_action("Open Git Graph", &Open, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(Open.boxed_clone(), cx) + }), + ), ), ) } diff --git a/script/docs-suggest-publish b/script/docs-suggest-publish index 23578785159b5fd720e84d3658f7f76dddf3ada9..fc420f3fbc774df0dbd7667a5cd6dd76682e9548 100755 --- a/script/docs-suggest-publish +++ b/script/docs-suggest-publish @@ -131,14 +131,14 @@ if [[ "$DRY_RUN" == "true" ]]; then echo "Would auto-apply suggestions to docs via Droid and create a draft PR." echo "Model: $MODEL" echo "" - + # Show each suggestion file for file in $(echo "$MANIFEST" | jq -r '.suggestions[].file'); do echo "--- $file ---" git show "origin/$SUGGESTIONS_BRANCH:$file" 2>/dev/null || echo "(file not found)" echo "" done - + echo -e "${YELLOW}=== END DRY RUN ===${NC}" echo "" echo "Run without --dry-run to create the PR." @@ -213,7 +213,7 @@ fi FLAGGED_PRS=() FLAGS_FILE="$REPO_ROOT/crates/feature_flags/src/flags.rs" if [[ -f "$FLAGS_FILE" ]]; then - # Extract feature flag struct names (e.g. SubagentsFeatureFlag, GitGraphFeatureFlag) + # Extract feature flag struct names (e.g. SubagentsFeatureFlag) FLAG_NAMES=$(grep -oE 'pub struct \w+FeatureFlag' "$FLAGS_FILE" | awk '{print $3}') if [[ -n "$FLAG_NAMES" ]]; then FLAG_PATTERN=$(echo "$FLAG_NAMES" | tr '\n' '|' | sed 's/|$//') @@ -538,10 +538,10 @@ echo -e "${GREEN}PR created:${NC} $PR_URL" if [[ "$KEEP_QUEUE" != "true" ]]; then echo "" echo "Resetting suggestions queue..." - + git checkout --orphan "${SUGGESTIONS_BRANCH}-reset" git rm -rf . > /dev/null 2>&1 || true - + cat > README.md << 'EOF' # Documentation Suggestions Queue @@ -562,19 +562,19 @@ run `script/docs-suggest-publish` to create a documentation PR from these sugges 3. At preview release, suggestions are collected into a docs PR 4. After docs PR is created, this branch is reset EOF - + mkdir -p suggestions echo '{"suggestions":[]}' > manifest.json git add README.md suggestions manifest.json git commit -m "Reset documentation suggestions queue Previous suggestions published in: $PR_URL" - + # Force push required: replacing the orphan suggestions branch with a clean slate git push -f origin "${SUGGESTIONS_BRANCH}-reset:$SUGGESTIONS_BRANCH" git checkout "$ORIGINAL_BRANCH" git branch -D "${SUGGESTIONS_BRANCH}-reset" - + echo "Suggestions queue reset." else git checkout "$ORIGINAL_BRANCH" From efc53c2173a787245c1a932764c12fac78e57cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Raz=20Guzm=C3=A1n=20Macedo?= Date: Thu, 2 Apr 2026 10:24:43 -0600 Subject: [PATCH 05/15] docs: Center and re-flow perf images (#53004) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A or Added/Fixed/Improved ... Co-authored-by: Joseph T. Lyons --- docs/src/performance.md | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/src/performance.md b/docs/src/performance.md index b8f76179e16fcf1f1b886a5c3ef00bcc85aa9ed4..d25ac246f3dbc03ba4286f8e130c566657bbf196 100644 --- a/docs/src/performance.md +++ b/docs/src/performance.md @@ -15,7 +15,7 @@ See [samply](https://github.com/mstange/samply)'s README on how to install and r The profile.json does not contain any symbols. Firefox profiler can add the local symbols to the profile for for. To do that hit the upload local profile button in the top right corner. -image +image # In depth CPU profiling (Tracing) @@ -53,20 +53,40 @@ Download the profiler: Open the profiler (tracy-profiler), you should see zed in the list of `Discovered clients` click it. -image +image Tracy is an incredibly powerful profiler which can do a lot however it's UI is not that friendly. This is not the place for an in depth guide to Tracy, I do however want to highlight one particular workflow that is helpful when figuring out why a piece of code is _sometimes_ slow. Here are the steps: 1. Click the flamechart button at the top. + +Click flamechart + 2. Click on a function that takes a lot of time. + +Click snapshot + 3. Expand the list of function calls by clicking on main thread. + +Click main thread + 4. Filter that list to the slower calls then click on one of the slow calls in the list + +Select the tail calls in the histogram to filter down the list of calls then click on one call + 5. Click zoom to zone to go to that specific function call in the timeline + +Click zoom to zone + 6. Scroll to zoom in and see more detail about the callers + +Scroll to zoom in + 7. Click on a caller to to get statistics on _it_. +Click on any of the zones to get statistics + While normally the blue bars in the Tracy timeline correspond to function calls they can time any part of a codebase. In the example below we have added an extra span "for block in edits" and added metadata to it: the block_height. You can do that like this: ```rust @@ -74,14 +94,6 @@ let span = ztracing::debug_span!("for block in edits", block_height = block.heig let _enter = span.enter(); // span guard, when this is dropped the span ends (and its duration is recorded) ``` -Click flamechart -Click snapshot -Click main thread -Select the tail calls in the histogram to filter down the list of calls then click on one call -Click zoom to zone -Scroll to zoom in -Click on any of the zones to get statistics - # Task/Async profiling Get a profile of the zed foreground executor and background executors. Check if From bd6dadaa0b2e950c80ab3c9610dc1678078b67a9 Mon Sep 17 00:00:00 2001 From: finico Date: Thu, 2 Apr 2026 19:26:08 +0300 Subject: [PATCH 06/15] languages: Change syntax highlighting for JSX elements (#49881) Syntax highlighting and its customization are very important to many developers, including me. I've looked through a number of issues and discussions on this topic and haven't found any active PRs. Currently, there's no way to customize highlighting for custom JSX tags, as they use `@type`. Since TSX has a particularly complex syntax and can often contain types/aliases/generics/tags in a dense sequence, they all blends into a single color and makes it difficult to "parse" by eyes. To avoid proposing something arbitrary, I looked into how this is done elsewhere. - VS Code `support.class.component.tsx` [TypeScriptReact.tmLanguage.json](https://github.com/microsoft/vscode/blob/724656efa2c26ab6e7eb2023426dcf2658dc3203/extensions/typescript-basics/syntaxes/TypeScriptReact.tmLanguage.json#L5802) But it relies on both legacy [tmLanguage naming conventions](https://macromates.com/manual/en/language_grammars#:~:text=rarely%20be%20used\).-,support,-%E2%80%94%20things%20provided%20by) and the outdated assumption that React components are always classes. - ReScript `@tag` [rescript-zed](https://github.com/rescript-lang/rescript-zed/blob/b3930c1754ab2762938244546ea2c7fb97d01cb3/languages/rescript/highlights.scm#L277) It's not entirely correct to just use a `@tag` - it's better to distinguish JSX Intrinsic Elements from custom ones. - Vue `@tag @tag.component.type.constructor` [zed-extensions/vue](https://github.com/zed-extensions/vue/blob/2697588c5cde11375d47f53f9449af8e32600d81/languages/vue/highlights.scm#L9C21-L9C52) - Svelte `@tag @tag.component.type.constructor` [zed-extensions/svelte](https://github.com/zed-extensions/svelte/blob/ae381a1217d14c26cbedfaf84b0a2f5ae508f40c/languages/svelte/highlights.scm#L46C21-L46C52) The similarity between Vue and Svelte implementations (perhaps one borrowed from the other) didn't seem coincidental and the approach felt appropriate. **I decided to adopt the same one to maintain consistency for theme creators.** So, how it looks: **Release (0.224.9) version** zed-one-release **Local version with changes** - no breaking changes for builtin themes - uses `type` color as before and can be changed in themes separately if needed zed-one-local **Local version with changes and theme overrides** zed-one-with-overrides With these changes in the config: ```jsonc "theme_overrides": { "One Light": { "syntax": { // "tag.component" also matches "type.component": { "color": "#d3604fff", }, }, }, }, ``` I'm pretty sure this will help many developers enjoy Zed even more. Release Notes: - Improved syntax highlighting for custom jsx elements in TSX and JavaScript languages. Theme authors and users can now highlight these in their theme/theme overrides using `tag.component.jsx` --- crates/grammars/src/javascript/highlights.scm | 18 +++++++++--------- crates/grammars/src/tsx/highlights.scm | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/grammars/src/javascript/highlights.scm b/crates/grammars/src/javascript/highlights.scm index 4af87cc578e3060e72d1e1374f4904d8c7629ddf..f6354dd3a016f544e5be1616c3dfb12144855775 100644 --- a/crates/grammars/src/javascript/highlights.scm +++ b/crates/grammars/src/javascript/highlights.scm @@ -328,26 +328,26 @@ ; JSX elements (jsx_opening_element [ - (identifier) @type + (identifier) @type @tag.component.jsx (member_expression - object: (identifier) @type - property: (property_identifier) @type) + object: (identifier) @type @tag.component.jsx + property: (property_identifier) @type @tag.component.jsx) ]) (jsx_closing_element [ - (identifier) @type + (identifier) @type @tag.component.jsx (member_expression - object: (identifier) @type - property: (property_identifier) @type) + object: (identifier) @type @tag.component.jsx + property: (property_identifier) @type @tag.component.jsx) ]) (jsx_self_closing_element [ - (identifier) @type + (identifier) @type @tag.component.jsx (member_expression - object: (identifier) @type - property: (property_identifier) @type) + object: (identifier) @type @tag.component.jsx + property: (property_identifier) @type @tag.component.jsx) ]) (jsx_opening_element diff --git a/crates/grammars/src/tsx/highlights.scm b/crates/grammars/src/tsx/highlights.scm index 482bba7f081a44b78a2f2d72c3435d8a6419b874..0f203e7112cf14268d0edfed39b5624375d1a859 100644 --- a/crates/grammars/src/tsx/highlights.scm +++ b/crates/grammars/src/tsx/highlights.scm @@ -389,26 +389,26 @@ (jsx_opening_element [ - (identifier) @type + (identifier) @type @tag.component.jsx (member_expression - object: (identifier) @type - property: (property_identifier) @type) + object: (identifier) @type @tag.component.jsx + property: (property_identifier) @type @tag.component.jsx) ]) (jsx_closing_element [ - (identifier) @type + (identifier) @type @tag.component.jsx (member_expression - object: (identifier) @type - property: (property_identifier) @type) + object: (identifier) @type @tag.component.jsx + property: (property_identifier) @type @tag.component.jsx) ]) (jsx_self_closing_element [ - (identifier) @type + (identifier) @type @tag.component.jsx (member_expression - object: (identifier) @type - property: (property_identifier) @type) + object: (identifier) @type @tag.component.jsx + property: (property_identifier) @type @tag.component.jsx) ]) (jsx_opening_element From fbdeb934519e955fd33653ff00a9ad29752fed5f Mon Sep 17 00:00:00 2001 From: Oliver Azevedo Barnes Date: Thu, 2 Apr 2026 17:43:42 +0100 Subject: [PATCH 07/15] devcontainer: Implement remote support for git checkpoint operations (#48896) Closes #47907 Implements the four git checkpoint operations (`create`, `restore`, `compare`, `diff`) that had been stubbed out for remote repositories, and related test infrastructure. Testing steps: 1. Open a project with a `.devcontainer` configuration and connect to the Dev Container 2. Open an Agent thread and ask the agent to make a code change 3. After the agent completes, verify the "Restore from checkpoint" button appears (previously missing in Dev Container sessions) 4. Click "Restore from checkpoint" and confirm the file reverts to its prior state Release Notes: - Added support for git checkpoint operations in remote/Dev Container sessions, restoring the "Restore from checkpoint" button in Agent threads. --------- Co-authored-by: KyleBarton --- crates/fs/src/fake_git_repo.rs | 84 +++++++++- crates/fs/tests/integration/fake_git_repo.rs | 23 ++- crates/project/src/git_store.rs | 144 ++++++++++++++++- crates/proto/proto/git.proto | 37 +++++ crates/proto/proto/zed.proto | 9 +- crates/proto/src/proto.rs | 15 ++ .../remote_server/src/remote_editing_tests.rs | 147 ++++++++++++++++++ 7 files changed, 449 insertions(+), 10 deletions(-) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index fc66e27fc9a32c2a8897eb5c9faee917c21177c5..a00061452e4dbd2051b961fdde9e33dc05fba0b1 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -1053,10 +1053,88 @@ impl GitRepository for FakeGitRepository { fn diff_checkpoints( &self, - _base_checkpoint: GitRepositoryCheckpoint, - _target_checkpoint: GitRepositoryCheckpoint, + base_checkpoint: GitRepositoryCheckpoint, + target_checkpoint: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result> { - unimplemented!() + let executor = self.executor.clone(); + let checkpoints = self.checkpoints.clone(); + async move { + executor.simulate_random_delay().await; + let checkpoints = checkpoints.lock(); + let base = checkpoints + .get(&base_checkpoint.commit_sha) + .context(format!( + "invalid base checkpoint: {}", + base_checkpoint.commit_sha + ))?; + let target = checkpoints + .get(&target_checkpoint.commit_sha) + .context(format!( + "invalid target checkpoint: {}", + target_checkpoint.commit_sha + ))?; + + fn collect_files( + entry: &FakeFsEntry, + prefix: String, + out: &mut std::collections::BTreeMap, + ) { + match entry { + FakeFsEntry::File { content, .. } => { + out.insert(prefix, String::from_utf8_lossy(content).into_owned()); + } + FakeFsEntry::Dir { entries, .. } => { + for (name, child) in entries { + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + collect_files(child, path, out); + } + } + FakeFsEntry::Symlink { .. } => {} + } + } + + let mut base_files = std::collections::BTreeMap::new(); + let mut target_files = std::collections::BTreeMap::new(); + collect_files(base, String::new(), &mut base_files); + collect_files(target, String::new(), &mut target_files); + + let all_paths: std::collections::BTreeSet<&String> = + base_files.keys().chain(target_files.keys()).collect(); + + let mut diff = String::new(); + for path in all_paths { + match (base_files.get(path), target_files.get(path)) { + (Some(base_content), Some(target_content)) + if base_content != target_content => + { + diff.push_str(&format!("diff --git a/{path} b/{path}\n")); + diff.push_str(&format!("--- a/{path}\n")); + diff.push_str(&format!("+++ b/{path}\n")); + for line in base_content.lines() { + diff.push_str(&format!("-{line}\n")); + } + for line in target_content.lines() { + diff.push_str(&format!("+{line}\n")); + } + } + (Some(_), None) => { + diff.push_str(&format!("diff --git a/{path} /dev/null\n")); + diff.push_str("deleted file\n"); + } + (None, Some(_)) => { + diff.push_str(&format!("diff --git /dev/null b/{path}\n")); + diff.push_str("new file\n"); + } + _ => {} + } + } + Ok(diff) + } + .boxed() } fn default_branch( diff --git a/crates/fs/tests/integration/fake_git_repo.rs b/crates/fs/tests/integration/fake_git_repo.rs index e327f92e996bfa0e89cc60a0a9c0d919bec8bc47..6428083c161235001ef29daf3583520e7f7d25a2 100644 --- a/crates/fs/tests/integration/fake_git_repo.rs +++ b/crates/fs/tests/integration/fake_git_repo.rs @@ -155,7 +155,10 @@ async fn test_checkpoints(executor: BackgroundExecutor) { .unwrap() ); - repository.restore_checkpoint(checkpoint_1).await.unwrap(); + repository + .restore_checkpoint(checkpoint_1.clone()) + .await + .unwrap(); assert_eq!( fs.files_with_contents(Path::new("")), [ @@ -164,4 +167,22 @@ async fn test_checkpoints(executor: BackgroundExecutor) { (Path::new(path!("/foo/b")).into(), b"ipsum".into()) ] ); + + // diff_checkpoints: identical checkpoints produce empty diff + let diff = repository + .diff_checkpoints(checkpoint_2.clone(), checkpoint_3.clone()) + .await + .unwrap(); + assert!( + diff.is_empty(), + "identical checkpoints should produce empty diff" + ); + + // diff_checkpoints: different checkpoints produce non-empty diff + let diff = repository + .diff_checkpoints(checkpoint_1.clone(), checkpoint_2.clone()) + .await + .unwrap(); + assert!(diff.contains("b"), "diff should mention changed file 'b'"); + assert!(diff.contains("c"), "diff should mention added file 'c'"); } diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 6f838f02768a38d1c84935f5a7e7a303e682847d..e22d13b5fe5fd0bc64b6d95c52432437a41569f1 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -560,6 +560,10 @@ impl GitStore { client.add_entity_request_handler(Self::handle_run_hook); client.add_entity_request_handler(Self::handle_reset); client.add_entity_request_handler(Self::handle_show); + client.add_entity_request_handler(Self::handle_create_checkpoint); + client.add_entity_request_handler(Self::handle_restore_checkpoint); + client.add_entity_request_handler(Self::handle_compare_checkpoints); + client.add_entity_request_handler(Self::handle_diff_checkpoints); client.add_entity_request_handler(Self::handle_load_commit_diff); client.add_entity_request_handler(Self::handle_file_history); client.add_entity_request_handler(Self::handle_checkout_files); @@ -2619,6 +2623,92 @@ impl GitStore { }) } + async fn handle_create_checkpoint( + 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 checkpoint = repository_handle + .update(&mut cx, |repository, _| repository.checkpoint()) + .await??; + + Ok(proto::GitCreateCheckpointResponse { + commit_sha: checkpoint.commit_sha.as_bytes().to_vec(), + }) + } + + async fn handle_restore_checkpoint( + 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 checkpoint = GitRepositoryCheckpoint { + commit_sha: Oid::from_bytes(&envelope.payload.commit_sha)?, + }; + + repository_handle + .update(&mut cx, |repository, _| { + repository.restore_checkpoint(checkpoint) + }) + .await??; + + Ok(proto::Ack {}) + } + + async fn handle_compare_checkpoints( + 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 left = GitRepositoryCheckpoint { + commit_sha: Oid::from_bytes(&envelope.payload.left_commit_sha)?, + }; + let right = GitRepositoryCheckpoint { + commit_sha: Oid::from_bytes(&envelope.payload.right_commit_sha)?, + }; + + let equal = repository_handle + .update(&mut cx, |repository, _| { + repository.compare_checkpoints(left, right) + }) + .await??; + + Ok(proto::GitCompareCheckpointsResponse { equal }) + } + + async fn handle_diff_checkpoints( + 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 base = GitRepositoryCheckpoint { + commit_sha: Oid::from_bytes(&envelope.payload.base_commit_sha)?, + }; + let target = GitRepositoryCheckpoint { + commit_sha: Oid::from_bytes(&envelope.payload.target_commit_sha)?, + }; + + let diff = repository_handle + .update(&mut cx, |repository, _| { + repository.diff_checkpoints(base, target) + }) + .await??; + + Ok(proto::GitDiffCheckpointsResponse { diff }) + } + async fn handle_load_commit_diff( this: Entity, envelope: TypedEnvelope, @@ -6229,12 +6319,24 @@ impl Repository { } pub fn checkpoint(&mut self) -> oneshot::Receiver> { - self.send_job(None, |repo, _cx| async move { + let id = self.id; + self.send_job(None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.checkpoint().await } - RepositoryState::Remote(..) => anyhow::bail!("not implemented yet"), + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let response = client + .request(proto::GitCreateCheckpoint { + project_id: project_id.0, + repository_id: id.to_proto(), + }) + .await?; + + Ok(GitRepositoryCheckpoint { + commit_sha: Oid::from_bytes(&response.commit_sha)?, + }) + } } }) } @@ -6243,12 +6345,22 @@ impl Repository { &mut self, checkpoint: GitRepositoryCheckpoint, ) -> oneshot::Receiver> { + let id = self.id; self.send_job(None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.restore_checkpoint(checkpoint).await } - RepositoryState::Remote { .. } => anyhow::bail!("not implemented yet"), + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitRestoreCheckpoint { + project_id: project_id.0, + repository_id: id.to_proto(), + commit_sha: checkpoint.commit_sha.as_bytes().to_vec(), + }) + .await?; + Ok(()) + } } }) } @@ -6342,12 +6454,23 @@ impl Repository { left: GitRepositoryCheckpoint, right: GitRepositoryCheckpoint, ) -> oneshot::Receiver> { + let id = self.id; self.send_job(None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.compare_checkpoints(left, right).await } - RepositoryState::Remote { .. } => anyhow::bail!("not implemented yet"), + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let response = client + .request(proto::GitCompareCheckpoints { + project_id: project_id.0, + repository_id: id.to_proto(), + left_commit_sha: left.commit_sha.as_bytes().to_vec(), + right_commit_sha: right.commit_sha.as_bytes().to_vec(), + }) + .await?; + Ok(response.equal) + } } }) } @@ -6357,6 +6480,7 @@ impl Repository { base_checkpoint: GitRepositoryCheckpoint, target_checkpoint: GitRepositoryCheckpoint, ) -> oneshot::Receiver> { + let id = self.id; self.send_job(None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { @@ -6364,7 +6488,17 @@ impl Repository { .diff_checkpoints(base_checkpoint, target_checkpoint) .await } - RepositoryState::Remote { .. } => anyhow::bail!("not implemented yet"), + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let response = client + .request(proto::GitDiffCheckpoints { + project_id: project_id.0, + repository_id: id.to_proto(), + base_commit_sha: base_checkpoint.commit_sha.as_bytes().to_vec(), + target_commit_sha: target_checkpoint.commit_sha.as_bytes().to_vec(), + }) + .await?; + Ok(response.diff) + } } }) } diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index cb878cade726002e7e09670cf7c190880d8e66cb..0cbb635d78dddc81aa7c75340f2fbebe83a474e3 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -586,6 +586,43 @@ message GitCreateWorktree { optional string commit = 5; } +message GitCreateCheckpoint { + uint64 project_id = 1; + uint64 repository_id = 2; +} + +message GitCreateCheckpointResponse { + bytes commit_sha = 1; +} + +message GitRestoreCheckpoint { + uint64 project_id = 1; + uint64 repository_id = 2; + bytes commit_sha = 3; +} + +message GitCompareCheckpoints { + uint64 project_id = 1; + uint64 repository_id = 2; + bytes left_commit_sha = 3; + bytes right_commit_sha = 4; +} + +message GitCompareCheckpointsResponse { + bool equal = 1; +} + +message GitDiffCheckpoints { + uint64 project_id = 1; + uint64 repository_id = 2; + bytes base_commit_sha = 3; + bytes target_commit_sha = 4; +} + +message GitDiffCheckpointsResponse { + string diff = 1; +} + message GitRemoveWorktree { uint64 project_id = 1; uint64 repository_id = 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index d165bcb9529a41294d2bc25572f454c425f8c3f0..24e7c5372f2679eab1726487e1967edcef6024ed 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -467,7 +467,14 @@ message Envelope { SpawnKernelResponse spawn_kernel_response = 427; KillKernel kill_kernel = 428; GitRemoveWorktree git_remove_worktree = 431; - GitRenameWorktree git_rename_worktree = 432; // current max + GitRenameWorktree git_rename_worktree = 432; + GitCreateCheckpoint git_create_checkpoint = 433; + GitCreateCheckpointResponse git_create_checkpoint_response = 434; + GitRestoreCheckpoint git_restore_checkpoint = 435; + GitCompareCheckpoints git_compare_checkpoints = 436; + GitCompareCheckpointsResponse git_compare_checkpoints_response = 437; + GitDiffCheckpoints git_diff_checkpoints = 438; + GitDiffCheckpointsResponse git_diff_checkpoints_response = 439; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 8c72fa08c57755dc45b9658db441a037d0a9fe2e..c21934338f97cc8ed3e04b917c7db84fccecd031 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -294,6 +294,13 @@ messages!( (GitCommitDetails, Background), (GitFileHistory, Background), (GitFileHistoryResponse, Background), + (GitCreateCheckpoint, Background), + (GitCreateCheckpointResponse, Background), + (GitRestoreCheckpoint, Background), + (GitCompareCheckpoints, Background), + (GitCompareCheckpointsResponse, Background), + (GitDiffCheckpoints, Background), + (GitDiffCheckpointsResponse, Background), (SetIndexText, Background), (Push, Background), (Fetch, Background), @@ -514,6 +521,10 @@ request_messages!( (RegisterBufferWithLanguageServers, Ack), (GitShow, GitCommitDetails), (GitFileHistory, GitFileHistoryResponse), + (GitCreateCheckpoint, GitCreateCheckpointResponse), + (GitRestoreCheckpoint, Ack), + (GitCompareCheckpoints, GitCompareCheckpointsResponse), + (GitDiffCheckpoints, GitDiffCheckpointsResponse), (GitReset, Ack), (GitDeleteBranch, Ack), (GitCheckoutFiles, Ack), @@ -696,6 +707,10 @@ entity_messages!( RegisterBufferWithLanguageServers, GitShow, GitFileHistory, + GitCreateCheckpoint, + GitRestoreCheckpoint, + GitCompareCheckpoints, + GitDiffCheckpoints, GitReset, GitDeleteBranch, GitCheckoutFiles, diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 86b7f93eb2c737cac55dbf2882f91ec277e4e174..90546773df234767489df96ee37d50e3fcaeea3b 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -1917,6 +1917,153 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA assert_eq!(server_branch.name(), "totally-new-branch"); } +#[gpui::test] +async fn test_remote_git_checkpoints(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "file.txt": "original content", + }, + }), + ) + .await; + + let (project, _headless) = init_test(&fs, cx, server_cx).await; + + let (_worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let repository = project.update(cx, |project, cx| project.active_repository(cx).unwrap()); + + // 1. Create a checkpoint of the original state + let checkpoint_1 = repository + .update(cx, |repository, _| repository.checkpoint()) + .await + .unwrap() + .unwrap(); + + // 2. Modify a file on the server-side fs + fs.write( + Path::new(path!("/code/project1/file.txt")), + b"modified content", + ) + .await + .unwrap(); + + // 3. Create a second checkpoint with the modified state + let checkpoint_2 = repository + .update(cx, |repository, _| repository.checkpoint()) + .await + .unwrap() + .unwrap(); + + // 4. compare_checkpoints: same checkpoint with itself => equal + let equal = repository + .update(cx, |repository, _| { + repository.compare_checkpoints(checkpoint_1.clone(), checkpoint_1.clone()) + }) + .await + .unwrap() + .unwrap(); + assert!(equal, "a checkpoint compared with itself should be equal"); + + // 5. compare_checkpoints: different states => not equal + let equal = repository + .update(cx, |repository, _| { + repository.compare_checkpoints(checkpoint_1.clone(), checkpoint_2.clone()) + }) + .await + .unwrap() + .unwrap(); + assert!( + !equal, + "checkpoints of different states should not be equal" + ); + + // 6. diff_checkpoints: same checkpoint => empty diff + let diff = repository + .update(cx, |repository, _| { + repository.diff_checkpoints(checkpoint_1.clone(), checkpoint_1.clone()) + }) + .await + .unwrap() + .unwrap(); + assert!( + diff.is_empty(), + "diff of identical checkpoints should be empty" + ); + + // 7. diff_checkpoints: different checkpoints => non-empty diff mentioning the changed file + let diff = repository + .update(cx, |repository, _| { + repository.diff_checkpoints(checkpoint_1.clone(), checkpoint_2.clone()) + }) + .await + .unwrap() + .unwrap(); + assert!( + !diff.is_empty(), + "diff of different checkpoints should be non-empty" + ); + assert!( + diff.contains("file.txt"), + "diff should mention the changed file" + ); + assert!( + diff.contains("original content"), + "diff should contain removed content" + ); + assert!( + diff.contains("modified content"), + "diff should contain added content" + ); + + // 8. restore_checkpoint: restore to original state + repository + .update(cx, |repository, _| { + repository.restore_checkpoint(checkpoint_1.clone()) + }) + .await + .unwrap() + .unwrap(); + cx.run_until_parked(); + + // 9. Create a checkpoint after restore + let checkpoint_3 = repository + .update(cx, |repository, _| repository.checkpoint()) + .await + .unwrap() + .unwrap(); + + // 10. compare_checkpoints: restored state matches original + let equal = repository + .update(cx, |repository, _| { + repository.compare_checkpoints(checkpoint_1.clone(), checkpoint_3.clone()) + }) + .await + .unwrap() + .unwrap(); + assert!(equal, "restored state should match original checkpoint"); + + // 11. diff_checkpoints: restored state vs original => empty diff + let diff = repository + .update(cx, |repository, _| { + repository.diff_checkpoints(checkpoint_1.clone(), checkpoint_3.clone()) + }) + .await + .unwrap() + .unwrap(); + assert!(diff.is_empty(), "diff after restore should be empty"); +} + #[gpui::test] async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { let fs = FakeFs::new(server_cx.executor()); From dc3f5b9972d2623330714af36cd127b3b1f791e5 Mon Sep 17 00:00:00 2001 From: Toni Alatalo Date: Thu, 2 Apr 2026 20:20:49 +0300 Subject: [PATCH 08/15] cli: Add --dev-container flag to open project in dev container (#51175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) ## Summary Adds a `--dev-container` CLI flag that automatically triggers "Reopen in Dev Container" when a `.devcontainer/` configuration is found in the project directory. ```sh zed --dev-container /path/to/project ``` ## Motivation This enables fully scripted dev container workflows — for example, creating a git worktree and immediately opening it in a dev container without any manual UI interaction: ```sh git worktree add ../feature-branch zed --dev-container ../feature-branch ``` The dev container modal fires automatically once the workspace finishes initializing, so the environment is ready by the time you look at the window. This is useful for automation scripts that prepare environments and kick off agent runs for tasks like bug report triage. Here's an [example script](https://github.com/antont/todo-rs-ts/blob/main/scripts/devcontainer-new.sh) that creates a worktree and opens it as a dev container in one step. Related: #48682 requests a `devcontainer://` protocol for connecting to already-running dev containers — a complementary but different use case. This PR covers the "open project and trigger dev container setup" path. ## How it works - The `--dev-container` flag flows through the CLI IPC protocol to the workspace as an `open_in_dev_container` option. - On the first worktree scan completion, if devcontainer configs are detected, the dev container modal opens automatically. - If no `.devcontainer/` config is found, the flag is cleared and a warning is logged. ## Notable changes - **`Workspace::worktree_scans_complete`** promoted from `#[cfg(test)]` to production. It was only test-gated because it had no production callers — it's a pure read-only future with no side effects. - **`suggest_on_worktree_updated`** now takes `&mut Workspace` to read and clear the CLI flag. - Extracted **`open_dev_container_modal`** helper shared between the CLI code path and the suggest notification. ## Test plan - [x] `cargo test -p zed open_listener` — includes `test_dev_container_flag_opens_modal` and `test_dev_container_flag_cleared_without_config` - [x] `cargo test -p recent_projects` — existing suggest tests still pass - [x] Manual: `cargo run -- --dev-container /path/to/project-with-devcontainer` opens the modal Release Notes: - Added `--dev-container` CLI flag to automatically open a project in a dev container when `.devcontainer/` configuration is present. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- crates/cli/src/cli.rs | 1 + crates/cli/src/main.rs | 7 + .../src/dev_container_suggest.rs | 35 ++++- crates/recent_projects/src/recent_projects.rs | 3 +- crates/workspace/src/workspace.rs | 32 ++++- crates/zed/src/main.rs | 14 +- crates/zed/src/zed.rs | 1 + crates/zed/src/zed/open_listener.rs | 127 ++++++++++++++++++ crates/zed/src/zed/windows_only_instance.rs | 1 + 9 files changed, 215 insertions(+), 6 deletions(-) diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 1a3ce059b8116ac7438f3eb0330b47660cc863de..d8da78c53210230597dab49ce297d9fa694e62f1 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -21,6 +21,7 @@ pub enum CliRequest { reuse: bool, env: Option>, user_data_dir: Option, + dev_container: bool, }, } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index b8af5896285d3080ca3320a5909b3f58f72de643..41f2d14c1908ac18e7ea297eef19d8d9bd1cf8b5 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -118,6 +118,12 @@ struct Args { /// Will attempt to give the correct command to run #[arg(long)] system_specs: bool, + /// Open the project in a dev container. + /// + /// Automatically triggers "Reopen in Dev Container" if a `.devcontainer/` + /// configuration is found in the project directory. + #[arg(long)] + dev_container: bool, /// Pairs of file paths to diff. Can be specified multiple times. /// When directories are provided, recurses into them and shows all changed files in a single multi-diff view. #[arg(long, action = clap::ArgAction::Append, num_args = 2, value_names = ["OLD_PATH", "NEW_PATH"])] @@ -670,6 +676,7 @@ fn main() -> Result<()> { reuse: args.reuse, env, user_data_dir: user_data_dir_for_thread, + dev_container: args.dev_container, })?; while let Ok(response) = rx.recv() { diff --git a/crates/recent_projects/src/dev_container_suggest.rs b/crates/recent_projects/src/dev_container_suggest.rs index b134833688fa081c288e5b90a371bc3c462401f0..759eef2ba32074a964979ee670c0b4ec216f404b 100644 --- a/crates/recent_projects/src/dev_container_suggest.rs +++ b/crates/recent_projects/src/dev_container_suggest.rs @@ -30,17 +30,20 @@ fn project_devcontainer_key(project_path: &str) -> String { } pub fn suggest_on_worktree_updated( + workspace: &mut Workspace, worktree_id: WorktreeId, updated_entries: &UpdatedEntriesSet, project: &gpui::Entity, window: &mut Window, cx: &mut Context, ) { + let cli_auto_open = workspace.open_in_dev_container(); + let devcontainer_updated = updated_entries.iter().any(|(path, _, _)| { path.as_ref() == devcontainer_dir_path() || path.as_ref() == devcontainer_json_path() }); - if !devcontainer_updated { + if !devcontainer_updated && !cli_auto_open { return; } @@ -54,7 +57,35 @@ pub fn suggest_on_worktree_updated( return; } - if find_configs_in_snapshot(worktree).is_empty() { + let has_configs = !find_configs_in_snapshot(worktree).is_empty(); + + if cli_auto_open { + workspace.set_open_in_dev_container(false); + let task = cx.spawn_in(window, async move |workspace, cx| { + let scans_complete = + workspace.update(cx, |workspace, cx| workspace.worktree_scans_complete(cx))?; + scans_complete.await; + + workspace.update_in(cx, |workspace, window, cx| { + let has_configs = workspace + .project() + .read(cx) + .worktrees(cx) + .any(|wt| !find_configs_in_snapshot(wt.read(cx)).is_empty()); + if has_configs { + cx.on_next_frame(window, move |_workspace, window, cx| { + window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx); + }); + } else { + log::warn!("--dev-container: no devcontainer configuration found in project"); + } + }) + }); + workspace.set_dev_container_task(task); + return; + } + + if !has_configs { return; } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index e1a0cb0609a9883bfe73048eda64cc8d1b299c2e..b3f918e204c5600193cd01a0f7569888d333edd9 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -475,11 +475,12 @@ pub fn init(cx: &mut App) { cx.subscribe_in( workspace.project(), window, - move |_, project, event, window, cx| { + move |workspace, project, event, window, cx| { if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) = event { dev_container_suggest::suggest_on_worktree_updated( + workspace, *worktree_id, updated_entries, project, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index aa692ab39a6084126c9b15b07856549364b13842..ecc03806f7eeffbb62ad1340022e0ea475fe9531 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1344,6 +1344,8 @@ pub struct Workspace { scheduled_tasks: Vec>, last_open_dock_positions: Vec, removing: bool, + open_in_dev_container: bool, + _dev_container_task: Option>>, _panels_task: Option>>, sidebar_focus_handle: Option, multi_workspace: Option>, @@ -1778,6 +1780,8 @@ impl Workspace { removing: false, sidebar_focus_handle: None, multi_workspace, + open_in_dev_container: false, + _dev_container_task: None, } } @@ -2800,6 +2804,18 @@ impl Workspace { self.debugger_provider = Some(Arc::new(provider)); } + pub fn set_open_in_dev_container(&mut self, value: bool) { + self.open_in_dev_container = value; + } + + pub fn open_in_dev_container(&self) -> bool { + self.open_in_dev_container + } + + pub fn set_dev_container_task(&mut self, task: Task>) { + self._dev_container_task = Some(task); + } + pub fn debugger_provider(&self) -> Option> { self.debugger_provider.clone() } @@ -3026,7 +3042,6 @@ impl Workspace { self.project.read(cx).visible_worktrees(cx) } - #[cfg(any(test, feature = "test-support"))] pub fn worktree_scans_complete(&self, cx: &App) -> impl Future + 'static + use<> { let futures = self .worktrees(cx) @@ -9214,6 +9229,7 @@ pub struct OpenOptions { pub requesting_window: Option>, pub open_mode: OpenMode, pub env: Option>, + pub open_in_dev_container: bool, } /// The result of opening a workspace via [`open_paths`], [`Workspace::new_local`], @@ -9393,12 +9409,17 @@ pub fn open_paths( } } + let open_in_dev_container = open_options.open_in_dev_container; + let result = if let Some((existing, target_workspace)) = existing { let open_task = existing .update(cx, |multi_workspace, window, cx| { window.activate_window(); multi_workspace.activate(target_workspace.clone(), window, cx); target_workspace.update(cx, |workspace, cx| { + if open_in_dev_container { + workspace.set_open_in_dev_container(true); + } workspace.open_paths( abs_paths, OpenOptions { @@ -9426,6 +9447,13 @@ pub fn open_paths( Ok(OpenResult { window: existing, workspace: target_workspace, opened_items: open_task }) } else { + let init = if open_in_dev_container { + Some(Box::new(|workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context| { + workspace.set_open_in_dev_container(true); + }) as Box) + Send>) + } else { + None + }; let result = cx .update(move |cx| { Workspace::new_local( @@ -9433,7 +9461,7 @@ pub fn open_paths( app_state.clone(), open_options.requesting_window, open_options.env, - None, + init, open_options.open_mode, cx, ) diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 303f21b8ffa62f9d9f380d9c18beecd77775df20..0e1cbc96ff1521626bfe8bcf62091404324132a0 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -857,6 +857,7 @@ fn main() { diff_paths, wsl, diff_all: diff_all_mode, + dev_container: args.dev_container, }) } @@ -1208,6 +1209,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut } let mut task = None; + let dev_container = request.dev_container; if !request.open_paths.is_empty() || !request.diff_paths.is_empty() { let app_state = app_state.clone(); task = Some(cx.spawn(async move |cx| { @@ -1218,7 +1220,10 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut &request.diff_paths, request.diff_all, app_state, - workspace::OpenOptions::default(), + workspace::OpenOptions { + open_in_dev_container: dev_container, + ..Default::default() + }, cx, ) .await?; @@ -1636,6 +1641,13 @@ struct Args { #[arg(long, value_name = "USER@DISTRO")] wsl: Option, + /// Open the project in a dev container. + /// + /// Automatically triggers "Reopen in Dev Container" if a `.devcontainer/` + /// configuration is found in the project directory. + #[arg(long)] + dev_container: bool, + /// Instructs zed to run as a dev server on this machine. (not implemented) #[arg(long)] dev_server_token: Option, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 75fe04feff794f21ff3fdd0e763084e4887a040b..fbebb37985c2ebd76a63db5b4b807a8a7e0203ce 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5167,6 +5167,7 @@ mod tests { app_state.languages.add(markdown_lang()); gpui_tokio::init(cx); + AppState::set_global(app_state.clone(), cx); theme_settings::init(theme::LoadThemes::JustBase, cx); audio::init(cx); channel::init(&app_state.client, app_state.user_store.clone(), cx); diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 7645eae88d69f777f650ac9f86724bfef0f10bc5..0a302291cacc8caa9e0618da00b8d7c6370ccf0e 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -37,6 +37,7 @@ pub struct OpenRequest { pub open_paths: Vec, pub diff_paths: Vec<[String; 2]>, pub diff_all: bool, + pub dev_container: bool, pub open_channel_notes: Vec<(u64, Option)>, pub join_channel: Option, pub remote_connection: Option, @@ -78,6 +79,7 @@ impl OpenRequest { this.diff_paths = request.diff_paths; this.diff_all = request.diff_all; + this.dev_container = request.dev_container; if let Some(wsl) = request.wsl { let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') { if user.is_empty() { @@ -256,6 +258,7 @@ pub struct RawOpenRequest { pub urls: Vec, pub diff_paths: Vec<[String; 2]>, pub diff_all: bool, + pub dev_container: bool, pub wsl: Option, } @@ -413,6 +416,7 @@ pub async fn handle_cli_connection( reuse, env, user_data_dir: _, + dev_container, } => { if !urls.is_empty() { cx.update(|cx| { @@ -421,6 +425,7 @@ pub async fn handle_cli_connection( urls, diff_paths, diff_all, + dev_container, wsl, }, cx, @@ -450,6 +455,7 @@ pub async fn handle_cli_connection( reuse, &responses, wait, + dev_container, app_state.clone(), env, cx, @@ -471,6 +477,7 @@ async fn open_workspaces( reuse: bool, responses: &IpcSender, wait: bool, + dev_container: bool, app_state: Arc, env: Option>, cx: &mut AsyncApp, @@ -532,6 +539,7 @@ async fn open_workspaces( requesting_window: replace_window, wait, env: env.clone(), + open_in_dev_container: dev_container, ..Default::default() }; @@ -1545,4 +1553,123 @@ mod tests { }) .unwrap(); } + + #[gpui::test] + async fn test_dev_container_flag_opens_modal(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| recent_projects::init(cx)); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/project"), + json!({ + ".devcontainer": { + "devcontainer.json": "{}" + }, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + + let (response_tx, _) = ipc::channel::().unwrap(); + let errored = cx + .spawn({ + let app_state = app_state.clone(); + |mut cx| async move { + open_local_workspace( + vec![path!("/project").to_owned()], + vec![], + false, + workspace::OpenOptions { + open_in_dev_container: true, + ..Default::default() + }, + &response_tx, + &app_state, + &mut cx, + ) + .await + } + }) + .await; + + assert!(!errored); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let flag = multi_workspace.workspace().read(cx).open_in_dev_container(); + assert!( + !flag, + "open_in_dev_container flag should be consumed by suggest_on_worktree_updated" + ); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_dev_container_flag_cleared_without_config(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| recent_projects::init(cx)); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/project"), + json!({ + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + + let (response_tx, _) = ipc::channel::().unwrap(); + let errored = cx + .spawn({ + let app_state = app_state.clone(); + |mut cx| async move { + open_local_workspace( + vec![path!("/project").to_owned()], + vec![], + false, + workspace::OpenOptions { + open_in_dev_container: true, + ..Default::default() + }, + &response_tx, + &app_state, + &mut cx, + ) + .await + } + }) + .await; + + assert!(!errored); + + // Let any pending worktree scan events and updates settle. + cx.run_until_parked(); + + // With no .devcontainer config, the flag should be cleared once the + // worktree scan completes, rather than persisting on the workspace. + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let flag = multi_workspace + .workspace() + .read(cx) + .open_in_dev_container(); + assert!( + !flag, + "open_in_dev_container flag should be cleared when no devcontainer config exists" + ); + }) + .unwrap(); + } } diff --git a/crates/zed/src/zed/windows_only_instance.rs b/crates/zed/src/zed/windows_only_instance.rs index 5790715bc13bdcc68d180519d9176873bd81bc50..f22f49e26a982cb8cb68e21645033819e059de36 100644 --- a/crates/zed/src/zed/windows_only_instance.rs +++ b/crates/zed/src/zed/windows_only_instance.rs @@ -162,6 +162,7 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> { reuse: false, env: None, user_data_dir: args.user_data_dir.clone(), + dev_container: args.dev_container, } }; From 05c749c3d7807dc5b655c8f0467fab32305e6bac Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:06:35 -0300 Subject: [PATCH 09/15] settings_ui: Make all number fields editable (#52986) Taking advantage that we do have this capability now within the number field component. I initially thought that some wouldn't make sense to be editable but upon further reflection, why not? The buttons continue to work, but if you want to type a more precise value, it should be possible, too! Release Notes: - N/A --- crates/settings_ui/src/settings_ui.rs | 59 ++++++--------------------- 1 file changed, 12 insertions(+), 47 deletions(-) diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 70aaaa15412793aae54c7c29fe8a2613854c8adb..634db0e247fdc370c479df0ed4f6d1f84a5284f6 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -500,18 +500,18 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::>(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) - .add_basic_renderer::(render_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::>(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) + .add_basic_renderer::(render_editable_number_field) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) @@ -4051,41 +4051,6 @@ fn render_toggle_button + From + Copy>( .into_any_element() } -fn render_number_field( - field: SettingField, - file: SettingsUiFile, - _metadata: Option<&SettingsFieldMetadata>, - window: &mut Window, - cx: &mut App, -) -> AnyElement { - let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick); - let value = value.copied().unwrap_or_else(T::min_value); - - let id = field - .json_path - .map(|p| format!("numeric_stepper_{}", p)) - .unwrap_or_else(|| "numeric_stepper".to_string()); - - NumberField::new(id, value, window, cx) - .tab_index(0_isize) - .on_change({ - move |value, window, cx| { - let value = *value; - update_settings_file( - file.clone(), - field.json_path, - window, - cx, - move |settings, _cx| { - (field.write)(settings, Some(value)); - }, - ) - .log_err(); // todo(settings_ui) don't log err - } - }) - .into_any_element() -} - fn render_editable_number_field( field: SettingField, file: SettingsUiFile, From 34f51c1b0d44e8611dbed12485446c127ee48618 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:15:09 -0300 Subject: [PATCH 10/15] agent_ui: Remeasure changed entries in the thread view list (#53017) This PR fixes an issue where, sometimes, you couldn't scroll all the way to the bottom of the thread's content. The scrollbar would show up at the bottom of the scrollable container but the content was visibly cut off. Turns out that's a consequence of the top-down thread generation introduced in https://github.com/zed-industries/zed/pull/52440, where changing the list alignment to `Top` made it visible that sometimes, the maximum scroll area would get underestimated because the items in the thread view's list would have a stale height measurement. So, the way this PR fixes the issue is by calling `splice_focusable` in the `EntryUpdated` event, too, so that the height of the items in the overdraw area get marked as unmeasured, triggering a list re-render and re-measuring. We started by writing a test at the list level that would reproduce the regression but then later figured out that this is not an inherent list problem; it was rather a problem with its use within the thread view layer. Then, we explored writing a test that documented the regression, but it turned out to be very hard to simulate this sort of set up in which certain elements would have its height changed during streaming, which would be how you'd get to a mismatched height situation. Therefore, given `AcpThreadEvent::NewEntry` already called `splice_focusable` and don't have a test for it, we figure it'd be safe to move forward without one, too. We then introduced a helper that's now shared between `AcpThreadEvent::NewEntry` and `AcpThreadEvent::EntryUpdated`. Release Notes: - Agent: Fixed an issue where sometimes you couldn't scroll all the way to the bottom of the thread even though there's visibly more content below the fold. Co-authored-by: Eric Holk --- crates/agent_ui/src/conversation_view.rs | 57 +++++++++++++++++++----- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 83a0c158a11c54be1ff54f553ce4b427da2cabc2..924f59437e51b02217289a5570f9560948c23ca2 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -1240,15 +1240,15 @@ impl ConversationView { if let Some(active) = self.thread_view(&thread_id) { let entry_view_state = active.read(cx).entry_view_state.clone(); let list_state = active.read(cx).list_state.clone(); - entry_view_state.update(cx, |view_state, cx| { - view_state.sync_entry(index, thread, window, cx); - list_state.splice_focusable( - index..index, - [view_state - .entry(index) - .and_then(|entry| entry.focus_handle(cx))], - ); - }); + notify_entry_changed( + &entry_view_state, + &list_state, + index..index, + index, + thread, + window, + cx, + ); active.update(cx, |active, cx| { active.sync_editor_mode_for_empty_state(cx); }); @@ -1257,9 +1257,16 @@ impl ConversationView { AcpThreadEvent::EntryUpdated(index) => { if let Some(active) = self.thread_view(&thread_id) { let entry_view_state = active.read(cx).entry_view_state.clone(); - entry_view_state.update(cx, |view_state, cx| { - view_state.sync_entry(*index, thread, window, cx) - }); + let list_state = active.read(cx).list_state.clone(); + notify_entry_changed( + &entry_view_state, + &list_state, + *index..*index + 1, + *index, + thread, + window, + cx, + ); active.update(cx, |active, cx| { active.auto_expand_streaming_thought(cx); }); @@ -2598,6 +2605,32 @@ impl ConversationView { } } +/// Syncs an entry's view state with the latest thread data and splices +/// the list item so the list knows to re-measure it on the next paint. +/// +/// Used by both `NewEntry` (splice range `index..index` to insert) and +/// `EntryUpdated` (splice range `index..index+1` to replace), which is +/// why the caller provides the splice range. +fn notify_entry_changed( + entry_view_state: &Entity, + list_state: &ListState, + splice_range: std::ops::Range, + index: usize, + thread: &Entity, + window: &mut Window, + cx: &mut App, +) { + entry_view_state.update(cx, |view_state, cx| { + view_state.sync_entry(index, thread, window, cx); + list_state.splice_focusable( + splice_range, + [view_state + .entry(index) + .and_then(|entry| entry.focus_handle(cx))], + ); + }); +} + fn loading_contents_spinner(size: IconSize) -> AnyElement { Icon::new(IconName::LoadCircle) .size(size) From cb99ab4ac7ba4c8e60c437491ff2891e039fde26 Mon Sep 17 00:00:00 2001 From: Aleksei Gusev Date: Thu, 2 Apr 2026 21:26:18 +0300 Subject: [PATCH 11/15] Add PageUp/PageDown scrolling in agent view (#52657) Fixes #52656 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #52656 Release Notes: - Added keybindings for scrolling in agent view --------- Co-authored-by: Oleksiy Syvokon --- assets/keymaps/default-linux.json | 24 ++++ assets/keymaps/default-macos.json | 24 ++++ assets/keymaps/default-windows.json | 24 ++++ crates/agent_ui/src/agent_ui.rs | 16 +++ crates/agent_ui/src/conversation_view.rs | 7 +- .../src/conversation_view/thread_view.rs | 131 ++++++++++++++++-- crates/gpui/src/elements/list.rs | 7 + crates/vim/src/test/vim_test_context.rs | 12 +- docs/src/ai/agent-panel.md | 4 +- 9 files changed, 231 insertions(+), 18 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 98053432c5a186ecc886318f2d677f73a62295a2..4930fbea84b2b449f3b5c35fee2a390525cb3551 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -284,12 +284,36 @@ "context": "AcpThread", "bindings": { "ctrl--": "pane::GoBack", + "pageup": "agent::ScrollOutputPageUp", + "pagedown": "agent::ScrollOutputPageDown", + "home": "agent::ScrollOutputToTop", + "end": "agent::ScrollOutputToBottom", + "up": "agent::ScrollOutputLineUp", + "down": "agent::ScrollOutputLineDown", + "shift-pageup": "agent::ScrollOutputToPreviousMessage", + "shift-pagedown": "agent::ScrollOutputToNextMessage", + "ctrl-alt-pageup": "agent::ScrollOutputPageUp", + "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", + "ctrl-alt-home": "agent::ScrollOutputToTop", + "ctrl-alt-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage", }, }, { "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-alt-pageup": "agent::ScrollOutputPageUp", + "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", + "ctrl-alt-home": "agent::ScrollOutputToTop", + "ctrl-alt-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-d": "git::Diff", "shift-alt-y": "agent::KeepAll", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index f0835a139a39602547d9d8da1cba93eaa7ee82a9..85c01bb33b54c30a55b5d046d03eb391d8c058c1 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -327,12 +327,36 @@ "context": "AcpThread", "bindings": { "ctrl--": "pane::GoBack", + "pageup": "agent::ScrollOutputPageUp", + "pagedown": "agent::ScrollOutputPageDown", + "home": "agent::ScrollOutputToTop", + "end": "agent::ScrollOutputToBottom", + "up": "agent::ScrollOutputLineUp", + "down": "agent::ScrollOutputLineDown", + "shift-pageup": "agent::ScrollOutputToPreviousMessage", + "shift-pagedown": "agent::ScrollOutputToNextMessage", + "ctrl-pageup": "agent::ScrollOutputPageUp", + "ctrl-pagedown": "agent::ScrollOutputPageDown", + "ctrl-home": "agent::ScrollOutputToTop", + "ctrl-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-pagedown": "agent::ScrollOutputToNextMessage", }, }, { "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-pageup": "agent::ScrollOutputPageUp", + "ctrl-pagedown": "agent::ScrollOutputPageDown", + "ctrl-home": "agent::ScrollOutputToTop", + "ctrl-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-pagedown": "agent::ScrollOutputToNextMessage", "shift-ctrl-r": "agent::OpenAgentDiff", "shift-ctrl-d": "git::Diff", "shift-alt-y": "agent::KeepAll", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 41f36638e1dec40890ddecc6a808c669672e9317..0705717062ab5015de20cc3b93f651f867b5116d 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -285,12 +285,36 @@ "context": "AcpThread", "bindings": { "ctrl--": "pane::GoBack", + "pageup": "agent::ScrollOutputPageUp", + "pagedown": "agent::ScrollOutputPageDown", + "home": "agent::ScrollOutputToTop", + "end": "agent::ScrollOutputToBottom", + "up": "agent::ScrollOutputLineUp", + "down": "agent::ScrollOutputLineDown", + "shift-pageup": "agent::ScrollOutputToPreviousMessage", + "shift-pagedown": "agent::ScrollOutputToNextMessage", + "ctrl-alt-pageup": "agent::ScrollOutputPageUp", + "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", + "ctrl-alt-home": "agent::ScrollOutputToTop", + "ctrl-alt-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage", }, }, { "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-alt-pageup": "agent::ScrollOutputPageUp", + "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", + "ctrl-alt-home": "agent::ScrollOutputToTop", + "ctrl-alt-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-d": "git::Diff", "shift-alt-y": "agent::KeepAll", diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 185a54825d3af18f16f2eb30188ea866c099bf32..e58c7eb3526cc1a53d7b8e6d449e968a5923425a 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -173,6 +173,22 @@ actions!( ToggleThinkingEffortMenu, /// Toggles fast mode for models that support it. ToggleFastMode, + /// Scroll the output by one page up. + ScrollOutputPageUp, + /// Scroll the output by one page down. + ScrollOutputPageDown, + /// Scroll the output up by three lines. + ScrollOutputLineUp, + /// Scroll the output down by three lines. + ScrollOutputLineDown, + /// Scroll the output to the top. + ScrollOutputToTop, + /// Scroll the output to the bottom. + ScrollOutputToBottom, + /// Scroll the output to the previous user message. + ScrollOutputToPreviousMessage, + /// Scroll the output to the next user message. + ScrollOutputToNextMessage, ] ); diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 924f59437e51b02217289a5570f9560948c23ca2..1b9d364e9ce03702b47c63e8a856f0ba4b8aba87 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -85,8 +85,11 @@ use crate::{ AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort, EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, - RemoveFirstQueuedMessage, SendImmediately, SendNextQueuedMessage, ToggleFastMode, - ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject, + RemoveFirstQueuedMessage, ScrollOutputLineDown, ScrollOutputLineUp, ScrollOutputPageDown, + ScrollOutputPageUp, ScrollOutputToBottom, ScrollOutputToNextMessage, + ScrollOutputToPreviousMessage, ScrollOutputToTop, SendImmediately, SendNextQueuedMessage, + ToggleFastMode, ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, + UndoLastReject, }; const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index c065c3de3d83c0eb5b68bf9a3610ff925762c952..c113eb0b768ee143eb69b5e705c15c91e367e6c2 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -552,17 +552,10 @@ impl ThreadView { let scroll_top = list_state.logical_scroll_top(); let _ = thread_view.update(cx, |this, cx| { if !is_following_tail { - let is_at_bottom = { - let current_offset = - list_state.scroll_px_offset_for_scrollbar().y.abs(); - let max_offset = list_state.max_offset_for_scrollbar().y; - current_offset >= max_offset - px(1.0) - }; - let is_generating = matches!(this.thread.read(cx).status(), ThreadStatus::Generating); - if is_at_bottom && is_generating { + if list_state.is_at_bottom() && is_generating { list_state.set_follow_tail(true); } } @@ -4952,7 +4945,7 @@ impl ThreadView { } pub fn scroll_to_end(&mut self, cx: &mut Context) { - self.list_state.scroll_to_end(); + self.list_state.set_follow_tail(true); cx.notify(); } @@ -4974,10 +4967,122 @@ impl ThreadView { } pub(crate) fn scroll_to_top(&mut self, cx: &mut Context) { + self.list_state.set_follow_tail(false); self.list_state.scroll_to(ListOffset::default()); cx.notify(); } + fn scroll_output_page_up( + &mut self, + _: &ScrollOutputPageUp, + _window: &mut Window, + cx: &mut Context, + ) { + let page_height = self.list_state.viewport_bounds().size.height; + self.list_state.set_follow_tail(false); + self.list_state.scroll_by(-page_height * 0.9); + cx.notify(); + } + + fn scroll_output_page_down( + &mut self, + _: &ScrollOutputPageDown, + _window: &mut Window, + cx: &mut Context, + ) { + let page_height = self.list_state.viewport_bounds().size.height; + self.list_state.set_follow_tail(false); + self.list_state.scroll_by(page_height * 0.9); + if self.list_state.is_at_bottom() { + self.list_state.set_follow_tail(true); + } + cx.notify(); + } + + fn scroll_output_line_up( + &mut self, + _: &ScrollOutputLineUp, + window: &mut Window, + cx: &mut Context, + ) { + self.list_state.set_follow_tail(false); + self.list_state.scroll_by(-window.line_height() * 3.); + cx.notify(); + } + + fn scroll_output_line_down( + &mut self, + _: &ScrollOutputLineDown, + window: &mut Window, + cx: &mut Context, + ) { + self.list_state.set_follow_tail(false); + self.list_state.scroll_by(window.line_height() * 3.); + if self.list_state.is_at_bottom() { + self.list_state.set_follow_tail(true); + } + cx.notify(); + } + + fn scroll_output_to_top( + &mut self, + _: &ScrollOutputToTop, + _window: &mut Window, + cx: &mut Context, + ) { + self.scroll_to_top(cx); + } + + fn scroll_output_to_bottom( + &mut self, + _: &ScrollOutputToBottom, + _window: &mut Window, + cx: &mut Context, + ) { + self.scroll_to_end(cx); + } + + fn scroll_output_to_previous_message( + &mut self, + _: &ScrollOutputToPreviousMessage, + _window: &mut Window, + cx: &mut Context, + ) { + let entries = self.thread.read(cx).entries(); + let current_ix = self.list_state.logical_scroll_top().item_ix; + if let Some(target_ix) = (0..current_ix) + .rev() + .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) + { + self.list_state.set_follow_tail(false); + self.list_state.scroll_to(ListOffset { + item_ix: target_ix, + offset_in_item: px(0.), + }); + cx.notify(); + } + } + + fn scroll_output_to_next_message( + &mut self, + _: &ScrollOutputToNextMessage, + _window: &mut Window, + cx: &mut Context, + ) { + let entries = self.thread.read(cx).entries(); + let current_ix = self.list_state.logical_scroll_top().item_ix; + if let Some(target_ix) = (current_ix + 1..entries.len()) + .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) + { + self.list_state.set_follow_tail(false); + self.list_state.scroll_to(ListOffset { + item_ix: target_ix, + offset_in_item: px(0.), + }); + cx.notify(); + } + } + pub fn open_thread_as_markdown( &self, workspace: Entity, @@ -8541,6 +8646,14 @@ impl Render for ThreadView { .on_action(cx.listener(Self::handle_toggle_command_pattern)) .on_action(cx.listener(Self::open_permission_dropdown)) .on_action(cx.listener(Self::open_add_context_menu)) + .on_action(cx.listener(Self::scroll_output_page_up)) + .on_action(cx.listener(Self::scroll_output_page_down)) + .on_action(cx.listener(Self::scroll_output_line_up)) + .on_action(cx.listener(Self::scroll_output_line_down)) + .on_action(cx.listener(Self::scroll_output_to_top)) + .on_action(cx.listener(Self::scroll_output_to_bottom)) + .on_action(cx.listener(Self::scroll_output_to_previous_message)) + .on_action(cx.listener(Self::scroll_output_to_next_message)) .on_action(cx.listener(|this, _: &ToggleFastMode, _window, cx| { this.toggle_fast_mode(cx); })) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index ed441e3b40534690d02b31109e719c60dd5802e0..b4c8e7ca9015190fb8bb1698f79f1b025bfa4829 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -427,6 +427,13 @@ impl ListState { self.0.borrow().follow_tail } + /// Returns whether the list is scrolled to the bottom (within 1px). + pub fn is_at_bottom(&self) -> bool { + let current_offset = self.scroll_px_offset_for_scrollbar().y.abs(); + let max_offset = self.max_offset_for_scrollbar().y; + current_offset >= max_offset - px(1.0) + } + /// Scroll the list to the given offset pub fn scroll_to(&self, mut scroll_top: ListOffset) { let state = &mut *self.0.borrow_mut(); diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 510d218df050455d0df0f9c2b7b782a651694cd7..6f15450aa3f70593c6877c293fecb765978e065d 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -109,12 +109,12 @@ impl VimTestContext { } cx.bind_keys(default_key_bindings); if enabled { - let vim_key_bindings = settings::KeymapFile::load_asset( - "keymaps/vim.json", - Some(settings::KeybindSource::Vim), - cx, - ) - .unwrap(); + let mut vim_key_bindings = + settings::KeymapFile::load_asset_allow_partial_failure("keymaps/vim.json", cx) + .unwrap(); + for key_binding in &mut vim_key_bindings { + key_binding.set_meta(settings::KeybindSource::Vim.meta()); + } cx.bind_keys(vim_key_bindings); } } diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 2da2f37a67edea48e0c34b14cab1ec0fc81a522b..89b0126c55a12b08d4f21a01fea38758c4d509b7 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -67,7 +67,9 @@ Right-click on any agent response in the thread view to access a context menu wi ### Navigating the Thread {#navigating-the-thread} -In long conversations, use the scroll arrow buttons at the bottom of the panel to jump to your most recent prompt or to the very beginning of the thread. +In long conversations, use the scroll arrow buttons at the bottom of the panel to jump to your most recent prompt or to the very beginning of the thread. You can also scroll the thread using arrow keys, Page Up/Down, Home/End, and Shift+Page Up/Down to jump between messages, when the thread pane is focused. + +When focus is in the message editor, you can also use {#kb agent::ScrollOutputPageUp}, {#kb agent::ScrollOutputPageDown}, {#kb agent::ScrollOutputToTop}, {#kb agent::ScrollOutputToBottom}, {#kb agent::ScrollOutputLineUp}, and {#kb agent::ScrollOutputLineDown} to navigate the thread, or {#kb agent::ScrollOutputToPreviousMessage} and {#kb agent::ScrollOutputToNextMessage} to jump between your prompts. ### Navigating History {#navigating-history} From 34c77a0eb9fd35bc19a19788ecdbf19c64f0b582 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:27:35 -0300 Subject: [PATCH 12/15] collab_panel: Add small design adjustments (#52994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some tiny tweaks so that things look just a bit tidier in the collab panel. | Before | After | |--------|--------| | Screenshot 2026-04-02 at 11  39@2x | Screenshot 2026-04-02 at 11 
34@2x | Release Notes: - N/A --- crates/collab_ui/src/collab_panel.rs | 113 +++++++++++++++++---------- 1 file changed, 71 insertions(+), 42 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 91385b298dc661c4a79e4fb52d5be0f38672bff5..d16db59ea4ae2d766018dfc03c245839e4862cb4 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -13,12 +13,13 @@ use db::kvp::KeyValueStore; use editor::{Editor, EditorElement, EditorStyle}; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ - AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, Context, DismissEvent, - Div, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, InteractiveElement, IntoElement, - KeyContext, ListOffset, ListState, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, - Render, SharedString, Styled, Subscription, Task, TextStyle, WeakEntity, Window, actions, - anchored, canvas, deferred, div, fill, list, point, prelude::*, px, + AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, DismissEvent, Div, + Empty, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, KeyContext, ListOffset, + ListState, MouseDownEvent, Pixels, Point, PromptLevel, SharedString, Subscription, Task, + TextStyle, WeakEntity, Window, actions, anchored, canvas, deferred, div, fill, list, point, + prelude::*, px, }; + use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious}; use project::{Fs, Project}; use rpc::{ @@ -1091,27 +1092,30 @@ impl CollabPanel { room.read(cx).local_participant().role == proto::ChannelRole::Admin }); + let end_slot = if is_pending { + Label::new("Calling").color(Color::Muted).into_any_element() + } else if is_current_user { + IconButton::new("leave-call", IconName::Exit) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Leave Call")) + .on_click(move |_, window, cx| Self::leave_call(window, cx)) + .into_any_element() + } else if role == proto::ChannelRole::Guest { + Label::new("Guest").color(Color::Muted).into_any_element() + } else if role == proto::ChannelRole::Talker { + Label::new("Mic only") + .color(Color::Muted) + .into_any_element() + } else { + Empty.into_any_element() + }; + ListItem::new(user.github_login.clone()) .start_slot(Avatar::new(user.avatar_uri.clone())) .child(render_participant_name_and_handle(user)) .toggle_state(is_selected) - .end_slot(if is_pending { - Label::new("Calling").color(Color::Muted).into_any_element() - } else if is_current_user { - IconButton::new("leave-call", IconName::Exit) - .style(ButtonStyle::Subtle) - .on_click(move |_, window, cx| Self::leave_call(window, cx)) - .tooltip(Tooltip::text("Leave Call")) - .into_any_element() - } else if role == proto::ChannelRole::Guest { - Label::new("Guest").color(Color::Muted).into_any_element() - } else if role == proto::ChannelRole::Talker { - Label::new("Mic only") - .color(Color::Muted) - .into_any_element() - } else { - div().into_any_element() - }) + .end_slot(end_slot) + .tooltip(Tooltip::text("Click to Follow")) .when_some(peer_id, |el, peer_id| { if role == proto::ChannelRole::Guest { return el; @@ -1156,6 +1160,7 @@ impl CollabPanel { .into(); ListItem::new(project_id as usize) + .height(px(24.)) .toggle_state(is_selected) .on_click(cx.listener(move |this, _, window, cx| { this.workspace @@ -1173,9 +1178,13 @@ impl CollabPanel { })) .start_slot( h_flex() - .gap_1() + .gap_1p5() .child(render_tree_branch(is_last, false, window, cx)) - .child(IconButton::new(0, IconName::Folder)), + .child( + Icon::new(IconName::Folder) + .size(IconSize::Small) + .color(Color::Muted), + ), ) .child(Label::new(project_name.clone())) .tooltip(Tooltip::text(format!("Open {}", project_name))) @@ -1192,12 +1201,17 @@ impl CollabPanel { let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize); ListItem::new(("screen", id)) + .height(px(24.)) .toggle_state(is_selected) .start_slot( h_flex() - .gap_1() + .gap_1p5() .child(render_tree_branch(is_last, false, window, cx)) - .child(IconButton::new(0, IconName::Screen)), + .child( + Icon::new(IconName::Screen) + .size(IconSize::Small) + .color(Color::Muted), + ), ) .child(Label::new("Screen")) .when_some(peer_id, |this, _| { @@ -1208,7 +1222,7 @@ impl CollabPanel { }) .ok(); })) - .tooltip(Tooltip::text("Open shared screen")) + .tooltip(Tooltip::text("Open Shared Screen")) }) } @@ -1232,7 +1246,9 @@ impl CollabPanel { ) -> impl IntoElement { let channel_store = self.channel_store.read(cx); let has_channel_buffer_changed = channel_store.has_channel_buffer_changed(channel_id); + ListItem::new("channel-notes") + .height(px(24.)) .toggle_state(is_selected) .on_click(cx.listener(move |this, _, window, cx| { this.open_channel_notes(channel_id, window, cx); @@ -1240,17 +1256,25 @@ impl CollabPanel { .start_slot( h_flex() .relative() - .gap_1() + .gap_1p5() .child(render_tree_branch(false, true, window, cx)) - .child(IconButton::new(0, IconName::File)) - .children(has_channel_buffer_changed.then(|| { - div() - .w_1p5() - .absolute() - .right(px(2.)) - .top(px(2.)) - .child(Indicator::dot().color(Color::Info)) - })), + .child( + h_flex() + .child( + Icon::new(IconName::Reader) + .size(IconSize::Small) + .color(Color::Muted), + ) + .when(has_channel_buffer_changed, |this| { + this.child( + div() + .absolute() + .top_neg_0p5() + .right_0() + .child(Indicator::dot().color(Color::Info)), + ) + }), + ), ) .child(Label::new("notes")) .tooltip(Tooltip::text("Open Channel Notes")) @@ -3144,10 +3168,14 @@ impl CollabPanel { (IconName::Star, Color::Default, "Add to Favorites") }; + let height = px(24.); + h_flex() .id(ix) .group("") + .h(height) .w_full() + .overflow_hidden() .when(!channel.is_root_channel(), |el| { el.on_drag(channel.clone(), move |channel, _, _, cx| { cx.new(|_| DraggedChannelView { @@ -3175,6 +3203,7 @@ impl CollabPanel { ) .child( ListItem::new(ix) + .height(height) // Add one level of depth for the disclosure arrow. .indent_level(depth + 1) .indent_step_size(px(20.)) @@ -3256,12 +3285,13 @@ impl CollabPanel { .child( h_flex() .visible_on_hover("") + .h_full() .absolute() .right_0() .px_1() .gap_px() - .bg(cx.theme().colors().background) .rounded_l_md() + .bg(cx.theme().colors().background) .child({ let focus_handle = self.focus_handle.clone(); IconButton::new("channel_favorite", favorite_icon) @@ -3335,9 +3365,8 @@ fn render_tree_branch( ) -> impl IntoElement { let rem_size = window.rem_size(); let line_height = window.text_style().line_height_in_pixels(rem_size); - let width = rem_size * 1.5; let thickness = px(1.); - let color = cx.theme().colors().text; + let color = cx.theme().colors().icon_disabled; canvas( |_, _, _| {}, @@ -3367,8 +3396,8 @@ fn render_tree_branch( )); }, ) - .w(width) - .h(line_height) + .w(rem_size) + .h(line_height - px(2.)) } fn render_participant_name_and_handle(user: &User) -> impl IntoElement { From 29609d3599c10fbec40a3756cfeac41d5e04e57e Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Thu, 2 Apr 2026 22:06:57 +0200 Subject: [PATCH 13/15] language_model: Decouple from Zed-specific implementation details (#52913) This PR decouples `language_model`'s dependence on Zed-specific implementation details. In particular * `credentials_provider` is split into a generic `credentials_provider` crate that provides a trait, and `zed_credentials_provider` that implements the said trait for Zed-specific providers and has functions that can populate a global state with them * `zed_env_vars` is split into a generic `env_var` crate that provides generic tooling for managing env vars, and `zed_env_vars` that contains Zed-specific statics * `client` is now dependent on `language_model` and not vice versa Release Notes: - N/A --- Cargo.lock | 40 +++- Cargo.toml | 4 + crates/agent/src/edit_agent/evals.rs | 5 +- crates/agent/src/tests/mod.rs | 8 +- .../src/tools/evals/streaming_edit_file.rs | 5 +- crates/agent_servers/Cargo.toml | 2 +- crates/agent_servers/src/custom.rs | 3 +- crates/agent_servers/src/e2e_tests.rs | 4 +- .../add_llm_provider_modal.rs | 2 +- crates/agent_ui/src/agent_diff.rs | 4 +- crates/agent_ui/src/inline_assistant.rs | 5 +- crates/client/Cargo.toml | 3 + crates/client/src/client.rs | 72 ++++++- crates/client/src/llm_token.rs | 116 +++++++++++ crates/codestral/Cargo.toml | 1 + crates/codestral/src/codestral.rs | 3 +- crates/credentials_provider/Cargo.toml | 4 - .../src/credentials_provider.rs | 167 +--------------- crates/edit_prediction/Cargo.toml | 2 + crates/edit_prediction/src/capture_example.rs | 4 +- crates/edit_prediction/src/edit_prediction.rs | 38 +++- .../src/edit_prediction_tests.rs | 9 +- crates/edit_prediction/src/mercury.rs | 7 +- .../edit_prediction/src/open_ai_compatible.rs | 3 +- crates/edit_prediction_cli/src/headless.rs | 5 +- crates/env_var/Cargo.toml | 15 ++ crates/env_var/LICENSE-GPL | 1 + crates/env_var/src/env_var.rs | 40 ++++ crates/eval_cli/src/headless.rs | 5 +- crates/language_model/Cargo.toml | 3 +- crates/language_model/src/api_key.rs | 22 +-- crates/language_model/src/language_model.rs | 13 +- .../language_model/src/model/cloud_model.rs | 158 ++------------- crates/language_models/src/language_models.rs | 78 ++++++-- .../language_models/src/provider/anthropic.rs | 37 +++- .../language_models/src/provider/bedrock.rs | 14 +- crates/language_models/src/provider/cloud.rs | 29 +-- .../language_models/src/provider/deepseek.rs | 37 +++- crates/language_models/src/provider/google.rs | 37 +++- .../language_models/src/provider/lmstudio.rs | 45 ++++- .../language_models/src/provider/mistral.rs | 37 +++- crates/language_models/src/provider/ollama.rs | 38 +++- .../language_models/src/provider/open_ai.rs | 37 +++- .../src/provider/open_ai_compatible.rs | 31 ++- .../src/provider/open_router.rs | 29 ++- .../language_models/src/provider/opencode.rs | 37 +++- crates/language_models/src/provider/vercel.rs | 37 +++- .../src/provider/vercel_ai_gateway.rs | 29 ++- crates/language_models/src/provider/x_ai.rs | 37 +++- crates/project/Cargo.toml | 1 + crates/project/src/context_server_store.rs | 11 +- crates/settings_ui/Cargo.toml | 1 + .../pages/edit_prediction_provider_setup.rs | 17 +- crates/web_search_providers/src/cloud.rs | 14 +- crates/zed/src/main.rs | 9 +- crates/zed/src/visual_test_runner.rs | 7 +- crates/zed/src/zed.rs | 7 +- .../zed/src/zed/edit_prediction_registry.rs | 7 +- crates/zed_credentials_provider/Cargo.toml | 22 +++ crates/zed_credentials_provider/LICENSE-GPL | 1 + .../src/zed_credentials_provider.rs | 181 ++++++++++++++++++ crates/zed_env_vars/Cargo.toml | 2 +- crates/zed_env_vars/src/zed_env_vars.rs | 41 +--- 63 files changed, 1122 insertions(+), 561 deletions(-) create mode 100644 crates/client/src/llm_token.rs create mode 100644 crates/env_var/Cargo.toml create mode 120000 crates/env_var/LICENSE-GPL create mode 100644 crates/env_var/src/env_var.rs create mode 100644 crates/zed_credentials_provider/Cargo.toml create mode 120000 crates/zed_credentials_provider/LICENSE-GPL create mode 100644 crates/zed_credentials_provider/src/zed_credentials_provider.rs diff --git a/Cargo.lock b/Cargo.lock index ce645cae5bf4bbf76dac037880e9e7038df67df9..aae7afecc5ea6f6ba3d63453321c829b677e1c58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -260,7 +260,6 @@ dependencies = [ "chrono", "client", "collections", - "credentials_provider", "env_logger 0.11.8", "feature_flags", "fs", @@ -289,6 +288,7 @@ dependencies = [ "util", "uuid", "watch", + "zed_credentials_provider", ] [[package]] @@ -2856,6 +2856,7 @@ dependencies = [ "chrono", "clock", "cloud_api_client", + "cloud_api_types", "cloud_llm_client", "collections", "credentials_provider", @@ -2869,6 +2870,7 @@ dependencies = [ "http_client", "http_client_tls", "httparse", + "language_model", "log", "objc2-foundation", "parking_lot", @@ -2900,6 +2902,7 @@ dependencies = [ "util", "windows 0.61.3", "worktree", + "zed_credentials_provider", ] [[package]] @@ -3059,6 +3062,7 @@ dependencies = [ "serde", "serde_json", "text", + "zed_credentials_provider", "zeta_prompt", ] @@ -4035,12 +4039,8 @@ name = "credentials_provider" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.31", "gpui", - "paths", - "release_channel", "serde", - "serde_json", ] [[package]] @@ -5115,6 +5115,7 @@ dependencies = [ "collections", "copilot", "copilot_ui", + "credentials_provider", "ctor", "db", "edit_prediction_context", @@ -5157,6 +5158,7 @@ dependencies = [ "workspace", "worktree", "zed_actions", + "zed_credentials_provider", "zeta_prompt", "zlog", "zstd", @@ -5583,6 +5585,13 @@ dependencies = [ "log", ] +[[package]] +name = "env_var" +version = "0.1.0" +dependencies = [ + "gpui", +] + [[package]] name = "envy" version = "0.4.2" @@ -9315,12 +9324,12 @@ dependencies = [ "anthropic", "anyhow", "base64 0.22.1", - "client", "cloud_api_client", "cloud_api_types", "cloud_llm_client", "collections", "credentials_provider", + "env_var", "futures 0.3.31", "gpui", "http_client", @@ -9336,7 +9345,6 @@ dependencies = [ "smol", "thiserror 2.0.17", "util", - "zed_env_vars", ] [[package]] @@ -13137,6 +13145,7 @@ dependencies = [ "wax", "which 6.0.3", "worktree", + "zed_credentials_provider", "zeroize", "zlog", "ztracing", @@ -15746,6 +15755,7 @@ dependencies = [ "util", "workspace", "zed_actions", + "zed_credentials_provider", ] [[package]] @@ -22180,10 +22190,24 @@ dependencies = [ ] [[package]] -name = "zed_env_vars" +name = "zed_credentials_provider" version = "0.1.0" dependencies = [ + "anyhow", + "credentials_provider", + "futures 0.3.31", "gpui", + "paths", + "release_channel", + "serde", + "serde_json", +] + +[[package]] +name = "zed_env_vars" +version = "0.1.0" +dependencies = [ + "env_var", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3a393237ab9f5a5a8cd4b02517f6d22382ff51ff..81bbb1176ddddcc117fc9082586cbc08dbb95d61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ members = [ "crates/edit_prediction_ui", "crates/editor", "crates/encoding_selector", + "crates/env_var", "crates/etw_tracing", "crates/eval_cli", "crates/eval_utils", @@ -220,6 +221,7 @@ members = [ "crates/x_ai", "crates/zed", "crates/zed_actions", + "crates/zed_credentials_provider", "crates/zed_env_vars", "crates/zeta_prompt", "crates/zlog", @@ -309,6 +311,7 @@ dev_container = { path = "crates/dev_container" } diagnostics = { path = "crates/diagnostics" } editor = { path = "crates/editor" } encoding_selector = { path = "crates/encoding_selector" } +env_var = { path = "crates/env_var" } etw_tracing = { path = "crates/etw_tracing" } eval_utils = { path = "crates/eval_utils" } extension = { path = "crates/extension" } @@ -465,6 +468,7 @@ worktree = { path = "crates/worktree" } x_ai = { path = "crates/x_ai" } zed = { path = "crates/zed" } zed_actions = { path = "crates/zed_actions" } +zed_credentials_provider = { path = "crates/zed_credentials_provider" } zed_env_vars = { path = "crates/zed_env_vars" } edit_prediction = { path = "crates/edit_prediction" } zeta_prompt = { path = "crates/zeta_prompt" } diff --git a/crates/agent/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs index e7b67e37bf4a8b71664a78b99b757c6985794ec6..ba8b7ed867ea26bcdcdee7f8bf20390c2f9592b3 100644 --- a/crates/agent/src/edit_agent/evals.rs +++ b/crates/agent/src/edit_agent/evals.rs @@ -4,7 +4,7 @@ use crate::{ ListDirectoryTool, ListDirectoryToolInput, ReadFileTool, ReadFileToolInput, }; use Role::*; -use client::{Client, UserStore}; +use client::{Client, RefreshLlmTokenListener, UserStore}; use eval_utils::{EvalOutput, EvalOutputProcessor, OutcomeKind}; use fs::FakeFs; use futures::{FutureExt, future::LocalBoxFuture}; @@ -1423,7 +1423,8 @@ impl EditAgentTest { let client = Client::production(cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); settings::init(cx); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.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 036a6f1030c43b16d51f864a1d0176891e90b772..9808b95dd0812f9a857da8a9c39e78fde40af1f9 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -6,7 +6,7 @@ use acp_thread::{ use agent_client_protocol::{self as acp}; use agent_settings::AgentProfileId; use anyhow::Result; -use client::{Client, UserStore}; +use client::{Client, RefreshLlmTokenListener, UserStore}; use collections::IndexMap; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use feature_flags::FeatureFlagAppExt as _; @@ -3253,7 +3253,8 @@ 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(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); language_models::init(user_store, client.clone(), cx); LanguageModelRegistry::test(cx); }); @@ -3982,7 +3983,8 @@ 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(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); language_models::init(user_store, client.clone(), cx); } }; diff --git a/crates/agent/src/tools/evals/streaming_edit_file.rs b/crates/agent/src/tools/evals/streaming_edit_file.rs index 6a55517037e54ae4166cd22427201d9325ef0f76..0c6290ec098f9c37a0f6a077daf0a041c013d8ff 100644 --- a/crates/agent/src/tools/evals/streaming_edit_file.rs +++ b/crates/agent/src/tools/evals/streaming_edit_file.rs @@ -6,7 +6,7 @@ use crate::{ }; use Role::*; use anyhow::{Context as _, Result}; -use client::{Client, UserStore}; +use client::{Client, RefreshLlmTokenListener, UserStore}; use fs::FakeFs; use futures::{FutureExt, StreamExt, future::LocalBoxFuture}; use gpui::{AppContext as _, AsyncApp, Entity, TestAppContext, UpdateGlobal as _}; @@ -274,7 +274,8 @@ impl StreamingEditToolTest { cx.set_http_client(http_client); let client = Client::production(cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); language_models::init(user_store, client, cx); }); diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 1542466be35bbce80983a73a3fc2e0998799160c..7151f0084b1cb7d9b206f57551ce715ef67483f7 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -32,7 +32,6 @@ futures.workspace = true gpui.workspace = true feature_flags.workspace = true gpui_tokio = { workspace = true, optional = true } -credentials_provider.workspace = true google_ai.workspace = true http_client.workspace = true indoc.workspace = true @@ -53,6 +52,7 @@ terminal.workspace = true uuid.workspace = true util.workspace = true watch.workspace = true +zed_credentials_provider.workspace = true [target.'cfg(unix)'.dependencies] libc.workspace = true diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 0dcd2240d6ecf6dc052cdd55953cff8ec1442eae..fb8d0a515244576d2cf02e4989cbd71beca448c7 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -3,7 +3,6 @@ use acp_thread::AgentConnection; use agent_client_protocol as acp; use anyhow::{Context as _, Result}; use collections::HashSet; -use credentials_provider::CredentialsProvider; use fs::Fs; use gpui::{App, AppContext as _, Entity, Task}; use language_model::{ApiKey, EnvVar}; @@ -392,7 +391,7 @@ fn api_key_for_gemini_cli(cx: &mut App) -> Task> { if let Some(key) = env_var.value { return Task::ready(Ok(key)); } - let credentials_provider = ::global(cx); + let credentials_provider = zed_credentials_provider::global(cx); let api_url = google_ai::API_URL.to_string(); cx.spawn(async move |cx| { Ok( diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 956d106df2a260bd2eb31c14f4f1f1705bf74cd6..aa29a0c230c13949b15f2b39a245ae41ead4884d 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -1,6 +1,7 @@ use crate::{AgentServer, AgentServerDelegate}; use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; use agent_client_protocol as acp; +use client::RefreshLlmTokenListener; use futures::{FutureExt, StreamExt, channel::mpsc, select}; use gpui::AppContext; use gpui::{Entity, TestAppContext}; @@ -413,7 +414,8 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { cx.set_http_client(Arc::new(http_client)); let client = client::Client::production(cx); let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx)); - language_model::init(user_store, client, cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store, cx); #[cfg(test)] project::agent_server_store::AllAgentServersSettings::override_global( 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 4e3dd63b0337f9be54b550f4f4a6a5ca2e7cdd42..b97583377a00d28ea1a8aae6a1380cff3b69e6a0 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 @@ -815,7 +815,7 @@ mod tests { cx.set_global(store); theme_settings::init(theme::LoadThemes::JustBase, cx); - language_model::init_settings(cx); + language_model::init(cx); editor::init(cx); }); diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index d5cf63f6cdde9a85a54daaa29f8fc2c6833bdd77..7b70740dd1ac462614a9d08d9e48d7d13ac2ed32 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1809,7 +1809,7 @@ mod tests { cx.set_global(settings_store); prompt_store::init(cx); theme_settings::init(theme::LoadThemes::JustBase, cx); - language_model::init_settings(cx); + language_model::init(cx); }); let fs = FakeFs::new(cx.executor()); @@ -1966,7 +1966,7 @@ mod tests { cx.set_global(settings_store); prompt_store::init(cx); theme_settings::init(theme::LoadThemes::JustBase, cx); - language_model::init_settings(cx); + language_model::init(cx); workspace::register_project_item::(cx); }); diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 20e0b702978b7e72a8526b03570854965335310c..39d70790e0d4a18554b2a1c11510e529d921cd1b 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -2025,7 +2025,7 @@ fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { pub mod evals { use crate::InlineAssistant; use agent::ThreadStore; - use client::{Client, UserStore}; + use client::{Client, RefreshLlmTokenListener, UserStore}; use editor::{Editor, MultiBuffer, MultiBufferOffset}; use eval_utils::{EvalOutput, NoProcessor}; use fs::FakeFs; @@ -2091,7 +2091,8 @@ pub mod evals { client::init(&client, cx); workspace::init(app_state.clone(), cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); language_models::init(user_store, client.clone(), cx); cx.set_global(inline_assistant); diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 1edbb3399e4332e2ebd23f812c66697bda72d587..7bbaccb22e0e6c7508240186103e216f83be2f0c 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -22,6 +22,7 @@ base64.workspace = true chrono = { workspace = true, features = ["serde"] } clock.workspace = true cloud_api_client.workspace = true +cloud_api_types.workspace = true cloud_llm_client.workspace = true collections.workspace = true credentials_provider.workspace = true @@ -35,6 +36,7 @@ gpui_tokio.workspace = true http_client.workspace = true http_client_tls.workspace = true httparse = "1.10" +language_model.workspace = true log.workspace = true parking_lot.workspace = true paths.workspace = true @@ -60,6 +62,7 @@ tokio.workspace = true url.workspace = true util.workspace = true worktree.workspace = true +zed_credentials_provider.workspace = true [dev-dependencies] clock = { workspace = true, features = ["test-support"] } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 6a11a6b924eed3dfd79ff379638ed4085e2b7bcb..dfd9963a0ee52d167f8d4edb0b850f4debed7fd4 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1,6 +1,7 @@ #[cfg(any(test, feature = "test-support"))] pub mod test; +mod llm_token; mod proxy; pub mod telemetry; pub mod user; @@ -13,8 +14,9 @@ use async_tungstenite::tungstenite::{ http::{HeaderValue, Request, StatusCode}, }; use clock::SystemClock; -use cloud_api_client::CloudApiClient; use cloud_api_client::websocket_protocol::MessageToClient; +use cloud_api_client::{ClientApiError, CloudApiClient}; +use cloud_api_types::OrganizationId; use credentials_provider::CredentialsProvider; use feature_flags::FeatureFlagAppExt as _; use futures::{ @@ -24,6 +26,7 @@ use futures::{ }; use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions}; use http_client::{HttpClient, HttpClientWithUrl, http, read_proxy_from_env}; +use language_model::LlmApiToken; use parking_lot::{Mutex, RwLock}; use postage::watch; use proxy::connect_proxy_stream; @@ -51,6 +54,7 @@ use tokio::net::TcpStream; use url::Url; use util::{ConnectionResult, ResultExt}; +pub use llm_token::*; pub use rpc::*; pub use telemetry_events::Event; pub use user::*; @@ -339,7 +343,7 @@ pub struct ClientCredentialsProvider { impl ClientCredentialsProvider { pub fn new(cx: &App) -> Self { Self { - provider: ::global(cx), + provider: zed_credentials_provider::global(cx), } } @@ -568,6 +572,10 @@ impl Client { self.http.clone() } + pub fn credentials_provider(&self) -> Arc { + self.credentials_provider.provider.clone() + } + pub fn cloud_client(&self) -> Arc { self.cloud_client.clone() } @@ -1513,6 +1521,66 @@ impl Client { }) } + pub async fn acquire_llm_token( + &self, + llm_token: &LlmApiToken, + organization_id: Option, + ) -> Result { + let system_id = self.telemetry().system_id().map(|x| x.to_string()); + let cloud_client = self.cloud_client(); + match llm_token + .acquire(&cloud_client, system_id, organization_id) + .await + { + Ok(token) => Ok(token), + Err(ClientApiError::Unauthorized) => { + self.request_sign_out(); + Err(ClientApiError::Unauthorized).context("Failed to create LLM token") + } + Err(err) => Err(anyhow::Error::from(err)), + } + } + + pub async fn refresh_llm_token( + &self, + llm_token: &LlmApiToken, + organization_id: Option, + ) -> Result { + let system_id = self.telemetry().system_id().map(|x| x.to_string()); + let cloud_client = self.cloud_client(); + match llm_token + .refresh(&cloud_client, system_id, organization_id) + .await + { + Ok(token) => Ok(token), + Err(ClientApiError::Unauthorized) => { + self.request_sign_out(); + return Err(ClientApiError::Unauthorized).context("Failed to create LLM token"); + } + Err(err) => return Err(anyhow::Error::from(err)), + } + } + + pub async fn clear_and_refresh_llm_token( + &self, + llm_token: &LlmApiToken, + organization_id: Option, + ) -> Result { + let system_id = self.telemetry().system_id().map(|x| x.to_string()); + let cloud_client = self.cloud_client(); + match llm_token + .clear_and_refresh(&cloud_client, system_id, organization_id) + .await + { + Ok(token) => Ok(token), + Err(ClientApiError::Unauthorized) => { + self.request_sign_out(); + return Err(ClientApiError::Unauthorized).context("Failed to create LLM token"); + } + Err(err) => return Err(anyhow::Error::from(err)), + } + } + pub async fn sign_out(self: &Arc, cx: &AsyncApp) { self.state.write().credentials = None; self.cloud_client.clear_credentials(); diff --git a/crates/client/src/llm_token.rs b/crates/client/src/llm_token.rs new file mode 100644 index 0000000000000000000000000000000000000000..f62aa6dd4dc3462bc3a0f6f46c35f0e4e5499816 --- /dev/null +++ b/crates/client/src/llm_token.rs @@ -0,0 +1,116 @@ +use super::{Client, UserStore}; +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 _, Subscription, +}; +use language_model::LlmApiToken; +use std::sync::Arc; + +pub trait NeedsLlmTokenRefresh { + /// Returns whether the LLM token needs to be refreshed. + fn needs_llm_token_refresh(&self) -> bool; +} + +impl NeedsLlmTokenRefresh for http_client::Response { + fn needs_llm_token_refresh(&self) -> bool { + self.headers().get(EXPIRED_LLM_TOKEN_HEADER_NAME).is_some() + || self.headers().get(OUTDATED_LLM_TOKEN_HEADER_NAME).is_some() + } +} + +enum TokenRefreshMode { + Refresh, + ClearAndRefresh, +} + +pub fn global_llm_token(cx: &App) -> LlmApiToken { + RefreshLlmTokenListener::global(cx) + .read(cx) + .llm_api_token + .clone() +} + +struct GlobalRefreshLlmTokenListener(Entity); + +impl Global for GlobalRefreshLlmTokenListener {} + +pub struct LlmTokenRefreshedEvent; + +pub struct RefreshLlmTokenListener { + client: Arc, + user_store: Entity, + llm_api_token: LlmApiToken, + _subscription: Subscription, +} + +impl EventEmitter for RefreshLlmTokenListener {} + +impl RefreshLlmTokenListener { + 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)); + } + + pub fn global(cx: &App) -> Entity { + GlobalRefreshLlmTokenListener::global(cx).0.clone() + } + + fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { + client.add_message_to_client_handler({ + let this = cx.weak_entity(); + move |message, cx| { + if let Some(this) = this.upgrade() { + Self::handle_refresh_llm_token(this, message, cx); + } + } + }); + + let subscription = cx.subscribe(&user_store, |this, _user_store, event, cx| { + if matches!(event, super::user::Event::OrganizationChanged) { + this.refresh(TokenRefreshMode::ClearAndRefresh, cx); + } + }); + + Self { + client, + user_store, + llm_api_token: LlmApiToken::default(), + _subscription: subscription, + } + } + + fn refresh(&self, mode: TokenRefreshMode, 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(|organization| organization.id.clone()); + cx.spawn(async move |this, cx| { + match mode { + TokenRefreshMode::Refresh => { + client + .refresh_llm_token(&llm_api_token, organization_id) + .await?; + } + TokenRefreshMode::ClearAndRefresh => { + client + .clear_and_refresh_llm_token(&llm_api_token, 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| this.refresh(TokenRefreshMode::Refresh, cx)); + } + } + } +} diff --git a/crates/codestral/Cargo.toml b/crates/codestral/Cargo.toml index 0daaee8fb1420c76757ca898655e8dd1a5244d7e..801221d3128b8aa2d25175e086a741d5d85da626 100644 --- a/crates/codestral/Cargo.toml +++ b/crates/codestral/Cargo.toml @@ -22,6 +22,7 @@ log.workspace = true serde.workspace = true serde_json.workspace = true text.workspace = true +zed_credentials_provider.workspace = true zeta_prompt.workspace = true [dev-dependencies] diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index 3930e2e873a91618bfae456bc188bbd90ffa64b9..7685fa8f5b1eae9e98a621484602e199c2b76f96 100644 --- a/crates/codestral/src/codestral.rs +++ b/crates/codestral/src/codestral.rs @@ -48,9 +48,10 @@ pub fn codestral_api_key(cx: &App) -> Option> { } pub fn load_codestral_api_key(cx: &mut App) -> Task> { + let credentials_provider = zed_credentials_provider::global(cx); let api_url = codestral_api_url(cx); codestral_api_key_state(cx).update(cx, |key_state, cx| { - key_state.load_if_needed(api_url, |s| s, cx) + key_state.load_if_needed(api_url, |s| s, credentials_provider, cx) }) } diff --git a/crates/credentials_provider/Cargo.toml b/crates/credentials_provider/Cargo.toml index bf47bb24b12b90d54bc04f766efe06489c730b43..da83c0cd79a1b71bbb84746b3e893f33094783d6 100644 --- a/crates/credentials_provider/Cargo.toml +++ b/crates/credentials_provider/Cargo.toml @@ -13,9 +13,5 @@ path = "src/credentials_provider.rs" [dependencies] anyhow.workspace = true -futures.workspace = true gpui.workspace = true -paths.workspace = true -release_channel.workspace = true serde.workspace = true -serde_json.workspace = true diff --git a/crates/credentials_provider/src/credentials_provider.rs b/crates/credentials_provider/src/credentials_provider.rs index 249b8333e114223aa558cd33637fd103294a8f8d..b98e97673cc11272826af24c76e8a0a6a38b9211 100644 --- a/crates/credentials_provider/src/credentials_provider.rs +++ b/crates/credentials_provider/src/credentials_provider.rs @@ -1,26 +1,8 @@ -use std::collections::HashMap; use std::future::Future; -use std::path::PathBuf; use std::pin::Pin; -use std::sync::{Arc, LazyLock}; use anyhow::Result; -use futures::FutureExt as _; -use gpui::{App, AsyncApp}; -use release_channel::ReleaseChannel; - -/// An environment variable whose presence indicates that the system keychain -/// should be used in development. -/// -/// By default, running Zed in development uses the development credentials -/// provider. Setting this environment variable allows you to interact with the -/// system keychain (for instance, if you need to test something). -/// -/// Only works in development. Setting this environment variable in other -/// release channels is a no-op. -static ZED_DEVELOPMENT_USE_KEYCHAIN: LazyLock = LazyLock::new(|| { - std::env::var("ZED_DEVELOPMENT_USE_KEYCHAIN").is_ok_and(|value| !value.is_empty()) -}); +use gpui::AsyncApp; /// A provider for credentials. /// @@ -50,150 +32,3 @@ pub trait CredentialsProvider: Send + Sync { cx: &'a AsyncApp, ) -> Pin> + 'a>>; } - -impl dyn CredentialsProvider { - /// Returns the global [`CredentialsProvider`]. - pub fn global(cx: &App) -> Arc { - // The `CredentialsProvider` trait has `Send + Sync` bounds on it, so it - // seems like this is a false positive from Clippy. - #[allow(clippy::arc_with_non_send_sync)] - Self::new(cx) - } - - fn new(cx: &App) -> Arc { - let use_development_provider = match ReleaseChannel::try_global(cx) { - Some(ReleaseChannel::Dev) => { - // In development we default to using the development - // credentials provider to avoid getting spammed by relentless - // keychain access prompts. - // - // However, if the `ZED_DEVELOPMENT_USE_KEYCHAIN` environment - // variable is set, we will use the actual keychain. - !*ZED_DEVELOPMENT_USE_KEYCHAIN - } - Some(ReleaseChannel::Nightly | ReleaseChannel::Preview | ReleaseChannel::Stable) - | None => false, - }; - - if use_development_provider { - Arc::new(DevelopmentCredentialsProvider::new()) - } else { - Arc::new(KeychainCredentialsProvider) - } - } -} - -/// A credentials provider that stores credentials in the system keychain. -struct KeychainCredentialsProvider; - -impl CredentialsProvider for KeychainCredentialsProvider { - fn read_credentials<'a>( - &'a self, - url: &'a str, - cx: &'a AsyncApp, - ) -> Pin)>>> + 'a>> { - async move { cx.update(|cx| cx.read_credentials(url)).await }.boxed_local() - } - - fn write_credentials<'a>( - &'a self, - url: &'a str, - username: &'a str, - password: &'a [u8], - cx: &'a AsyncApp, - ) -> Pin> + 'a>> { - async move { - cx.update(move |cx| cx.write_credentials(url, username, password)) - .await - } - .boxed_local() - } - - fn delete_credentials<'a>( - &'a self, - url: &'a str, - cx: &'a AsyncApp, - ) -> Pin> + 'a>> { - async move { cx.update(move |cx| cx.delete_credentials(url)).await }.boxed_local() - } -} - -/// A credentials provider that stores credentials in a local file. -/// -/// This MUST only be used in development, as this is not a secure way of storing -/// credentials on user machines. -/// -/// Its existence is purely to work around the annoyance of having to constantly -/// re-allow access to the system keychain when developing Zed. -struct DevelopmentCredentialsProvider { - path: PathBuf, -} - -impl DevelopmentCredentialsProvider { - fn new() -> Self { - let path = paths::config_dir().join("development_credentials"); - - Self { path } - } - - fn load_credentials(&self) -> Result)>> { - let json = std::fs::read(&self.path)?; - let credentials: HashMap)> = serde_json::from_slice(&json)?; - - Ok(credentials) - } - - fn save_credentials(&self, credentials: &HashMap)>) -> Result<()> { - let json = serde_json::to_string(credentials)?; - std::fs::write(&self.path, json)?; - - Ok(()) - } -} - -impl CredentialsProvider for DevelopmentCredentialsProvider { - fn read_credentials<'a>( - &'a self, - url: &'a str, - _cx: &'a AsyncApp, - ) -> Pin)>>> + 'a>> { - async move { - Ok(self - .load_credentials() - .unwrap_or_default() - .get(url) - .cloned()) - } - .boxed_local() - } - - fn write_credentials<'a>( - &'a self, - url: &'a str, - username: &'a str, - password: &'a [u8], - _cx: &'a AsyncApp, - ) -> Pin> + 'a>> { - async move { - let mut credentials = self.load_credentials().unwrap_or_default(); - credentials.insert(url.to_string(), (username.to_string(), password.to_vec())); - - self.save_credentials(&credentials) - } - .boxed_local() - } - - fn delete_credentials<'a>( - &'a self, - url: &'a str, - _cx: &'a AsyncApp, - ) -> Pin> + 'a>> { - async move { - let mut credentials = self.load_credentials()?; - credentials.remove(url); - - self.save_credentials(&credentials) - } - .boxed_local() - } -} diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index 75a589dea8f9c7fefe7bf13400cbdde54bf90bf1..eabb1641fd4fbec7b2f8ef0ba399a8fe9600dfa3 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -26,6 +26,7 @@ cloud_llm_client.workspace = true collections.workspace = true copilot.workspace = true copilot_ui.workspace = true +credentials_provider.workspace = true db.workspace = true edit_prediction_types.workspace = true edit_prediction_context.workspace = true @@ -65,6 +66,7 @@ uuid.workspace = true workspace.workspace = true worktree.workspace = true zed_actions.workspace = true +zed_credentials_provider.workspace = true zeta_prompt.workspace = true zstd.workspace = true diff --git a/crates/edit_prediction/src/capture_example.rs b/crates/edit_prediction/src/capture_example.rs index 5eb422246775c4409f7f15e3a672a2d407386acc..9463456132ce391b54aca8327cb6f900d81481d6 100644 --- a/crates/edit_prediction/src/capture_example.rs +++ b/crates/edit_prediction/src/capture_example.rs @@ -258,6 +258,7 @@ fn generate_timestamp_name() -> String { mod tests { use super::*; use crate::EditPredictionStore; + use client::RefreshLlmTokenListener; use client::{Client, UserStore}; use clock::FakeSystemClock; use gpui::{AppContext as _, TestAppContext, http_client::FakeHttpClient}; @@ -548,7 +549,8 @@ mod tests { let http_client = FakeHttpClient::with_404_response(); let client = Client::new(Arc::new(FakeSystemClock::new()), http_client, cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); EditPredictionStore::global(&client, &user_store, cx); }) } diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 61690c470829ca4bb16a6af9f1df2ea6e7cc6023..280427df006b510e1854ffb40cd7f995fcd9fdc6 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use client::{Client, EditPredictionUsage, UserStore}; +use client::{Client, EditPredictionUsage, NeedsLlmTokenRefresh, UserStore, global_llm_token}; use cloud_api_types::{OrganizationId, SubmitEditPredictionFeedbackBody}; use cloud_llm_client::predict_edits_v3::{ PredictEditsV3Request, PredictEditsV3Response, RawCompletionRequest, RawCompletionResponse, @@ -11,6 +11,7 @@ use cloud_llm_client::{ }; use collections::{HashMap, HashSet}; use copilot::{Copilot, Reinstall, SignIn, SignOut}; +use credentials_provider::CredentialsProvider; use db::kvp::{Dismissable, KeyValueStore}; use edit_prediction_context::{RelatedExcerptStore, RelatedExcerptStoreEvent, RelatedFile}; use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; @@ -30,7 +31,7 @@ use heapless::Vec as ArrayVec; 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}; +use language_model::LlmApiToken; use project::{DisableAiSettings, Project, ProjectPath, WorktreeId}; use release_channel::AppVersion; use semver::Version; @@ -150,6 +151,7 @@ pub struct EditPredictionStore { rated_predictions: HashSet, #[cfg(test)] settled_event_callback: Option>, + credentials_provider: Arc, } pub(crate) struct EditPredictionRejectionPayload { @@ -746,7 +748,7 @@ impl EditPredictionStore { pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { let data_collection_choice = Self::load_data_collection_choice(cx); - let llm_token = LlmApiToken::global(cx); + let llm_token = global_llm_token(cx); let (reject_tx, reject_rx) = mpsc::unbounded(); cx.background_spawn({ @@ -787,6 +789,8 @@ impl EditPredictionStore { .log_err(); }); + let credentials_provider = zed_credentials_provider::global(cx); + let this = Self { projects: HashMap::default(), client, @@ -807,6 +811,8 @@ impl EditPredictionStore { shown_predictions: Default::default(), #[cfg(test)] settled_event_callback: None, + + credentials_provider, }; this @@ -871,7 +877,9 @@ impl EditPredictionStore { let experiments = cx .background_spawn(async move { let http_client = client.http_client(); - let token = llm_token.acquire(&client, organization_id).await?; + let token = client + .acquire_llm_token(&llm_token, organization_id.clone()) + .await?; let url = http_client.build_zed_llm_url("/edit_prediction_experiments", &[])?; let request = http_client::Request::builder() .method(Method::GET) @@ -2315,7 +2323,10 @@ impl EditPredictionStore { zeta::request_prediction_with_zeta(self, inputs, capture_data, cx) } EditPredictionModel::Fim { format } => fim::request_prediction(inputs, format, cx), - EditPredictionModel::Mercury => self.mercury.request_prediction(inputs, cx), + EditPredictionModel::Mercury => { + self.mercury + .request_prediction(inputs, self.credentials_provider.clone(), cx) + } }; cx.spawn(async move |this, cx| { @@ -2536,12 +2547,15 @@ impl EditPredictionStore { Res: DeserializeOwned, { let http_client = client.http_client(); - let mut token = if require_auth { - Some(llm_token.acquire(&client, organization_id.clone()).await?) + Some( + client + .acquire_llm_token(&llm_token, organization_id.clone()) + .await?, + ) } else { - llm_token - .acquire(&client, organization_id.clone()) + client + .acquire_llm_token(&llm_token, organization_id.clone()) .await .ok() }; @@ -2585,7 +2599,11 @@ impl EditPredictionStore { return Ok((serde_json::from_slice(&body)?, usage)); } else if !did_retry && token.is_some() && response.needs_llm_token_refresh() { did_retry = true; - token = Some(llm_token.refresh(&client, organization_id.clone()).await?); + token = Some( + client + .refresh_llm_token(&llm_token, organization_id.clone()) + .await?, + ); } else { let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 6fe61338e764a40aec9cf6f3191f1191bafe9200..1ba8b27aa785024a47a09c3299a1f3786a028ccf 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::udiff::apply_diff_to_string; -use client::{UserStore, test::FakeServer}; +use client::{RefreshLlmTokenListener, UserStore, test::FakeServer}; use clock::FakeSystemClock; use clock::ReplicaId; use cloud_api_types::{CreateLlmTokenResponse, LlmToken}; @@ -23,7 +23,7 @@ use language::{ Anchor, Buffer, Capability, 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}; @@ -2439,7 +2439,8 @@ fn init_test_with_fake_client( client.cloud_client().set_credentials(1, "test".into()); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); let ep_store = EditPredictionStore::global(&client, &user_store, cx); ( @@ -2891,7 +2892,7 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut 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(), user_store.clone(), cx); + 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/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index df47a38062344512a784c6d2feb563e9848afb27..155fd449904687081da0a9eae3d4731863f02254 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -5,6 +5,7 @@ use crate::{ }; use anyhow::{Context as _, Result}; use cloud_llm_client::EditPredictionRejectReason; +use credentials_provider::CredentialsProvider; use futures::AsyncReadExt as _; use gpui::{ App, AppContext as _, Context, Entity, Global, SharedString, Task, @@ -51,10 +52,11 @@ impl Mercury { debug_tx, .. }: EditPredictionModelInput, + credentials_provider: Arc, cx: &mut Context, ) -> Task>> { self.api_token.update(cx, |key_state, cx| { - _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx); + _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, credentials_provider, cx); }); let Some(api_token) = self.api_token.read(cx).key(&MERCURY_CREDENTIALS_URL) else { return Task::ready(Ok(None)); @@ -387,8 +389,9 @@ pub fn mercury_api_token(cx: &mut App) -> Entity { } pub fn load_mercury_api_token(cx: &mut App) -> Task> { + let credentials_provider = zed_credentials_provider::global(cx); mercury_api_token(cx).update(cx, |key_state, cx| { - key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx) + key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, credentials_provider, cx) }) } diff --git a/crates/edit_prediction/src/open_ai_compatible.rs b/crates/edit_prediction/src/open_ai_compatible.rs index ca378ba1fd0bc9bdbb3e85c7610e1b94c1be388f..9a11164822857d78c2fe0d9245faeb5d4f7400a0 100644 --- a/crates/edit_prediction/src/open_ai_compatible.rs +++ b/crates/edit_prediction/src/open_ai_compatible.rs @@ -42,9 +42,10 @@ pub fn open_ai_compatible_api_token(cx: &mut App) -> Entity { pub fn load_open_ai_compatible_api_token( cx: &mut App, ) -> Task> { + let credentials_provider = zed_credentials_provider::global(cx); let api_url = open_ai_compatible_api_url(cx); open_ai_compatible_api_token(cx).update(cx, |key_state, cx| { - key_state.load_if_needed(api_url, |s| s, cx) + key_state.load_if_needed(api_url, |s| s, credentials_provider, cx) }) } diff --git a/crates/edit_prediction_cli/src/headless.rs b/crates/edit_prediction_cli/src/headless.rs index 3a204a7052f8a41d6e7c2c49860b62f588358644..48b7381020f48d868d9f6413ef343b30718e5be6 100644 --- a/crates/edit_prediction_cli/src/headless.rs +++ b/crates/edit_prediction_cli/src/headless.rs @@ -1,4 +1,4 @@ -use client::{Client, ProxySettings, UserStore}; +use client::{Client, ProxySettings, RefreshLlmTokenListener, UserStore}; use db::AppDatabase; use extension::ExtensionHostProxy; use fs::RealFs; @@ -109,7 +109,8 @@ 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(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.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/env_var/Cargo.toml b/crates/env_var/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..2cbbd08c7833d3e57a09766d42ffffe35c620a93 --- /dev/null +++ b/crates/env_var/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "env_var" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/env_var.rs" + +[dependencies] +gpui.workspace = true diff --git a/crates/env_var/LICENSE-GPL b/crates/env_var/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/env_var/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/env_var/src/env_var.rs b/crates/env_var/src/env_var.rs new file mode 100644 index 0000000000000000000000000000000000000000..79f671e0147ebfaad4ab76a123cc477dc7e55cb7 --- /dev/null +++ b/crates/env_var/src/env_var.rs @@ -0,0 +1,40 @@ +use gpui::SharedString; + +#[derive(Clone)] +pub struct EnvVar { + pub name: SharedString, + /// Value of the environment variable. Also `None` when set to an empty string. + pub value: Option, +} + +impl EnvVar { + pub fn new(name: SharedString) -> Self { + let value = std::env::var(name.as_str()).ok(); + if value.as_ref().is_some_and(|v| v.is_empty()) { + Self { name, value: None } + } else { + Self { name, value } + } + } + + pub fn or(self, other: EnvVar) -> EnvVar { + if self.value.is_some() { self } else { other } + } +} + +/// Creates a `LazyLock` expression for use in a `static` declaration. +#[macro_export] +macro_rules! env_var { + ($name:expr) => { + ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into())) + }; +} + +/// Generates a `LazyLock` expression for use in a `static` declaration. Checks if the +/// environment variable exists and is non-empty. +#[macro_export] +macro_rules! bool_env_var { + ($name:expr) => { + ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some()) + }; +} diff --git a/crates/eval_cli/src/headless.rs b/crates/eval_cli/src/headless.rs index 72feaacbae270224240f1da9e6e6c1008ba97c84..0ddd99e8f8abd9dbd73e1d7461526f3e7cb24f11 100644 --- a/crates/eval_cli/src/headless.rs +++ b/crates/eval_cli/src/headless.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use std::sync::Arc; -use client::{Client, ProxySettings, UserStore}; +use client::{Client, ProxySettings, RefreshLlmTokenListener, UserStore}; use db::AppDatabase; use extension::ExtensionHostProxy; use fs::RealFs; @@ -108,7 +108,8 @@ 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(user_store.clone(), client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.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/Cargo.toml b/crates/language_model/Cargo.toml index 911100fc25b498ba5471c85d6177052495974665..4712d86dff6c44f9cdd8576a08349ccfa7d0ecca 100644 --- a/crates/language_model/Cargo.toml +++ b/crates/language_model/Cargo.toml @@ -20,11 +20,11 @@ anthropic = { workspace = true, features = ["schemars"] } anyhow.workspace = true credentials_provider.workspace = true base64.workspace = true -client.workspace = true cloud_api_client.workspace = true cloud_api_types.workspace = true cloud_llm_client.workspace = true collections.workspace = true +env_var.workspace = true futures.workspace = true gpui.workspace = true http_client.workspace = true @@ -40,7 +40,6 @@ serde_json.workspace = true smol.workspace = true thiserror.workspace = true util.workspace = true -zed_env_vars.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/language_model/src/api_key.rs b/crates/language_model/src/api_key.rs index 754fde069295d8799820020bef286b1a1a3c590c..4be5a64d3db6231c98b830a524d5e299faace457 100644 --- a/crates/language_model/src/api_key.rs +++ b/crates/language_model/src/api_key.rs @@ -1,5 +1,6 @@ use anyhow::{Result, anyhow}; use credentials_provider::CredentialsProvider; +use env_var::EnvVar; use futures::{FutureExt, future}; use gpui::{AsyncApp, Context, SharedString, Task}; use std::{ @@ -7,7 +8,6 @@ use std::{ sync::Arc, }; use util::ResultExt as _; -use zed_env_vars::EnvVar; use crate::AuthenticateError; @@ -101,6 +101,7 @@ impl ApiKeyState { url: SharedString, key: Option, get_this: impl Fn(&mut Ent) -> &mut Self + 'static, + provider: Arc, cx: &Context, ) -> Task> { if self.is_from_env_var() { @@ -108,18 +109,14 @@ impl ApiKeyState { "bug: attempted to store API key in system keychain when API key is from env var", ))); } - let credentials_provider = ::global(cx); cx.spawn(async move |ent, cx| { if let Some(key) = &key { - credentials_provider + provider .write_credentials(&url, "Bearer", key.as_bytes(), cx) .await .log_err(); } else { - credentials_provider - .delete_credentials(&url, cx) - .await - .log_err(); + provider.delete_credentials(&url, cx).await.log_err(); } ent.update(cx, |ent, cx| { let this = get_this(ent); @@ -144,12 +141,13 @@ impl ApiKeyState { &mut self, url: SharedString, get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static, + provider: Arc, cx: &mut Context, ) { if url != self.url { if !self.is_from_env_var() { // loading will continue even though this result task is dropped - let _task = self.load_if_needed(url, get_this, cx); + let _task = self.load_if_needed(url, get_this, provider, cx); } } } @@ -163,6 +161,7 @@ impl ApiKeyState { &mut self, url: SharedString, get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static, + provider: Arc, cx: &mut Context, ) -> Task> { if let LoadStatus::Loaded { .. } = &self.load_status @@ -185,7 +184,7 @@ impl ApiKeyState { let task = if let Some(load_task) = &self.load_task { load_task.clone() } else { - let load_task = Self::load(url.clone(), get_this.clone(), cx).shared(); + let load_task = Self::load(url.clone(), get_this.clone(), provider, cx).shared(); self.url = url; self.load_status = LoadStatus::NotPresent; self.load_task = Some(load_task.clone()); @@ -206,14 +205,13 @@ impl ApiKeyState { fn load( url: SharedString, get_this: impl Fn(&mut Ent) -> &mut Self + 'static, + provider: Arc, cx: &Context, ) -> Task<()> { - let credentials_provider = ::global(cx); cx.spawn({ async move |ent, cx| { let load_status = - ApiKey::load_from_system_keychain_impl(&url, credentials_provider.as_ref(), cx) - .await; + ApiKey::load_from_system_keychain_impl(&url, provider.as_ref(), cx).await; ent.update(cx, |ent, cx| { let this = get_this(ent); this.url = url; diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index ce71cee6bcaf4f7ea1e210cc3756bd3162715f55..3f309b7b1d4152c54324efaaf0ad3bdb7035eea4 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -11,12 +11,10 @@ pub mod tool_schema; pub mod fake_provider; 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, Entity, SharedString, Task, Window}; +use gpui::{AnyView, App, AsyncApp, SharedString, Task, Window}; use http_client::{StatusCode, http}; use icons::IconName; use parking_lot::Mutex; @@ -36,15 +34,10 @@ pub use crate::registry::*; pub use crate::request::*; pub use crate::role::*; pub use crate::tool_schema::LanguageModelToolSchemaFormat; +pub use env_var::{EnvVar, env_var}; pub use provider::*; -pub use zed_env_vars::{EnvVar, env_var}; -pub fn init(user_store: Entity, client: Arc, cx: &mut App) { - init_settings(cx); - RefreshLlmTokenListener::register(client, user_store, cx); -} - -pub fn init_settings(cx: &mut App) { +pub fn init(cx: &mut App) { registry::init(cx); } diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index a1362d78292082522f4e883efe42b2ca1e0a0300..db926aab1f70a46a4e70b1b67c2c9e4c4f465c2c 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -1,16 +1,9 @@ use std::fmt; use std::sync::Arc; -use anyhow::{Context as _, Result}; -use client::Client; -use client::UserStore; use cloud_api_client::ClientApiError; +use cloud_api_client::CloudApiClient; 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 _, Subscription, -}; use smol::lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard}; use thiserror::Error; @@ -30,18 +23,12 @@ 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, + client: &CloudApiClient, + system_id: Option, organization_id: Option, - ) -> Result { + ) -> Result { let lock = self.0.upgradable_read().await; if let Some(token) = lock.as_ref() { Ok(token.to_string()) @@ -49,6 +36,7 @@ impl LlmApiToken { Self::fetch( RwLockUpgradableReadGuard::upgrade(lock).await, client, + system_id, organization_id, ) .await @@ -57,10 +45,11 @@ impl LlmApiToken { pub async fn refresh( &self, - client: &Arc, + client: &CloudApiClient, + system_id: Option, organization_id: Option, - ) -> Result { - Self::fetch(self.0.write().await, client, organization_id).await + ) -> Result { + Self::fetch(self.0.write().await, client, system_id, organization_id).await } /// Clears the existing token before attempting to fetch a new one. @@ -69,28 +58,22 @@ impl LlmApiToken { /// leave a token for the wrong organization. pub async fn clear_and_refresh( &self, - client: &Arc, + client: &CloudApiClient, + system_id: Option, organization_id: Option, - ) -> Result { + ) -> Result { let mut lock = self.0.write().await; *lock = None; - Self::fetch(lock, client, organization_id).await + Self::fetch(lock, client, system_id, organization_id).await } async fn fetch( mut lock: RwLockWriteGuard<'_, Option>, - client: &Arc, + client: &CloudApiClient, + system_id: Option, organization_id: Option, - ) -> Result { - let system_id = client - .telemetry() - .system_id() - .map(|system_id| system_id.to_string()); - - let result = client - .cloud_client() - .create_llm_token(system_id, organization_id) - .await; + ) -> Result { + let result = client.create_llm_token(system_id, organization_id).await; match result { Ok(response) => { *lock = Some(response.token.0.clone()); @@ -98,112 +81,7 @@ impl LlmApiToken { } Err(err) => { *lock = None; - match err { - ClientApiError::Unauthorized => { - client.request_sign_out(); - Err(err).context("Failed to create LLM token") - } - ClientApiError::Other(err) => Err(err), - } - } - } - } -} - -pub trait NeedsLlmTokenRefresh { - /// Returns whether the LLM token needs to be refreshed. - fn needs_llm_token_refresh(&self) -> bool; -} - -impl NeedsLlmTokenRefresh for http_client::Response { - fn needs_llm_token_refresh(&self) -> bool { - self.headers().get(EXPIRED_LLM_TOKEN_HEADER_NAME).is_some() - || self.headers().get(OUTDATED_LLM_TOKEN_HEADER_NAME).is_some() - } -} - -enum TokenRefreshMode { - Refresh, - ClearAndRefresh, -} - -struct GlobalRefreshLlmTokenListener(Entity); - -impl Global for GlobalRefreshLlmTokenListener {} - -pub struct LlmTokenRefreshedEvent; - -pub struct RefreshLlmTokenListener { - client: Arc, - user_store: Entity, - llm_api_token: LlmApiToken, - _subscription: Subscription, -} - -impl EventEmitter for RefreshLlmTokenListener {} - -impl RefreshLlmTokenListener { - 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)); - } - - pub fn global(cx: &App) -> Entity { - GlobalRefreshLlmTokenListener::global(cx).0.clone() - } - - fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { - client.add_message_to_client_handler({ - let this = cx.weak_entity(); - move |message, cx| { - if let Some(this) = this.upgrade() { - Self::handle_refresh_llm_token(this, message, cx); - } - } - }); - - let subscription = cx.subscribe(&user_store, |this, _user_store, event, cx| { - if matches!(event, client::user::Event::OrganizationChanged) { - this.refresh(TokenRefreshMode::ClearAndRefresh, cx); - } - }); - - Self { - client, - user_store, - llm_api_token: LlmApiToken::default(), - _subscription: subscription, - } - } - - fn refresh(&self, mode: TokenRefreshMode, 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(|organization| organization.id.clone()); - cx.spawn(async move |this, cx| { - match mode { - TokenRefreshMode::Refresh => { - llm_api_token.refresh(&client, organization_id).await?; - } - TokenRefreshMode::ClearAndRefresh => { - llm_api_token - .clear_and_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| this.refresh(TokenRefreshMode::Refresh, cx)); + Err(err) } } } diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 4db1db8fa6ce5afb9d77a6685bfc0861d0fb8885..3154db91a43d1381f5b3f122a724be249adeb79b 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use ::settings::{Settings, SettingsStore}; use client::{Client, UserStore}; use collections::HashSet; +use credentials_provider::CredentialsProvider; use gpui::{App, Context, Entity}; use language_model::{LanguageModelProviderId, LanguageModelRegistry}; use provider::deepseek::DeepSeekLanguageModelProvider; @@ -31,9 +32,16 @@ use crate::provider::x_ai::XAiLanguageModelProvider; pub use crate::settings::*; pub fn init(user_store: Entity, client: Arc, cx: &mut App) { + let credentials_provider = client.credentials_provider(); let registry = LanguageModelRegistry::global(cx); registry.update(cx, |registry, cx| { - register_language_model_providers(registry, user_store, client.clone(), cx); + register_language_model_providers( + registry, + user_store, + client.clone(), + credentials_provider.clone(), + cx, + ); }); // Subscribe to extension store events to track LLM extension installations @@ -104,6 +112,7 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { &HashSet::default(), &openai_compatible_providers, client.clone(), + credentials_provider.clone(), cx, ); }); @@ -124,6 +133,7 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { &openai_compatible_providers, &openai_compatible_providers_new, client.clone(), + credentials_provider.clone(), cx, ); }); @@ -138,6 +148,7 @@ fn register_openai_compatible_providers( old: &HashSet>, new: &HashSet>, client: Arc, + credentials_provider: Arc, cx: &mut Context, ) { for provider_id in old { @@ -152,6 +163,7 @@ fn register_openai_compatible_providers( Arc::new(OpenAiCompatibleLanguageModelProvider::new( provider_id.clone(), client.http_client(), + credentials_provider.clone(), cx, )), cx, @@ -164,6 +176,7 @@ fn register_language_model_providers( registry: &mut LanguageModelRegistry, user_store: Entity, client: Arc, + credentials_provider: Arc, cx: &mut Context, ) { registry.register_provider( @@ -177,62 +190,105 @@ fn register_language_model_providers( registry.register_provider( Arc::new(AnthropicLanguageModelProvider::new( client.http_client(), + credentials_provider.clone(), cx, )), cx, ); registry.register_provider( - Arc::new(OpenAiLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(OpenAiLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( - Arc::new(OllamaLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(OllamaLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( - Arc::new(LmStudioLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(LmStudioLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( - Arc::new(DeepSeekLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(DeepSeekLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( - Arc::new(GoogleLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(GoogleLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( - MistralLanguageModelProvider::global(client.http_client(), cx), + MistralLanguageModelProvider::global( + client.http_client(), + credentials_provider.clone(), + cx, + ), cx, ); registry.register_provider( - Arc::new(BedrockLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(BedrockLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( Arc::new(OpenRouterLanguageModelProvider::new( client.http_client(), + credentials_provider.clone(), cx, )), cx, ); registry.register_provider( - Arc::new(VercelLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(VercelLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( Arc::new(VercelAiGatewayLanguageModelProvider::new( client.http_client(), + credentials_provider.clone(), cx, )), cx, ); registry.register_provider( - Arc::new(XAiLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(XAiLanguageModelProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, ); registry.register_provider( - Arc::new(OpenCodeLanguageModelProvider::new(client.http_client(), cx)), + Arc::new(OpenCodeLanguageModelProvider::new( + client.http_client(), + credentials_provider, + cx, + )), cx, ); registry.register_provider(Arc::new(CopilotChatLanguageModelProvider::new(cx)), cx); diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index a98a0ce142dfdbaaaddc056ab378455a45147830..c1b8bc1a3bb1b602b67ae5563d8acc3b05a94d47 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -6,6 +6,7 @@ use anthropic::{ }; use anyhow::Result; use collections::{BTreeMap, HashMap}; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream}; use gpui::{AnyView, App, AsyncApp, Context, Entity, Task}; use http_client::HttpClient; @@ -51,6 +52,7 @@ static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -59,30 +61,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = AnthropicLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = AnthropicLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl AnthropicLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index f53f145dbd387aa948b977d854ba77f1cbe49ded..4320763e2c5c6de7f3fe9238d7a4991565c3bfcd 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -195,12 +195,13 @@ pub struct State { settings: Option, /// Whether credentials came from environment variables (only relevant for static credentials) credentials_from_env: bool, + credentials_provider: Arc, _subscription: Subscription, } impl State { fn reset_auth(&self, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); + let credentials_provider = self.credentials_provider.clone(); cx.spawn(async move |this, cx| { credentials_provider .delete_credentials(AMAZON_AWS_URL, cx) @@ -220,7 +221,7 @@ impl State { cx: &mut Context, ) -> Task> { let auth = credentials.clone().into_auth(); - let credentials_provider = ::global(cx); + let credentials_provider = self.credentials_provider.clone(); cx.spawn(async move |this, cx| { credentials_provider .write_credentials( @@ -287,7 +288,7 @@ impl State { &self, cx: &mut Context, ) -> Task> { - let credentials_provider = ::global(cx); + let credentials_provider = self.credentials_provider.clone(); cx.spawn(async move |this, cx| { // Try environment variables first let (auth, from_env) = if let Some(bearer_token) = &ZED_BEDROCK_BEARER_TOKEN_VAR.value { @@ -400,11 +401,16 @@ pub struct BedrockLanguageModelProvider { } impl BedrockLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| State { auth: None, settings: Some(AllLanguageModelSettings::get_global(cx).bedrock.clone()), credentials_from_env: false, + credentials_provider, _subscription: cx.observe_global::(|_, cx| { cx.notify(); }), diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index f9372a4d7ea9c078c58f633cc58bd5597ef49212..29623cc998ad0fe933e9a29c45c651f7be010b07 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1,7 +1,9 @@ use ai_onboarding::YoungAccountBanner; use anthropic::AnthropicModelMode; use anyhow::{Context as _, Result, anyhow}; -use client::{Client, UserStore, zed_urls}; +use client::{ + Client, NeedsLlmTokenRefresh, RefreshLlmTokenListener, UserStore, global_llm_token, zed_urls, +}; use cloud_api_types::{OrganizationId, Plan}; use cloud_llm_client::{ CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CLIENT_SUPPORTS_STATUS_STREAM_ENDED_HEADER_NAME, @@ -24,10 +26,9 @@ use language_model::{ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolSchemaFormat, LlmApiToken, NeedsLlmTokenRefresh, - OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, PaymentRequiredError, RateLimiter, - RefreshLlmTokenListener, X_AI_PROVIDER_ID, X_AI_PROVIDER_NAME, ZED_CLOUD_PROVIDER_ID, - ZED_CLOUD_PROVIDER_NAME, + LanguageModelToolChoice, LanguageModelToolSchemaFormat, LlmApiToken, OPEN_AI_PROVIDER_ID, + OPEN_AI_PROVIDER_NAME, PaymentRequiredError, RateLimiter, X_AI_PROVIDER_ID, X_AI_PROVIDER_NAME, + ZED_CLOUD_PROVIDER_ID, ZED_CLOUD_PROVIDER_NAME, }; use release_channel::AppVersion; use schemars::JsonSchema; @@ -111,7 +112,7 @@ impl State { cx: &mut Context, ) -> Self { let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); - let llm_api_token = LlmApiToken::global(cx); + let llm_api_token = global_llm_token(cx); Self { client: client.clone(), llm_api_token, @@ -226,7 +227,9 @@ impl State { organization_id: Option, ) -> Result { let http_client = &client.http_client(); - let token = llm_api_token.acquire(&client, organization_id).await?; + let token = client + .acquire_llm_token(&llm_api_token, organization_id) + .await?; let request = http_client::Request::builder() .method(Method::GET) @@ -414,8 +417,8 @@ impl CloudLanguageModel { ) -> Result { let http_client = &client.http_client(); - let mut token = llm_api_token - .acquire(&client, organization_id.clone()) + let mut token = client + .acquire_llm_token(&llm_api_token, organization_id.clone()) .await?; let mut refreshed_token = false; @@ -447,8 +450,8 @@ impl CloudLanguageModel { } if !refreshed_token && response.needs_llm_token_refresh() { - token = llm_api_token - .refresh(&client, organization_id.clone()) + token = client + .refresh_llm_token(&llm_api_token, organization_id.clone()) .await?; refreshed_token = true; continue; @@ -713,7 +716,9 @@ impl LanguageModel for CloudLanguageModel { into_google(request, model_id.clone(), GoogleModelMode::Default); async move { let http_client = &client.http_client(); - let token = llm_api_token.acquire(&client, organization_id).await?; + let token = client + .acquire_llm_token(&llm_api_token, organization_id) + .await?; let request_body = CountTokensBody { provider: cloud_llm_client::LanguageModelProvider::Google, diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index bd2469d865fd8421d6ad31208e6a4be413c0fe14..0cfb1af425c7cb0279d98fa124a589437f1bb1a1 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -1,5 +1,6 @@ use anyhow::{Result, anyhow}; use collections::{BTreeMap, HashMap}; +use credentials_provider::CredentialsProvider; use deepseek::DEEPSEEK_API_URL; use futures::Stream; @@ -49,6 +50,7 @@ pub struct DeepSeekLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -57,30 +59,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = DeepSeekLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = DeepSeekLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl DeepSeekLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 8fdfb514ac6e872bd24968d33f2c1169401d5a9c..244f7835a85ff67f0c4826321910ea13516371cb 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -1,5 +1,6 @@ use anyhow::{Context as _, Result}; use collections::BTreeMap; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture}; use google_ai::{ FunctionDeclaration, GenerateContentResponse, GoogleModelMode, Part, SystemInstruction, @@ -60,6 +61,7 @@ pub struct GoogleLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } const GEMINI_API_KEY_VAR_NAME: &str = "GEMINI_API_KEY"; @@ -76,30 +78,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = GoogleLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = GoogleLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl GoogleLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 6c8d3c6e1c50185a4b09e9afc80c688f4c8d1381..0d60fef16791087e35bac7d846b2ec99821d5470 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -1,5 +1,6 @@ use anyhow::{Result, anyhow}; use collections::HashMap; +use credentials_provider::CredentialsProvider; use fs::Fs; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; @@ -52,6 +53,7 @@ pub struct LmStudioLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, http_client: Arc, available_models: Vec, fetch_model_task: Option>>, @@ -64,10 +66,15 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); 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); + let task = self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); self.restart_fetch_models_task(cx); task } @@ -114,10 +121,14 @@ impl State { } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); 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); + let _task = self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); if self.is_authenticated() { return Task::ready(Ok(())); @@ -152,16 +163,29 @@ impl State { } impl LmStudioLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let this = Self { http_client: http_client.clone(), state: cx.new(|cx| { let subscription = cx.observe_global::({ let mut settings = AllLanguageModelSettings::get_global(cx).lmstudio.clone(); move |this: &mut State, cx| { - let new_settings = &AllLanguageModelSettings::get_global(cx).lmstudio; - if &settings != new_settings { - settings = new_settings.clone(); + let new_settings = + AllLanguageModelSettings::get_global(cx).lmstudio.clone(); + if settings != new_settings { + let credentials_provider = this.credentials_provider.clone(); + let api_url = Self::api_url(cx).into(); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); + settings = new_settings; this.restart_fetch_models_task(cx); cx.notify(); } @@ -173,6 +197,7 @@ impl LmStudioLanguageModelProvider { Self::api_url(cx).into(), (*API_KEY_ENV_VAR).clone(), ), + credentials_provider, http_client, available_models: Default::default(), fetch_model_task: None, diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 72f0cae2993da4efb3e19cb19ec42b186290920d..4cd1375fe50cd792a3a7bc8c85ba7b5b5af9520a 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -1,5 +1,6 @@ use anyhow::{Result, anyhow}; use collections::BTreeMap; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream}; use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, Window}; @@ -43,6 +44,7 @@ pub struct MistralLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -51,15 +53,26 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = MistralLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = MistralLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } @@ -73,20 +86,30 @@ impl MistralLanguageModelProvider { .map(|this| &this.0) } - pub fn global(http_client: Arc, cx: &mut App) -> Arc { + pub fn global( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Arc { if let Some(this) = cx.try_global::() { return this.0.clone(); } let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 551fcd55358c11bdf64bf2f27b32fa9a7f702252..49c326683a225bf73f604a584307ea1316a710c4 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -1,4 +1,5 @@ use anyhow::{Result, anyhow}; +use credentials_provider::CredentialsProvider; use fs::Fs; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; use futures::{Stream, TryFutureExt, stream}; @@ -54,6 +55,7 @@ pub struct OllamaLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, http_client: Arc, fetched_models: Vec, fetch_model_task: Option>>, @@ -65,10 +67,15 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OllamaLanguageModelProvider::api_url(cx); - let task = self - .api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx); + let task = self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); self.fetched_models.clear(); cx.spawn(async move |this, cx| { @@ -80,10 +87,14 @@ impl State { } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OllamaLanguageModelProvider::api_url(cx); - let task = self - .api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx); + let task = self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); // Always try to fetch models - if no API key is needed (local Ollama), it will work // If API key is needed and provided, it will work @@ -157,7 +168,11 @@ impl State { } impl OllamaLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let this = Self { http_client: http_client.clone(), state: cx.new(|cx| { @@ -170,6 +185,14 @@ impl OllamaLanguageModelProvider { let url_changed = last_settings.api_url != current_settings.api_url; last_settings = current_settings.clone(); if url_changed { + let credentials_provider = this.credentials_provider.clone(); + let api_url = Self::api_url(cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); this.fetched_models.clear(); this.authenticate(cx).detach(); } @@ -184,6 +207,7 @@ impl OllamaLanguageModelProvider { fetched_models: Default::default(), fetch_model_task: None, api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }), }; diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 9289c66b2a4c9213826d2d027555511c9746d00e..6a2313487f4a1922cdc2aa20d23ede01c4b7d158 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -1,5 +1,6 @@ use anyhow::{Result, anyhow}; use collections::{BTreeMap, HashMap}; +use credentials_provider::CredentialsProvider; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; @@ -55,6 +56,7 @@ pub struct OpenAiLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -63,30 +65,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OpenAiLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OpenAiLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl OpenAiLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 87a08097782198238a5d2467af32cc66b3183664..9f63a1e1a039998c275637f3831b51474c8049ac 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -1,5 +1,6 @@ use anyhow::Result; use convert_case::{Case, Casing}; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; @@ -44,6 +45,7 @@ pub struct State { id: Arc, api_key_state: ApiKeyState, settings: OpenAiCompatibleSettings, + credentials_provider: Arc, } impl State { @@ -52,20 +54,36 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = SharedString::new(self.settings.api_url.as_str()); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = SharedString::new(self.settings.api_url.clone()); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl OpenAiCompatibleLanguageModelProvider { - pub fn new(id: Arc, http_client: Arc, cx: &mut App) -> Self { + pub fn new( + id: Arc, + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { fn resolve_settings<'a>(id: &'a str, cx: &'a App) -> Option<&'a OpenAiCompatibleSettings> { crate::AllLanguageModelSettings::get_global(cx) .openai_compatible @@ -79,10 +97,12 @@ impl OpenAiCompatibleLanguageModelProvider { return; }; if &this.settings != &settings { + let credentials_provider = this.credentials_provider.clone(); let api_url = SharedString::new(settings.api_url.as_str()); this.api_key_state.handle_url_change( api_url, |this| &mut this.api_key_state, + credentials_provider, cx, ); this.settings = settings; @@ -98,6 +118,7 @@ impl OpenAiCompatibleLanguageModelProvider { EnvVar::new(api_key_env_var_name), ), settings, + credentials_provider, } }); diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index a4a679be73c0276351a6524ad7e8fc40e2c26860..09c8eb768d12c61ed1dc86a1251ad52114be6162 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -1,5 +1,6 @@ use anyhow::Result; use collections::HashMap; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task}; use http_client::HttpClient; @@ -42,6 +43,7 @@ pub struct OpenRouterLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, http_client: Arc, available_models: Vec, fetch_models_task: Option>>, @@ -53,16 +55,26 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OpenRouterLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OpenRouterLanguageModelProvider::api_url(cx); - let task = self - .api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx); + let task = self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.spawn(async move |this, cx| { let result = task.await; @@ -114,7 +126,11 @@ impl State { } impl OpenRouterLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::({ let mut last_settings = OpenRouterLanguageModelProvider::settings(cx).clone(); @@ -131,6 +147,7 @@ impl OpenRouterLanguageModelProvider { .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, http_client: http_client.clone(), available_models: Vec::new(), fetch_models_task: None, diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index f3953f3cafa4a1f59ff86004628c0a4022f6257e..aae3a552544ebf2cc59255da954d84cf7b78c7da 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -1,5 +1,6 @@ use anyhow::Result; use collections::BTreeMap; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; @@ -43,6 +44,7 @@ pub struct OpenCodeLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -51,30 +53,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OpenCodeLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = OpenCodeLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl OpenCodeLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/vercel.rs b/crates/language_models/src/provider/vercel.rs index b71da5b7db05710ee30115ab54379c9ee4e4c750..cedbc9c3cb988375b90864ceb23a3b14fc50abdd 100644 --- a/crates/language_models/src/provider/vercel.rs +++ b/crates/language_models/src/provider/vercel.rs @@ -1,5 +1,6 @@ use anyhow::Result; use collections::BTreeMap; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; @@ -38,6 +39,7 @@ pub struct VercelLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -46,30 +48,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = VercelLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = VercelLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl VercelLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index 78f900de0c94fd3bbbff3962e92d1a8cb9f3e118..66767edd809531b4b020263654922d742a1a04be 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -1,5 +1,6 @@ use anyhow::Result; use collections::BTreeMap; +use credentials_provider::CredentialsProvider; use futures::{AsyncReadExt, FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, http}; @@ -41,6 +42,7 @@ pub struct VercelAiGatewayLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, http_client: Arc, available_models: Vec, fetch_models_task: Option>>, @@ -52,16 +54,26 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = VercelAiGatewayLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = VercelAiGatewayLanguageModelProvider::api_url(cx); - let task = self - .api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx); + let task = self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.spawn(async move |this, cx| { let result = task.await; @@ -100,7 +112,11 @@ impl State { } impl VercelAiGatewayLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::({ let mut last_settings = VercelAiGatewayLanguageModelProvider::settings(cx).clone(); @@ -116,6 +132,7 @@ impl VercelAiGatewayLanguageModelProvider { .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, http_client: http_client.clone(), available_models: Vec::new(), fetch_models_task: None, diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index c00637bce7e67b624f5cdcae9aebe43fb43971f8..88189864c7b4b650a24afb2b872c1d6105cf9782 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -1,5 +1,6 @@ use anyhow::Result; use collections::BTreeMap; +use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, Window}; use http_client::HttpClient; @@ -39,6 +40,7 @@ pub struct XAiLanguageModelProvider { pub struct State { api_key_state: ApiKeyState, + credentials_provider: Arc, } impl State { @@ -47,30 +49,51 @@ impl State { } fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = XAiLanguageModelProvider::api_url(cx); - self.api_key_state - .store(api_url, api_key, |this| &mut this.api_key_state, cx) + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } fn authenticate(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); let api_url = XAiLanguageModelProvider::api_url(cx); - self.api_key_state - .load_if_needed(api_url, |this| &mut this.api_key_state, cx) + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ) } } impl XAiLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); let api_url = Self::api_url(cx); - this.api_key_state - .handle_url_change(api_url, |this| &mut this.api_key_state, cx); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); cx.notify(); }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + credentials_provider, } }); diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index ccffbd29f4bd03b0d4bb0a070f4229a517597468..cd037786a399eb979fd5d9053c57efe3100dd473 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -98,6 +98,7 @@ watch.workspace = true wax.workspace = true which.workspace = true worktree.workspace = true +zed_credentials_provider.workspace = true zeroize.workspace = true zlog.workspace = true ztracing.workspace = true diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index 395056384a79d39c978e14643166148685ea0b90..7b9fc16f10022805ea62df2f8b3df279fc96ae3d 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -684,7 +684,7 @@ impl ContextServerStore { let server_url = url.clone(); let id = id.clone(); cx.spawn(async move |_this, cx| { - let credentials_provider = cx.update(|cx| ::global(cx)); + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); if let Err(err) = Self::clear_session(&credentials_provider, &server_url, &cx).await { log::warn!("{} failed to clear OAuth session on removal: {}", id, err); @@ -797,8 +797,7 @@ impl ContextServerStore { if configuration.has_static_auth_header() { None } else { - let credentials_provider = - cx.update(|cx| ::global(cx)); + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); let http_client = cx.update(|cx| cx.http_client()); match Self::load_session(&credentials_provider, url, &cx).await { @@ -1070,7 +1069,7 @@ impl ContextServerStore { .context("Failed to start OAuth callback server")?; let http_client = cx.update(|cx| cx.http_client()); - let credentials_provider = cx.update(|cx| ::global(cx)); + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); let server_url = match configuration.as_ref() { ContextServerConfiguration::Http { url, .. } => url.clone(), _ => anyhow::bail!("OAuth authentication only supported for HTTP servers"), @@ -1233,7 +1232,7 @@ impl ContextServerStore { self.stop_server(&id, cx)?; cx.spawn(async move |this, cx| { - let credentials_provider = cx.update(|cx| ::global(cx)); + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); if let Err(err) = Self::clear_session(&credentials_provider, &server_url, &cx).await { log::error!("{} failed to clear OAuth session: {}", id, err); } @@ -1451,7 +1450,7 @@ async fn resolve_start_failure( // (e.g. timeout because the server rejected the token silently). Clear it // so the next start attempt can get a clean 401 and trigger the auth flow. if www_authenticate.is_none() { - let credentials_provider = cx.update(|cx| ::global(cx)); + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); match ContextServerStore::load_session(&credentials_provider, &server_url, cx).await { Ok(Some(_)) => { log::info!("{id} start failed with a cached OAuth session present; clearing it"); diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 9d79481596f4b4259760ff6c2f19f8f5cf709d1e..0228f6886fc741505ffbe02fe82242d5f3e1dfd4 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -59,6 +59,7 @@ ui.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true +zed_credentials_provider.workspace = true [dev-dependencies] fs = { workspace = true, features = ["test-support"] } diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs index 193be67aad4760763637f116fad23066438b5b61..a2a457d33eb0788ff0bed981ce5666423890f05a 100644 --- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs +++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs @@ -185,9 +185,15 @@ fn render_api_key_provider( cx: &mut Context, ) -> impl IntoElement { let weak_page = cx.weak_entity(); + let credentials_provider = zed_credentials_provider::global(cx); _ = window.use_keyed_state(current_url(cx), cx, |_, cx| { let task = api_key_state.update(cx, |key_state, cx| { - key_state.load_if_needed(current_url(cx), |state| state, cx) + key_state.load_if_needed( + current_url(cx), + |state| state, + credentials_provider.clone(), + cx, + ) }); cx.spawn(async move |_, cx| { task.await.ok(); @@ -208,10 +214,17 @@ fn render_api_key_provider( }); let write_key = move |api_key: Option, cx: &mut App| { + let credentials_provider = zed_credentials_provider::global(cx); api_key_state .update(cx, |key_state, cx| { let url = current_url(cx); - key_state.store(url, api_key, |key_state| key_state, cx) + key_state.store( + url, + api_key, + |key_state| key_state, + credentials_provider, + cx, + ) }) .detach_and_log_err(cx); }; diff --git a/crates/web_search_providers/src/cloud.rs b/crates/web_search_providers/src/cloud.rs index 17addd24d445a666138a1b37fef872beedd07aed..11227d8fb5c7152dc5b7e03b95fadea6cb714717 100644 --- a/crates/web_search_providers/src/cloud.rs +++ b/crates/web_search_providers/src/cloud.rs @@ -1,13 +1,13 @@ use std::sync::Arc; use anyhow::{Context as _, Result}; -use client::{Client, UserStore}; +use client::{Client, NeedsLlmTokenRefresh, UserStore, global_llm_token}; use cloud_api_types::OrganizationId; use cloud_llm_client::{WebSearchBody, WebSearchResponse}; use futures::AsyncReadExt as _; use gpui::{App, AppContext, Context, Entity, Task}; use http_client::{HttpClient, Method}; -use language_model::{LlmApiToken, NeedsLlmTokenRefresh}; +use language_model::LlmApiToken; use web_search::{WebSearchProvider, WebSearchProviderId}; pub struct CloudWebSearchProvider { @@ -30,7 +30,7 @@ pub struct State { impl State { pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { - let llm_api_token = LlmApiToken::global(cx); + let llm_api_token = global_llm_token(cx); Self { client, @@ -73,8 +73,8 @@ async fn perform_web_search( let http_client = &client.http_client(); let mut retries_remaining = MAX_RETRIES; - let mut token = llm_api_token - .acquire(&client, organization_id.clone()) + let mut token = client + .acquire_llm_token(&llm_api_token, organization_id.clone()) .await?; loop { @@ -100,8 +100,8 @@ async fn perform_web_search( response.body_mut().read_to_string(&mut body).await?; return Ok(serde_json::from_str(&body)?); } else if response.needs_llm_token_refresh() { - token = llm_api_token - .refresh(&client, organization_id.clone()) + token = client + .refresh_llm_token(&llm_api_token, organization_id.clone()) .await?; retries_remaining -= 1; } else { diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 0e1cbc96ff1521626bfe8bcf62091404324132a0..902d147084ce42b34a34477593ecc755bc6aa7cc 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -10,7 +10,7 @@ use agent_ui::AgentPanel; use anyhow::{Context as _, Error, Result}; use clap::Parser; use cli::FORCE_CLI_MODE_ENV_VAR_NAME; -use client::{Client, ProxySettings, UserStore, parse_zed_link}; +use client::{Client, ProxySettings, RefreshLlmTokenListener, UserStore, parse_zed_link}; use collab_ui::channel_view::ChannelView; use collections::HashMap; use crashes::InitCrashHandler; @@ -664,7 +664,12 @@ fn main() { ); copilot_ui::init(&app_state, cx); - language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx); + language_model::init(cx); + RefreshLlmTokenListener::register( + app_state.client.clone(), + app_state.user_store.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 7e081c15a564cb996f176345ee3330f00ee6b6f3..ad44ba4128b436597a74621694ae47c661f57bd1 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -201,7 +201,12 @@ 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.user_store.clone(), app_state.client.clone(), cx); + language_model::init(cx); + client::RefreshLlmTokenListener::register( + app_state.client.clone(), + app_state.user_store.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 fbebb37985c2ebd76a63db5b4b807a8a7e0203ce..8d7759948fcabba7388a5c63e0bfa6710aa21f74 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5189,7 +5189,12 @@ mod tests { cx, ); image_viewer::init(cx); - language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx); + language_model::init(cx); + client::RefreshLlmTokenListener::register( + app_state.client.clone(), + app_state.user_store.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 8c9e74a42e6c3ddb2b340ac58da39752009825f0..d09dc07af839a681cea96d43217c4217927864d5 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -313,7 +313,12 @@ 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.user_store.clone(), app_state.client.clone(), cx); + language_model::init(cx); + client::RefreshLlmTokenListener::register( + app_state.client.clone(), + app_state.user_store.clone(), + cx, + ); editor::init(cx); app_state }); diff --git a/crates/zed_credentials_provider/Cargo.toml b/crates/zed_credentials_provider/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..9f64801d4664111bceb0fb7b9ee8c007977b6389 --- /dev/null +++ b/crates/zed_credentials_provider/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "zed_credentials_provider" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/zed_credentials_provider.rs" + +[dependencies] +anyhow.workspace = true +credentials_provider.workspace = true +futures.workspace = true +gpui.workspace = true +paths.workspace = true +release_channel.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/zed_credentials_provider/LICENSE-GPL b/crates/zed_credentials_provider/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/zed_credentials_provider/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/zed_credentials_provider/src/zed_credentials_provider.rs b/crates/zed_credentials_provider/src/zed_credentials_provider.rs new file mode 100644 index 0000000000000000000000000000000000000000..6705e58d400b1a66990f2451d318b5950ea08dde --- /dev/null +++ b/crates/zed_credentials_provider/src/zed_credentials_provider.rs @@ -0,0 +1,181 @@ +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::{Arc, LazyLock}; + +use anyhow::Result; +use credentials_provider::CredentialsProvider; +use futures::FutureExt as _; +use gpui::{App, AsyncApp, Global}; +use release_channel::ReleaseChannel; + +/// An environment variable whose presence indicates that the system keychain +/// should be used in development. +/// +/// By default, running Zed in development uses the development credentials +/// provider. Setting this environment variable allows you to interact with the +/// system keychain (for instance, if you need to test something). +/// +/// Only works in development. Setting this environment variable in other +/// release channels is a no-op. +static ZED_DEVELOPMENT_USE_KEYCHAIN: LazyLock = LazyLock::new(|| { + std::env::var("ZED_DEVELOPMENT_USE_KEYCHAIN").is_ok_and(|value| !value.is_empty()) +}); + +pub struct ZedCredentialsProvider(pub Arc); + +impl Global for ZedCredentialsProvider {} + +/// Returns the global [`CredentialsProvider`]. +pub fn init_global(cx: &mut App) { + // The `CredentialsProvider` trait has `Send + Sync` bounds on it, so it + // seems like this is a false positive from Clippy. + #[allow(clippy::arc_with_non_send_sync)] + let provider = new(cx); + cx.set_global(ZedCredentialsProvider(provider)); +} + +pub fn global(cx: &App) -> Arc { + cx.try_global::() + .map(|provider| provider.0.clone()) + .unwrap_or_else(|| new(cx)) +} + +fn new(cx: &App) -> Arc { + let use_development_provider = match ReleaseChannel::try_global(cx) { + Some(ReleaseChannel::Dev) => { + // In development we default to using the development + // credentials provider to avoid getting spammed by relentless + // keychain access prompts. + // + // However, if the `ZED_DEVELOPMENT_USE_KEYCHAIN` environment + // variable is set, we will use the actual keychain. + !*ZED_DEVELOPMENT_USE_KEYCHAIN + } + Some(ReleaseChannel::Nightly | ReleaseChannel::Preview | ReleaseChannel::Stable) | None => { + false + } + }; + + if use_development_provider { + Arc::new(DevelopmentCredentialsProvider::new()) + } else { + Arc::new(KeychainCredentialsProvider) + } +} + +/// A credentials provider that stores credentials in the system keychain. +struct KeychainCredentialsProvider; + +impl CredentialsProvider for KeychainCredentialsProvider { + fn read_credentials<'a>( + &'a self, + url: &'a str, + cx: &'a AsyncApp, + ) -> Pin)>>> + 'a>> { + async move { cx.update(|cx| cx.read_credentials(url)).await }.boxed_local() + } + + fn write_credentials<'a>( + &'a self, + url: &'a str, + username: &'a str, + password: &'a [u8], + cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + async move { + cx.update(move |cx| cx.write_credentials(url, username, password)) + .await + } + .boxed_local() + } + + fn delete_credentials<'a>( + &'a self, + url: &'a str, + cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + async move { cx.update(move |cx| cx.delete_credentials(url)).await }.boxed_local() + } +} + +/// A credentials provider that stores credentials in a local file. +/// +/// This MUST only be used in development, as this is not a secure way of storing +/// credentials on user machines. +/// +/// Its existence is purely to work around the annoyance of having to constantly +/// re-allow access to the system keychain when developing Zed. +struct DevelopmentCredentialsProvider { + path: PathBuf, +} + +impl DevelopmentCredentialsProvider { + fn new() -> Self { + let path = paths::config_dir().join("development_credentials"); + + Self { path } + } + + fn load_credentials(&self) -> Result)>> { + let json = std::fs::read(&self.path)?; + let credentials: HashMap)> = serde_json::from_slice(&json)?; + + Ok(credentials) + } + + fn save_credentials(&self, credentials: &HashMap)>) -> Result<()> { + let json = serde_json::to_string(credentials)?; + std::fs::write(&self.path, json)?; + + Ok(()) + } +} + +impl CredentialsProvider for DevelopmentCredentialsProvider { + fn read_credentials<'a>( + &'a self, + url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin)>>> + 'a>> { + async move { + Ok(self + .load_credentials() + .unwrap_or_default() + .get(url) + .cloned()) + } + .boxed_local() + } + + fn write_credentials<'a>( + &'a self, + url: &'a str, + username: &'a str, + password: &'a [u8], + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + async move { + let mut credentials = self.load_credentials().unwrap_or_default(); + credentials.insert(url.to_string(), (username.to_string(), password.to_vec())); + + self.save_credentials(&credentials) + } + .boxed_local() + } + + fn delete_credentials<'a>( + &'a self, + url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + async move { + let mut credentials = self.load_credentials()?; + credentials.remove(url); + + self.save_credentials(&credentials) + } + .boxed_local() + } +} diff --git a/crates/zed_env_vars/Cargo.toml b/crates/zed_env_vars/Cargo.toml index 1cf32174c351c28ec7eb16deab7b7986655d4a48..bf863b742568f3f607ba7cb54bc8fc267f045cc9 100644 --- a/crates/zed_env_vars/Cargo.toml +++ b/crates/zed_env_vars/Cargo.toml @@ -15,4 +15,4 @@ path = "src/zed_env_vars.rs" default = [] [dependencies] -gpui.workspace = true +env_var.workspace = true diff --git a/crates/zed_env_vars/src/zed_env_vars.rs b/crates/zed_env_vars/src/zed_env_vars.rs index e601cc9536602ac943bd76bf1bfd8b8ac8979dd9..13451911295735762074bcb1cf152470afa55c36 100644 --- a/crates/zed_env_vars/src/zed_env_vars.rs +++ b/crates/zed_env_vars/src/zed_env_vars.rs @@ -1,45 +1,6 @@ -use gpui::SharedString; +pub use env_var::{EnvVar, bool_env_var, env_var}; use std::sync::LazyLock; /// Whether Zed is running in stateless mode. /// When true, Zed will use in-memory databases instead of persistent storage. pub static ZED_STATELESS: LazyLock = bool_env_var!("ZED_STATELESS"); - -#[derive(Clone)] -pub struct EnvVar { - pub name: SharedString, - /// Value of the environment variable. Also `None` when set to an empty string. - pub value: Option, -} - -impl EnvVar { - pub fn new(name: SharedString) -> Self { - let value = std::env::var(name.as_str()).ok(); - if value.as_ref().is_some_and(|v| v.is_empty()) { - Self { name, value: None } - } else { - Self { name, value } - } - } - - pub fn or(self, other: EnvVar) -> EnvVar { - if self.value.is_some() { self } else { other } - } -} - -/// Creates a `LazyLock` expression for use in a `static` declaration. -#[macro_export] -macro_rules! env_var { - ($name:expr) => { - ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into())) - }; -} - -/// Generates a `LazyLock` expression for use in a `static` declaration. Checks if the -/// environment variable exists and is non-empty. -#[macro_export] -macro_rules! bool_env_var { - ($name:expr) => { - ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some()) - }; -} From 9537861e458709537824b6d082d78e4eead73e3c Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:17:29 -0300 Subject: [PATCH 14/15] Refine split diff icons (#53022) Follow-up to https://github.com/zed-industries/zed/pull/52781, adding some different icons to better express the state in which the split diff _is selected_ but _isn't active_, which happens when the editor is smaller than a given amount of defined columns. https://github.com/user-attachments/assets/2e7aaf6c-077f-4be5-9439-ce6c2050e63d Release Notes: - N/A --- assets/icons/diff_split.svg | 5 +- assets/icons/diff_split_auto.svg | 7 ++ assets/icons/diff_unified.svg | 4 +- crates/icons/src/icons.rs | 1 + crates/search/src/buffer_search.rs | 144 +++++++++++++++-------------- 5 files changed, 87 insertions(+), 74 deletions(-) create mode 100644 assets/icons/diff_split_auto.svg diff --git a/assets/icons/diff_split.svg b/assets/icons/diff_split.svg index de2056466f7ef1081ee00dabb8b4d5baa8fc9217..dcafeb8df5c28bcac1f1fe8cf5783eebd8d8cd8a 100644 --- a/assets/icons/diff_split.svg +++ b/assets/icons/diff_split.svg @@ -1,5 +1,4 @@ - - - + + diff --git a/assets/icons/diff_split_auto.svg b/assets/icons/diff_split_auto.svg new file mode 100644 index 0000000000000000000000000000000000000000..f9dd7076be75aaf3e90286140a60deece5016114 --- /dev/null +++ b/assets/icons/diff_split_auto.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/diff_unified.svg b/assets/icons/diff_unified.svg index b2d3895ae5466454e9cefc4e77e3c3f2a19cde8c..28735c16f682159b6b0a099176d6fc3b75cd248e 100644 --- a/assets/icons/diff_unified.svg +++ b/assets/icons/diff_unified.svg @@ -1,4 +1,4 @@ - - + + diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 6929ae4e4ca8ca0ee00c9793c948892043dd6dd6..e29b7d3593025556771d62dc0124786672c540de 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -95,6 +95,7 @@ pub enum IconName { DebugStepOver, Diff, DiffSplit, + DiffSplitAuto, DiffUnified, Disconnected, Download, diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 93fbab59a6f1b9da0cb9faf0657fc4a1c5f679bd..2ea386b85df21a72262b70eb7016028a49c2b8c0 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -114,81 +114,23 @@ impl Render for BufferSearchBar { .map(|splittable_editor| { let editor_ref = splittable_editor.read(cx); let diff_view_style = editor_ref.diff_view_style(); - let is_split = editor_ref.is_split(); + + let is_split_set = diff_view_style == DiffViewStyle::Split; + let is_split_active = editor_ref.is_split(); let min_columns = EditorSettings::get_global(cx).minimum_split_diff_width as u32; - let mut split_button = IconButton::new("diff-split", IconName::DiffSplit) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::element(move |_, cx| { - let message = if min_columns == 0 { - SharedString::from("Split") - } else { - format!("Split when wider than {} columns", min_columns).into() - }; - - v_flex() - .child(message) - .child( - h_flex() - .gap_0p5() - .text_ui_sm(cx) - .text_color(Color::Muted.color(cx)) - .children(render_modifiers( - &gpui::Modifiers::secondary_key(), - PlatformStyle::platform(), - None, - Some(TextSize::Small.rems(cx).into()), - false, - )) - .child("click to change min width"), - ) - .into_any() - })) - .on_click({ - let splittable_editor = splittable_editor.downgrade(); - move |_, window, cx| { - if window.modifiers().secondary() { - window.dispatch_action( - OpenSettingsAt { - path: "minimum_split_diff_width".to_string(), - } - .boxed_clone(), - cx, - ); - } else { - update_settings_file( - ::global(cx), - cx, - |settings, _| { - settings.editor.diff_view_style = - Some(DiffViewStyle::Split); - }, - ); - if diff_view_style == DiffViewStyle::Unified { - splittable_editor - .update(cx, |editor, cx| { - editor.toggle_split(&ToggleSplitDiff, window, cx); - }) - .ok(); - } - } - } - }); - - if diff_view_style == DiffViewStyle::Split { - if !is_split { - split_button = split_button.icon_color(Color::Disabled) - } else { - split_button = split_button.toggle_state(true) - } - } + let split_icon = if is_split_set && !is_split_active { + IconName::DiffSplitAuto + } else { + IconName::DiffSplit + }; h_flex() .gap_1() .child( IconButton::new("diff-unified", IconName::DiffUnified) - .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) .toggle_state(diff_view_style == DiffViewStyle::Unified) .tooltip(Tooltip::text("Unified")) .on_click({ @@ -216,7 +158,71 @@ impl Render for BufferSearchBar { } }), ) - .child(split_button) + .child( + IconButton::new("diff-split", split_icon) + .toggle_state(diff_view_style == DiffViewStyle::Split) + .icon_size(IconSize::Small) + .tooltip(Tooltip::element(move |_, cx| { + let message = if is_split_set && !is_split_active { + format!("Split when wider than {} columns", min_columns) + .into() + } else { + SharedString::from("Split") + }; + + v_flex() + .child(message) + .child( + h_flex() + .gap_0p5() + .text_ui_sm(cx) + .text_color(Color::Muted.color(cx)) + .children(render_modifiers( + &gpui::Modifiers::secondary_key(), + PlatformStyle::platform(), + None, + Some(TextSize::Small.rems(cx).into()), + false, + )) + .child("click to change min width"), + ) + .into_any() + })) + .on_click({ + let splittable_editor = splittable_editor.downgrade(); + move |_, window, cx| { + if window.modifiers().secondary() { + window.dispatch_action( + OpenSettingsAt { + path: "minimum_split_diff_width".to_string(), + } + .boxed_clone(), + cx, + ); + } else { + update_settings_file( + ::global(cx), + cx, + |settings, _| { + settings.editor.diff_view_style = + Some(DiffViewStyle::Split); + }, + ); + if diff_view_style == DiffViewStyle::Unified { + splittable_editor + .update(cx, |editor, cx| { + editor.toggle_split( + &ToggleSplitDiff, + window, + cx, + ); + }) + .ok(); + } + } + } + }), + ) }) } else { None @@ -240,7 +246,7 @@ impl Render for BufferSearchBar { let collapse_expand_icon_button = |id| { IconButton::new(id, icon) - .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) .tooltip(move |_, cx| { Tooltip::for_action_in( tooltip_label, From d430cc5945f371ec87dd295d1f01dd840cbed3d8 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:08:22 -0300 Subject: [PATCH 15/15] sidebar: Add some design tweaks (#53026) - Make notification icons show up even for threads of the currently active workspace - When with a notification/any other status, replace thread item's agent icon a status icon for higher visbility - Remove hover state from currently active project/workspace's header - Make project/workspace label brighter if I'm inside of it - Adjust colors all around a bit (sidebar background and border, and icons within the project header) Release Notes: - N/A --- crates/sidebar/src/sidebar.rs | 40 +++++++------- crates/ui/src/components/ai/thread_item.rs | 62 ++++++++++------------ 2 files changed, 46 insertions(+), 56 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index a9664a048123253d617a08507cfe4288914d0e9e..7d7786fd59087f7d78088ae4517933ad089e8584 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -974,21 +974,21 @@ impl Sidebar { let session_id = &thread.metadata.session_id; - let is_thread_workspace_active = match &thread.workspace { - ThreadEntryWorkspace::Open(thread_workspace) => active_workspace - .as_ref() - .is_some_and(|active| active == thread_workspace), - ThreadEntryWorkspace::Closed(_) => false, - }; + let is_active_thread = self.active_entry.as_ref().is_some_and(|entry| { + entry.is_active_thread(session_id) + && active_workspace + .as_ref() + .is_some_and(|active| active == entry.workspace()) + }); if thread.status == AgentThreadStatus::Completed - && !is_thread_workspace_active + && !is_active_thread && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running) { notified_threads.insert(session_id.clone()); } - if is_thread_workspace_active && !thread.is_background { + if is_active_thread && !thread.is_background { notified_threads.remove(session_id); } } @@ -1280,7 +1280,7 @@ impl Sidebar { v_flex() .w_full() .border_t_1() - .border_color(cx.theme().colors().border.opacity(0.5)) + .border_color(cx.theme().colors().border) .child(rendered) .into_any_element() } else { @@ -1327,7 +1327,7 @@ impl Sidebar { has_running_threads: bool, waiting_thread_count: usize, is_active: bool, - is_selected: bool, + is_focused: bool, cx: &mut Context, ) -> AnyElement { let id_prefix = if is_sticky { "sticky-" } else { "" }; @@ -1359,11 +1359,11 @@ impl Sidebar { let label = if highlight_positions.is_empty() { Label::new(label.clone()) - .color(Color::Muted) + .when(!is_active, |this| this.color(Color::Muted)) .into_any_element() } else { HighlightedLabel::new(label.clone(), highlight_positions.to_vec()) - .color(Color::Muted) + .when(!is_active, |this| this.color(Color::Muted)) .into_any_element() }; @@ -1381,14 +1381,13 @@ impl Sidebar { .pr_1p5() .border_1() .map(|this| { - if is_selected { + if is_focused { this.border_color(color.border_focused) } else { this.border_color(gpui::transparent_black()) } }) .justify_between() - .hover(|s| s.bg(hover_color)) .child( h_flex() .when(!is_active, |this| this.cursor_pointer()) @@ -1469,7 +1468,6 @@ impl Sidebar { 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(); @@ -1491,7 +1489,6 @@ impl Sidebar { IconName::Plus, ) .icon_size(IconSize::Small) - .icon_color(Color::Muted) .tooltip(Tooltip::text("New Thread")) .on_click(cx.listener({ let workspace_for_new_thread = workspace_for_new_thread.clone(); @@ -1508,7 +1505,9 @@ impl Sidebar { }) }) .when(!is_active, |this| { - this.tooltip(Tooltip::text("Activate Workspace")) + this.cursor_pointer() + .hover(|s| s.bg(hover_color)) + .tooltip(Tooltip::text("Activate Workspace")) .on_click(cx.listener({ move |this, _, window, cx| { this.active_entry = @@ -1690,8 +1689,7 @@ impl Sidebar { IconName::Ellipsis, ) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), + .icon_size(IconSize::Small), ) .anchor(gpui::Corner::TopRight) .offset(gpui::Point { @@ -2825,7 +2823,7 @@ impl Sidebar { let color = cx.theme().colors(); let sidebar_bg = color .title_bar_background - .blend(color.panel_background.opacity(0.32)); + .blend(color.panel_background.opacity(0.25)); let timestamp = format_history_entry_timestamp( self.thread_last_message_sent_or_queued @@ -3682,7 +3680,7 @@ impl Render for Sidebar { let color = cx.theme().colors(); let bg = color .title_bar_background - .blend(color.panel_background.opacity(0.32)); + .blend(color.panel_background.opacity(0.25)); let no_open_projects = !self.contents.has_open_projects; let no_search_results = self.contents.entries.is_empty(); diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index d6b5f56e0abb33521ae69acc0b61b36b015cf987..7658946b6395d6314d90db52716020a922c85ccc 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -1,7 +1,4 @@ -use crate::{ - CommonAnimationExt, DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration, - IconDecorationKind, Tooltip, prelude::*, -}; +use crate::{CommonAnimationExt, DiffStat, GradientFade, HighlightedLabel, Tooltip, prelude::*}; use gpui::{ Animation, AnimationExt, AnyView, ClickEvent, Hsla, MouseButton, SharedString, @@ -218,7 +215,7 @@ impl RenderOnce for ThreadItem { let color = cx.theme().colors(); let sidebar_base_bg = color .title_bar_background - .blend(color.panel_background.opacity(0.32)); + .blend(color.panel_background.opacity(0.25)); let raw_bg = self.base_bg.unwrap_or(sidebar_base_bg); let apparent_bg = color.background.blend(raw_bg); @@ -266,31 +263,31 @@ impl RenderOnce for ThreadItem { Icon::new(self.icon).color(icon_color).size(IconSize::Small) }; - let decoration = |icon: IconDecorationKind, color: Hsla| { - IconDecoration::new(icon, base_bg, cx) - .color(color) - .position(gpui::Point { - x: px(-2.), - y: px(-2.), - }) - }; - - let (decoration, icon_tooltip) = if self.status == AgentThreadStatus::Error { + let (status_icon, icon_tooltip) = if self.status == AgentThreadStatus::Error { ( - Some(decoration(IconDecorationKind::X, cx.theme().status().error)), + Some( + Icon::new(IconName::Close) + .size(IconSize::Small) + .color(Color::Error), + ), Some("Thread has an Error"), ) } else if self.status == AgentThreadStatus::WaitingForConfirmation { ( - Some(decoration( - IconDecorationKind::Triangle, - cx.theme().status().warning, - )), + Some( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), + ), Some("Thread is Waiting for Confirmation"), ) } else if self.notified { ( - Some(decoration(IconDecorationKind::Dot, color.text_accent)), + Some( + Icon::new(IconName::Circle) + .size(IconSize::Small) + .color(Color::Accent), + ), Some("Thread's Generation is Complete"), ) } else { @@ -306,9 +303,9 @@ impl RenderOnce for ThreadItem { .with_rotate_animation(2), ) .into_any_element() - } else if let Some(decoration) = decoration { + } else if let Some(status_icon) = status_icon { icon_container() - .child(DecoratedIcon::new(agent_icon, Some(decoration))) + .child(status_icon) .when_some(icon_tooltip, |icon, tooltip| { icon.tooltip(Tooltip::text(tooltip)) }) @@ -551,12 +548,17 @@ impl Component for ThreadItem { } fn preview(_window: &mut Window, cx: &mut App) -> Option { + let color = cx.theme().colors(); + let bg = color + .title_bar_background + .blend(color.panel_background.opacity(0.25)); + let container = || { v_flex() .w_72() .border_1() - .border_color(cx.theme().colors().border_variant) - .bg(cx.theme().colors().panel_background) + .border_color(color.border_variant) + .bg(bg) }; let thread_item_examples = vec![ @@ -570,16 +572,6 @@ impl Component for ThreadItem { ) .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 (weeks)", container()