From b6cf398eab34bbc515a94f77ec01d0cd44570c61 Mon Sep 17 00:00:00 2001 From: Balboa Codes <20909423+balboacodes@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:25:59 -0400 Subject: [PATCH 01/21] docs: Fix PHP docs typo (#34836) This fixes a minor typo in the PHP docs. Release Notes: - N/A --- docs/src/languages/php.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/languages/php.md b/docs/src/languages/php.md index 9cb7c40762e7af0f0680cbbcf564d4d989e7f0e9..4e94c134467c5a3484ede7a2146f2f09c172e859 100644 --- a/docs/src/languages/php.md +++ b/docs/src/languages/php.md @@ -15,7 +15,7 @@ The PHP extension offers both `phpactor` and `intelephense` language server supp ## Phpactor -The Zed PHP Extension can install `phpactor` automatically but requires `php` to installed and available in your path: +The Zed PHP Extension can install `phpactor` automatically but requires `php` to be installed and available in your path: ```sh # brew install php # macOS From 254c7a330a9c95d4ca2d7251cf601a5dac165768 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 21 Jul 2025 20:48:07 +0300 Subject: [PATCH 02/21] Regroup LSP context menu items by the worktree name (#34838) Also * remove the feature gate * open buffers with an error when no logs are present * adjust the hover text to indicate that difference image Release Notes: - N/A --- Cargo.lock | 1 - .../src/activity_indicator.rs | 4 +- crates/language_tools/Cargo.toml | 1 - crates/language_tools/src/lsp_tool.rs | 592 +++++++++++------- 4 files changed, 357 insertions(+), 241 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1dcfb877562cd86a9edf8a4aa58138b6cbce5181..4537d440ccd97b4934164eee3f0bdf0230edd4ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9165,7 +9165,6 @@ dependencies = [ "collections", "copilot", "editor", - "feature_flags", "futures 0.3.31", "gpui", "itertools 0.14.0", diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index aee25fc9e39d533409b980782fa8f0cac3977935..f8ea7173d8afecb14a8e8ed9e1de6a87660ddc1a 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -231,7 +231,6 @@ impl ActivityIndicator { status, } => { let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx)); - let project = project.clone(); let status = status.clone(); let server_name = server_name.clone(); cx.spawn_in(window, async move |workspace, cx| { @@ -247,8 +246,7 @@ impl ActivityIndicator { workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane( Box::new(cx.new(|cx| { - let mut editor = - Editor::for_buffer(buffer, Some(project.clone()), window, cx); + let mut editor = Editor::for_buffer(buffer, None, window, cx); editor.set_read_only(true); editor })), diff --git a/crates/language_tools/Cargo.toml b/crates/language_tools/Cargo.toml index 45af7518d589166e26788203c919d2267b544756..5aa914311a6eccc1cb68efa37e878ad12249d6fd 100644 --- a/crates/language_tools/Cargo.toml +++ b/crates/language_tools/Cargo.toml @@ -18,7 +18,6 @@ client.workspace = true collections.workspace = true copilot.workspace = true editor.workspace = true -feature_flags.workspace = true futures.workspace = true gpui.workspace = true itertools.workspace = true diff --git a/crates/language_tools/src/lsp_tool.rs b/crates/language_tools/src/lsp_tool.rs index fd843916800a552692f53404a5165b85f48d172e..9e95ed46734940f3f8de429ad6a581dc092b4614 100644 --- a/crates/language_tools/src/lsp_tool.rs +++ b/crates/language_tools/src/lsp_tool.rs @@ -1,13 +1,17 @@ -use std::{collections::hash_map, path::PathBuf, rc::Rc, time::Duration}; +use std::{ + collections::{BTreeMap, HashMap}, + path::{Path, PathBuf}, + rc::Rc, + time::Duration, +}; use client::proto; -use collections::{HashMap, HashSet}; +use collections::HashSet; use editor::{Editor, EditorEvent}; -use feature_flags::FeatureFlagAppExt as _; use gpui::{Corner, Entity, Subscription, Task, WeakEntity, actions}; -use language::{BinaryStatus, BufferId, LocalFile, ServerHealth}; +use language::{BinaryStatus, BufferId, ServerHealth}; use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector}; -use project::{LspStore, LspStoreEvent, project_settings::ProjectSettings}; +use project::{LspStore, LspStoreEvent, Worktree, project_settings::ProjectSettings}; use settings::{Settings as _, SettingsStore}; use ui::{ Context, ContextMenu, ContextMenuEntry, ContextMenuItem, DocumentationAside, DocumentationSide, @@ -36,8 +40,7 @@ pub struct LspTool { #[derive(Debug)] struct LanguageServerState { - items: Vec, - other_servers_start_index: Option, + items: Vec, workspace: WeakEntity, lsp_store: WeakEntity, active_editor: Option, @@ -63,8 +66,13 @@ impl std::fmt::Debug for ActiveEditor { struct LanguageServers { health_statuses: HashMap, binary_statuses: HashMap, - servers_per_buffer_abs_path: - HashMap>>, + servers_per_buffer_abs_path: HashMap, +} + +#[derive(Debug, Clone)] +struct ServersForPath { + servers: HashMap>, + worktree: Option>, } #[derive(Debug, Clone)] @@ -120,8 +128,8 @@ impl LanguageServerState { }; let mut first_button_encountered = false; - for (i, item) in self.items.iter().enumerate() { - if let LspItem::ToggleServersButton { restart } = item { + for item in &self.items { + if let LspMenuItem::ToggleServersButton { restart } = item { let label = if *restart { "Restart All Servers" } else { @@ -140,22 +148,19 @@ impl LanguageServerState { }; let project = workspace.read(cx).project().clone(); let buffer_store = project.read(cx).buffer_store().clone(); - let worktree_store = project.read(cx).worktree_store(); - let buffers = state .read(cx) .language_servers .servers_per_buffer_abs_path - .keys() - .filter_map(|abs_path| { - worktree_store.read(cx).find_worktree(abs_path, cx) - }) - .filter_map(|(worktree, relative_path)| { - let entry = - worktree.read(cx).entry_for_path(&relative_path)?; - project.read(cx).path_for_entry(entry.id, cx) - }) - .filter_map(|project_path| { + .iter() + .filter_map(|(abs_path, servers)| { + let worktree = + servers.worktree.as_ref()?.upgrade()?.read(cx); + let relative_path = + abs_path.strip_prefix(&worktree.abs_path()).ok()?; + let entry = worktree.entry_for_path(&relative_path)?; + let project_path = + project.read(cx).path_for_entry(entry.id, cx)?; buffer_store.read(cx).get_by_path(&project_path) }) .collect(); @@ -165,13 +170,16 @@ impl LanguageServerState { .iter() // Do not try to use IDs as we have stopped all servers already, when allowing to restart them all .flat_map(|item| match item { - LspItem::ToggleServersButton { .. } => None, - LspItem::WithHealthCheck(_, status, ..) => Some( - LanguageServerSelector::Name(status.name.clone()), - ), - LspItem::WithBinaryStatus(_, server_name, ..) => Some( - LanguageServerSelector::Name(server_name.clone()), + LspMenuItem::Header { .. } => None, + LspMenuItem::ToggleServersButton { .. } => None, + LspMenuItem::WithHealthCheck { health, .. } => Some( + LanguageServerSelector::Name(health.name.clone()), ), + LspMenuItem::WithBinaryStatus { + server_name, .. + } => Some(LanguageServerSelector::Name( + server_name.clone(), + )), }) .collect(); lsp_store.restart_language_servers_for_buffers( @@ -190,13 +198,17 @@ impl LanguageServerState { } menu = menu.item(button); continue; - }; + } else if let LspMenuItem::Header { header, separator } = item { + menu = menu + .when(*separator, |menu| menu.separator()) + .when_some(header.as_ref(), |menu, header| menu.header(header)); + continue; + } let Some(server_info) = item.server_info() else { continue; }; - let workspace = self.workspace.clone(); let server_selector = server_info.server_selector(); // TODO currently, Zed remote does not work well with the LSP logs // https://github.com/zed-industries/zed/issues/28557 @@ -205,6 +217,7 @@ impl LanguageServerState { let status_color = server_info .binary_status + .as_ref() .and_then(|binary_status| match binary_status.status { BinaryStatus::None => None, BinaryStatus::CheckingForUpdate @@ -223,17 +236,20 @@ impl LanguageServerState { }) .unwrap_or(Color::Success); - if self - .other_servers_start_index - .is_some_and(|index| index == i) - { - menu = menu.separator().header("Other Buffers"); - } - - if i == 0 && self.other_servers_start_index.is_some() { - menu = menu.header("Current Buffer"); - } + let message = server_info + .message + .as_ref() + .or_else(|| server_info.binary_status.as_ref()?.message.as_ref()) + .cloned(); + let hover_label = if has_logs { + Some("View Logs") + } else if message.is_some() { + Some("View Message") + } else { + None + }; + let server_name = server_info.name.clone(); menu = menu.item(ContextMenuItem::custom_entry( move |_, _| { h_flex() @@ -245,42 +261,99 @@ impl LanguageServerState { h_flex() .gap_2() .child(Indicator::dot().color(status_color)) - .child(Label::new(server_info.name.0.clone())), - ) - .child( - h_flex() - .visible_on_hover("menu_item") - .child( - Label::new("View Logs") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Icon::new(IconName::ChevronRight) - .size(IconSize::Small) - .color(Color::Muted), - ), + .child(Label::new(server_name.0.clone())), ) + .when_some(hover_label, |div, hover_label| { + div.child( + h_flex() + .visible_on_hover("menu_item") + .child( + Label::new(hover_label) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Icon::new(IconName::ChevronRight) + .size(IconSize::Small) + .color(Color::Muted), + ), + ) + }) .into_any_element() }, { let lsp_logs = lsp_logs.clone(); + let message = message.clone(); + let server_selector = server_selector.clone(); + let server_name = server_info.name.clone(); + let workspace = self.workspace.clone(); move |window, cx| { - if !has_logs { + if has_logs { + lsp_logs.update(cx, |lsp_logs, cx| { + lsp_logs.open_server_trace( + workspace.clone(), + server_selector.clone(), + window, + cx, + ); + }); + } else if let Some(message) = &message { + let Some(create_buffer) = workspace + .update(cx, |workspace, cx| { + workspace + .project() + .update(cx, |project, cx| project.create_buffer(cx)) + }) + .ok() + else { + return; + }; + + let window = window.window_handle(); + let workspace = workspace.clone(); + let message = message.clone(); + let server_name = server_name.clone(); + cx.spawn(async move |cx| { + let buffer = create_buffer.await?; + buffer.update(cx, |buffer, cx| { + buffer.edit( + [( + 0..0, + format!("Language server {server_name}:\n\n{message}"), + )], + None, + cx, + ); + buffer.set_capability(language::Capability::ReadOnly, cx); + })?; + + workspace.update(cx, |workspace, cx| { + window.update(cx, |_, window, cx| { + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + let mut editor = + Editor::for_buffer(buffer, None, window, cx); + editor.set_read_only(true); + editor + })), + None, + true, + window, + cx, + ); + }) + })??; + + anyhow::Ok(()) + }) + .detach(); + } else { cx.propagate(); return; } - lsp_logs.update(cx, |lsp_logs, cx| { - lsp_logs.open_server_trace( - workspace.clone(), - server_selector.clone(), - window, - cx, - ); - }); } }, - server_info.message.map(|server_message| { + message.map(|server_message| { DocumentationAside::new( DocumentationSide::Right, Rc::new(move |_| Label::new(server_message.clone()).into_any_element()), @@ -345,81 +418,95 @@ impl LanguageServers { #[derive(Debug)] enum ServerData<'a> { - WithHealthCheck( - LanguageServerId, - &'a LanguageServerHealthStatus, - Option<&'a LanguageServerBinaryStatus>, - ), - WithBinaryStatus( - Option, - &'a LanguageServerName, - &'a LanguageServerBinaryStatus, - ), + WithHealthCheck { + server_id: LanguageServerId, + health: &'a LanguageServerHealthStatus, + binary_status: Option<&'a LanguageServerBinaryStatus>, + }, + WithBinaryStatus { + server_id: Option, + server_name: &'a LanguageServerName, + binary_status: &'a LanguageServerBinaryStatus, + }, } #[derive(Debug)] -enum LspItem { - WithHealthCheck( - LanguageServerId, - LanguageServerHealthStatus, - Option, - ), - WithBinaryStatus( - Option, - LanguageServerName, - LanguageServerBinaryStatus, - ), +enum LspMenuItem { + WithHealthCheck { + server_id: LanguageServerId, + health: LanguageServerHealthStatus, + binary_status: Option, + }, + WithBinaryStatus { + server_id: Option, + server_name: LanguageServerName, + binary_status: LanguageServerBinaryStatus, + }, ToggleServersButton { restart: bool, }, + Header { + header: Option, + separator: bool, + }, } -impl LspItem { +impl LspMenuItem { fn server_info(&self) -> Option { match self { - LspItem::ToggleServersButton { .. } => None, - LspItem::WithHealthCheck( - language_server_id, - language_server_health_status, - language_server_binary_status, - ) => Some(ServerInfo { - name: language_server_health_status.name.clone(), - id: Some(*language_server_id), - health: language_server_health_status.health(), - binary_status: language_server_binary_status.clone(), - message: language_server_health_status.message(), + Self::Header { .. } => None, + Self::ToggleServersButton { .. } => None, + Self::WithHealthCheck { + server_id, + health, + binary_status, + .. + } => Some(ServerInfo { + name: health.name.clone(), + id: Some(*server_id), + health: health.health(), + binary_status: binary_status.clone(), + message: health.message(), }), - LspItem::WithBinaryStatus( + Self::WithBinaryStatus { server_id, - language_server_name, - language_server_binary_status, - ) => Some(ServerInfo { - name: language_server_name.clone(), + server_name, + binary_status, + .. + } => Some(ServerInfo { + name: server_name.clone(), id: *server_id, health: None, - binary_status: Some(language_server_binary_status.clone()), - message: language_server_binary_status.message.clone(), + binary_status: Some(binary_status.clone()), + message: binary_status.message.clone(), }), } } } impl ServerData<'_> { - fn name(&self) -> &LanguageServerName { - match self { - Self::WithHealthCheck(_, state, _) => &state.name, - Self::WithBinaryStatus(_, name, ..) => name, - } - } - - fn into_lsp_item(self) -> LspItem { + fn into_lsp_item(self) -> LspMenuItem { match self { - Self::WithHealthCheck(id, name, status) => { - LspItem::WithHealthCheck(id, name.clone(), status.cloned()) - } - Self::WithBinaryStatus(server_id, name, status) => { - LspItem::WithBinaryStatus(server_id, name.clone(), status.clone()) - } + Self::WithHealthCheck { + server_id, + health, + binary_status, + .. + } => LspMenuItem::WithHealthCheck { + server_id, + health: health.clone(), + binary_status: binary_status.cloned(), + }, + Self::WithBinaryStatus { + server_id, + server_name, + binary_status, + .. + } => LspMenuItem::WithBinaryStatus { + server_id, + server_name: server_name.clone(), + binary_status: binary_status.clone(), + }, } } } @@ -452,7 +539,6 @@ impl LspTool { let state = cx.new(|_| LanguageServerState { workspace: workspace.weak_handle(), items: Vec::new(), - other_servers_start_index: None, lsp_store: lsp_store.downgrade(), active_editor: None, language_servers: LanguageServers::default(), @@ -542,13 +628,28 @@ impl LspTool { message: proto::update_language_server::Variant::RegisteredForBuffer(update), .. } => { - self.server_state.update(cx, |state, _| { - state + self.server_state.update(cx, |state, cx| { + let Ok(worktree) = state.workspace.update(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .find_worktree(Path::new(&update.buffer_abs_path), cx) + .map(|(worktree, _)| worktree.downgrade()) + }) else { + return; + }; + let entry = state .language_servers .servers_per_buffer_abs_path .entry(PathBuf::from(&update.buffer_abs_path)) - .or_default() - .insert(*language_server_id, name.clone()); + .or_insert_with(|| ServersForPath { + servers: HashMap::default(), + worktree: worktree.clone(), + }); + entry.servers.insert(*language_server_id, name.clone()); + if worktree.is_some() { + entry.worktree = worktree; + } }); updated = true; } @@ -562,94 +663,95 @@ impl LspTool { fn regenerate_items(&mut self, cx: &mut App) { self.server_state.update(cx, |state, cx| { - let editor_buffers = state + let active_worktrees = state .active_editor .as_ref() - .map(|active_editor| active_editor.editor_buffers.clone()) - .unwrap_or_default(); - let editor_buffer_paths = editor_buffers - .iter() - .filter_map(|buffer_id| { - let buffer_path = state - .lsp_store - .update(cx, |lsp_store, cx| { - Some( - project::File::from_dyn( - lsp_store - .buffer_store() - .read(cx) - .get(*buffer_id)? - .read(cx) - .file(), - )? - .abs_path(cx), - ) + .into_iter() + .flat_map(|active_editor| { + active_editor + .editor + .upgrade() + .into_iter() + .flat_map(|active_editor| { + active_editor + .read(cx) + .buffer() + .read(cx) + .all_buffers() + .into_iter() + .filter_map(|buffer| { + project::File::from_dyn(buffer.read(cx).file()) + }) + .map(|buffer_file| buffer_file.worktree.clone()) }) - .ok()??; - Some(buffer_path) }) - .collect::>(); + .collect::>(); - let mut servers_with_health_checks = HashSet::default(); - let mut server_ids_with_health_checks = HashSet::default(); - let mut buffer_servers = - Vec::with_capacity(state.language_servers.health_statuses.len()); - let mut other_servers = - Vec::with_capacity(state.language_servers.health_statuses.len()); - let buffer_server_ids = editor_buffer_paths - .iter() - .filter_map(|buffer_path| { - state - .language_servers - .servers_per_buffer_abs_path - .get(buffer_path) - }) - .flatten() - .fold(HashMap::default(), |mut acc, (server_id, name)| { - match acc.entry(*server_id) { - hash_map::Entry::Occupied(mut o) => { - let old_name: &mut Option<&LanguageServerName> = o.get_mut(); - if old_name.is_none() { - *old_name = name.as_ref(); - } - } - hash_map::Entry::Vacant(v) => { - v.insert(name.as_ref()); + let mut server_ids_to_worktrees = + HashMap::>::default(); + let mut server_names_to_worktrees = HashMap::< + LanguageServerName, + HashSet<(Entity, LanguageServerId)>, + >::default(); + for servers_for_path in state.language_servers.servers_per_buffer_abs_path.values() { + if let Some(worktree) = servers_for_path + .worktree + .as_ref() + .and_then(|worktree| worktree.upgrade()) + { + for (server_id, server_name) in &servers_for_path.servers { + server_ids_to_worktrees.insert(*server_id, worktree.clone()); + if let Some(server_name) = server_name { + server_names_to_worktrees + .entry(server_name.clone()) + .or_default() + .insert((worktree.clone(), *server_id)); } } - acc + } + } + + let mut servers_per_worktree = BTreeMap::>::new(); + let mut servers_without_worktree = Vec::::new(); + let mut servers_with_health_checks = HashSet::default(); + + for (server_id, health) in &state.language_servers.health_statuses { + let worktree = server_ids_to_worktrees.get(server_id).or_else(|| { + let worktrees = server_names_to_worktrees.get(&health.name)?; + worktrees + .iter() + .find(|(worktree, _)| active_worktrees.contains(worktree)) + .or_else(|| worktrees.iter().next()) + .map(|(worktree, _)| worktree) }); - for (server_id, server_state) in &state.language_servers.health_statuses { - let binary_status = state - .language_servers - .binary_statuses - .get(&server_state.name); - servers_with_health_checks.insert(&server_state.name); - server_ids_with_health_checks.insert(*server_id); - if buffer_server_ids.contains_key(server_id) { - buffer_servers.push(ServerData::WithHealthCheck( - *server_id, - server_state, - binary_status, - )); - } else { - other_servers.push(ServerData::WithHealthCheck( - *server_id, - server_state, - binary_status, - )); + servers_with_health_checks.insert(&health.name); + let worktree_name = + worktree.map(|worktree| SharedString::new(worktree.read(cx).root_name())); + + let binary_status = state.language_servers.binary_statuses.get(&health.name); + let server_data = ServerData::WithHealthCheck { + server_id: *server_id, + health, + binary_status, + }; + match worktree_name { + Some(worktree_name) => servers_per_worktree + .entry(worktree_name.clone()) + .or_default() + .push(server_data), + None => servers_without_worktree.push(server_data), } } let mut can_stop_all = !state.language_servers.health_statuses.is_empty(); let mut can_restart_all = state.language_servers.health_statuses.is_empty(); - for (server_name, status) in state + for (server_name, binary_status) in state .language_servers .binary_statuses .iter() .filter(|(name, _)| !servers_with_health_checks.contains(name)) { - match status.status { + match binary_status.status { BinaryStatus::None => { can_restart_all = false; can_stop_all |= true; @@ -674,52 +776,73 @@ impl LspTool { BinaryStatus::Failed { .. } => {} } - let matching_server_id = state - .language_servers - .servers_per_buffer_abs_path - .iter() - .filter(|(path, _)| editor_buffer_paths.contains(path)) - .flat_map(|(_, server_associations)| server_associations.iter()) - .find_map(|(id, name)| { - if name.as_ref() == Some(server_name) { - Some(*id) - } else { - None + match server_names_to_worktrees.get(server_name) { + Some(worktrees_for_name) => { + match worktrees_for_name + .iter() + .find(|(worktree, _)| active_worktrees.contains(worktree)) + .or_else(|| worktrees_for_name.iter().next()) + { + Some((worktree, server_id)) => { + let worktree_name = + SharedString::new(worktree.read(cx).root_name()); + servers_per_worktree + .entry(worktree_name.clone()) + .or_default() + .push(ServerData::WithBinaryStatus { + server_name, + binary_status, + server_id: Some(*server_id), + }); + } + None => servers_without_worktree.push(ServerData::WithBinaryStatus { + server_name, + binary_status, + server_id: None, + }), } - }); - if let Some(server_id) = matching_server_id { - buffer_servers.push(ServerData::WithBinaryStatus( - Some(server_id), + } + None => servers_without_worktree.push(ServerData::WithBinaryStatus { server_name, - status, - )); - } else { - other_servers.push(ServerData::WithBinaryStatus(None, server_name, status)); + binary_status, + server_id: None, + }), } } - buffer_servers.sort_by_key(|data| data.name().clone()); - other_servers.sort_by_key(|data| data.name().clone()); - - let mut other_servers_start_index = None; let mut new_lsp_items = - Vec::with_capacity(buffer_servers.len() + other_servers.len() + 1); - new_lsp_items.extend(buffer_servers.into_iter().map(ServerData::into_lsp_item)); - if !new_lsp_items.is_empty() { - other_servers_start_index = Some(new_lsp_items.len()); + Vec::with_capacity(servers_per_worktree.len() + servers_without_worktree.len() + 2); + for (worktree_name, worktree_servers) in servers_per_worktree { + if worktree_servers.is_empty() { + continue; + } + new_lsp_items.push(LspMenuItem::Header { + header: Some(worktree_name), + separator: false, + }); + new_lsp_items.extend(worktree_servers.into_iter().map(ServerData::into_lsp_item)); + } + if !servers_without_worktree.is_empty() { + new_lsp_items.push(LspMenuItem::Header { + header: Some(SharedString::from("Unknown worktree")), + separator: false, + }); + new_lsp_items.extend( + servers_without_worktree + .into_iter() + .map(ServerData::into_lsp_item), + ); } - new_lsp_items.extend(other_servers.into_iter().map(ServerData::into_lsp_item)); if !new_lsp_items.is_empty() { if can_stop_all { - new_lsp_items.push(LspItem::ToggleServersButton { restart: true }); - new_lsp_items.push(LspItem::ToggleServersButton { restart: false }); + new_lsp_items.push(LspMenuItem::ToggleServersButton { restart: true }); + new_lsp_items.push(LspMenuItem::ToggleServersButton { restart: false }); } else if can_restart_all { - new_lsp_items.push(LspItem::ToggleServersButton { restart: true }); + new_lsp_items.push(LspMenuItem::ToggleServersButton { restart: true }); } } state.items = new_lsp_items; - state.other_servers_start_index = other_servers_start_index; }); } @@ -841,10 +964,7 @@ impl StatusItemView for LspTool { impl Render for LspTool { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl ui::IntoElement { - if !cx.is_staff() - || self.server_state.read(cx).language_servers.is_empty() - || self.lsp_menu.is_none() - { + if self.server_state.read(cx).language_servers.is_empty() || self.lsp_menu.is_none() { return div(); } @@ -852,12 +972,12 @@ impl Render for LspTool { let mut has_warnings = false; let mut has_other_notifications = false; let state = self.server_state.read(cx); - for server in state.language_servers.health_statuses.values() { - if let Some(binary_status) = &state.language_servers.binary_statuses.get(&server.name) { - has_errors |= matches!(binary_status.status, BinaryStatus::Failed { .. }); - has_other_notifications |= binary_status.message.is_some(); - } + for binary_status in state.language_servers.binary_statuses.values() { + has_errors |= matches!(binary_status.status, BinaryStatus::Failed { .. }); + has_other_notifications |= binary_status.message.is_some(); + } + for server in state.language_servers.health_statuses.values() { if let Some((message, health)) = &server.health { has_other_notifications |= message.is_some(); match health { From 589af59dfe6fe5f54e37c4b99f9aa944ff864f30 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 21 Jul 2025 20:02:28 +0200 Subject: [PATCH 03/21] collab: Refresh the LLM token once the terms of service have been accepted (#34833) Release Notes: - N/A --- crates/collab/src/rpc.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 7a454e11cfced2fa7f9f1dc8c0263934830c7cad..924784109b1de0a56abca60c4866ab137d14e7c3 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -4167,6 +4167,13 @@ async fn accept_terms_of_service( response.send(proto::AcceptTermsOfServiceResponse { accepted_tos_at: accepted_tos_at.timestamp() as u64, })?; + + // When the user accepts the terms of service, we want to refresh their LLM + // token to grant access. + session + .peer + .send(session.connection_id, proto::RefreshLlmToken {})?; + Ok(()) } From da8bf9ad795312fb83eb030586c9e369852d1f38 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 21 Jul 2025 14:32:22 -0400 Subject: [PATCH 04/21] Auto-retry agent errors by default (#34842) Now we explicitly carve out exceptions for which HTTP responses we do *not* retry for, and retry at least once on all others. Release Notes: - The Agent panel now automatically retries failed requests under more circumstances. --- crates/agent/src/thread.rs | 75 ++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 180cc88390eb21d96c5109fac5802f42e425f83a..e50763535a461bef6769b4f7c1aadeb2f219b904 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -51,7 +51,7 @@ use util::{ResultExt as _, debug_panic, post_inc}; use uuid::Uuid; use zed_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; -const MAX_RETRY_ATTEMPTS: u8 = 3; +const MAX_RETRY_ATTEMPTS: u8 = 4; const BASE_RETRY_DELAY: Duration = Duration::from_secs(5); #[derive(Debug, Clone)] @@ -2182,8 +2182,8 @@ impl Thread { // General strategy here: // - If retrying won't help (e.g. invalid API key or payload too large), return None so we don't retry at all. - // - If it's a time-based issue (e.g. server overloaded, rate limit exceeded), try multiple times with exponential backoff. - // - If it's an issue that *might* be fixed by retrying (e.g. internal server error), just retry once. + // - If it's a time-based issue (e.g. server overloaded, rate limit exceeded), retry up to 4 times with exponential backoff. + // - If it's an issue that *might* be fixed by retrying (e.g. internal server error), retry up to 3 times. match error { HttpResponseError { status_code: StatusCode::TOO_MANY_REQUESTS, @@ -2211,8 +2211,8 @@ impl Thread { } StatusCode::INTERNAL_SERVER_ERROR => Some(RetryStrategy::Fixed { delay: retry_after.unwrap_or(BASE_RETRY_DELAY), - // Internal Server Error could be anything, so only retry once. - max_attempts: 1, + // Internal Server Error could be anything, retry up to 3 times. + max_attempts: 3, }), status => { // There is no StatusCode variant for the unofficial HTTP 529 ("The service is overloaded"), @@ -2223,20 +2223,23 @@ impl Thread { max_attempts: MAX_RETRY_ATTEMPTS, }) } else { - None + Some(RetryStrategy::Fixed { + delay: retry_after.unwrap_or(BASE_RETRY_DELAY), + max_attempts: 2, + }) } } }, ApiInternalServerError { .. } => Some(RetryStrategy::Fixed { delay: BASE_RETRY_DELAY, - max_attempts: 1, + max_attempts: 3, }), ApiReadResponseError { .. } | HttpSend { .. } | DeserializeResponse { .. } | BadRequestFormat { .. } => Some(RetryStrategy::Fixed { delay: BASE_RETRY_DELAY, - max_attempts: 1, + max_attempts: 3, }), // Retrying these errors definitely shouldn't help. HttpResponseError { @@ -2244,24 +2247,31 @@ impl Thread { StatusCode::PAYLOAD_TOO_LARGE | StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED, .. } - | SerializeRequest { .. } + | AuthenticationError { .. } + | PermissionError { .. } => None, + // These errors might be transient, so retry them + SerializeRequest { .. } | BuildRequestBody { .. } | PromptTooLarge { .. } - | AuthenticationError { .. } - | PermissionError { .. } | ApiEndpointNotFound { .. } - | NoApiKey { .. } => None, + | NoApiKey { .. } => Some(RetryStrategy::Fixed { + delay: BASE_RETRY_DELAY, + max_attempts: 2, + }), // Retry all other 4xx and 5xx errors once. HttpResponseError { status_code, .. } if status_code.is_client_error() || status_code.is_server_error() => { Some(RetryStrategy::Fixed { delay: BASE_RETRY_DELAY, - max_attempts: 1, + max_attempts: 3, }) } // Conservatively assume that any other errors are non-retryable - HttpResponseError { .. } | Other(..) => None, + HttpResponseError { .. } | Other(..) => Some(RetryStrategy::Fixed { + delay: BASE_RETRY_DELAY, + max_attempts: 2, + }), } } @@ -4352,7 +4362,7 @@ fn main() {{ let retry_state = thread.retry_state.as_ref().unwrap(); assert_eq!(retry_state.attempt, 1, "Should be first retry attempt"); assert_eq!( - retry_state.max_attempts, 1, + retry_state.max_attempts, 3, "Should have correct max attempts" ); }); @@ -4368,8 +4378,9 @@ fn main() {{ if let MessageSegment::Text(text) = seg { text.contains("internal") && text.contains("Fake") - && text.contains("Retrying in") - && !text.contains("attempt") + && text.contains("Retrying") + && text.contains("attempt 1 of 3") + && text.contains("seconds") } else { false } @@ -4464,8 +4475,8 @@ fn main() {{ let retry_state = thread.retry_state.as_ref().unwrap(); assert_eq!(retry_state.attempt, 1, "Should be first retry attempt"); assert_eq!( - retry_state.max_attempts, 1, - "Internal server errors should only retry once" + retry_state.max_attempts, 3, + "Internal server errors should retry up to 3 times" ); }); @@ -4473,7 +4484,15 @@ fn main() {{ cx.executor().advance_clock(BASE_RETRY_DELAY); cx.run_until_parked(); - // Should have scheduled second retry - count retry messages + // Advance clock for second retry + cx.executor().advance_clock(BASE_RETRY_DELAY); + cx.run_until_parked(); + + // Advance clock for third retry + cx.executor().advance_clock(BASE_RETRY_DELAY); + cx.run_until_parked(); + + // Should have completed all retries - count retry messages let retry_count = thread.update(cx, |thread, _| { thread .messages @@ -4491,24 +4510,24 @@ fn main() {{ .count() }); assert_eq!( - retry_count, 1, - "Should have only one retry for internal server errors" + retry_count, 3, + "Should have 3 retries for internal server errors" ); - // For internal server errors, we only retry once and then give up - // Check that retry_state is cleared after the single retry + // For internal server errors, we retry 3 times and then give up + // Check that retry_state is cleared after all retries thread.read_with(cx, |thread, _| { assert!( thread.retry_state.is_none(), - "Retry state should be cleared after single retry" + "Retry state should be cleared after all retries" ); }); - // Verify total attempts (1 initial + 1 retry) + // Verify total attempts (1 initial + 3 retries) assert_eq!( *completion_count.lock(), - 2, - "Should have attempted once plus 1 retry" + 4, + "Should have attempted once plus 3 retries" ); } From 3e50d997ddfb5e0fa483c4010cf1b1f4de6fc93c Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 21 Jul 2025 20:44:45 +0200 Subject: [PATCH 05/21] agent: Fix double-lease panic when clicking on thread to jump (#34843) Closes #ISSUE Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/agent_ui/src/active_thread.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/agent_ui/src/active_thread.rs b/crates/agent_ui/src/active_thread.rs index 14e7cf05b51b8f302d58ee928c7c0bbde0d4fc31..bfed81f5b7e07baf7b2f4db489742ce0944cf538 100644 --- a/crates/agent_ui/src/active_thread.rs +++ b/crates/agent_ui/src/active_thread.rs @@ -3724,8 +3724,11 @@ pub(crate) fn open_context( AgentContextHandle::Thread(thread_context) => workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.open_thread(thread_context.thread.clone(), window, cx); + let thread = thread_context.thread.clone(); + window.defer(cx, move |window, cx| { + panel.update(cx, |panel, cx| { + panel.open_thread(thread, window, cx); + }); }); } }), @@ -3733,8 +3736,11 @@ pub(crate) fn open_context( AgentContextHandle::TextThread(text_thread_context) => { workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.open_prompt_editor(text_thread_context.context.clone(), window, cx) + let context = text_thread_context.context.clone(); + window.defer(cx, move |window, cx| { + panel.update(cx, |panel, cx| { + panel.open_prompt_editor(context, window, cx) + }); }); } }) From 6ea09beea8b5038c6101f0fed2715acb80faa735 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 21 Jul 2025 20:51:27 +0200 Subject: [PATCH 06/21] terminal: Handle spaces in cwds of remote terminals (#34844) Closes #34807 Release Notes: - Fixed "Open in terminal" action not working with paths that contain spaces in SSH projects. --- crates/project/src/terminals.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 3d62b4156b7b96caade454d5d05a3c02c44dae8c..8cfbdff31183cc6763455e4cbe5acf635440343e 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -662,7 +662,7 @@ pub fn wrap_for_ssh( format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}") } else { - format!("cd {path}; {env_changes} {to_run}") + format!("cd \"{path}\"; {env_changes} {to_run}") } } else { format!("cd; {env_changes} {to_run}") From 241acbe4be9dff7320a10817faafb9fd2518049d Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Mon, 21 Jul 2025 15:02:02 -0400 Subject: [PATCH 07/21] Stop onboarding page from showing up instead of welcome page (#34845) This is from PR #34723 where I was working on developing the onboarding page, but I forgot to switch the first page back to our current version. Release Notes: - N/A --- crates/zed/src/main.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 9d85923ca2f8e837c2e1f280c145947e43404594..a9d3d63381dfbb24bbc9e7882e1e035887dccecb 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -24,7 +24,6 @@ use reqwest_client::ReqwestClient; use assets::Assets; use node_runtime::{NodeBinaryOptions, NodeRuntime}; -use onboarding::show_onboarding_view; use parking_lot::Mutex; use project::project_settings::ProjectSettings; use recent_projects::{SshSettings, open_ssh_project}; @@ -1041,10 +1040,6 @@ async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp ); } } - } else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) { - let state = app_state.clone(); - cx.update(|cx| show_onboarding_view(state, cx))?.await?; - // cx.update(|cx| show_welcome_view(app_state, cx))?.await?; } else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) { cx.update(|cx| show_welcome_view(app_state, cx))?.await?; } else { From 1a1715766f0f5af9d1ae5df23d58cb1763bd886d Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 21 Jul 2025 14:16:47 -0500 Subject: [PATCH 08/21] Fix enter to select model in agent panel (#34846) Broken by #34664 Release Notes: - N/A *or* Added/Fixed/Improved ... --- assets/keymaps/default-linux.json | 4 ++-- assets/keymaps/default-macos.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 6aba27fec8fee9ed601e6bae43d575a5d050f95b..9585bcdecd297ba7ef9c14d237804fd53d45849e 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -277,7 +277,7 @@ } }, { - "context": "MessageEditor > Editor && !use_modifier_to_send", + "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", "bindings": { "enter": "agent::Chat", "ctrl-enter": "agent::ChatWithFollow", @@ -288,7 +288,7 @@ } }, { - "context": "MessageEditor > Editor && use_modifier_to_send", + "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", "bindings": { "ctrl-enter": "agent::Chat", "enter": "editor::Newline", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ba903c07821fec6fad805e3a4b1cac81831216ed..9a72bc912cfbb11ecaf7fee3d71bbd0d5fd8d602 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -318,7 +318,7 @@ } }, { - "context": "MessageEditor > Editor && !use_modifier_to_send", + "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", "use_key_equivalents": true, "bindings": { "enter": "agent::Chat", @@ -330,7 +330,7 @@ } }, { - "context": "MessageEditor > Editor && use_modifier_to_send", + "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", "use_key_equivalents": true, "bindings": { "cmd-enter": "agent::Chat", From 8eca7f32e2041c648c3c9b0ac1e8b686fec4e5df Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Mon, 21 Jul 2025 16:09:02 -0400 Subject: [PATCH 09/21] Fix for vim bindings in Pickers on Linux (#34840) Closes: https://github.com/zed-industries/zed/issues/34780 Also relocated undo/redo selection in the keymap (no-op) as they are from Sublime, not VSCode. Release Notes: - vim: Fixed an issue so `ctrl-w` / `ctrl-h` and `ctrl-u` work in pickers on Linux when Vim mode is enabled. --- assets/keymaps/default-linux.json | 4 ++-- assets/keymaps/default-macos.json | 4 ++-- assets/keymaps/vim.json | 8 ++++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 9585bcdecd297ba7ef9c14d237804fd53d45849e..377a26242b995a3db4d67a9c9dea082c9c11b93d 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -484,8 +484,6 @@ "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch "ctrl-k ctrl-i": "editor::Hover", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], - "ctrl-u": "editor::UndoSelection", - "ctrl-shift-u": "editor::RedoSelection", "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", @@ -663,6 +661,8 @@ { "context": "Editor", "bindings": { + "ctrl-u": "editor::UndoSelection", + "ctrl-shift-u": "editor::RedoSelection", "ctrl-shift-j": "editor::JoinLines", "ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart", "ctrl-alt-h": "editor::DeleteToPreviousSubwordStart", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 9a72bc912cfbb11ecaf7fee3d71bbd0d5fd8d602..712d73b8ec5d6dfd3aa6abb5c1420f3e81c2ee0a 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -538,8 +538,6 @@ "cmd-k ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch "cmd-k cmd-i": "editor::Hover", "cmd-/": ["editor::ToggleComments", { "advance_downwards": false }], - "cmd-u": "editor::UndoSelection", - "cmd-shift-u": "editor::RedoSelection", "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", @@ -726,6 +724,8 @@ "context": "Editor", "use_key_equivalents": true, "bindings": { + "cmd-u": "editor::UndoSelection", + "cmd-shift-u": "editor::RedoSelection", "ctrl-j": "editor::JoinLines", "ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart", "ctrl-alt-h": "editor::DeleteToPreviousSubwordStart", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 2ef282c21edc87fe5f062f8083296b43cc0a571d..04e6b0bcd40720067dea4b8ecaf2bdb72adcdc2d 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -858,6 +858,14 @@ "shift-n": null } }, + { + "context": "Picker > Editor", + "bindings": { + "ctrl-h": "editor::Backspace", + "ctrl-u": "editor::DeleteToBeginningOfLine", + "ctrl-w": "editor::DeleteToPreviousWordStart" + } + }, { "context": "GitCommit > Editor && VimControl && vim_mode == normal", "bindings": { From 5b3e37181240d8ed1ef8e04aa0593c7ca5f46a6c Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 21 Jul 2025 22:24:33 +0200 Subject: [PATCH 10/21] gpui: Round `scroll_max` to two decimal places (#34832) Follow up to #31836 After enabling rounding in the Taffy layout engine, we frequently run into cases where the bounds produced by Taffy and ours slightly differ after 5 or more decimal places. This leads to cases where containers become scrollable for less than 0.0000x Pixels. In case this happens for e.g. hover popovers, we render a scrollbar due to the container being technically scrollable, even though the scroll amount here will in practice never be visible. This change fixes this by rounding the `scroll_max` by which we clamp the current scroll position to two decimal places. We don't benefit from the additional floating point precision here at all and it stops such containers from becoming scrollable altogether. Furthermore, we now store the `scroll_max` instead of the `padded_content_size` as the former gives a much better idea on whether the corresponding container is scrollable or not. | `main` | After these changes | | -- | -- | | main | scroll_max_rounded | Release Notes: - Fixed an issue where scrollbars would appear in containers where no scrolling was possible. --- crates/gpui/src/elements/div.rs | 29 ++++++++++++----- crates/gpui/src/elements/list.rs | 6 ++-- .../terminal_view/src/terminal_scrollbar.rs | 11 +++++-- crates/ui/src/components/scrollbar.rs | 31 ++++++++++--------- crates/workspace/src/pane.rs | 11 +++---- 5 files changed, 55 insertions(+), 33 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index ed1666c53060dfdf3ed4c10a85a730d69f87986d..4655c92409d3f21fd8a2a919154368a56da9567e 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1664,6 +1664,11 @@ impl Interactivity { window: &mut Window, _cx: &mut App, ) -> Point { + fn round_to_two_decimals(pixels: Pixels) -> Pixels { + const ROUNDING_FACTOR: f32 = 100.0; + (pixels * ROUNDING_FACTOR).round() / ROUNDING_FACTOR + } + if let Some(scroll_offset) = self.scroll_offset.as_ref() { let mut scroll_to_bottom = false; let mut tracked_scroll_handle = self @@ -1678,8 +1683,16 @@ impl Interactivity { let rem_size = window.rem_size(); let padding = style.padding.to_pixels(bounds.size.into(), rem_size); let padding_size = size(padding.left + padding.right, padding.top + padding.bottom); + // The floating point values produced by Taffy and ours often vary + // slightly after ~5 decimal places. This can lead to cases where after + // subtracting these, the container becomes scrollable for less than + // 0.00000x pixels. As we generally don't benefit from a precision that + // high for the maximum scroll, we round the scroll max to 2 decimal + // places here. let padded_content_size = self.content_size + padding_size; - let scroll_max = (padded_content_size - bounds.size).max(&Size::default()); + let scroll_max = (padded_content_size - bounds.size) + .map(round_to_two_decimals) + .max(&Default::default()); // Clamp scroll offset in case scroll max is smaller now (e.g., if children // were removed or the bounds became larger). let mut scroll_offset = scroll_offset.borrow_mut(); @@ -1692,7 +1705,7 @@ impl Interactivity { } if let Some(mut scroll_handle_state) = tracked_scroll_handle { - scroll_handle_state.padded_content_size = padded_content_size; + scroll_handle_state.max_offset = scroll_max; } *scroll_offset @@ -2936,7 +2949,7 @@ impl ScrollAnchor { struct ScrollHandleState { offset: Rc>>, bounds: Bounds, - padded_content_size: Size, + max_offset: Size, child_bounds: Vec>, scroll_to_bottom: bool, overflow: Point, @@ -2965,6 +2978,11 @@ impl ScrollHandle { *self.0.borrow().offset.borrow() } + /// Get the maximum scroll offset. + pub fn max_offset(&self) -> Size { + self.0.borrow().max_offset + } + /// Get the top child that's scrolled into view. pub fn top_item(&self) -> usize { let state = self.0.borrow(); @@ -2999,11 +3017,6 @@ impl ScrollHandle { self.0.borrow().child_bounds.get(ix).cloned() } - /// Get the size of the content with padding of the container. - pub fn padded_content_size(&self) -> Size { - self.0.borrow().padded_content_size - } - /// scroll_to_item scrolls the minimal amount to ensure that the child is /// fully visible pub fn scroll_to_item(&self, ix: usize) { diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 35a3b622b2e53028218ce0c42ab0a5ad7f1a4ec3..f24d38794f7611ee173dbe913ed51a53bdef73ed 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -411,9 +411,9 @@ impl ListState { self.0.borrow_mut().set_offset_from_scrollbar(point); } - /// Returns the size of items we have measured. + /// Returns the maximum scroll offset according to the items we have measured. /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly. - pub fn content_size_for_scrollbar(&self) -> Size { + pub fn max_offset_for_scrollbar(&self) -> Size { let state = self.0.borrow(); let bounds = state.last_layout_bounds.unwrap_or_default(); @@ -421,7 +421,7 @@ impl ListState { .scrollbar_drag_start_height .unwrap_or_else(|| state.items.summary().height); - Size::new(bounds.size.width, height) + Size::new(Pixels::ZERO, Pixels::ZERO.max(height - bounds.size.height)) } /// Returns the current scroll offset adjusted for the scrollbar diff --git a/crates/terminal_view/src/terminal_scrollbar.rs b/crates/terminal_view/src/terminal_scrollbar.rs index 18e135be2eef3b8e7ec71c070f2a60a46792a271..c8565a42bee0858e0928e557b9fae590dba319fb 100644 --- a/crates/terminal_view/src/terminal_scrollbar.rs +++ b/crates/terminal_view/src/terminal_scrollbar.rs @@ -46,9 +46,16 @@ impl TerminalScrollHandle { } impl ScrollableHandle for TerminalScrollHandle { - fn content_size(&self) -> Size { + fn max_offset(&self) -> Size { let state = self.state.borrow(); - size(Pixels::ZERO, state.total_lines as f32 * state.line_height) + size( + Pixels::ZERO, + state + .total_lines + .checked_sub(state.viewport_lines) + .unwrap_or(0) as f32 + * state.line_height, + ) } fn offset(&self) -> Point { diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index 2a8c4885acff5f3b5e75c7e2f6ae62335f9b8ebe..17ab2e788f3d7ef37fe99136b99397f200512124 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -29,8 +29,8 @@ impl ThumbState { } impl ScrollableHandle for UniformListScrollHandle { - fn content_size(&self) -> Size { - self.0.borrow().base_handle.content_size() + fn max_offset(&self) -> Size { + self.0.borrow().base_handle.max_offset() } fn set_offset(&self, point: Point) { @@ -47,8 +47,8 @@ impl ScrollableHandle for UniformListScrollHandle { } impl ScrollableHandle for ListState { - fn content_size(&self) -> Size { - self.content_size_for_scrollbar() + fn max_offset(&self) -> Size { + self.max_offset_for_scrollbar() } fn set_offset(&self, point: Point) { @@ -73,8 +73,8 @@ impl ScrollableHandle for ListState { } impl ScrollableHandle for ScrollHandle { - fn content_size(&self) -> Size { - self.padded_content_size() + fn max_offset(&self) -> Size { + self.max_offset() } fn set_offset(&self, point: Point) { @@ -91,7 +91,10 @@ impl ScrollableHandle for ScrollHandle { } pub trait ScrollableHandle: Any + Debug { - fn content_size(&self) -> Size; + fn content_size(&self) -> Size { + self.viewport().size + self.max_offset() + } + fn max_offset(&self) -> Size; fn set_offset(&self, point: Point); fn offset(&self) -> Point; fn viewport(&self) -> Bounds; @@ -149,17 +152,17 @@ impl ScrollbarState { fn thumb_range(&self, axis: ScrollbarAxis) -> Option> { const MINIMUM_THUMB_SIZE: Pixels = px(25.); - let content_size = self.scroll_handle.content_size().along(axis); + let max_offset = self.scroll_handle.max_offset().along(axis); let viewport_size = self.scroll_handle.viewport().size.along(axis); - if content_size.is_zero() || viewport_size.is_zero() || content_size <= viewport_size { + if max_offset.is_zero() || viewport_size.is_zero() { return None; } + let content_size = viewport_size + max_offset; let visible_percentage = viewport_size / content_size; let thumb_size = MINIMUM_THUMB_SIZE.max(viewport_size * visible_percentage); if thumb_size > viewport_size { return None; } - let max_offset = content_size - viewport_size; let current_offset = self .scroll_handle .offset() @@ -307,7 +310,7 @@ impl Element for Scrollbar { let compute_click_offset = move |event_position: Point, - item_size: Size, + max_offset: Size, event_type: ScrollbarMouseEvent| { let viewport_size = padded_bounds.size.along(axis); @@ -323,7 +326,7 @@ impl Element for Scrollbar { - thumb_offset) .clamp(px(0.), viewport_size - thumb_size); - let max_offset = (item_size.along(axis) - viewport_size).max(px(0.)); + let max_offset = max_offset.along(axis); let percentage = if viewport_size > thumb_size { thumb_start / (viewport_size - thumb_size) } else { @@ -347,7 +350,7 @@ impl Element for Scrollbar { } else { let click_offset = compute_click_offset( event.position, - scroll.content_size(), + scroll.max_offset(), ScrollbarMouseEvent::GutterClick, ); scroll.set_offset(scroll.offset().apply_along(axis, |_| click_offset)); @@ -373,7 +376,7 @@ impl Element for Scrollbar { ThumbState::Dragging(drag_state) if event.dragging() => { let drag_offset = compute_click_offset( event.position, - scroll.content_size(), + scroll.max_offset(), ScrollbarMouseEvent::ThumbDrag(drag_state), ); scroll.set_offset(scroll.offset().apply_along(axis, |_| drag_offset)); diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 7cc10c27f714bec6480c44cb241d8012eda138d6..e57b103c61988c4a48f2078cdeb600cc3bd34978 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -18,7 +18,7 @@ use futures::{StreamExt, stream::FuturesUnordered}; use gpui::{ Action, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Corner, Div, DragMoveEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, FocusOutEvent, - Focusable, KeyContext, MouseButton, MouseDownEvent, NavigationDirection, Pixels, Point, + Focusable, IsZero, KeyContext, MouseButton, MouseDownEvent, NavigationDirection, Pixels, Point, PromptLevel, Render, ScrollHandle, Subscription, Task, WeakEntity, WeakFocusHandle, Window, actions, anchored, deferred, prelude::*, }; @@ -46,8 +46,8 @@ use theme::ThemeSettings; use ui::{ ButtonSize, Color, ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButton, IconButtonShape, IconDecoration, IconDecorationKind, IconName, IconSize, Indicator, Label, - PopoverMenu, PopoverMenuHandle, ScrollableHandle, Tab, TabBar, TabPosition, Tooltip, - prelude::*, right_click_menu, + PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, Tooltip, prelude::*, + right_click_menu, }; use util::{ResultExt, debug_panic, maybe, truncate_and_remove_front}; @@ -2865,10 +2865,9 @@ impl Pane { } }) .children(pinned_tabs.len().ne(&0).then(|| { - let content_width = self.tab_bar_scroll_handle.content_size().width; - let viewport_width = self.tab_bar_scroll_handle.viewport().size.width; + let max_scroll = self.tab_bar_scroll_handle.max_offset().width; // We need to check both because offset returns delta values even when the scroll handle is not scrollable - let is_scrollable = content_width > viewport_width; + let is_scrollable = !max_scroll.is_zero(); let is_scrolled = self.tab_bar_scroll_handle.offset().x < px(0.); let has_active_unpinned_tab = self.active_item_index >= self.pinned_tab_count; h_flex() From 722a05bc21aaa451908e208c674a45f0b1139b1c Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Mon, 21 Jul 2025 17:33:59 -0300 Subject: [PATCH 11/21] Wire up stop button in claude threads (#34839) Release Notes: - N/A --------- Co-authored-by: Ben Brandt --- Cargo.lock | 3 + crates/agent_servers/Cargo.toml | 5 + crates/agent_servers/src/claude.rs | 189 +++++++++++++++++++++-------- 3 files changed, 145 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4537d440ccd97b4934164eee3f0bdf0230edd4ec..ad6c40bcf20ccc8cf770313d19deb831d864be3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,7 +150,9 @@ dependencies = [ "indoc", "itertools 0.14.0", "language", + "libc", "log", + "nix 0.29.0", "paths", "project", "schemars", @@ -162,6 +164,7 @@ dependencies = [ "tempfile", "ui", "util", + "uuid", "watch", "which 6.0.3", "workspace-hack", diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index f3df25f70914e95c63265e4c711ca76fd66050b1..4714245b94fd9b519cfe1987817d51ff6ecbc7fd 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -37,10 +37,15 @@ strum.workspace = true tempfile.workspace = true ui.workspace = true util.workspace = true +uuid.workspace = true watch.workspace = true which.workspace = true workspace-hack.workspace = true +[target.'cfg(unix)'.dependencies] +libc.workspace = true +nix.workspace = true + [dev-dependencies] env_logger.workspace = true language.workspace = true diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 8b3d93a122d07448ddbfb9daf2dfc9226fb11545..835efbd6552423e7e5bcd1d321ca193581e5ab0a 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -4,10 +4,13 @@ mod tools; use collections::HashMap; use project::Project; use settings::SettingsStore; +use smol::process::Child; use std::cell::RefCell; use std::fmt::Display; use std::path::Path; +use std::pin::pin; use std::rc::Rc; +use uuid::Uuid; use agentic_coding_protocol::{ self as acp, AnyAgentRequest, AnyAgentResult, Client, ProtocolVersion, @@ -16,7 +19,7 @@ use agentic_coding_protocol::{ use anyhow::{Result, anyhow}; use futures::channel::oneshot; use futures::future::LocalBoxFuture; -use futures::{AsyncBufReadExt, AsyncWriteExt}; +use futures::{AsyncBufReadExt, AsyncWriteExt, SinkExt}; use futures::{ AsyncRead, AsyncWrite, FutureExt, StreamExt, channel::mpsc::{self, UnboundedReceiver, UnboundedSender}, @@ -69,13 +72,12 @@ impl AgentServer for ClaudeCode { let (mut delegate_tx, delegate_rx) = watch::channel(None); let tool_id_map = Rc::new(RefCell::new(HashMap::default())); - let permission_mcp_server = - ClaudeMcpServer::new(delegate_rx, tool_id_map.clone(), cx).await?; + let mcp_server = ClaudeMcpServer::new(delegate_rx, tool_id_map.clone(), cx).await?; let mut mcp_servers = HashMap::default(); mcp_servers.insert( mcp_server::SERVER_NAME.to_string(), - permission_mcp_server.server_config()?, + mcp_server.server_config()?, ); let mcp_config = McpConfig { mcp_servers }; @@ -98,50 +100,58 @@ impl AgentServer for ClaudeCode { anyhow::bail!("Failed to find claude binary"); }; - let mut child = util::command::new_smol_command(&command.path) - .args( - [ - "--input-format", - "stream-json", - "--output-format", - "stream-json", - "--print", - "--verbose", - "--mcp-config", - mcp_config_path.to_string_lossy().as_ref(), - "--permission-prompt-tool", - &format!( - "mcp__{}__{}", - mcp_server::SERVER_NAME, - mcp_server::PERMISSION_TOOL - ), - "--allowedTools", - "mcp__zed__Read,mcp__zed__Edit", - "--disallowedTools", - "Read,Edit", - ] - .into_iter() - .chain(command.args.iter().map(|arg| arg.as_str())), - ) - .current_dir(root_dir) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::inherit()) - .kill_on_drop(true) - .spawn()?; - - let stdin = child.stdin.take().unwrap(); - let stdout = child.stdout.take().unwrap(); - let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded(); let (outgoing_tx, outgoing_rx) = mpsc::unbounded(); + let (cancel_tx, mut cancel_rx) = mpsc::unbounded::>>(); + + let session_id = Uuid::new_v4(); + + log::trace!("Starting session with id: {}", session_id); - let io_task = - ClaudeAgentConnection::handle_io(outgoing_rx, incoming_message_tx, stdin, stdout); cx.background_spawn(async move { - io_task.await.log_err(); + let mut outgoing_rx = Some(outgoing_rx); + let mut mode = ClaudeSessionMode::Start; + + loop { + let mut child = + spawn_claude(&command, mode, session_id, &mcp_config_path, &root_dir) + .await?; + mode = ClaudeSessionMode::Resume; + + let pid = child.id(); + log::trace!("Spawned (pid: {})", pid); + + let mut io_fut = pin!( + ClaudeAgentConnection::handle_io( + outgoing_rx.take().unwrap(), + incoming_message_tx.clone(), + child.stdin.take().unwrap(), + child.stdout.take().unwrap(), + ) + .fuse() + ); + + select_biased! { + done_tx = cancel_rx.next() => { + if let Some(done_tx) = done_tx { + log::trace!("Interrupted (pid: {})", pid); + let result = send_interrupt(pid as i32); + outgoing_rx.replace(io_fut.await?); + done_tx.send(result).log_err(); + continue; + } + } + result = io_fut => { + result?; + } + } + + log::trace!("Stopped (pid: {})", pid); + break; + } + drop(mcp_config_path); - drop(child); + anyhow::Ok(()) }) .detach(); @@ -171,17 +181,32 @@ impl AgentServer for ClaudeCode { delegate, outgoing_tx, end_turn_tx, + cancel_tx, + session_id, _handler_task: handler_task, _mcp_server: None, }; - connection._mcp_server = Some(permission_mcp_server); + connection._mcp_server = Some(mcp_server); acp_thread::AcpThread::new(connection, title, None, project.clone(), cx) }) }) } } +#[cfg(unix)] +fn send_interrupt(pid: libc::pid_t) -> anyhow::Result<()> { + let pid = nix::unistd::Pid::from_raw(pid); + + nix::sys::signal::kill(pid, nix::sys::signal::SIGINT) + .map_err(|e| anyhow!("Failed to interrupt process: {}", e)) +} + +#[cfg(windows)] +fn send_interrupt(_pid: i32) -> anyhow::Result<()> { + panic!("Cancel not implemented on Windows") +} + impl AgentConnection for ClaudeAgentConnection { /// Send a request to the agent and wait for a response. fn request_any( @@ -191,6 +216,8 @@ impl AgentConnection for ClaudeAgentConnection { let delegate = self.delegate.clone(); let end_turn_tx = self.end_turn_tx.clone(); let outgoing_tx = self.outgoing_tx.clone(); + let mut cancel_tx = self.cancel_tx.clone(); + let session_id = self.session_id; async move { match params { // todo: consider sending an empty request so we get the init response? @@ -229,26 +256,83 @@ impl AgentConnection for ClaudeAgentConnection { stop_sequence: None, usage: None, }, - session_id: None, + session_id: Some(session_id), })?; rx.await??; Ok(AnyAgentResult::SendUserMessageResponse( acp::SendUserMessageResponse, )) } - AnyAgentRequest::CancelSendMessageParams(_) => Ok( - AnyAgentResult::CancelSendMessageResponse(acp::CancelSendMessageResponse), - ), + AnyAgentRequest::CancelSendMessageParams(_) => { + let (done_tx, done_rx) = oneshot::channel(); + cancel_tx.send(done_tx).await?; + done_rx.await??; + + Ok(AnyAgentResult::CancelSendMessageResponse( + acp::CancelSendMessageResponse, + )) + } } } .boxed_local() } } +#[derive(Clone, Copy)] +enum ClaudeSessionMode { + Start, + Resume, +} + +async fn spawn_claude( + command: &AgentServerCommand, + mode: ClaudeSessionMode, + session_id: Uuid, + mcp_config_path: &Path, + root_dir: &Path, +) -> Result { + let child = util::command::new_smol_command(&command.path) + .args([ + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--print", + "--verbose", + "--mcp-config", + mcp_config_path.to_string_lossy().as_ref(), + "--permission-prompt-tool", + &format!( + "mcp__{}__{}", + mcp_server::SERVER_NAME, + mcp_server::PERMISSION_TOOL + ), + "--allowedTools", + "mcp__zed__Read,mcp__zed__Edit", + "--disallowedTools", + "Read,Edit", + ]) + .args(match mode { + ClaudeSessionMode::Start => ["--session-id".to_string(), session_id.to_string()], + ClaudeSessionMode::Resume => ["--resume".to_string(), session_id.to_string()], + }) + .args(command.args.iter().map(|arg| arg.as_str())) + .current_dir(root_dir) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true) + .spawn()?; + + Ok(child) +} + struct ClaudeAgentConnection { delegate: AcpClientDelegate, + session_id: Uuid, outgoing_tx: UnboundedSender, end_turn_tx: Rc>>>>, + cancel_tx: UnboundedSender>>, _mcp_server: Option, _handler_task: Task<()>, } @@ -350,7 +434,7 @@ impl ClaudeAgentConnection { incoming_tx: UnboundedSender, mut outgoing_bytes: impl Unpin + AsyncWrite, incoming_bytes: impl Unpin + AsyncRead, - ) -> Result<()> { + ) -> Result> { let mut output_reader = BufReader::new(incoming_bytes); let mut outgoing_line = Vec::new(); let mut incoming_line = String::new(); @@ -384,7 +468,8 @@ impl ClaudeAgentConnection { } } } - Ok(()) + + Ok(outgoing_rx) } } @@ -507,14 +592,14 @@ enum SdkMessage { Assistant { message: Message, // from Anthropic SDK #[serde(skip_serializing_if = "Option::is_none")] - session_id: Option, + session_id: Option, }, // A user message User { message: Message, // from Anthropic SDK #[serde(skip_serializing_if = "Option::is_none")] - session_id: Option, + session_id: Option, }, // Emitted as the last message in a conversation From 19ab1eb79258759e3ee0fef7017d259345b912e6 Mon Sep 17 00:00:00 2001 From: Sergei Surovtsev <97428129+stillonearth@users.noreply.github.com> Date: Tue, 22 Jul 2025 02:16:44 +0300 Subject: [PATCH 12/21] Fix an issue where xkb defined hotkeys for arrows would not work (#34823) Addresses https://github.com/zed-industries/zed/pull/34053#issuecomment-3096447601 where custom-defined arrows would stop working in Zed. How to reproduce: 1. Define custom keyboard layout ```bash cd /usr/share/X11/xkb/symbols/ sudo nano mykbd ``` ``` default partial alphanumeric_keys xkb_symbols "custom" { name[Group1]= "Custom Layout"; key { [ q, Q, Escape, Escape ] }; key { [ w, W, Home, Home ] }; key { [ e, E, Up, Up ] }; key { [ r, R, End, End ] }; key { [ t, T, Tab, Tab ] }; key { [ a, A, Return, Return ] }; key { [ s, S, Left, Left ] }; key { [ d, D, Down, Down ] }; key { [ f, F, Right, Right ] }; key { [ g, G, BackSpace, BackSpace ] }; // include a base layout to inherit the rest include "us(basic)" }; ``` 2. Activate custom layout with win-key as AltGr ```bash setxkbmap mykbd -variant custom -option lv3:win_switch ``` 3. Now Win-S should produce left arrow, Win-F right arrow 4. Test whether it works in Zed Release Notes: - linux: xkb-defined hotkeys for arrow keys should behave as expected. --------- Co-authored-by: Conrad Irwin --- crates/gpui/src/platform/linux/platform.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index a52841e1afe4b0a396c68ef72587777edd5eb14e..d65118e994e4cd488c23436bea8d3666495881ec 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -828,6 +828,13 @@ impl crate::Keystroke { Keysym::Delete => "delete".to_owned(), Keysym::Escape => "escape".to_owned(), + Keysym::Left => "left".to_owned(), + Keysym::Right => "right".to_owned(), + Keysym::Up => "up".to_owned(), + Keysym::Down => "down".to_owned(), + Keysym::Home => "home".to_owned(), + Keysym::End => "end".to_owned(), + _ => { let name = xkb::keysym_get_name(key_sym).to_lowercase(); if key_sym.is_keypad_key() { From 8515487bbce339414940dd2deeac8e962f14118d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 21 Jul 2025 20:39:29 -0300 Subject: [PATCH 13/21] agent: Add new thread start buttons to the empty state (#34829) Release Notes: - N/A --- assets/icons/ai_claude.svg | 4 +- assets/icons/ai_gemini.svg | 4 +- assets/icons/new_from_summary.svg | 7 + assets/icons/new_text_thread.svg | 7 + assets/icons/new_thread.svg | 3 + crates/agent_ui/src/agent_panel.rs | 230 +++++++++++++++++--- crates/agent_ui/src/ui.rs | 2 + crates/agent_ui/src/ui/new_thread_button.rs | 75 +++++++ crates/icons/src/icons.rs | 3 + 9 files changed, 297 insertions(+), 38 deletions(-) create mode 100644 assets/icons/new_from_summary.svg create mode 100644 assets/icons/new_text_thread.svg create mode 100644 assets/icons/new_thread.svg create mode 100644 crates/agent_ui/src/ui/new_thread_button.rs diff --git a/assets/icons/ai_claude.svg b/assets/icons/ai_claude.svg index 423a963eba9b9492a9807082922a4c072786d843..a3e3e1f4cd7bcc4924ed3f8164c35c5c8e2a9c4c 100644 --- a/assets/icons/ai_claude.svg +++ b/assets/icons/ai_claude.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/ai_gemini.svg b/assets/icons/ai_gemini.svg index 60197dc4adcf912128756b32ead43b8b1da61222..bdde44ed2475313f0dfd418a496f372ca61db22d 100644 --- a/assets/icons/ai_gemini.svg +++ b/assets/icons/ai_gemini.svg @@ -1 +1,3 @@ -Google Gemini + + + diff --git a/assets/icons/new_from_summary.svg b/assets/icons/new_from_summary.svg new file mode 100644 index 0000000000000000000000000000000000000000..3b61ca51a08ca8901333d8beb172147b1f1cfcd0 --- /dev/null +++ b/assets/icons/new_from_summary.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/new_text_thread.svg b/assets/icons/new_text_thread.svg new file mode 100644 index 0000000000000000000000000000000000000000..75afa934a028f1bddd104effe536db70ad4f241c --- /dev/null +++ b/assets/icons/new_text_thread.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/new_thread.svg b/assets/icons/new_thread.svg new file mode 100644 index 0000000000000000000000000000000000000000..8c2596a4c9fca9f75a122dc85225f33696320030 --- /dev/null +++ b/assets/icons/new_thread.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 36851e44bac6e2455c19a403e417f9722db6b7c6..57d16d6e59ec9ef83831cfe6ff3ab5eeebc4a354 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::NewExternalAgentThread; use crate::agent_diff::AgentDiffThread; use crate::message_editor::{MAX_EDITOR_LINES, MIN_EDITOR_LINES}; +use crate::ui::NewThreadButton; use crate::{ AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, @@ -66,8 +67,8 @@ use theme::ThemeSettings; use time::UtcOffset; use ui::utils::WithRemSize; use ui::{ - Banner, Callout, ContextMenu, ElevationIndex, KeyBinding, PopoverMenu, PopoverMenuHandle, - ProgressBar, Tab, Tooltip, prelude::*, + Banner, Callout, ContextMenu, ContextMenuEntry, ElevationIndex, KeyBinding, PopoverMenu, + PopoverMenuHandle, ProgressBar, Tab, Tooltip, prelude::*, }; use util::ResultExt as _; use workspace::{ @@ -1906,16 +1907,39 @@ impl AgentPanel { .when(cx.has_flag::(), |this| { this.header("Zed Agent") }) - .action("New Thread", NewThread::default().boxed_clone()) - .action("New Text Thread", NewTextThread.boxed_clone()) + .item( + ContextMenuEntry::new("New Thread") + .icon(IconName::NewThread) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action(NewThread::default().boxed_clone(), cx); + }), + ) + .item( + ContextMenuEntry::new("New Text Thread") + .icon(IconName::NewTextThread) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action(NewTextThread.boxed_clone(), cx); + }), + ) .when_some(active_thread, |this, active_thread| { let thread = active_thread.read(cx); + if !thread.is_empty() { - this.action( - "New From Summary", - Box::new(NewThread { - from_thread_id: Some(thread.id().clone()), - }), + let thread_id = thread.id().clone(); + this.item( + ContextMenuEntry::new("New From Summary") + .icon(IconName::NewFromSummary) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + Box::new(NewThread { + from_thread_id: Some(thread_id.clone()), + }), + cx, + ); + }), ) } else { this @@ -1924,19 +1948,33 @@ impl AgentPanel { .when(cx.has_flag::(), |this| { this.separator() .header("External Agents") - .action( - "New Gemini Thread", - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::Gemini), - } - .boxed_clone(), + .item( + ContextMenuEntry::new("New Gemini Thread") + .icon(IconName::AiGemini) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + NewExternalAgentThread { + agent: Some(crate::ExternalAgent::Gemini), + } + .boxed_clone(), + cx, + ); + }), ) - .action( - "New Claude Code Thread", - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::ClaudeCode), - } - .boxed_clone(), + .item( + ContextMenuEntry::new("New Claude Code Thread") + .icon(IconName::AiClaude) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + NewExternalAgentThread { + agent: Some(crate::ExternalAgent::ClaudeCode), + } + .boxed_clone(), + cx, + ); + }), ) }); menu @@ -2285,6 +2323,28 @@ impl AgentPanel { }))) } + fn render_empty_state_section_header( + &self, + label: impl Into, + action_slot: Option, + cx: &mut Context, + ) -> impl IntoElement { + h_flex() + .mt_2() + .pl_1p5() + .pb_1() + .w_full() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new(label.into()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .children(action_slot) + } + fn render_thread_empty_state( &self, window: &mut Window, @@ -2407,19 +2467,9 @@ impl AgentPanel { .justify_end() .gap_1() .child( - h_flex() - .pl_1p5() - .pb_1() - .w_full() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new("Recent") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( + self.render_empty_state_section_header( + "Recent", + Some( Button::new("view-history", "View All") .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) @@ -2434,8 +2484,11 @@ impl AgentPanel { ) .on_click(move |_event, window, cx| { window.dispatch_action(OpenHistory.boxed_clone(), cx); - }), + }) + .into_any_element(), ), + cx, + ), ) .child( v_flex() @@ -2463,6 +2516,113 @@ impl AgentPanel { }, )), ) + .child(self.render_empty_state_section_header("Start", None, cx)) + .child( + v_flex() + .p_1() + .gap_2() + .child( + h_flex() + .w_full() + .gap_2() + .child( + NewThreadButton::new( + "new-thread-btn", + "New Thread", + IconName::NewThread, + ) + .keybinding(KeyBinding::for_action_in( + &NewThread::default(), + &self.focus_handle(cx), + window, + cx, + )) + .on_click( + |window, cx| { + window.dispatch_action( + NewThread::default().boxed_clone(), + cx, + ) + }, + ), + ) + .child( + NewThreadButton::new( + "new-text-thread-btn", + "New Text Thread", + IconName::NewTextThread, + ) + .keybinding(KeyBinding::for_action_in( + &NewTextThread, + &self.focus_handle(cx), + window, + cx, + )) + .on_click( + |window, cx| { + window.dispatch_action(Box::new(NewTextThread), cx) + }, + ), + ), + ) + .when(cx.has_flag::(), |this| { + this.child( + h_flex() + .w_full() + .gap_2() + .child( + NewThreadButton::new( + "new-gemini-thread-btn", + "New Gemini Thread", + IconName::AiGemini, + ) + // .keybinding(KeyBinding::for_action_in( + // &OpenHistory, + // &self.focus_handle(cx), + // window, + // cx, + // )) + .on_click( + |window, cx| { + window.dispatch_action( + Box::new(NewExternalAgentThread { + agent: Some( + crate::ExternalAgent::Gemini, + ), + }), + cx, + ) + }, + ), + ) + .child( + NewThreadButton::new( + "new-claude-thread-btn", + "New Claude Code Thread", + IconName::AiClaude, + ) + // .keybinding(KeyBinding::for_action_in( + // &OpenHistory, + // &self.focus_handle(cx), + // window, + // cx, + // )) + .on_click( + |window, cx| { + window.dispatch_action( + Box::new(NewExternalAgentThread { + agent: Some( + crate::ExternalAgent::ClaudeCode, + ), + }), + cx, + ) + }, + ), + ), + ) + }), + ) .when_some(configuration_error.as_ref(), |this, err| { this.child(self.render_configuration_error(err, &focus_handle, window, cx)) }) diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index 6398f64abb65bb6c9639c71c59e31e1d1a214bba..15f2e28e5824242c3a6da258c15810263c0d9b83 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -2,6 +2,7 @@ mod agent_notification; mod burn_mode_tooltip; mod context_pill; mod end_trial_upsell; +mod new_thread_button; mod onboarding_modal; pub mod preview; mod upsell; @@ -10,4 +11,5 @@ pub use agent_notification::*; pub use burn_mode_tooltip::*; pub use context_pill::*; pub use end_trial_upsell::*; +pub use new_thread_button::*; pub use onboarding_modal::*; diff --git a/crates/agent_ui/src/ui/new_thread_button.rs b/crates/agent_ui/src/ui/new_thread_button.rs new file mode 100644 index 0000000000000000000000000000000000000000..7764144150762f9b828ea98f1c917332759bd5ad --- /dev/null +++ b/crates/agent_ui/src/ui/new_thread_button.rs @@ -0,0 +1,75 @@ +use gpui::{ClickEvent, ElementId, IntoElement, ParentElement, Styled}; +use ui::prelude::*; + +#[derive(IntoElement)] +pub struct NewThreadButton { + id: ElementId, + label: SharedString, + icon: IconName, + keybinding: Option, + on_click: Option>, +} + +impl NewThreadButton { + pub fn new(id: impl Into, label: impl Into, icon: IconName) -> Self { + Self { + id: id.into(), + label: label.into(), + icon, + keybinding: None, + on_click: None, + } + } + + pub fn keybinding(mut self, keybinding: Option) -> Self { + self.keybinding = keybinding; + self + } + + pub fn on_click(mut self, handler: F) -> Self + where + F: Fn(&mut Window, &mut App) + 'static, + { + self.on_click = Some(Box::new( + move |_: &ClickEvent, window: &mut Window, cx: &mut App| handler(window, cx), + )); + self + } +} + +impl RenderOnce for NewThreadButton { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .id(self.id) + .w_full() + .py_1p5() + .px_2() + .gap_1() + .justify_between() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border.opacity(0.4)) + .bg(cx.theme().colors().element_active.opacity(0.2)) + .hover(|style| { + style + .bg(cx.theme().colors().element_hover) + .border_color(cx.theme().colors().border) + }) + .child( + h_flex() + .gap_1p5() + .child( + Icon::new(self.icon) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child(Label::new(self.label).size(LabelSize::Small)), + ) + .when_some(self.keybinding, |this, keybinding| { + this.child(keybinding.size(rems_from_px(10.))) + }) + .when_some(self.on_click, |this, on_click| { + this.on_click(move |event, window, cx| on_click(event, window, cx)) + }) + } +} diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 631ccc1af3123defdc07c3e5dfb9756c0f235ec1..b85e5b517d6587ffdc39abb2295a2bcf6381fc19 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -181,6 +181,9 @@ pub enum IconName { MicMute, Microscope, Minimize, + NewFromSummary, + NewTextThread, + NewThread, Option, PageDown, PageUp, From 5289b815fe8b386a737b21ec837fb4827381de07 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 21 Jul 2025 19:58:16 -0400 Subject: [PATCH 14/21] ai_onboarding: Send users directly into the trial checkout flow when starting the trial (#34859) This PR makes it so users will be sent immediately into the trial checkout flow (by hitting zed.dev/account/start-trial) when they click the "Start Pro Trial" button. Release Notes: - N/A --- crates/ai_onboarding/src/ai_onboarding.rs | 2 +- crates/client/src/zed_urls.rs | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index f19b8821fa2cbcda063bc9a47f9b7736ef639d8e..9c53078e5a32ee421b88b680c0c7854b34859f42 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -146,7 +146,7 @@ impl ZedAiOnboarding { let (button_label, button_url) = if self.account_too_young { ("Start with Pro", zed_urls::upgrade_to_zed_pro_url(cx)) } else { - ("Start Pro Trial", zed_urls::account_url(cx)) + ("Start Pro Trial", zed_urls::start_trial_url(cx)) }; v_flex() diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 442875b45132c1d7990f82ac93248ebd0477362c..e36f5f65dae646bae361eea34e582c308ea64dae 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -18,6 +18,14 @@ pub fn account_url(cx: &App) -> String { format!("{server_url}/account", server_url = server_url(cx)) } +/// Returns the URL to the start trial page on zed.dev. +pub fn start_trial_url(cx: &App) -> String { + format!( + "{server_url}/account/start-trial", + server_url = server_url(cx) + ) +} + /// Returns the URL to the upgrade page on zed.dev. pub fn upgrade_to_zed_pro_url(cx: &App) -> String { format!("{server_url}/account/upgrade", server_url = server_url(cx)) From 15353630e4f8f1f3db8f230e078de2f42bdbfb7b Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 21 Jul 2025 21:11:11 -0400 Subject: [PATCH 15/21] zed: Add `OpenRequestKind` (#34860) This PR refactors the `OpenRequest` to introduce an `OpenRequestKind` enum. It seems most of the fields on `OpenRequest` are mutually-exclusive, so it is better to model it as an enum rather than using a bunch of `Option`s. There are likely more of the existing fields that can be converted into `OpenRequestKind` variants, but I'm being conservative for this first pass. Release Notes: - N/A --- crates/zed/src/main.rs | 54 ++++++++++++++++------------- crates/zed/src/zed/open_listener.rs | 23 ++++++++---- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index a9d3d63381dfbb24bbc9e7882e1e035887dccecb..c7856931ef0716e80ab34ffeb799eea2399cac15 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -55,6 +55,8 @@ use zed::{ inline_completion_registry, open_paths_with_positions, }; +use crate::zed::OpenRequestKind; + #[cfg(feature = "mimalloc")] #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; @@ -746,32 +748,34 @@ pub fn main() { } fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut App) { - if let Some(connection) = request.cli_connection { - let app_state = app_state.clone(); - cx.spawn(async move |cx| handle_cli_connection(connection, app_state, cx).await) - .detach(); - return; - } - - if let Some(action_index) = request.dock_menu_action { - cx.perform_dock_menu_action(action_index); - return; - } + if let Some(kind) = request.kind { + match kind { + OpenRequestKind::CliConnection(connection) => { + let app_state = app_state.clone(); + cx.spawn(async move |cx| handle_cli_connection(connection, app_state, cx).await) + .detach(); + } + OpenRequestKind::Extension { extension_id } => { + cx.spawn(async move |cx| { + let workspace = + workspace::get_any_active_workspace(app_state, cx.clone()).await?; + workspace.update(cx, |_, window, cx| { + window.dispatch_action( + Box::new(zed_actions::Extensions { + category_filter: None, + id: Some(extension_id), + }), + cx, + ); + }) + }) + .detach_and_log_err(cx); + } + OpenRequestKind::DockMenuAction { index } => { + cx.perform_dock_menu_action(index); + } + } - if let Some(extension) = request.extension_id { - cx.spawn(async move |cx| { - let workspace = workspace::get_any_active_workspace(app_state, cx.clone()).await?; - workspace.update(cx, |_, window, cx| { - window.dispatch_action( - Box::new(zed_actions::Extensions { - category_filter: None, - id: Some(extension), - }), - cx, - ); - }) - }) - .detach_and_log_err(cx); return; } diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 42eb8198a4c091d0ce6dd4ecbae3f0ced7bdf7d3..af646465be2ca6bb33128b1685f4067063ce177f 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -30,14 +30,19 @@ use workspace::{AppState, OpenOptions, SerializedWorkspaceLocation, Workspace}; #[derive(Default, Debug)] pub struct OpenRequest { - pub cli_connection: Option<(mpsc::Receiver, IpcSender)>, + pub kind: Option, pub open_paths: Vec, pub diff_paths: Vec<[String; 2]>, pub open_channel_notes: Vec<(u64, Option)>, pub join_channel: Option, pub ssh_connection: Option, - pub dock_menu_action: Option, - pub extension_id: Option, +} + +#[derive(Debug)] +pub enum OpenRequestKind { + CliConnection((mpsc::Receiver, IpcSender)), + Extension { extension_id: String }, + DockMenuAction { index: usize }, } impl OpenRequest { @@ -45,9 +50,11 @@ impl OpenRequest { let mut this = Self::default(); for url in request.urls { if let Some(server_name) = url.strip_prefix("zed-cli://") { - this.cli_connection = Some(connect_to_cli(server_name)?); + this.kind = Some(OpenRequestKind::CliConnection(connect_to_cli(server_name)?)); } else if let Some(action_index) = url.strip_prefix("zed-dock-action://") { - this.dock_menu_action = Some(action_index.parse()?); + this.kind = Some(OpenRequestKind::DockMenuAction { + index: action_index.parse()?, + }); } else if let Some(file) = url.strip_prefix("file://") { this.parse_file_path(file) } else if let Some(file) = url.strip_prefix("zed://file") { @@ -55,8 +62,10 @@ impl OpenRequest { } else if let Some(file) = url.strip_prefix("zed://ssh") { let ssh_url = "ssh:/".to_string() + file; this.parse_ssh_file_path(&ssh_url, cx)? - } else if let Some(file) = url.strip_prefix("zed://extension/") { - this.extension_id = Some(file.to_string()) + } else if let Some(extension_id) = url.strip_prefix("zed://extension/") { + this.kind = Some(OpenRequestKind::Extension { + extension_id: extension_id.to_string(), + }); } else if url.starts_with("ssh://") { this.parse_ssh_file_path(&url, cx)? } else if let Some(request_path) = parse_zed_link(&url, cx) { From 233e66d35f888a84f76317f5e23ab14a279ae806 Mon Sep 17 00:00:00 2001 From: Daste Date: Tue, 22 Jul 2025 03:30:23 +0200 Subject: [PATCH 16/21] Add `editor::BlameHover` action for triggering the blame popover via keyboard (#32096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the git blame popover available via the keymap by making it an action. The blame popover stays open after being shown via the action, similar to the `editor::Hover` action. I added a default vim-mode key binding for `g b`, which goes in hand with `g h` for hover. I'm not sure what the keybind would be for regular layouts, if any would be set by default. I'm opening this as a draft because I coludn't figure out a way to position the popover correctly above/under the cursor head. I saw some uses of `content_origin` in other places for calculating absolute pixel positions, but I'm not sure how to make use of it here without doing a big refactor of the blame popover code 🤔. I would appreciate some help/tips with positioning, because it seems like the last thing to implement here. Opening as a draft for now because I think without the correct positioning this feature is not complete. Closes https://github.com/zed-industries/zed/discussions/26447 Release Notes: - Added `editor::BlameHover` action for showing the git blame popover under the cursor. By default bound to `ctrl-k ctrl-b` and to `g h` in vim mode. --- assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 1 + assets/keymaps/vim.json | 1 + crates/editor/src/actions.rs | 2 ++ crates/editor/src/editor.rs | 44 ++++++++++++++++++++++++++++--- crates/editor/src/element.rs | 9 +++++-- 6 files changed, 52 insertions(+), 6 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 377a26242b995a3db4d67a9c9dea082c9c11b93d..4918e654fc50e7282cf5ee99228c77381d6997ee 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -483,6 +483,7 @@ "ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch "ctrl-k ctrl-i": "editor::Hover", + "ctrl-k ctrl-b": "editor::BlameHover", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 712d73b8ec5d6dfd3aa6abb5c1420f3e81c2ee0a..60f29b1da148e26d72744f42252f6086894cd5db 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -537,6 +537,7 @@ "ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToPreviousFindMatch "cmd-k ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch "cmd-k cmd-i": "editor::Hover", + "cmd-k cmd-b": "editor::BlameHover", "cmd-/": ["editor::ToggleComments", { "advance_downwards": false }], "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 04e6b0bcd40720067dea4b8ecaf2bdb72adcdc2d..d0cf4621a59d8614022f2a4ba176a79b40f8d331 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -124,6 +124,7 @@ "g r a": "editor::ToggleCodeActions", "g g": "vim::StartOfDocument", "g h": "editor::Hover", + "g B": "editor::BlameHover", "g t": "pane::ActivateNextItem", "g shift-t": "pane::ActivatePreviousItem", "g d": "editor::GoToDefinition", diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 87463d246d2aba96485247467b15025c58f9d5d5..8557b57f4602e35174cc711aa62e2bebf8c8a140 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -322,6 +322,8 @@ actions!( ApplyDiffHunk, /// Deletes the character before the cursor. Backspace, + /// Shows git blame information for the current line. + BlameHover, /// Cancels the current operation. Cancel, /// Cancels the running flycheck operation. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b8dcdd35e07102afd613e99c22a60df6f4699604..1f985eeb7c225be0778cf13de925276583063e16 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -950,6 +950,7 @@ struct InlineBlamePopover { hide_task: Option>, popover_bounds: Option>, popover_state: InlineBlamePopoverState, + keyboard_grace: bool, } enum SelectionDragState { @@ -6517,21 +6518,55 @@ impl Editor { } } + pub fn blame_hover(&mut self, _: &BlameHover, window: &mut Window, cx: &mut Context) { + let snapshot = self.snapshot(window, cx); + let cursor = self.selections.newest::(cx).head(); + let Some((buffer, point, _)) = snapshot.buffer_snapshot.point_to_buffer_point(cursor) + else { + return; + }; + + let Some(blame) = self.blame.as_ref() else { + return; + }; + + let row_info = RowInfo { + buffer_id: Some(buffer.remote_id()), + buffer_row: Some(point.row), + ..Default::default() + }; + let Some(blame_entry) = blame + .update(cx, |blame, cx| blame.blame_for_rows(&[row_info], cx).next()) + .flatten() + else { + return; + }; + + let anchor = self.selections.newest_anchor().head(); + let position = self.to_pixel_point(anchor, &snapshot, window); + if let (Some(position), Some(last_bounds)) = (position, self.last_bounds) { + self.show_blame_popover(&blame_entry, position + last_bounds.origin, true, cx); + }; + } + fn show_blame_popover( &mut self, blame_entry: &BlameEntry, position: gpui::Point, + ignore_timeout: bool, cx: &mut Context, ) { if let Some(state) = &mut self.inline_blame_popover { state.hide_task.take(); } else { - let delay = EditorSettings::get_global(cx).hover_popover_delay; + let blame_popover_delay = EditorSettings::get_global(cx).hover_popover_delay; let blame_entry = blame_entry.clone(); let show_task = cx.spawn(async move |editor, cx| { - cx.background_executor() - .timer(std::time::Duration::from_millis(delay)) - .await; + if !ignore_timeout { + cx.background_executor() + .timer(std::time::Duration::from_millis(blame_popover_delay)) + .await; + } editor .update(cx, |editor, cx| { editor.inline_blame_popover_show_task.take(); @@ -6560,6 +6595,7 @@ impl Editor { commit_message: details, markdown, }, + keyboard_grace: ignore_timeout, }); cx.notify(); }) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index fef185bb156085655c8a144cb3d06c70d8558f2c..cbff544c7e2e4159ae290e37b3fbe8b631696184 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -216,6 +216,7 @@ impl EditorElement { register_action(editor, window, Editor::newline_above); register_action(editor, window, Editor::newline_below); register_action(editor, window, Editor::backspace); + register_action(editor, window, Editor::blame_hover); register_action(editor, window, Editor::delete); register_action(editor, window, Editor::tab); register_action(editor, window, Editor::backtab); @@ -1143,10 +1144,14 @@ impl EditorElement { .as_ref() .and_then(|state| state.popover_bounds) .map_or(false, |bounds| bounds.contains(&event.position)); + let keyboard_grace = editor + .inline_blame_popover + .as_ref() + .map_or(false, |state| state.keyboard_grace); if mouse_over_inline_blame || mouse_over_popover { - editor.show_blame_popover(&blame_entry, event.position, cx); - } else { + editor.show_blame_popover(&blame_entry, event.position, false, cx); + } else if !keyboard_grace { editor.hide_blame_popover(cx); } } else { From 5a530ecd39f8be386a98cabef45c13163ff1da19 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 21 Jul 2025 21:40:33 -0400 Subject: [PATCH 17/21] zed: Add support for `zed://agent` links (#34862) This PR adds support for `zed://agent` links for opening the Agent Panel. Release Notes: - N/A --- crates/zed/src/main.rs | 15 ++++++++++++++- crates/zed/src/zed/open_listener.rs | 3 +++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index c7856931ef0716e80ab34ffeb799eea2399cac15..c9b8eebff6a9001c2350a2e1bc2e56ad6708c5ee 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1,6 +1,7 @@ mod reliability; mod zed; +use agent_ui::AgentPanel; use anyhow::{Context as _, Result}; use clap::{Parser, command}; use cli::FORCE_CLI_MODE_ENV_VAR_NAME; @@ -14,7 +15,7 @@ use extension_host::ExtensionStore; use fs::{Fs, RealFs}; use futures::{StreamExt, channel::oneshot, future}; use git::GitHostingProviderRegistry; -use gpui::{App, AppContext as _, Application, AsyncApp, UpdateGlobal as _}; +use gpui::{App, AppContext as _, Application, AsyncApp, Focusable as _, UpdateGlobal as _}; use gpui_tokio::Tokio; use http_client::{Url, read_proxy_from_env}; @@ -771,6 +772,18 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut }) .detach_and_log_err(cx); } + OpenRequestKind::AgentPanel => { + cx.spawn(async move |cx| { + let workspace = + workspace::get_any_active_workspace(app_state, cx.clone()).await?; + workspace.update(cx, |workspace, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.focus_handle(cx).focus(window); + } + }) + }) + .detach_and_log_err(cx); + } OpenRequestKind::DockMenuAction { index } => { cx.perform_dock_menu_action(index); } diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index af646465be2ca6bb33128b1685f4067063ce177f..b6feb0073e3d46fb43c45e0f17069eef730f2481 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -42,6 +42,7 @@ pub struct OpenRequest { pub enum OpenRequestKind { CliConnection((mpsc::Receiver, IpcSender)), Extension { extension_id: String }, + AgentPanel, DockMenuAction { index: usize }, } @@ -66,6 +67,8 @@ impl OpenRequest { this.kind = Some(OpenRequestKind::Extension { extension_id: extension_id.to_string(), }); + } else if url == "zed://agent" { + this.kind = Some(OpenRequestKind::AgentPanel); } else if url.starts_with("ssh://") { this.parse_ssh_file_path(&url, cx)? } else if let Some(request_path) = parse_zed_link(&url, cx) { From eaccd542fd5e03a93fd1b2f7d02c874f3d491abf Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 22 Jul 2025 02:09:05 -0300 Subject: [PATCH 18/21] Add fast-follows to the AI onboarding flow (#34737) Follow-up to https://github.com/zed-industries/zed/pull/33738. Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner --- crates/agent_ui/src/agent_panel.rs | 30 +- crates/agent_ui/src/message_editor.rs | 23 +- .../src/agent_api_keys_onboarding.rs | 135 +++++++++ .../src/agent_panel_onboarding_card.rs | 8 +- .../src/agent_panel_onboarding_content.rs | 114 ++----- crates/ai_onboarding/src/ai_onboarding.rs | 279 +++++++++++------- .../ai_onboarding/src/young_account_banner.rs | 2 +- crates/client/src/zed_urls.rs | 5 + crates/language_model/src/registry.rs | 4 +- crates/language_models/src/provider/cloud.rs | 12 +- 10 files changed, 403 insertions(+), 209 deletions(-) create mode 100644 crates/ai_onboarding/src/agent_api_keys_onboarding.rs diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 57d16d6e59ec9ef83831cfe6ff3ab5eeebc4a354..fc803c730eab7741ba33c9423e6558e54c8cba8d 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2300,7 +2300,20 @@ impl AgentPanel { return None; } - Some(div().size_full().child(self.onboarding.clone())) + let thread_view = matches!(&self.active_view, ActiveView::Thread { .. }); + let text_thread_view = matches!(&self.active_view, ActiveView::TextThread { .. }); + + Some( + div() + .size_full() + .when(thread_view, |this| { + this.bg(cx.theme().colors().panel_background) + }) + .when(text_thread_view, |this| { + this.bg(cx.theme().colors().editor_background) + }) + .child(self.onboarding.clone()), + ) } fn render_trial_end_upsell( @@ -3237,7 +3250,20 @@ impl Render for AgentPanel { .into_any(), ) }) - .child(h_flex().child(message_editor.clone())) + .child(h_flex().relative().child(message_editor.clone()).when( + !LanguageModelRegistry::read_global(cx).has_authenticated_provider(cx), + |this| { + this.child( + div() + .size_full() + .absolute() + .inset_0() + .bg(cx.theme().colors().panel_background) + .opacity(0.8) + .block_mouse_except_scroll(), + ) + }, + )) .child(self.render_drag_target(cx)), ActiveView::ExternalAgentThread { thread_view, .. } => parent .relative() diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index a2cf4aac48a0eb6e368596bc7458615ea1f008a1..69eae982f8008849d02e4b695c4b00c08cff0b46 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -14,6 +14,7 @@ use agent::{ context_store::ContextStoreEvent, }; use agent_settings::{AgentSettings, CompletionMode}; +use ai_onboarding::ApiKeysWithProviders; use buffer_diff::BufferDiff; use client::UserStore; use collections::{HashMap, HashSet}; @@ -33,7 +34,8 @@ use gpui::{ }; use language::{Buffer, Language, Point}; use language_model::{ - ConfiguredModel, LanguageModelRequestMessage, MessageContent, ZED_CLOUD_PROVIDER_ID, + ConfiguredModel, LanguageModelRegistry, LanguageModelRequestMessage, MessageContent, + ZED_CLOUD_PROVIDER_ID, }; use multi_buffer; use project::Project; @@ -1655,9 +1657,28 @@ impl Render for MessageEditor { let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5; + let enrolled_in_trial = matches!( + self.user_store.read(cx).current_plan(), + Some(proto::Plan::ZedProTrial) + ); + + let configured_providers: Vec<(IconName, SharedString)> = + LanguageModelRegistry::read_global(cx) + .providers() + .iter() + .filter(|provider| { + provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID + }) + .map(|provider| (provider.icon(), provider.name().0.clone())) + .collect(); + let has_existing_providers = configured_providers.len() > 0; + v_flex() .size_full() .bg(cx.theme().colors().panel_background) + .when(has_existing_providers && !enrolled_in_trial, |this| { + this.child(cx.new(ApiKeysWithProviders::new)) + }) .when(changed_buffers.len() > 0, |parent| { parent.child(self.render_edits_bar(&changed_buffers, window, cx)) }) diff --git a/crates/ai_onboarding/src/agent_api_keys_onboarding.rs b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs new file mode 100644 index 0000000000000000000000000000000000000000..4f9e20cf77ed2685241cd72e5971df26cd918563 --- /dev/null +++ b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs @@ -0,0 +1,135 @@ +use gpui::{Action, IntoElement, ParentElement, RenderOnce, point}; +use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; +use ui::{Divider, List, prelude::*}; + +use crate::BulletItem; + +pub struct ApiKeysWithProviders { + configured_providers: Vec<(IconName, SharedString)>, +} + +impl ApiKeysWithProviders { + pub fn new(cx: &mut Context) -> Self { + cx.subscribe( + &LanguageModelRegistry::global(cx), + |this: &mut Self, _registry, event: &language_model::Event, cx| match event { + language_model::Event::ProviderStateChanged + | language_model::Event::AddedProvider(_) + | language_model::Event::RemovedProvider(_) => { + this.configured_providers = Self::compute_configured_providers(cx) + } + _ => {} + }, + ) + .detach(); + + Self { + configured_providers: Self::compute_configured_providers(cx), + } + } + + fn compute_configured_providers(cx: &App) -> Vec<(IconName, SharedString)> { + LanguageModelRegistry::read_global(cx) + .providers() + .iter() + .filter(|provider| { + provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID + }) + .map(|provider| (provider.icon(), provider.name().0.clone())) + .collect() + } + + pub fn has_providers(&self) -> bool { + !self.configured_providers.is_empty() + } +} + +impl Render for ApiKeysWithProviders { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let configured_providers_list = + self.configured_providers + .iter() + .cloned() + .map(|(icon, name)| { + h_flex() + .gap_1p5() + .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) + .child(Label::new(name)) + }); + + h_flex() + .mx_2p5() + .p_1() + .pb_0() + .gap_2() + .rounded_t_lg() + .border_t_1() + .border_x_1() + .border_color(cx.theme().colors().border.opacity(0.5)) + .bg(cx.theme().colors().background.alpha(0.5)) + .shadow(vec![gpui::BoxShadow { + color: gpui::black().opacity(0.15), + offset: point(px(1.), px(-1.)), + blur_radius: px(3.), + spread_radius: px(0.), + }]) + .child( + h_flex() + .px_2p5() + .py_1p5() + .gap_2() + .flex_wrap() + .rounded_t(px(5.)) + .overflow_hidden() + .border_t_1() + .border_x_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().panel_background) + .child(Icon::new(IconName::Info).size(IconSize::XSmall).color(Color::Muted)) + .child(Label::new("Or start now using API keys from your environment for the following providers:").color(Color::Muted)) + .children(configured_providers_list) + ) + } +} + +#[derive(IntoElement)] +pub struct ApiKeysWithoutProviders; + +impl ApiKeysWithoutProviders { + pub fn new() -> Self { + Self + } +} + +impl RenderOnce for ApiKeysWithoutProviders { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + v_flex() + .mt_2() + .gap_1() + .child( + h_flex() + .gap_2() + .child( + Label::new("API Keys") + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx), + ) + .child(Divider::horizontal()), + ) + .child(List::new().child(BulletItem::new( + "You can also use AI in Zed by bringing your own API keys", + ))) + .child( + Button::new("configure-providers", "Configure Providers") + .full_width() + .style(ButtonStyle::Outlined) + .on_click(move |_, window, cx| { + window.dispatch_action( + zed_actions::agent::OpenConfiguration.boxed_clone(), + cx, + ); + }), + ) + } +} diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_card.rs b/crates/ai_onboarding/src/agent_panel_onboarding_card.rs index 8ec9ccfe2230cedd921d3a18d0cb6236a043c716..c63c5926428ab47f80afd2e157f90f8852dbf4ee 100644 --- a/crates/ai_onboarding/src/agent_panel_onboarding_card.rs +++ b/crates/ai_onboarding/src/agent_panel_onboarding_card.rs @@ -24,7 +24,7 @@ impl ParentElement for AgentPanelOnboardingCard { impl RenderOnce for AgentPanelOnboardingCard { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { div() - .m_4() + .m_2p5() .p(px(3.)) .elevation_2(cx) .rounded_lg() @@ -49,6 +49,7 @@ impl RenderOnce for AgentPanelOnboardingCard { .right_0() .w(px(400.)) .h(px(92.)) + .rounded_md() .child( Vector::new( VectorName::AiGrid, @@ -61,11 +62,12 @@ impl RenderOnce for AgentPanelOnboardingCard { .child( div() .absolute() - .top_0() - .right_0() + .top_0p5() + .right_0p5() .w(px(660.)) .h(px(401.)) .overflow_hidden() + .rounded_md() .bg(linear_gradient( 75., linear_color_stop( diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs index f3f7d6c3d7e152ee8e46c6cf28b1d0bc0322c057..771482abf3f5ba871f2955d8579514013c6704f0 100644 --- a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs +++ b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs @@ -1,12 +1,11 @@ use std::sync::Arc; use client::{Client, UserStore}; -use gpui::{Action, ClickEvent, Entity, IntoElement, ParentElement}; +use gpui::{Entity, IntoElement, ParentElement}; use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; -use ui::{Divider, List, prelude::*}; -use zed_actions::agent::{OpenConfiguration, ToggleModelSelector}; +use ui::prelude::*; -use crate::{AgentPanelOnboardingCard, BulletItem, ZedAiOnboarding}; +use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding}; pub struct AgentPanelOnboarding { user_store: Entity, @@ -53,93 +52,34 @@ impl AgentPanelOnboarding { .map(|provider| (provider.icon(), provider.name().0.clone())) .collect() } - - fn configure_providers(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { - window.dispatch_action(OpenConfiguration.boxed_clone(), cx); - cx.notify(); - } - - fn render_api_keys_section(&mut self, cx: &mut Context) -> impl IntoElement { - let has_existing_providers = self.configured_providers.len() > 0; - let configure_provider_label = if has_existing_providers { - "Configure Other Provider" - } else { - "Configure Providers" - }; - - let content = if has_existing_providers { - List::new() - .child(BulletItem::new( - "Or start now using API keys from your environment for the following providers:" - )) - .child( - h_flex() - .px_5() - .gap_2() - .flex_wrap() - .children(self.configured_providers.iter().cloned().map(|(icon, name)| - h_flex() - .gap_1p5() - .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) - .child(Label::new(name)) - )) - ) - .child(BulletItem::new( - "No need for any of the plans or even to sign in", - )) - } else { - List::new() - .child(BulletItem::new( - "You can also use AI in Zed by bringing your own API keys", - )) - .child(BulletItem::new( - "No need for any of the plans or even to sign in", - )) - }; - - v_flex() - .mt_2() - .gap_1() - .child( - h_flex() - .gap_2() - .child( - Label::new("API Keys") - .size(LabelSize::Small) - .color(Color::Muted) - .buffer_font(cx), - ) - .child(Divider::horizontal()), - ) - .child(content) - .when(has_existing_providers, |this| { - this.child( - Button::new("pick-model", "Choose Model") - .full_width() - .style(ButtonStyle::Outlined) - .on_click(|_event, window, cx| { - window.dispatch_action(ToggleModelSelector.boxed_clone(), cx) - }), - ) - }) - .child( - Button::new("configure-providers", configure_provider_label) - .full_width() - .style(ButtonStyle::Outlined) - .on_click(cx.listener(Self::configure_providers)), - ) - } } impl Render for AgentPanelOnboarding { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let enrolled_in_trial = matches!( + self.user_store.read(cx).current_plan(), + Some(proto::Plan::ZedProTrial) + ); + AgentPanelOnboardingCard::new() - .child(ZedAiOnboarding::new( - self.client.clone(), - &self.user_store, - self.continue_with_zed_ai.clone(), - cx, - )) - .child(self.render_api_keys_section(cx)) + .child( + ZedAiOnboarding::new( + self.client.clone(), + &self.user_store, + self.continue_with_zed_ai.clone(), + cx, + ) + .with_dismiss({ + let callback = self.continue_with_zed_ai.clone(); + move |window, cx| callback(window, cx) + }), + ) + .map(|this| { + if enrolled_in_trial || self.configured_providers.len() >= 1 { + this + } else { + this.child(ApiKeysWithoutProviders::new()) + } + }) } } diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 9c53078e5a32ee421b88b680c0c7854b34859f42..88c962c1ba5271c8bf713af72a7aa2492d10c84d 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -1,8 +1,10 @@ +mod agent_api_keys_onboarding; mod agent_panel_onboarding_card; mod agent_panel_onboarding_content; mod edit_prediction_onboarding_content; mod young_account_banner; +pub use agent_api_keys_onboarding::{ApiKeysWithProviders, ApiKeysWithoutProviders}; pub use agent_panel_onboarding_card::AgentPanelOnboardingCard; pub use agent_panel_onboarding_content::AgentPanelOnboarding; pub use edit_prediction_onboarding_content::EditPredictionOnboarding; @@ -12,7 +14,7 @@ use std::sync::Arc; use client::{Client, UserStore, zed_urls}; use gpui::{AnyElement, Entity, IntoElement, ParentElement, SharedString}; -use ui::{Divider, List, ListItem, RegisterComponent, TintColor, prelude::*}; +use ui::{Divider, List, ListItem, RegisterComponent, TintColor, Tooltip, prelude::*}; pub struct BulletItem { label: SharedString, @@ -69,6 +71,7 @@ pub struct ZedAiOnboarding { pub continue_with_zed_ai: Arc, pub sign_in: Arc, pub accept_terms_of_service: Arc, + pub dismiss_onboarding: Option>, } impl ZedAiOnboarding { @@ -80,6 +83,7 @@ impl ZedAiOnboarding { ) -> Self { let store = user_store.read(cx); let status = *client.status().borrow(); + Self { sign_in_status: status.into(), has_accepted_terms_of_service: store.current_user_has_accepted_terms().unwrap_or(false), @@ -102,14 +106,22 @@ impl ZedAiOnboarding { }) .detach(); }), + dismiss_onboarding: None, } } - fn render_free_plan_section(&self, cx: &mut App) -> impl IntoElement { + pub fn with_dismiss( + mut self, + dismiss_callback: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + self.dismiss_onboarding = Some(Arc::new(dismiss_callback)); + self + } + + fn free_plan_definition(&self, cx: &mut App) -> impl IntoElement { v_flex() .mt_2() .gap_1() - .when(self.account_too_young, |this| this.opacity(0.4)) .child( h_flex() .gap_2() @@ -119,6 +131,12 @@ impl ZedAiOnboarding { .color(Color::Muted) .buffer_font(cx), ) + .child( + Label::new("(Current Plan)") + .size(LabelSize::Small) + .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.6))) + .buffer_font(cx), + ) .child(Divider::horizontal()), ) .child( @@ -130,65 +148,89 @@ impl ZedAiOnboarding { "2000 accepted edit predictions using our open-source Zeta model", )), ) - .child( - Button::new("continue", "Continue Free") - .disabled(self.account_too_young) - .full_width() - .style(ButtonStyle::Outlined) - .on_click({ - let callback = self.continue_with_zed_ai.clone(); - move |_, window, cx| callback(window, cx) - }), - ) } - fn render_pro_plan_section(&self, cx: &mut App) -> impl IntoElement { - let (button_label, button_url) = if self.account_too_young { - ("Start with Pro", zed_urls::upgrade_to_zed_pro_url(cx)) - } else { - ("Start Pro Trial", zed_urls::start_trial_url(cx)) - }; + fn pro_trial_definition(&self) -> impl IntoElement { + List::new() + .child(BulletItem::new( + "150 prompts per month with the Claude models", + )) + .child(BulletItem::new( + "Unlimited accepted edit predictions using our open-source Zeta model", + )) + } - v_flex() - .mt_2() - .gap_1() - .child( - h_flex() - .gap_2() - .child( - Label::new("Pro") - .size(LabelSize::Small) - .color(Color::Accent) - .buffer_font(cx), - ) - .child(Divider::horizontal()), - ) - .child( - List::new() - .child(BulletItem::new("500 prompts per month with Claude models")) - .child(BulletItem::new("Unlimited edit predictions")) - .when(!self.account_too_young, |this| { - this.child(BulletItem::new( - "Try it out for 14 days with no charge, no credit card required", + fn pro_plan_definition(&self, cx: &mut App) -> impl IntoElement { + v_flex().mt_2().gap_1().map(|this| { + if self.account_too_young { + this.child( + h_flex() + .gap_2() + .child( + Label::new("Pro") + .size(LabelSize::Small) + .color(Color::Accent) + .buffer_font(cx), + ) + .child(Divider::horizontal()), + ) + .child( + List::new() + .child(BulletItem::new("500 prompts per month with Claude models")) + .child(BulletItem::new( + "Unlimited accepted edit predictions using our open-source Zeta model", )) - }), - ) - .child( - Button::new("pro", button_label) - .full_width() - .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .on_click(move |_, _window, cx| cx.open_url(&button_url)), - ) + .child(BulletItem::new("USD $20 per month")), + ) + .child( + Button::new("pro", "Start with Pro") + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .on_click(move |_, _window, cx| { + cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)) + }), + ) + } else { + this.child( + h_flex() + .gap_2() + .child( + Label::new("Pro Trial") + .size(LabelSize::Small) + .color(Color::Accent) + .buffer_font(cx), + ) + .child(Divider::horizontal()), + ) + .child( + List::new() + .child(self.pro_trial_definition()) + .child(BulletItem::new( + "Try it out for 14 days with no charge and no credit card required", + )), + ) + .child( + Button::new("pro", "Start Pro Trial") + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .on_click(move |_, _window, cx| { + cx.open_url(&zed_urls::start_trial_url(cx)) + }), + ) + } + }) } - fn render_accept_terms_of_service(&self) -> Div { + fn render_accept_terms_of_service(&self) -> AnyElement { v_flex() - .w_full() .gap_1() + .w_full() .child(Headline::new("Before starting…")) - .child(Label::new( - "Make sure you have read and accepted Zed AI's terms of service.", - )) + .child( + Label::new("Make sure you have read and accepted Zed AI's terms of service.") + .color(Color::Muted) + .mb_2(), + ) .child( Button::new("terms_of_service", "View and Read the Terms of Service") .full_width() @@ -196,9 +238,7 @@ impl ZedAiOnboarding { .icon(IconName::ArrowUpRight) .icon_color(Color::Muted) .icon_size(IconSize::XSmall) - .on_click(move |_, _window, cx| { - cx.open_url("https://zed.dev/terms-of-service") - }), + .on_click(move |_, _window, cx| cx.open_url(&zed_urls::terms_of_service(cx))), ) .child( Button::new("accept_terms", "I've read it and accept it") @@ -209,23 +249,23 @@ impl ZedAiOnboarding { move |_, window, cx| (callback)(window, cx) }), ) + .into_any_element() } - fn render_sign_in_disclaimer(&self, _cx: &mut App) -> Div { - const SIGN_IN_DISCLAIMER: &str = - "To start using AI in Zed with our hosted models, sign in and subscribe to a plan."; + fn render_sign_in_disclaimer(&self, _cx: &mut App) -> AnyElement { let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn); v_flex() - .gap_2() + .gap_1() .child(Headline::new("Welcome to Zed AI")) - .child(div().w_full().child(Label::new(SIGN_IN_DISCLAIMER))) .child( - Button::new("sign_in", "Sign In with GitHub") - .icon(IconName::Github) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + Label::new("Sign in to start using AI in Zed with a free trial of the Pro plan, which includes:") + .color(Color::Muted) + .mb_2(), + ) + .child(self.pro_trial_definition()) + .child( + Button::new("sign_in", "Sign in to Start Trial") .disabled(signing_in) .full_width() .style(ButtonStyle::Tinted(ui::TintColor::Accent)) @@ -234,36 +274,55 @@ impl ZedAiOnboarding { move |_, window, cx| callback(window, cx) }), ) + .into_any_element() } - fn render_free_plan_onboarding(&self, cx: &mut App) -> Div { - const PLANS_DESCRIPTION: &str = "Choose how you want to start."; + fn render_free_plan_state(&self, cx: &mut App) -> AnyElement { let young_account_banner = YoungAccountBanner; v_flex() + .relative() + .gap_1() .child(Headline::new("Welcome to Zed AI")) .child( - Label::new(PLANS_DESCRIPTION) - .size(LabelSize::Small) + Label::new("Choose how you want to start.") .color(Color::Muted) - .mt_1() - .mb_3(), + .mb_2(), ) - .when(self.account_too_young, |this| { - this.child(young_account_banner) + .map(|this| { + if self.account_too_young { + this.child(young_account_banner) + } else { + this.child(self.free_plan_definition(cx)).when_some( + self.dismiss_onboarding.as_ref(), + |this, dismiss_callback| { + let callback = dismiss_callback.clone(); + + this.child( + h_flex().absolute().top_0().right_0().child( + IconButton::new("dismiss_onboarding", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Dismiss")) + .on_click(move |_, window, cx| callback(window, cx)), + ), + ) + }, + ) + } }) - .child(self.render_free_plan_section(cx)) - .child(self.render_pro_plan_section(cx)) + .child(self.pro_plan_definition(cx)) + .into_any_element() } - fn render_trial_onboarding(&self, _cx: &mut App) -> Div { + fn render_trial_state(&self, _cx: &mut App) -> AnyElement { v_flex() - .child(Headline::new("Welcome to the trial of Zed Pro")) + .relative() + .gap_1() + .child(Headline::new("Welcome to the Zed Pro free trial")) .child( Label::new("Here's what you get for the next 14 days:") - .size(LabelSize::Small) .color(Color::Muted) - .mt_1(), + .mb_2(), ) .child( List::new() @@ -272,25 +331,31 @@ impl ZedAiOnboarding { "Unlimited edit predictions with Zeta, our open-source model", )), ) - .child( - Button::new("trial", "Start Trial") - .full_width() - .style(ButtonStyle::Outlined) - .on_click({ - let callback = self.continue_with_zed_ai.clone(); - move |_, window, cx| callback(window, cx) - }), + .when_some( + self.dismiss_onboarding.as_ref(), + |this, dismiss_callback| { + let callback = dismiss_callback.clone(); + this.child( + h_flex().absolute().top_0().right_0().child( + IconButton::new("dismiss_onboarding", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Dismiss")) + .on_click(move |_, window, cx| callback(window, cx)), + ), + ) + }, ) + .into_any_element() } - fn render_pro_plan_onboarding(&self, _cx: &mut App) -> Div { + fn render_pro_plan_state(&self, _cx: &mut App) -> AnyElement { v_flex() + .gap_1() .child(Headline::new("Welcome to Zed Pro")) .child( Label::new("Here's what you get:") - .size(LabelSize::Small) .color(Color::Muted) - .mt_1(), + .mb_2(), ) .child( List::new() @@ -306,6 +371,7 @@ impl ZedAiOnboarding { move |_, window, cx| callback(window, cx) }), ) + .into_any_element() } } @@ -314,9 +380,9 @@ impl RenderOnce for ZedAiOnboarding { if matches!(self.sign_in_status, SignInStatus::SignedIn) { if self.has_accepted_terms_of_service { match self.plan { - None | Some(proto::Plan::Free) => self.render_free_plan_onboarding(cx), - Some(proto::Plan::ZedProTrial) => self.render_trial_onboarding(cx), - Some(proto::Plan::ZedPro) => self.render_pro_plan_onboarding(cx), + None | Some(proto::Plan::Free) => self.render_free_plan_state(cx), + Some(proto::Plan::ZedProTrial) => self.render_trial_state(cx), + Some(proto::Plan::ZedPro) => self.render_pro_plan_state(cx), } } else { self.render_accept_terms_of_service() @@ -339,18 +405,17 @@ impl Component for ZedAiOnboarding { plan: Option, account_too_young: bool, ) -> AnyElement { - div() - .w(px(800.)) - .child(ZedAiOnboarding { - sign_in_status, - has_accepted_terms_of_service, - plan, - account_too_young, - continue_with_zed_ai: Arc::new(|_, _| {}), - sign_in: Arc::new(|_, _| {}), - accept_terms_of_service: Arc::new(|_, _| {}), - }) - .into_any_element() + ZedAiOnboarding { + sign_in_status, + has_accepted_terms_of_service, + plan, + account_too_young, + continue_with_zed_ai: Arc::new(|_, _| {}), + sign_in: Arc::new(|_, _| {}), + accept_terms_of_service: Arc::new(|_, _| {}), + dismiss_onboarding: None, + } + .into_any_element() } Some( @@ -368,7 +433,7 @@ impl Component for ZedAiOnboarding { ), single_example( "Account too young", - onboarding(SignInStatus::SignedIn, true, None, true), + onboarding(SignInStatus::SignedIn, false, None, true), ), single_example( "Free Plan", diff --git a/crates/ai_onboarding/src/young_account_banner.rs b/crates/ai_onboarding/src/young_account_banner.rs index f6e1446fd05cc719e8a6674ae9246084185162c7..1e1ed3a8653d0cb39955fb54b10dd1dc3937ceb3 100644 --- a/crates/ai_onboarding/src/young_account_banner.rs +++ b/crates/ai_onboarding/src/young_account_banner.rs @@ -6,7 +6,7 @@ pub struct YoungAccountBanner; impl RenderOnce for YoungAccountBanner { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - const YOUNG_ACCOUNT_DISCLAIMER: &str = "Given your GitHub account was created less than 30 days ago, we cannot put you in the Free plan or offer you a free trial of the Pro plan. We hope you'll understand, as this is unfortunately required to prevent abuse of our service. To continue, upgrade to Pro or use your own API keys for other providers."; + const YOUNG_ACCOUNT_DISCLAIMER: &str = "To prevent abuse of our service, we cannot offer plans to GitHub accounts created fewer than 30 days ago. To request an exception, reach out to billing@zed.dev."; let label = div() .w_full() diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index e36f5f65dae646bae361eea34e582c308ea64dae..693c7bf836330fc8c6cd36ca72ee862a9e2b865b 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -30,3 +30,8 @@ pub fn start_trial_url(cx: &App) -> String { pub fn upgrade_to_zed_pro_url(cx: &App) -> String { format!("{server_url}/account/upgrade", server_url = server_url(cx)) } + +/// Returns the URL to Zed's terms of service. +pub fn terms_of_service(cx: &App) -> String { + format!("{server_url}/terms-of-service", server_url = server_url(cx)) +} diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index 840fda38dec4714a32f3397a28dd2d116bb67f5d..6e8e8e91088bf3197c523c75209c56f9edda82be 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -206,8 +206,8 @@ impl LanguageModelRegistry { None } - /// Check that we have at least one provider that is authenticated. - fn has_authenticated_provider(&self, cx: &App) -> bool { + /// Returns `true` if at least one provider that is authenticated. + pub fn has_authenticated_provider(&self, cx: &App) -> bool { self.providers.values().any(|p| p.is_authenticated(cx)) } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 736107570b395c3014e25dce1cbe21737de9e96b..fac88107143919437c1851b8417210343bb44b0c 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1140,19 +1140,19 @@ impl RenderOnce for ZedAiConfiguration { let is_pro = self.plan == Some(proto::Plan::ZedPro); let subscription_text = match (self.plan, self.subscription_period) { (Some(proto::Plan::ZedPro), Some(_)) => { - "You have access to Zed's hosted LLMs through your Pro subscription." + "You have access to Zed's hosted models through your Pro subscription." } (Some(proto::Plan::ZedProTrial), Some(_)) => { - "You have access to Zed's hosted LLMs through your Pro trial." + "You have access to Zed's hosted models through your Pro trial." } (Some(proto::Plan::Free), Some(_)) => { - "You have basic access to Zed's hosted LLMs through the Free plan." + "You have basic access to Zed's hosted models through the Free plan." } _ => { if self.eligible_for_trial { - "Subscribe for access to Zed's hosted LLMs. Start with a 14 day free trial." + "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." } else { - "Subscribe for access to Zed's hosted LLMs." + "Subscribe for access to Zed's hosted models." } } }; @@ -1166,7 +1166,7 @@ impl RenderOnce for ZedAiConfiguration { Button::new("start_trial", "Start 14-day Free Pro Trial") .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) .full_width() - .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))) + .on_click(|_, _, cx| cx.open_url(&zed_urls::start_trial_url(cx))) .into_any_element() } else { Button::new("upgrade", "Upgrade to Pro") From 2b671a46f23e2f5d1393e58973f7305e6b78494d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 22 Jul 2025 02:39:22 -0300 Subject: [PATCH 19/21] ai onboarding: Don't show API keys section if user is already in Pro (#34867) Release Notes: - N/A --- crates/agent_ui/src/message_editor.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 69eae982f8008849d02e4b695c4b00c08cff0b46..78037532925d8214b3f6fe8c780039e3e590a7f7 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -1657,11 +1657,16 @@ impl Render for MessageEditor { let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5; - let enrolled_in_trial = matches!( + let in_pro_trial = matches!( self.user_store.read(cx).current_plan(), Some(proto::Plan::ZedProTrial) ); + let pro_user = matches!( + self.user_store.read(cx).current_plan(), + Some(proto::Plan::ZedPro) + ); + let configured_providers: Vec<(IconName, SharedString)> = LanguageModelRegistry::read_global(cx) .providers() @@ -1676,9 +1681,10 @@ impl Render for MessageEditor { v_flex() .size_full() .bg(cx.theme().colors().panel_background) - .when(has_existing_providers && !enrolled_in_trial, |this| { - this.child(cx.new(ApiKeysWithProviders::new)) - }) + .when( + has_existing_providers && !in_pro_trial && !pro_user, + |this| this.child(cx.new(ApiKeysWithProviders::new)), + ) .when(changed_buffers.len() > 0, |parent| { parent.child(self.render_edits_bar(&changed_buffers, window, cx)) }) From 87014cec71335c6ae20fbe3fe6299f95542103c4 Mon Sep 17 00:00:00 2001 From: Bret Comnes <166301+bcomnes@users.noreply.github.com> Date: Tue, 22 Jul 2025 02:50:26 -0700 Subject: [PATCH 20/21] theme: Add `panel.overlay_background` and `panel.overlay_hover` (#34655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In https://github.com/zed-industries/zed/pull/33994 sticky scroll was added to project_panel. I love this feature! This introduces a new element layering not seen before. On themes that use transparency, the overlapping elements can make it difficult to read project panel entries. This PR introduces a new selector: ~~panel.sticky_entry.background~~ `panel.overlay_background` This selector lets you set the background of entries when they become sticky. Closes https://github.com/zed-industries/zed/issues/34654 Before: Screenshot 2025-07-17 at 10 19 11 AM After: Screenshot 2025-07-17 at 11 46 57 AM Screenshot 2025-07-17 at 11 39 57 AM Screenshot 2025-07-17 at 11 39 29 AM Release Notes: - Add `panel.sticky_entry.background` theme selector for modifying project panel entries when they become sticky when scrolling and overlap with entries below them. --------- Co-authored-by: Smit Barmase --- crates/project_panel/src/project_panel.rs | 16 ++++++++--- crates/theme/src/default_colors.rs | 4 +++ crates/theme/src/fallback_themes.rs | 9 ++++-- crates/theme/src/schema.rs | 34 +++++++++++++++++------ crates/theme/src/styles/colors.rs | 10 +++++++ 5 files changed, 58 insertions(+), 15 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index b6fdcd6fa5bac837df5cab8aad3b9c69cd1613d8..44f4e8985ad90462f3c68b21e7f12274725e3673 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -384,12 +384,20 @@ struct ItemColors { focused: Hsla, } -fn get_item_color(cx: &App) -> ItemColors { +fn get_item_color(is_sticky: bool, cx: &App) -> ItemColors { let colors = cx.theme().colors(); ItemColors { - default: colors.panel_background, - hover: colors.element_hover, + default: if is_sticky { + colors.panel_overlay_background + } else { + colors.panel_background + }, + hover: if is_sticky { + colors.panel_overlay_hover + } else { + colors.element_hover + }, marked: colors.element_selected, focused: colors.panel_focused_border, drag_over: colors.drop_target_background, @@ -3903,7 +3911,7 @@ impl ProjectPanel { let filename_text_color = details.filename_text_color; let diagnostic_severity = details.diagnostic_severity; - let item_colors = get_item_color(cx); + let item_colors = get_item_color(is_sticky, cx); let canonical_path = details .canonical_path diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 3424e0fe04cdbc11544fa81018edba4ff2b357c1..1c3f48b548d3fdd4a2a554b476afaa08dcbae150 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -83,6 +83,8 @@ impl ThemeColors { panel_indent_guide: neutral().light_alpha().step_5(), panel_indent_guide_hover: neutral().light_alpha().step_6(), panel_indent_guide_active: neutral().light_alpha().step_6(), + panel_overlay_background: neutral().light().step_2(), + panel_overlay_hover: neutral().light_alpha().step_4(), pane_focused_border: blue().light().step_5(), pane_group_border: neutral().light().step_6(), scrollbar_thumb_background: neutral().light_alpha().step_3(), @@ -206,6 +208,8 @@ impl ThemeColors { panel_indent_guide: neutral().dark_alpha().step_4(), panel_indent_guide_hover: neutral().dark_alpha().step_6(), panel_indent_guide_active: neutral().dark_alpha().step_6(), + panel_overlay_background: neutral().dark().step_2(), + panel_overlay_hover: neutral().dark_alpha().step_4(), pane_focused_border: blue().dark().step_5(), pane_group_border: neutral().dark().step_6(), scrollbar_thumb_background: neutral().dark_alpha().step_3(), diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 5e9967d4603a5bac8c9f1a7e461c7319f52f82d7..4d77dd5d81dfc45427bda4034ff7a2085dbcb489 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -59,6 +59,7 @@ pub(crate) fn zed_default_dark() -> Theme { let bg = hsla(215. / 360., 12. / 100., 15. / 100., 1.); let editor = hsla(220. / 360., 12. / 100., 18. / 100., 1.); let elevated_surface = hsla(225. / 360., 12. / 100., 17. / 100., 1.); + let hover = hsla(225.0 / 360., 11.8 / 100., 26.7 / 100., 1.0); let blue = hsla(207.8 / 360., 81. / 100., 66. / 100., 1.0); let gray = hsla(218.8 / 360., 10. / 100., 40. / 100., 1.0); @@ -108,14 +109,14 @@ pub(crate) fn zed_default_dark() -> Theme { surface_background: bg, background: bg, element_background: hsla(223.0 / 360., 13. / 100., 21. / 100., 1.0), - element_hover: hsla(225.0 / 360., 11.8 / 100., 26.7 / 100., 1.0), + element_hover: hover, element_active: hsla(220.0 / 360., 11.8 / 100., 20.0 / 100., 1.0), element_selected: hsla(224.0 / 360., 11.3 / 100., 26.1 / 100., 1.0), element_disabled: SystemColors::default().transparent, element_selection_background: player.local().selection.alpha(0.25), drop_target_background: hsla(220.0 / 360., 8.3 / 100., 21.4 / 100., 1.0), ghost_element_background: SystemColors::default().transparent, - ghost_element_hover: hsla(225.0 / 360., 11.8 / 100., 26.7 / 100., 1.0), + ghost_element_hover: hover, ghost_element_active: hsla(220.0 / 360., 11.8 / 100., 20.0 / 100., 1.0), ghost_element_selected: hsla(224.0 / 360., 11.3 / 100., 26.1 / 100., 1.0), ghost_element_disabled: SystemColors::default().transparent, @@ -202,10 +203,12 @@ pub(crate) fn zed_default_dark() -> Theme { panel_indent_guide: hsla(228. / 360., 8. / 100., 25. / 100., 1.), panel_indent_guide_hover: hsla(225. / 360., 13. / 100., 12. / 100., 1.), panel_indent_guide_active: hsla(225. / 360., 13. / 100., 12. / 100., 1.), + panel_overlay_background: bg, + panel_overlay_hover: hover, pane_focused_border: blue, pane_group_border: hsla(225. / 360., 13. / 100., 12. / 100., 1.), scrollbar_thumb_background: gpui::transparent_black(), - scrollbar_thumb_hover_background: hsla(225.0 / 360., 11.8 / 100., 26.7 / 100., 1.0), + scrollbar_thumb_hover_background: hover, scrollbar_thumb_active_background: hsla( 225.0 / 360., 11.8 / 100., diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index bed25d0c054fc4e1767cc852597db13dc2cb434c..bfa2adcedf73ec9d51c25d30785b1e81cd83173e 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -351,6 +351,12 @@ pub struct ThemeColorsContent { #[serde(rename = "panel.indent_guide_active")] pub panel_indent_guide_active: Option, + #[serde(rename = "panel.overlay_background")] + pub panel_overlay_background: Option, + + #[serde(rename = "panel.overlay_hover")] + pub panel_overlay_hover: Option, + #[serde(rename = "pane.focused_border")] pub pane_focused_border: Option, @@ -674,6 +680,14 @@ impl ThemeColorsContent { .scrollbar_thumb_border .as_ref() .and_then(|color| try_parse_color(color).ok()); + let element_hover = self + .element_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let panel_background = self + .panel_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); ThemeColorsRefinement { border, border_variant: self @@ -712,10 +726,7 @@ impl ThemeColorsContent { .element_background .as_ref() .and_then(|color| try_parse_color(color).ok()), - element_hover: self - .element_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()), + element_hover, element_active: self .element_active .as_ref() @@ -832,10 +843,7 @@ impl ThemeColorsContent { .search_match_background .as_ref() .and_then(|color| try_parse_color(color).ok()), - panel_background: self - .panel_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), + panel_background, panel_focused_border: self .panel_focused_border .as_ref() @@ -852,6 +860,16 @@ impl ThemeColorsContent { .panel_indent_guide_active .as_ref() .and_then(|color| try_parse_color(color).ok()), + panel_overlay_background: self + .panel_overlay_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(panel_background), + panel_overlay_hover: self + .panel_overlay_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(element_hover), pane_focused_border: self .pane_focused_border .as_ref() diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index 7c5270e3612dfbe1fb6b1ec45dc4787dac0e9463..aab11803f4d810453f5bfc286624ea8e4efb4a61 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -131,6 +131,12 @@ pub struct ThemeColors { pub panel_indent_guide: Hsla, pub panel_indent_guide_hover: Hsla, pub panel_indent_guide_active: Hsla, + + /// The color of the overlay surface on top of panel. + pub panel_overlay_background: Hsla, + /// The color of the overlay surface on top of panel when hovered over. + pub panel_overlay_hover: Hsla, + pub pane_focused_border: Hsla, pub pane_group_border: Hsla, /// The color of the scrollbar thumb. @@ -326,6 +332,8 @@ pub enum ThemeColorField { PanelIndentGuide, PanelIndentGuideHover, PanelIndentGuideActive, + PanelOverlayBackground, + PanelOverlayHover, PaneFocusedBorder, PaneGroupBorder, ScrollbarThumbBackground, @@ -438,6 +446,8 @@ impl ThemeColors { ThemeColorField::PanelIndentGuide => self.panel_indent_guide, ThemeColorField::PanelIndentGuideHover => self.panel_indent_guide_hover, ThemeColorField::PanelIndentGuideActive => self.panel_indent_guide_active, + ThemeColorField::PanelOverlayBackground => self.panel_overlay_background, + ThemeColorField::PanelOverlayHover => self.panel_overlay_hover, ThemeColorField::PaneFocusedBorder => self.pane_focused_border, ThemeColorField::PaneGroupBorder => self.pane_group_border, ThemeColorField::ScrollbarThumbBackground => self.scrollbar_thumb_background, From 3a651c546b13c7b9a2a25a551975f5313b7e3793 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 22 Jul 2025 12:12:07 +0200 Subject: [PATCH 21/21] context_server: Change command string field to PathBuf (#34873) Release Notes: - N/A --- .../configure_context_server_modal.rs | 8 +++++--- crates/context_server/src/context_server.rs | 4 ++-- crates/extension/src/types.rs | 4 ++-- .../src/wasm_host/wit/since_v0_6_0.rs | 4 ++-- crates/project/src/context_server_store.rs | 18 +++++++++--------- .../src/context_server_store/extension.rs | 5 +---- crates/project/src/project_settings.rs | 2 +- 7 files changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 9e5f6e09c82489dd4ccdc89f188e962ceeec596d..06d035d836853068c8ed402ee0e85ff85d9af6b2 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -1,4 +1,5 @@ use std::{ + path::PathBuf, sync::{Arc, Mutex}, time::Duration, }; @@ -188,7 +189,7 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) } None => ( "some-mcp-server".to_string(), - "".to_string(), + PathBuf::new(), "[]".to_string(), "{}".to_string(), ), @@ -199,13 +200,14 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) /// The name of your MCP server "{name}": {{ /// The command which runs the MCP server - "command": "{command}", + "command": "{}", /// The arguments to pass to the MCP server "args": {args}, /// The environment variables to set "env": {env} }} -}}"# +}}"#, + command.display() ) } diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs index 807b17f1ca64fcc253084d553b8ec700c60fb74e..f2517feb27e9ceab2187e0f86bc752e14de5d63f 100644 --- a/crates/context_server/src/context_server.rs +++ b/crates/context_server/src/context_server.rs @@ -6,9 +6,9 @@ pub mod test; pub mod transport; pub mod types; -use std::fmt::Display; use std::path::Path; use std::sync::Arc; +use std::{fmt::Display, path::PathBuf}; use anyhow::Result; use client::Client; @@ -31,7 +31,7 @@ impl Display for ContextServerId { #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] pub struct ContextServerCommand { #[serde(rename = "command")] - pub path: String, + pub path: PathBuf, pub args: Vec, pub env: Option>, } diff --git a/crates/extension/src/types.rs b/crates/extension/src/types.rs index cb24e5077b839a0c5ded24c084fbdd7c1cbeab7c..ed9eb2ec2fb96a3b19125355be90e6ba7a5a6e90 100644 --- a/crates/extension/src/types.rs +++ b/crates/extension/src/types.rs @@ -3,7 +3,7 @@ mod dap; mod lsp; mod slash_command; -use std::ops::Range; +use std::{ops::Range, path::PathBuf}; use util::redact::should_redact; @@ -18,7 +18,7 @@ pub type EnvVars = Vec<(String, String)>; /// A command. pub struct Command { /// The command to execute. - pub command: String, + pub command: PathBuf, /// The arguments to pass to the command. pub args: Vec, /// The environment variables to set for the command. diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs index ced2ea9c677022e95f106ac6ba0543303fe5a372..d25328ee7f6744528e606e5043ff51fbc0896aee 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs @@ -75,7 +75,7 @@ impl From for std::ops::Range { impl From for extension::Command { fn from(value: Command) -> Self { Self { - command: value.command, + command: value.command.into(), args: value.args, env: value.env, } @@ -958,7 +958,7 @@ impl ExtensionImports for WasmState { command, } => Ok(serde_json::to_string(&settings::ContextServerSettings { command: Some(settings::CommandSettings { - path: Some(command.path), + path: command.path.to_str().map(|path| path.to_string()), arguments: Some(command.args), env: command.env.map(|env| env.into_iter().collect()), }), diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index fd31e638d4bf7774af83d430dca232d1ade74f01..ceec0c0a52b70cb68f0fb41d8c415a13e39e8b85 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -610,7 +610,7 @@ mod tests { use context_server::test::create_fake_transport; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; use serde_json::json; - use std::{cell::RefCell, rc::Rc}; + use std::{cell::RefCell, path::PathBuf, rc::Rc}; use util::path; #[gpui::test] @@ -931,7 +931,7 @@ mod tests { ContextServerSettings::Custom { enabled: true, command: ContextServerCommand { - path: "somebinary".to_string(), + path: "somebinary".into(), args: vec!["arg".to_string()], env: None, }, @@ -971,7 +971,7 @@ mod tests { ContextServerSettings::Custom { enabled: true, command: ContextServerCommand { - path: "somebinary".to_string(), + path: "somebinary".into(), args: vec!["anotherArg".to_string()], env: None, }, @@ -1053,7 +1053,7 @@ mod tests { ContextServerSettings::Custom { enabled: true, command: ContextServerCommand { - path: "somebinary".to_string(), + path: "somebinary".into(), args: vec!["arg".to_string()], env: None, }, @@ -1104,7 +1104,7 @@ mod tests { ContextServerSettings::Custom { enabled: false, command: ContextServerCommand { - path: "somebinary".to_string(), + path: "somebinary".into(), args: vec!["arg".to_string()], env: None, }, @@ -1132,7 +1132,7 @@ mod tests { ContextServerSettings::Custom { enabled: true, command: ContextServerCommand { - path: "somebinary".to_string(), + path: "somebinary".into(), args: vec!["arg".to_string()], env: None, }, @@ -1184,7 +1184,7 @@ mod tests { ContextServerSettings::Custom { enabled: true, command: ContextServerCommand { - path: "somebinary".to_string(), + path: "somebinary".into(), args: vec!["arg".to_string()], env: None, }, @@ -1256,11 +1256,11 @@ mod tests { } struct FakeContextServerDescriptor { - path: String, + path: PathBuf, } impl FakeContextServerDescriptor { - fn new(path: impl Into) -> Self { + fn new(path: impl Into) -> Self { Self { path: path.into() } } } diff --git a/crates/project/src/context_server_store/extension.rs b/crates/project/src/context_server_store/extension.rs index 1eaecd987dd51158fc2f505c1ae9b0c8fcc076a3..1eb0fe7da129ba9dbd3ee640cb6e02474a3990b6 100644 --- a/crates/project/src/context_server_store/extension.rs +++ b/crates/project/src/context_server_store/extension.rs @@ -61,10 +61,7 @@ impl registry::ContextServerDescriptor for ContextServerDescriptor { let mut command = extension .context_server_command(id.clone(), extension_project.clone()) .await?; - command.command = extension - .path_from_extension(command.command.as_ref()) - .to_string_lossy() - .to_string(); + command.command = extension.path_from_extension(&command.command); log::info!("loaded command for context server {id}: {command:?}"); diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index a85d90fe33575ecd15fd7f55166b35e2489e222b..20be7fef85c79910904fe577f0691fba57424d45 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -581,7 +581,7 @@ impl Settings for ProjectSettings { #[derive(Deserialize)] struct VsCodeContextServerCommand { - command: String, + command: PathBuf, args: Option>, env: Option>, // note: we don't support envFile and type