diff --git a/crates/ui/src/chat/components/activity.rs b/crates/ui/src/chat/components/activity.rs index 0177c01d1..e63963030 100644 --- a/crates/ui/src/chat/components/activity.rs +++ b/crates/ui/src/chat/components/activity.rs @@ -1,4 +1,5 @@ use crate::theme::ActiveTheme as _; +use crate::widgets::menu::{ContextMenuExt as _, CopyText}; use crate::widgets::spinner::Spinner; use crate::{ icon::{Icon, IconName}, @@ -180,7 +181,47 @@ pub(crate) fn activity_row( if expanded && expandable { block = block.child(activity_detail(entry, command_detail, elided_output, cx)); } - block.into_any_element() + let copy_items = activity_copy_items(entry); + block + .context_menu(move |menu, _, _| { + copy_items + .iter() + .cloned() + .fold(menu, |menu, (label, text)| { + menu.menu(label, Box::new(CopyText(text))) + }) + }) + .into_any_element() +} + +/// What the row's context menu copies: the detail's parts are plain text, so +/// the window selection cannot reach them, and a reader wants the whole +/// command or output rather than its one-line preview. +fn activity_copy_items(entry: &TimelineEntry) -> Vec<(String, String)> { + let mut items = Vec::new(); + let mut push = |key: &str, text: &str| { + if !text.trim().is_empty() { + items.push((crate::tr!(key).into_owned(), text.to_string())); + } + }; + match &entry.content { + EntryContent::Item(ItemContent::CommandExecution { + command, output, .. + }) => { + push("chat.copy_command", command); + push("chat.copy_output", output); + } + EntryContent::Item(ItemContent::ToolCall { input, output, .. }) => { + let input = serde_json::to_string_pretty(input).unwrap_or_else(|_| input.to_string()); + push("chat.copy_input", &input); + push("chat.copy_output", output.as_deref().unwrap_or_default()); + } + EntryContent::Item(ItemContent::Reasoning { text }) => push("chat.copy_text", text), + EntryContent::Item(ItemContent::WebSearch { query }) => push("chat.copy_text", query), + EntryContent::Item(ItemContent::Other { summary, .. }) => push("chat.copy_text", summary), + _ => {} + } + items } /// A row summary in the tool-chips grammar: the bare verb, then its argument diff --git a/crates/ui/src/chat/components/assistant.rs b/crates/ui/src/chat/components/assistant.rs index f692bfb9a..23234ec31 100644 --- a/crates/ui/src/chat/components/assistant.rs +++ b/crates/ui/src/chat/components/assistant.rs @@ -1,22 +1,24 @@ use std::path::Path; +use std::rc::Rc; use std::sync::Arc; use std::time::Duration; use crate::theme::ActiveTheme as _; use crate::widgets::button::{Button, ButtonVariants as _}; +use crate::widgets::menu::{ContextMenuExt as _, CopyText, PopupMenu}; use crate::{ icon::{Icon, IconName}, sizing::Sizable as _, }; use gpui::{ - Animation, AnimationExt as _, AnyElement, App, AppContext as _, ClickEvent, Div, Entity, - InteractiveElement as _, IntoElement, ParentElement as _, SharedString, Styled as _, Window, - div, prelude::FluentBuilder as _, px, + Animation, AnimationExt as _, AnyElement, App, AppContext as _, ClickEvent, Context, Div, + Entity, InteractiveElement as _, IntoElement, ParentElement as _, SharedString, Styled as _, + Window, div, prelude::FluentBuilder as _, px, }; use gpui_base::{h_flex, v_flex}; use crate::markdown::parse::ParsedDocument; -use crate::markdown::{MarkdownState, MarkdownView}; +use crate::markdown::{MarkdownState, MarkdownView, MenuExtension}; use super::super::model::{MdSync, md_sync}; @@ -68,6 +70,18 @@ pub(crate) struct AssistantData<'a> { pub(crate) copied: bool, } +/// The message's own context-menu item: the whole text, as the hover Copy +/// button copies it. Offered inside the Markdown (below the view's own +/// items) and on the margin around it. +pub(crate) fn copy_message_items(text: Arc) -> MenuExtension { + Rc::new(move |menu: PopupMenu, _: &mut Window, _: &mut App| { + menu.menu( + crate::tr!("chat.copy_message").into_owned(), + Box::new(CopyText(text.to_string())), + ) + }) +} + /// An assistant message: rendered markdown plus a hover-revealed Copy action. pub(crate) fn assistant( data: AssistantData<'_>, @@ -84,6 +98,7 @@ pub(crate) fn assistant( show_actions, copied, } = data; + let menu_items = copy_message_items(Arc::from(text)); let content = markdown.map_or_else( || div().child(text.to_string()).into_any_element(), |markdown| { @@ -91,6 +106,7 @@ pub(crate) fn assistant( .compact_headings(true) .selectable(true) .base_dir(cwd) + .menu_extension(menu_items.clone()) .into_any_element() }, ); @@ -101,9 +117,12 @@ pub(crate) fn assistant( .line_height(px(26.)) .child(content), ); + let margin_menu = move |menu: PopupMenu, window: &mut Window, cx: &mut Context| { + menu_items(menu, window, cx) + }; if !show_actions { - return message.into_any_element(); + return message.context_menu(margin_menu).into_any_element(); } let group_key = SharedString::from(format!("assistant-{id}")); @@ -123,6 +142,7 @@ pub(crate) fn assistant( |element, delta| element.opacity(delta), ), ) + .context_menu(margin_menu) .into_any_element() } diff --git a/crates/ui/src/chat/components/bubble.rs b/crates/ui/src/chat/components/bubble.rs index 9ac71f028..3e8e96323 100644 --- a/crates/ui/src/chat/components/bubble.rs +++ b/crates/ui/src/chat/components/bubble.rs @@ -2,21 +2,25 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::theme::ActiveTheme as _; +use crate::widgets::menu::{ContextMenuExt as _, PopupMenu}; use crate::{ icon::{Icon, IconName}, sizing::Sizable as _, }; +use agent::RewindMode; use gpui::{ Anchor, AnyElement, App, ClickEvent, Entity, InteractiveElement as _, IntoElement as _, ObjectFit, ParentElement as _, Role, SharedString, StatefulInteractiveElement as _, Styled as _, StyledImage as _, Window, div, img, prelude::FluentBuilder as _, px, }; use gpui_base::{h_flex, v_flex}; +use std::rc::Rc; use tcode_core::session::SteeringStatus; +use super::super::RewindTurn; use super::assistant; -use crate::markdown::{MarkdownState, MarkdownView}; +use crate::markdown::{MarkdownState, MarkdownView, MenuExtension}; pub(crate) type ClickHandler = Box; pub(crate) type SharedClickHandler = Arc; @@ -42,6 +46,52 @@ pub(crate) struct BubbleData<'a> { pub(crate) copied: bool, pub(crate) markdown: Option>, pub(crate) rewind: Option, + /// The rewind the context menu offers, when the harness has one: the + /// turn, whether it is blocked right now, and whether a conversation + /// rewind is possible (there is none for the first turn). + pub(crate) rewind_menu: Option, +} + +#[derive(Clone, Copy)] +pub(crate) struct RewindMenu { + pub(crate) turn: usize, + pub(crate) disabled: bool, +} + +/// The bubble's context-menu items: copy the message, then the rewind modes +/// the popover button offers. Shared by the Markdown inside the bubble and +/// the bubble's own margin. +fn bubble_menu_items(copy_text: Arc, rewind: Option) -> MenuExtension { + Rc::new(move |menu: PopupMenu, window: &mut Window, cx: &mut App| { + let menu = if copy_text.trim().is_empty() { + menu + } else { + assistant::copy_message_items(copy_text.clone())(menu, window, cx) + }; + let Some(RewindMenu { turn, disabled }) = rewind else { + return menu; + }; + let mut modes = Vec::new(); + if turn > 0 { + modes.push(( + crate::tr!("chat.rewind_all").into_owned(), + RewindMode::FilesAndConversation, + )); + modes.push(( + crate::tr!("chat.rewind_conversation").into_owned(), + RewindMode::Conversation, + )); + } + modes.push(( + crate::tr!("chat.rewind_files").into_owned(), + RewindMode::Files, + )); + modes + .into_iter() + .fold(menu.separator(), |menu, (label, mode)| { + menu.menu_with_enable(label, Box::new(RewindTurn { turn, mode }), !disabled) + }) + }) } pub(crate) struct BubbleHandlers { @@ -165,8 +215,10 @@ pub(crate) fn user_bubble( copied, markdown, rewind, + rewind_menu, } = data; let BubbleHandlers { copy, images } = handlers; + let menu_items = bubble_menu_items(Arc::from(visible), rewind_menu); let text_style = window.text_style(); let text_width = visible.lines().fold(px(0.), |width, line| { let run = text_style.to_run(line.len()); @@ -201,6 +253,7 @@ pub(crate) fn user_bubble( .zip(images) .enumerate() .map(|(image_index, (path, on_click))| { + let menu_path = path.clone(); let path = PathBuf::from(path); div() .id(SharedString::from(format!( @@ -218,6 +271,7 @@ pub(crate) fn user_bubble( .object_fit(ObjectFit::Cover), ) .on_click(on_click) + .context_menu(move |menu, _, _| menu.path_items(&menu_path, None)) }) .collect::>(), ) @@ -230,6 +284,7 @@ pub(crate) fn user_bubble( .selectable(true) .compact_headings(true) .base_dir(cwd) + .menu_extension(menu_items.clone()) .into_any_element() }, ); @@ -284,5 +339,6 @@ pub(crate) fn user_bubble( .child(assistant::reserve_action_row( actions, group_key, pinned, compact, )) + .context_menu(move |menu, window, cx| menu_items(menu, window, cx)) .into_any_element() } diff --git a/crates/ui/src/chat/components/changed_files.rs b/crates/ui/src/chat/components/changed_files.rs index cd5b52d48..c998eddc0 100644 --- a/crates/ui/src/chat/components/changed_files.rs +++ b/crates/ui/src/chat/components/changed_files.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use crate::icon::{Icon, IconName}; use crate::theme::ActiveTheme as _; use crate::theme::HighlightTheme; +use crate::widgets::menu::{ContextMenuExt as _, PopupMenu}; use crate::widgets::tooltip::Tooltip; use agent::{ChangeCompleteness, FileChange, FileChangeKind}; use gpui::{ @@ -146,6 +147,21 @@ pub(crate) fn changed_files( .cursor_pointer() .hover(|chip| chip.bg(cx.theme().accent)) .on_click(on_click) + .context_menu({ + let path = change.path.clone(); + let cwd = cwd.to_path_buf(); + move |menu, _, _| { + menu.menu( + crate::tr!("chat.view_diff").into_owned(), + Box::new(super::super::OpenFileDiff { + turn: index, + path: path.clone(), + }), + ) + .separator() + .path_items(&path, Some(&cwd)) + } + }) .into_any_element(), ); } @@ -234,6 +250,7 @@ impl FileEditRowStyle { pub(crate) fn file_edit_row( key: &str, row: &LiveEditRow, + cwd: &Path, expanded: bool, inline_diff: Option, on_toggle: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -242,6 +259,13 @@ pub(crate) fn file_edit_row( let style = FileEditRowStyle::from_theme(cx); let expandable = row.counts.is_some(); let header = file_edit_row_header(row, expanded, expandable, &style); + let path_menu = { + let path = row.path.clone(); + let cwd = cwd.to_path_buf(); + move |menu: PopupMenu, _: &mut Window, _: &mut gpui::Context| { + menu.path_items(&path, Some(&cwd)) + } + }; let header: AnyElement = if expandable { crate::material::accessible_clickable( header, @@ -255,9 +279,10 @@ pub(crate) fn file_edit_row( .cursor_pointer() .hover(|header| header.bg(cx.theme().accent)) .on_click(on_toggle) + .context_menu(path_menu) .into_any_element() } else { - header.into_any_element() + header.context_menu(path_menu).into_any_element() }; v_flex() @@ -569,6 +594,7 @@ mod tests { gpui_base::v_flex().size_full().child(file_edit_row( key, &self.row, + std::path::Path::new("/"), self.expanded, diff, |_, _, _| {}, diff --git a/crates/ui/src/chat/components/disclosure.rs b/crates/ui/src/chat/components/disclosure.rs index e6df4ff64..0d59e441a 100644 --- a/crates/ui/src/chat/components/disclosure.rs +++ b/crates/ui/src/chat/components/disclosure.rs @@ -4,6 +4,7 @@ use std::path::Path; use crate::theme::ActiveTheme as _; use crate::widgets::button::{Button, ButtonVariants as _}; +use crate::widgets::menu::ContextMenuExt as _; use crate::{ icon::{Icon, IconName}, sizing::Sizable as _, @@ -19,6 +20,7 @@ use tcode_core::session::OrchestrateCallback; use super::super::model::one_line; use crate::markdown::{MarkdownState, MarkdownView}; +use crate::plan_panel::plan_menu_items; const CALLBACK_TITLE_MAX_CHARS: usize = 24; const DISCLOSURE_LINE_HEIGHT: f32 = 20.; @@ -169,6 +171,7 @@ pub(crate) fn proposed_plan_card( let title = tcode_core::session::plan_title(markdown) .unwrap_or_else(|| crate::tr!("plan.proposed_plan").into_owned()); let long = markdown.chars().count() > 900 || markdown.lines().count() > 20; + let menu_items = plan_menu_items(markdown.to_string()); let body: AnyElement = if collapsed { div().into_any_element() @@ -180,7 +183,8 @@ pub(crate) fn proposed_plan_card( .child( MarkdownView::new(&markdown_state) .selectable(true) - .base_dir(cwd), + .base_dir(cwd) + .menu_extension(menu_items.clone()), ) .into_any_element() } else { @@ -289,6 +293,7 @@ pub(crate) fn proposed_plan_card( .bg(cx.theme().info), ) .child(content) + .context_menu(move |menu, window, cx| menu_items(menu, window, cx)) .into_any_element() } diff --git a/crates/ui/src/chat/components/error_card.rs b/crates/ui/src/chat/components/error_card.rs index b25ad2d1b..92e0b7667 100644 --- a/crates/ui/src/chat/components/error_card.rs +++ b/crates/ui/src/chat/components/error_card.rs @@ -1,5 +1,6 @@ use crate::theme::ActiveTheme as _; use crate::widgets::button::{Button, ButtonVariants as _}; +use crate::widgets::menu::{ContextMenuExt as _, CopyText}; use crate::{ icon::{Icon, IconName}, sizing::Sizable as _, @@ -140,5 +141,14 @@ pub(crate) fn error_card( .bg(danger), ) .child(content) + .context_menu({ + let message = message.to_string(); + move |menu, _, _| { + menu.menu( + crate::tr!("chat.copy_error").into_owned(), + Box::new(CopyText(message.clone())), + ) + } + }) .into_any_element() } diff --git a/crates/ui/src/chat/mod.rs b/crates/ui/src/chat/mod.rs index a05c74965..dffdba4c3 100644 --- a/crates/ui/src/chat/mod.rs +++ b/crates/ui/src/chat/mod.rs @@ -15,9 +15,10 @@ pub(crate) mod components; mod model; mod residency; -use crate::overlay::{Notification, OverlayExt as _}; +use crate::overlay::OverlayExt as _; use crate::theme::ActiveTheme as _; use crate::widgets::button::{Button, ButtonVariants as _}; +use crate::widgets::menu::{ContextMenuExt as _, PopupMenu, open_in_zed}; use crate::widgets::tooltip::Tooltip; use crate::{ icon::{Icon, IconName}, @@ -25,12 +26,13 @@ use crate::{ }; use agent::{ItemContent, RewindMode}; use gpui::{ - Anchor, AnyElement, App, AppContext as _, ClickEvent, ClipboardItem, Context, Entity, + Action, Anchor, AnyElement, App, AppContext as _, ClickEvent, ClipboardItem, Context, Entity, FollowMode, InteractiveElement as _, IntoElement, ListAlignment, ListOffset, ListState, ParentElement as _, Render, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task, Window, div, list, prelude::FluentBuilder as _, px, }; use gpui_base::{ElementExt as _, Scrollbar, StyledExt as _, h_flex, v_flex}; +use serde::Deserialize; use tcode_core::git::GitAction; use tcode_core::session::{ @@ -41,6 +43,7 @@ use tcode_core::ui::RightTab; use crate::commit_dialog::CommitDialog; use crate::composer::Composer; use crate::git::{git_action_label_key, git_hint_key}; +use crate::plan_panel::{PlanMenu, PlanMenuKind}; use crate::shortcut::format_secondary_shortcut; use crate::store::WorkspaceStore; use crate::terminal_drawer::TerminalDrawer; @@ -50,6 +53,24 @@ use crate::window_drag_area; use crate::window_state::WindowState; use self::components::assistant::MdState; + +/// A message's context-menu rewind, which the chat applies as the bubble's +/// rewind popover does. +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_chat, no_json)] +pub(crate) struct RewindTurn { + pub(crate) turn: usize, + pub(crate) mode: RewindMode, +} + +/// A changed-file chip's context-menu "View diff". +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_chat, no_json)] +pub(crate) struct OpenFileDiff { + pub(crate) turn: usize, + pub(crate) path: String, +} + use self::components::changed_files::InlineDiffCache; use self::components::command_panel::CommandPanelCache; use self::model::{ @@ -1651,6 +1672,39 @@ impl ChatView { column.into_any_element() } + fn on_rewind_turn(&mut self, action: &RewindTurn, _: &mut Window, cx: &mut Context) { + let RewindTurn { turn, mode } = *action; + self.workspace_store + .update(cx, |store, _cx| store.rewind_turn(turn, mode)); + } + + fn on_open_file_diff(&mut self, action: &OpenFileDiff, _: &mut Window, cx: &mut Context) { + let OpenFileDiff { turn, path } = action.clone(); + self.workspace_store + .update(cx, |store, cx| store.open_diff_for_file(turn, path, cx)); + } + + fn on_plan_menu(&mut self, action: &PlanMenu, _: &mut Window, cx: &mut Context) { + let markdown = action.markdown.clone(); + match action.kind { + PlanMenuKind::Copy => { + self.workspace_store + .update(cx, |store, _cx| store.copy_plan(markdown)); + self.mark_copied("plan".into(), cx); + } + PlanMenuKind::Download => { + let fallback_title = crate::tr!("plan.proposed_plan").into_owned(); + self.workspace_store.update(cx, |store, _cx| { + store.download_plan(markdown, fallback_title) + }); + } + PlanMenuKind::Save => { + self.workspace_store + .update(cx, |store, _cx| store.save_plan_to_workspace(markdown)); + } + } + } + fn compose_user( &self, args: components::bubble::UserMessageArgs<'_>, @@ -1674,6 +1728,12 @@ impl ChatView { .filter(|len| *len <= text.len() && text.is_char_boundary(*len)) .map(|len| &text[..len]); let visible = user_visible_text(text, context_len); + let rewind_menu = steering + .is_none() + .then(|| self.workspace_store.read(cx).chat_native_rewind_state(turn)) + .flatten() + .filter(|(available, _)| *available) + .map(|(_, disabled)| components::bubble::RewindMenu { turn, disabled }); let rewind = steering .is_none() .then(|| { @@ -1733,6 +1793,7 @@ impl ChatView { copied, markdown, rewind, + rewind_menu, }, components::bubble::BubbleHandlers { copy: Box::new(cx.listener(move |this, _, _, cx| { @@ -1931,6 +1992,7 @@ impl ChatView { rows.push(components::changed_files::file_edit_row( &key, row, + cwd, expanded, inline_diff, cx.listener(move |this, _, _, cx| { @@ -2344,9 +2406,25 @@ impl ChatView { let preview_showing = right_panel_open && right_tab == RightTab::Preview; let terminal_open = panel.terminal_open && !self.window_state.read(cx).compact; let diff_showing = right_panel_open && right_tab == RightTab::Diff; + let title_menu = { + let title = title.clone(); + let cwd = cwd.clone(); + move |menu: PopupMenu, _: &mut Window, _: &mut Context| { + let menu = menu.when_some(title.clone(), |menu, title| { + menu.menu( + crate::tr!("chat.copy_title").into_owned(), + Box::new(crate::widgets::menu::CopyText(title)), + ) + }); + match &cwd { + Some(cwd) => menu.separator().path_items(&cwd.to_string_lossy(), None), + None => menu, + } + } + }; window_drag_area("chat-header-drag", base, window, cx) .child(sidebar_toggle) - .child(window_caption::drag_region(title_el)) + .child(window_caption::drag_region(title_el).context_menu(title_menu)) .when(show_actions, |this| { this.children(self.render_git_button(cx)) .children(cwd.clone().map(|cwd| self.render_open_button(cwd, cx))) @@ -3408,6 +3486,9 @@ impl Render for ChatView { let main = v_flex() .size_full() .min_h_0() + .on_action(cx.listener(Self::on_rewind_turn)) + .on_action(cx.listener(Self::on_open_file_diff)) + .on_action(cx.listener(Self::on_plan_menu)) .child( div() .id("timeline") @@ -3548,18 +3629,6 @@ fn render_commit_footer( .into_any_element() } -/// Launching an editor is the client's own process work, so it is injected -/// through the client host rather than linked here. A client without one (a -/// phone, a browser) reports that plainly. -fn open_in_zed(cwd: &Path, window: &mut Window, cx: &mut App) { - if !matches!(crate::remote::open_in_editor(cwd, cx), Some(Ok(()))) { - window.push_notification( - Notification::error(crate::tr!("errors.zed_cli_missing")), - cx, - ); - } -} - /// The breathing room between the timeline and the header/composer. A phone /// has none: its rows already inset themselves and the screen is short. fn timeline_inset(cx: &App) -> gpui::Pixels { diff --git a/crates/ui/src/composer/components/images.rs b/crates/ui/src/composer/components/images.rs index b367dcb54..41902ea0d 100644 --- a/crates/ui/src/composer/components/images.rs +++ b/crates/ui/src/composer/components/images.rs @@ -1,6 +1,7 @@ use super::super::*; use crate::attachments::{TransferVerdict, transfer_verdict}; use crate::thread_export::format_size; +use crate::widgets::menu::ContextMenuExt as _; use image::GenericImageView as _; use std::cell::RefCell; use tcode_core::attachments::{AttachError, MAX_EDGE_PX, MAX_IMAGES, MAX_SOURCE_BYTES}; @@ -459,6 +460,7 @@ impl Composer { let mut row = h_flex().w_full().gap_1().flex_wrap(); for (index, image) in self.pending_images.iter().enumerate() { let path = image.path.clone(); + let menu_path = path.to_string_lossy().into_owned(); let name = image.name.clone(); row = row.child( h_flex() @@ -510,7 +512,20 @@ impl Composer { cx.stop_propagation(); this.remove_image(index, cx); })), - ), + ) + .context_menu(move |menu, _, _| { + menu.menu( + crate::tr!("markdown.open_image").into_owned(), + Box::new(ComposerMenu::ImageOpen(index)), + ) + .separator() + .path_items(&menu_path, None) + .separator() + .menu( + crate::tr!("composer.remove_image").into_owned(), + Box::new(ComposerMenu::ImageRemove(index)), + ) + }), ); } Some(row.into_any_element()) diff --git a/crates/ui/src/composer/components/queue_strip.rs b/crates/ui/src/composer/components/queue_strip.rs index 4aef48b9b..8ce06d615 100644 --- a/crates/ui/src/composer/components/queue_strip.rs +++ b/crates/ui/src/composer/components/queue_strip.rs @@ -1,5 +1,6 @@ use super::super::*; use crate::scroll::ScrollableElement as _; +use crate::widgets::menu::{ContextMenuExt as _, CopyText}; #[cfg(not(target_family = "wasm"))] use std::time::{SystemTime, UNIX_EPOCH}; #[cfg(target_family = "wasm")] @@ -147,7 +148,40 @@ impl Composer { .on_click(cx.listener(move |this, _, window, cx| { this.drop_queued_and_refill(id, text.clone(), window, cx); })), - ), + ) + .context_menu({ + let text = message.text.clone(); + let interactive = self.interactive(cx); + move |menu, _, _| { + menu.menu( + crate::tr!("chat.copy_text").into_owned(), + Box::new(CopyText(text.clone())), + ) + .separator() + .menu_with_enable( + if scheduled { + crate::tr!("composer.send_now").into_owned() + } else { + crate::tr!("composer.steer_queued").into_owned() + }, + Box::new(ComposerMenu::QueueSteer(id)), + interactive && (scheduled || can_steer), + ) + .menu_with_enable( + crate::tr!("composer.edit_queued").into_owned(), + Box::new(ComposerMenu::QueueEdit { + id, + text: text.clone(), + }), + interactive, + ) + .menu_with_enable( + crate::tr!("composer.drop_queued").into_owned(), + Box::new(ComposerMenu::QueueDrop(id)), + interactive, + ) + } + }), ); } Some( diff --git a/crates/ui/src/composer/mod.rs b/crates/ui/src/composer/mod.rs index 474a3b33a..4611d77c3 100644 --- a/crates/ui/src/composer/mod.rs +++ b/crates/ui/src/composer/mod.rs @@ -20,6 +20,7 @@ use crate::overlay::{DialogButtons, Notification, OverlayExt as _}; use crate::theme::ActiveTheme as _; use crate::widgets::button::{Button, ButtonVariants as _}; use crate::widgets::input::{Input, InputEvent, InputState, Paste, Textarea, TextareaState}; +use crate::widgets::menu::{ContextMenuExt as _, CopyText}; use crate::widgets::spinner::Spinner; use crate::{ icon::{Icon, IconName}, @@ -43,6 +44,20 @@ use gpui_base::input::{MoveDown, MoveUp}; pub(crate) const CONTEXT: &str = "Composer"; gpui::actions!(tcode_composer, [ToggleInteractionMode]); + +/// A context-menu action on the composer's chips, queue rows and pending +/// images, which the composer applies as their buttons do. +#[derive(gpui::Action, Clone, PartialEq, Eq, serde::Deserialize)] +#[action(namespace = tcode_composer, no_json)] +enum ComposerMenu { + RemoveTerminalContext(u64), + RemoveReviewComment(usize), + QueueSteer(u64), + QueueEdit { id: u64, text: String }, + QueueDrop(u64), + ImageOpen(usize), + ImageRemove(usize), +} use gpui_base::{ElementExt as _, StyledExt as _, h_flex, v_flex}; use crate::attachments::attach_error_message; @@ -497,6 +512,28 @@ impl Composer { /// Remove a queued message and return its text to the composer for editing. /// Selecting the replacement lets the user discard it with one keystroke, /// while queued attachments are deliberately ignored. + fn on_menu(&mut self, action: &ComposerMenu, window: &mut Window, cx: &mut Context) { + match action.clone() { + ComposerMenu::RemoveTerminalContext(id) => self + .workspace_store + .update(cx, |store, _cx| store.remove_terminal_context(id)), + ComposerMenu::RemoveReviewComment(index) => self + .workspace_store + .update(cx, |store, _cx| store.remove_review_comment(index)), + ComposerMenu::QueueSteer(id) => self + .workspace_store + .update(cx, |store, _cx| store.steer_queued(id)), + ComposerMenu::QueueEdit { id, text } => { + self.drop_queued_and_refill(id, text, window, cx) + } + ComposerMenu::QueueDrop(id) => self + .workspace_store + .update(cx, |store, _cx| store.drop_queued(id)), + ComposerMenu::ImageOpen(index) => self.open_image_preview(index, window, cx), + ComposerMenu::ImageRemove(index) => self.remove_image(index, cx), + } + } + fn drop_queued_and_refill( &mut self, id: u64, @@ -1163,6 +1200,7 @@ impl Render for Composer { } else { format!("L{}-L{}", context.line_start, context.line_end) }; + let text = context.text.clone(); Button::new(("terminal-context-chip", id)) .ghost() .small() @@ -1176,6 +1214,17 @@ impl Render for Composer { this.workspace_store .update(cx, |store, _cx| store.remove_terminal_context(id)); })) + .context_menu(move |menu, _, _| { + menu.menu( + crate::tr!("chat.copy_text").into_owned(), + Box::new(CopyText(text.clone())), + ) + .separator() + .menu( + crate::tr!("composer.remove_context").into_owned(), + Box::new(ComposerMenu::RemoveTerminalContext(id)), + ) + }) })); let review_comments = self.workspace_store.read(cx).review_comments(); let has_review_comments = !review_comments.is_empty(); @@ -1189,6 +1238,7 @@ impl Render for Composer { } else { format!("L{}-L{}", comment.line_start, comment.line_end) }; + let text = comment.text.clone(); Button::new(("review-comment-chip", index)) .ghost() .small() @@ -1202,6 +1252,17 @@ impl Render for Composer { this.workspace_store .update(cx, |store, _cx| store.remove_review_comment(index)); })) + .context_menu(move |menu, _, _| { + menu.menu( + crate::tr!("chat.copy_text").into_owned(), + Box::new(CopyText(text.clone())), + ) + .separator() + .menu( + crate::tr!("composer.remove_context").into_owned(), + Box::new(ComposerMenu::RemoveReviewComment(index)), + ) + }) }), ); @@ -1320,6 +1381,7 @@ impl Render for Composer { })) .pb_2() .key_context(CONTEXT) + .on_action(cx.listener(Self::on_menu)) .on_action(cx.listener(|this, _: &ToggleInteractionMode, _, cx| { if !this.interactive(cx) { cx.propagate(); diff --git a/crates/ui/src/diff/view.rs b/crates/ui/src/diff/view.rs index d69672874..b464a822e 100644 --- a/crates/ui/src/diff/view.rs +++ b/crates/ui/src/diff/view.rs @@ -12,6 +12,7 @@ use crate::theme::ActiveTheme as _; use crate::widgets::Popover; use crate::widgets::button::{Button, ButtonVariants as _}; use crate::widgets::input::{Input, InputState}; +use crate::widgets::menu::{ContextMenuExt as _, CopyText}; use crate::{ icon::{Icon, IconName}, sizing::Sizable as _, @@ -56,6 +57,14 @@ enum DiffViewOption { Invisibles, } +/// A code row's context-menu action on the review selection. +#[derive(Action, Clone, Copy, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_diff, no_json)] +enum DiffSelectionMenu { + CopyLines, + AddComment, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum DiffScope { Turn(usize), @@ -825,6 +834,67 @@ impl DiffPanel { self.apply_view_option(*option, cx); } + fn on_selection_menu( + &mut self, + action: &DiffSelectionMenu, + window: &mut Window, + cx: &mut Context, + ) { + let Some(selection) = self.selection.clone() else { + return; + }; + match action { + DiffSelectionMenu::CopyLines => { + let text = self.selected_lines(&selection, cx); + if !text.is_empty() { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); + } + } + DiffSelectionMenu::AddComment => self.start_comment(window, cx), + } + } + + /// The selected rows' text, without diff markers, in file order. + fn selected_lines(&self, selection: &CommentSelection, _: &App) -> String { + let Some(file) = self + .cache + .as_ref() + .and_then(|cache| cache.files.iter().find(|file| file.path == selection.file)) + else { + return String::new(); + }; + let start = selection.row_start.min(selection.row_end); + let end = selection.row_start.max(selection.row_end); + file.all_rows + .iter() + .skip(start) + .take(end + 1 - start) + .map(|row| row.text.as_str()) + .collect::>() + .join("\n") + } + + fn start_comment(&mut self, window: &mut Window, cx: &mut Context) { + self.comment_input = Some(cx.new(|cx| { + InputState::new(window, cx).placeholder(crate::tr!("diff.comment_placeholder")) + })); + self.remeasure_lists(); + cx.notify(); + } + + /// The file's absolute path: the rendered path is relative to the + /// workspace for display. + fn absolute_file_path(&self, file: &RenderedFile, cx: &App) -> String { + let path = Path::new(&file.path); + if path.is_absolute() { + return file.path.clone(); + } + match self.workspace_store.read(cx).diff_active_state() { + Some(active) => active.cwd.join(path).to_string_lossy().into_owned(), + None => file.path.clone(), + } + } + fn apply_view_option(&mut self, option: DiffViewOption, cx: &mut Context) { match option { DiffViewOption::Split => { @@ -1574,6 +1644,15 @@ impl DiffPanel { .child(format!("-{}", file.removed)), ), ) + .context_menu({ + let path = self.absolute_file_path(file, cx); + let cwd = self + .workspace_store + .read(cx) + .diff_active_state() + .map(|active| active.cwd); + move |menu, _, _| menu.path_items(&path, cwd.as_deref()) + }) .into_any_element() } @@ -1765,12 +1844,7 @@ impl DiffPanel { .small() .label(crate::tr!("diff.add_comment")) .on_click(cx.listener(|this, _, window, cx| { - this.comment_input = Some(cx.new(|cx| { - InputState::new(window, cx) - .placeholder(crate::tr!("diff.comment_placeholder")) - })); - this.remeasure_lists(); - cx.notify(); + this.start_comment(window, cx); })), ) .into_any_element(), @@ -1893,7 +1967,31 @@ impl DiffPanel { .child(gutter(ReviewSide::Old, cx)) .child(gutter(ReviewSide::New, cx)); } - cell.child(code).into_any_element() + let line = row.text.clone(); + let selected_here = self + .selection + .as_ref() + .is_some_and(|selection| selection.file == file.path); + let commenting = self.comment_input.is_some(); + cell.child(code) + .context_menu(move |menu, _, _| { + menu.menu( + crate::tr!("diff.copy_line").into_owned(), + Box::new(CopyText(line.clone())), + ) + .menu_with_enable( + crate::tr!("diff.copy_selected_lines").into_owned(), + Box::new(DiffSelectionMenu::CopyLines), + selected_here, + ) + .separator() + .menu_with_enable( + crate::tr!("diff.add_comment").into_owned(), + Box::new(DiffSelectionMenu::AddComment), + selected_here && !commenting, + ) + }) + .into_any_element() } fn render_empty(&self, cx: &mut Context) -> AnyElement { @@ -2030,6 +2128,7 @@ impl Render for DiffPanel { .min_w_0() .text_color(cx.theme().foreground) .on_action(cx.listener(Self::on_view_option)) + .on_action(cx.listener(Self::on_selection_menu)) .when(!compact, |root| { root.child(self.render_tab_strip(window, cx)) }); diff --git a/crates/ui/src/gallery_support.rs b/crates/ui/src/gallery_support.rs index eb0d72610..a3a3b7707 100644 --- a/crates/ui/src/gallery_support.rs +++ b/crates/ui/src/gallery_support.rs @@ -44,6 +44,7 @@ pub fn user_bubble( copied: false, markdown: None, rewind: None, + rewind_menu: None, }, bubble::BubbleHandlers { copy: click(), diff --git a/crates/ui/src/markdown/inline.rs b/crates/ui/src/markdown/inline.rs index 51fd5adff..f0d2e44d8 100644 --- a/crates/ui/src/markdown/inline.rs +++ b/crates/ui/src/markdown/inline.rs @@ -19,7 +19,7 @@ use gpui::{ use super::{ link_target::LinkTarget, nodes::LinkMark, - state::{MarkdownState, PendingLinkMenu}, + state::{MarkdownState, PendingContextTarget, PendingLinkMenu}, }; /// Mutable paint-time data retained by the parsed IR. @@ -368,14 +368,18 @@ impl Element for Inline { let target = view.read(cx).resolve_link(&link.url); let text = text.get(range).map(SharedString::from).unwrap_or_default(); - PendingLinkMenu { + PendingContextTarget::Link(PendingLinkMenu { target, text, raw_url: link.url, - } + }) }, ); - view.update(cx, |state, cx| state.set_pending_context_link(pending, cx)); + // A press beside a link keeps the enclosing block's + // target (a code block's), which bubbled first. + if pending.is_some() { + view.update(cx, |state, cx| state.set_pending_context(pending, cx)); + } } _ => {} } diff --git a/crates/ui/src/markdown/inline_flow.rs b/crates/ui/src/markdown/inline_flow.rs index c4db49f6c..207f81e60 100644 --- a/crates/ui/src/markdown/inline_flow.rs +++ b/crates/ui/src/markdown/inline_flow.rs @@ -10,17 +10,17 @@ use crate::widgets::tooltip::Tooltip; use gpui::{ AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, Element, ElementId, Entity, GlobalElementId, HighlightStyle, Hsla, InspectorElementId, InteractiveElement as _, - IntoElement, LayoutId, LineFragment as WrapLineFragment, ObjectFit, ParentElement as _, Pixels, - Role, ShapedLine, SharedString, SharedUri, Size, StatefulInteractiveElement as _, Styled as _, - StyledImage as _, TextRun, TextStyle, WhiteSpace, Window, div, img, point, - prelude::FluentBuilder as _, px, relative, size, + IntoElement, LayoutId, LineFragment as WrapLineFragment, MouseButton, ObjectFit, + ParentElement as _, Pixels, Role, ShapedLine, SharedString, SharedUri, Size, + StatefulInteractiveElement as _, Styled as _, StyledImage as _, TextRun, TextStyle, WhiteSpace, + Window, div, img, point, prelude::FluentBuilder as _, px, relative, size, }; use super::{ inline::{Inline, InlineState}, link_target::LinkTarget, nodes::LinkMark, - state::MarkdownState, + state::{MarkdownState, PendingContextTarget}, }; const ELEMENT_LEN: usize = 1; @@ -154,6 +154,9 @@ impl InlineFlow { let link = link.clone(); let title = title.to_string(); let tooltip_title = title.clone(); + let context_view = view.clone(); + let context_url = url.clone(); + let context_title = title.clone(); let label = if !title.trim().is_empty() { title.clone() } else if let Some(link) = &link { @@ -177,6 +180,17 @@ impl InlineFlow { Tooltip::new(tooltip_title.clone()).build(window, cx) }) }) + .on_mouse_down(MouseButton::Right, move |_, _, cx| { + context_view.update(cx, |state, cx| { + state.set_pending_context( + Some(PendingContextTarget::Image { + url: context_url.clone(), + title: context_title.clone(), + }), + cx, + ) + }); + }) .on_click(move |_, window, cx| { gpui_base::TextSelection::end(window, cx); cx.stop_propagation(); diff --git a/crates/ui/src/markdown/mod.rs b/crates/ui/src/markdown/mod.rs index 34f3b0fb8..95d337442 100644 --- a/crates/ui/src/markdown/mod.rs +++ b/crates/ui/src/markdown/mod.rs @@ -22,7 +22,7 @@ use gpui_base::input::SelectAll; #[cfg(test)] pub(crate) use parse::parse; pub use state::MarkdownState; -pub use view::MarkdownView; +pub use view::{MarkdownView, MenuExtension}; pub(super) const CONTEXT: &str = "MarkdownView"; diff --git a/crates/ui/src/markdown/render.rs b/crates/ui/src/markdown/render.rs index 2ef1ccdc6..c144533b9 100644 --- a/crates/ui/src/markdown/render.rs +++ b/crates/ui/src/markdown/render.rs @@ -16,9 +16,9 @@ use crate::widgets::tooltip::Tooltip; use gpui::{ AnyElement, App, AvailableSpace, Bounds, Element, ElementId, Entity, FontStyle, FontWeight, GlobalElementId, HighlightStyle, InspectorElementId, InteractiveElement as _, IntoElement, - LayoutId, ListState, ObjectFit, ParentElement as _, Pixels, Rems, Role, SharedString, - StatefulInteractiveElement as _, Style, Styled as _, StyledImage as _, Window, div, img, - prelude::FluentBuilder as _, px, relative, rems, size, + LayoutId, ListState, MouseButton, ObjectFit, ParentElement as _, Pixels, Rems, Role, + SharedString, StatefulInteractiveElement as _, Style, Styled as _, StyledImage as _, Window, + div, img, prelude::FluentBuilder as _, px, relative, rems, size, }; use gpui_base::{h_flex, v_flex}; @@ -29,7 +29,7 @@ use super::{ inline_flow::{InlineCodeStyle, InlineFlow, InlineFlowItem}, link_target::LinkTarget, nodes::{BlockNode, CodeBlock, ColumnumnAlign, Paragraph, Table, TextMark}, - state::MarkdownState, + state::{MarkdownState, PendingContextTarget}, utils::list_item_prefix, }; @@ -571,6 +571,9 @@ fn render_paragraph( let view = view.clone(); let source = view.read(cx).image_source(&image.url); let link = image.link.clone(); + let context_view = view.clone(); + let context_url = image.url.clone(); + let context_title = title.clone(); let label = if !title.trim().is_empty() { title.clone() } else if let Some(link) = &link { @@ -595,6 +598,17 @@ fn render_paragraph( Tooltip::new(tooltip_title.clone()).build(window, cx) }) }) + .on_mouse_down(MouseButton::Right, move |_, _, cx| { + context_view.update(cx, |state, cx| { + state.set_pending_context( + Some(PendingContextTarget::Image { + url: context_url.clone(), + title: context_title.clone(), + }), + cx, + ) + }); + }) .on_click(move |_, window, cx| { gpui_base::TextSelection::end(window, cx); cx.stop_propagation(); @@ -1044,9 +1058,24 @@ fn render_code_block( offset = end.saturating_add(1); } let radius = cx.theme().tokens.radius.md; + // The whole fence, not the painted span: a long block is split across + // list items and the menu copies the code a reader sees as one block. + let whole_code = code_text + .strip_suffix('\n') + .unwrap_or(code_text) + .to_string(); + let context_view = view.clone(); div() .id(span_id(&options.path, &span)) .when(last && !options.is_last, |block| block.pb(rems(1.))) + .on_mouse_down(MouseButton::Right, move |_, _, cx| { + context_view.update(cx, |state, cx| { + state.set_pending_context( + Some(PendingContextTarget::CodeBlock(whole_code.clone())), + cx, + ) + }); + }) .child( div() .px_3() diff --git a/crates/ui/src/markdown/state.rs b/crates/ui/src/markdown/state.rs index ec1cd6f96..aeef0d927 100644 --- a/crates/ui/src/markdown/state.rs +++ b/crates/ui/src/markdown/state.rs @@ -27,6 +27,15 @@ pub(super) struct PendingLinkMenu { pub(super) raw_url: SharedString, } +/// What the last right-click landed on, recorded by the inline that was hit +/// and read by the view's context menu, which opens after the press settles. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum PendingContextTarget { + Link(PendingLinkMenu), + CodeBlock(String), + Image { url: SharedUri, title: String }, +} + /// State backing a [`super::MarkdownView`]. pub struct MarkdownState { pub(super) focus_handle: FocusHandle, @@ -35,7 +44,7 @@ pub struct MarkdownState { pub(super) compact_headings: bool, pub(super) base_dir: Option, link_targets: LinkTargetCache, - pub(super) pending_context_link: Option, + pub(super) pending_context: Option, /// Window position of the last left mouse-down that landed on a link; /// a mouse-up nearby is a click, anything farther is a drag-selection. pub(super) link_press_origin: Option>, @@ -76,7 +85,7 @@ impl MarkdownState { compact_headings: false, base_dir: None, link_targets: LinkTargetCache::default(), - pending_context_link: None, + pending_context: None, link_press_origin: None, is_selecting: false, text: text.to_string(), @@ -205,15 +214,15 @@ impl MarkdownState { crate::store::host_image(path) } - pub(super) fn set_pending_context_link( + pub(super) fn set_pending_context( &mut self, - pending_context_link: Option, + pending_context: Option, cx: &mut Context, ) { - if self.pending_context_link == pending_context_link { + if self.pending_context == pending_context { return; } - self.pending_context_link = pending_context_link; + self.pending_context = pending_context; cx.notify(); } diff --git a/crates/ui/src/markdown/view.rs b/crates/ui/src/markdown/view.rs index ef0f74c30..c1a155819 100644 --- a/crates/ui/src/markdown/view.rs +++ b/crates/ui/src/markdown/view.rs @@ -2,47 +2,35 @@ //! `text/text_view.rs` implementation. use std::path::{Path, PathBuf}; +use std::rc::Rc; -use crate::overlay::{Notification, OverlayExt as _}; use crate::theme::ActiveTheme as _; use crate::touch_selection::SelectAllTouched; use crate::widgets::input::SelectAll; -use crate::widgets::menu::ContextMenuExt as _; +use crate::widgets::menu::{ContextMenuExt as _, CopyText, OpenUrl, PopupMenu}; use gpui::{ - Action, AnyElement, App, Bounds, ClipboardItem, Element, ElementId, Entity, GlobalElementId, - Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId, + Action, AnyElement, App, Bounds, Element, ElementId, Entity, GlobalElementId, Hitbox, + HitboxBehavior, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId, MouseButton, MouseDownEvent, ParentElement as _, Pixels, StyleRefinement, Styled, Window, div, - prelude::FluentBuilder as _, }; use gpui_base::{StyledExt as _, TouchHandleLayout}; use serde::Deserialize; -use super::{link_target::LinkTarget, state::MarkdownState}; +use super::{ + link_target::LinkTarget, + state::{MarkdownState, PendingContextTarget}, +}; #[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = tcode_markdown_link, no_json)] -struct OpenLink(String); -#[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = tcode_markdown_link, no_json)] -struct CopyLinkAddress(String); -#[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = tcode_markdown_link, no_json)] -struct CopyLinkText(String); -#[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = tcode_markdown_link, no_json)] -struct OpenPath(String); -#[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = tcode_markdown_link, no_json)] -struct OpenPathInZed(String); -#[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = tcode_markdown_link, no_json)] -struct RevealPath(String); -#[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = tcode_markdown_link, no_json)] -struct CopyPath(String); -#[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = tcode_markdown_link, no_json)] -struct CopyRelativePath(String); +#[action(namespace = tcode_markdown, no_json)] +struct OpenImage { + url: String, + title: String, +} + +/// Items a surface adds below the view's own: a message offers to copy or +/// rewind itself, a plan to download itself. +pub type MenuExtension = Rc PopupMenu>; /// A GPUI element that renders an [`Entity`]. #[derive(Clone)] @@ -53,6 +41,7 @@ pub struct MarkdownView { selectable: Option, compact_headings: Option, base_dir: Option, + menu_extension: Option, } impl MarkdownView { @@ -65,6 +54,7 @@ impl MarkdownView { selectable: None, compact_headings: None, base_dir: None, + menu_extension: None, } } @@ -86,6 +76,12 @@ impl MarkdownView { self.base_dir = Some(base_dir.into()); self } + + /// Append the surface's own items to the view's context menu. + pub fn menu_extension(mut self, extension: MenuExtension) -> Self { + self.menu_extension = Some(extension); + self + } } impl Styled for MarkdownView { @@ -175,80 +171,84 @@ impl Element for MarkdownView { state.update(cx, |_, cx| cx.notify()); } }) - .on_action(|action: &OpenLink, _, cx| cx.open_url(&action.0)) - .on_action(|action: &CopyLinkAddress, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(action.0.clone())) - }) - .on_action(|action: &CopyLinkText, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(action.0.clone())) - }) - .on_action(|action: &OpenPath, _, cx| cx.open_with_system(Path::new(&action.0))) - .on_action(|action: &OpenPathInZed, window, cx| { - open_in_zed(Path::new(&action.0), window, cx) - }) - .on_action(|action: &RevealPath, _, cx| cx.reveal_path(Path::new(&action.0))) - .on_action(|action: &CopyPath, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(action.0.clone())) - }) - .on_action(|action: &CopyRelativePath, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(action.0.clone())) + .on_action({ + let state = state.clone(); + move |action: &OpenImage, window, cx| { + let source = state.read(cx).image_source(&action.url.clone().into()); + crate::attachments::open_image_lightbox( + source, + action.title.clone(), + window, + cx, + ); + } }) .child(state.clone()) .refine_style(&self.style) .context_menu({ let state = state.clone(); - move |menu, _window, cx| { - let markdown = state.read(cx); - let Some(pending) = markdown.pending_context_link.clone() else { - return menu; + let extension = self.menu_extension.clone(); + move |menu, window, cx| { + let (selectable, pending, base_dir) = { + let markdown = state.read(cx); + ( + markdown.is_selectable(), + markdown.pending_context.clone(), + markdown.base_dir().map(Path::to_path_buf), + ) }; - match pending.target { - LinkTarget::Web(url) => menu - .menu( - crate::tr!("markdown.link_open").into_owned(), - Box::new(OpenLink(url)), - ) - .separator() - .menu( - crate::tr!("markdown.link_copy_address").into_owned(), - Box::new(CopyLinkAddress(pending.raw_url.to_string())), - ) - .menu( - crate::tr!("markdown.link_copy_text").into_owned(), - Box::new(CopyLinkText(pending.text.to_string())), - ), - LinkTarget::Local(path) => { - let path = path.to_string_lossy().into_owned(); - let relative_path = markdown.base_dir().map(|base_dir| { - crate::workspace_walk::relativize_to_workspace(&path, base_dir) - }); - menu.menu( - crate::tr!("chat.open").into_owned(), - Box::new(OpenPath(path.clone())), - ) - .menu( - crate::tr!("chat.open_zed").into_owned(), - Box::new(OpenPathInZed(path.clone())), - ) - .menu( - crate::tr!("chat.reveal_in_file_manager").into_owned(), - Box::new(RevealPath(path.clone())), - ) - .separator() - .menu( - crate::tr!("chat.copy_path").into_owned(), - Box::new(CopyPath(path)), - ) - .when_some( - relative_path, - |menu, relative_path| { - menu.menu( - crate::tr!("markdown.path_copy_relative").into_owned(), - Box::new(CopyRelativePath(relative_path)), - ) - }, - ) + let menu = if selectable { + menu.selection_items(true, window, cx) + } else { + menu + }; + let menu = match pending { + None => menu, + Some(PendingContextTarget::Link(pending)) => match pending.target { + LinkTarget::Web(url) => menu + .separator() + .menu( + crate::tr!("markdown.link_open").into_owned(), + Box::new(OpenUrl(url)), + ) + .menu( + crate::tr!("markdown.link_copy_address").into_owned(), + Box::new(CopyText(pending.raw_url.to_string())), + ) + .menu( + crate::tr!("markdown.link_copy_text").into_owned(), + Box::new(CopyText(pending.text.to_string())), + ), + LinkTarget::Local(path) => menu + .separator() + .path_items(&path.to_string_lossy(), base_dir.as_deref()), + }, + Some(PendingContextTarget::CodeBlock(code)) => menu.separator().menu( + crate::tr!("markdown.code_copy").into_owned(), + Box::new(CopyText(code)), + ), + Some(PendingContextTarget::Image { url, title }) => { + let menu = menu.separator().menu( + crate::tr!("markdown.open_image").into_owned(), + Box::new(OpenImage { + url: url.to_string(), + title, + }), + ); + match state.read(cx).resolve_link(&url) { + LinkTarget::Web(address) => menu.menu( + crate::tr!("markdown.image_copy_address").into_owned(), + Box::new(CopyText(address)), + ), + LinkTarget::Local(path) => menu + .separator() + .path_items(&path.to_string_lossy(), base_dir.as_deref()), + } } + }; + match &extension { + Some(extension) => extension(menu.separator(), window, cx), + None => menu, } } }) @@ -307,7 +307,7 @@ impl Element for MarkdownView { && event.button == MouseButton::Right && hitbox.is_hovered(window) { - state.update(cx, |state, cx| state.set_pending_context_link(None, cx)); + state.update(cx, |state, cx| state.set_pending_context(None, cx)); } } }); @@ -343,18 +343,6 @@ impl Element for MarkdownView { } } -/// Launching an editor is the client's own process work, so it is injected -/// through the client host rather than linked here. A client without one (a -/// phone, a browser) reports that plainly. -fn open_in_zed(path: &Path, window: &mut Window, cx: &mut App) { - if !matches!(crate::remote::open_in_editor(path, cx), Some(Ok(()))) { - window.push_notification( - Notification::error(crate::tr!("errors.zed_cli_missing")), - cx, - ); - } -} - #[cfg(test)] mod tests { use std::{cell::RefCell, rc::Rc}; @@ -1246,13 +1234,29 @@ mod tests { assert_eq!(cx.opened_url().as_deref(), Some("https://example.com")); } + /// The view under the window `Root`, which owns Copy for the window + /// selection, as the shell mounts it. + fn right_click_window( + cx: &mut TestAppContext, + ) -> (Entity, &mut VisualTestContext) { + let view = Rc::new(std::cell::OnceCell::new()); + let (_, cx) = cx.add_window_view({ + let view = view.clone(); + move |window, cx| { + let root = cx.new(RightClickRoot::new); + view.set(root.clone()).ok().unwrap(); + gpui_base::Root::new(root, window, cx) + } + }); + (view.get().unwrap().clone(), cx) + } + #[gpui::test] fn link_context_menu_action_opens_the_url(cx: &mut TestAppContext) { cx.update(crate::theme::init); cx.update(crate::markdown::init); cx.update(crate::widgets::menu::init); - let (view, cx) = cx.add_window_view(|_, cx| RightClickRoot::new(cx)); - let cx: &mut VisualTestContext = cx; + let (view, cx) = right_click_window(cx); cx.run_until_parked(); cx.update(|window, cx| { let _ = window.draw(cx); @@ -1274,19 +1278,20 @@ mod tests { let _ = window.draw(cx); }); - // Select "Open Link" and confirm; the action must dispatch through - // the trigger's ancestor chain to the OpenLink handler. - cx.simulate_keystrokes("down enter"); + // Copy is disabled with nothing selected, so the first Down lands on + // Select All and the second on "Open link"; the action must dispatch + // through the trigger's ancestor chain to the OpenUrl handler. + cx.simulate_keystrokes("down down enter"); cx.run_until_parked(); assert_eq!(cx.opened_url().as_deref(), Some("https://example.com")); } #[gpui::test] - fn right_click_off_link_is_swallowed_so_no_menu_opens(cx: &mut TestAppContext) { + fn right_click_on_text_opens_the_selection_menu_and_is_swallowed(cx: &mut TestAppContext) { cx.update(crate::theme::init); cx.update(crate::markdown::init); - let (view, cx) = cx.add_window_view(|_, cx| RightClickRoot::new(cx)); - let cx: &mut VisualTestContext = cx; + cx.update(crate::widgets::menu::init); + let (view, cx) = right_click_window(cx); cx.run_until_parked(); cx.update(|window, cx| { let _ = window.draw(cx); @@ -1302,31 +1307,61 @@ mod tests { ) }); - // Plain (and selectable) text: the press must be swallowed before the - // context-menu popover — and thus before the root handler — sees it. + // Plain (and selectable) text: the view's own menu (Copy, Select + // All) opens and takes focus, and the press is consumed before the + // root handler sees it. cx.simulate_mouse_down(text_pos, MouseButton::Right, Modifiers::default()); cx.simulate_mouse_up(text_pos, MouseButton::Right, Modifiers::default()); + cx.run_until_parked(); cx.update(|window, cx| { let _ = window.draw(cx); }); let bubbled = view.read_with(cx, |root, _| *root.bubbled_right_clicks.borrow()); assert_eq!(bubbled, 0, "right-click on plain text must not propagate"); - - let focused = cx.update(|window, cx| window.focused(cx)); let markdown_focus = view.read_with(cx, |root, cx| root.markdown.read(cx).focus_handle.clone()); + let focused = cx.update(|window, cx| window.focused(cx)); assert!( - focused.is_none() || focused == Some(markdown_focus.clone()), - "no menu may take focus on a plain-text right-click" + focused.is_some_and(|focused| focused != markdown_focus), + "a plain-text right-click must open and focus the context menu" + ); + + // Select All from that menu selects the whole view, and Copy, which + // was disabled with nothing selected, then copies it. + cx.simulate_keystrokes("down enter"); + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + assert_eq!( + cx.update(gpui_base::TextSelection::selected_text).trim(), + "Alpha beta\nclick" + ); + cx.simulate_mouse_down(text_pos, MouseButton::Right, Modifiers::default()); + cx.simulate_mouse_up(text_pos, MouseButton::Right, Modifiers::default()); + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.simulate_keystrokes("down enter"); + cx.run_until_parked(); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("Alpha beta\nclick") ); - // A link still surfaces its context menu: the popover consumes the - // press (so the root handler stays at zero) and focuses the menu. + // A link's menu adds the link items after the view's own; the press + // is consumed the same way and the menu takes focus. cx.simulate_mouse_down(link_pos, MouseButton::Right, Modifiers::default()); cx.simulate_mouse_up(link_pos, MouseButton::Right, Modifiers::default()); + cx.run_until_parked(); cx.update(|window, cx| { let _ = window.draw(cx); }); + let bubbled = view.read_with(cx, |root, _| *root.bubbled_right_clicks.borrow()); + assert_eq!(bubbled, 0, "right-click on a link must not propagate"); let focused = cx.update(|window, cx| window.focused(cx)); assert!( focused.is_some_and(|focused| focused != markdown_focus), diff --git a/crates/ui/src/plan_panel.rs b/crates/ui/src/plan_panel.rs index e4b298d3b..487dc898e 100644 --- a/crates/ui/src/plan_panel.rs +++ b/crates/ui/src/plan_panel.rs @@ -1,9 +1,11 @@ //! Proposed-plan Markdown and structured task steps, hosted beside the diff view. +use std::rc::Rc; use std::time::Duration; use crate::theme::ActiveTheme as _; use crate::widgets::button::{Button, ButtonVariants as _}; +use crate::widgets::menu::PopupMenu; use crate::widgets::spinner::Spinner; use crate::{ icon::{Icon, IconName}, @@ -11,18 +13,57 @@ use crate::{ }; use agent::{PlanStep, PlanStepStatus}; use gpui::{ - AnyElement, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, - ParentElement as _, Render, ScrollHandle, StatefulInteractiveElement as _, Styled as _, - Subscription, Task, Window, div, px, + Action, AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, + IntoElement, ParentElement as _, Render, ScrollHandle, StatefulInteractiveElement as _, + Styled as _, Subscription, Task, Window, div, px, }; use gpui_base::{InteractiveElementExt as _, StyledExt as _, h_flex, v_flex}; +use serde::Deserialize; use tcode_core::session::plan_title; -use crate::markdown::{MarkdownState, MarkdownView}; +use crate::markdown::{MarkdownState, MarkdownView, MenuExtension}; use crate::material; use crate::store::{TopicKind, WorkspaceStore, observe_store_topics}; +/// A proposed plan's context-menu action: the same three its buttons offer, +/// here and on the chat's plan card. +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_plan, no_json)] +pub(crate) struct PlanMenu { + pub(crate) markdown: String, + pub(crate) kind: PlanMenuKind, +} + +#[derive(Clone, Copy, PartialEq, Eq, Deserialize)] +pub(crate) enum PlanMenuKind { + Copy, + Download, + Save, +} + +/// The plan's actions as menu items, for the Markdown that shows it and the +/// margin around it. +pub(crate) fn plan_menu_items(markdown: String) -> MenuExtension { + Rc::new(move |menu: PopupMenu, _: &mut Window, _: &mut App| { + [ + ("plan.copy", PlanMenuKind::Copy), + ("plan.download", PlanMenuKind::Download), + ("plan.save_workspace", PlanMenuKind::Save), + ] + .into_iter() + .fold(menu, |menu, (key, kind)| { + menu.menu( + crate::tr!(key).into_owned(), + Box::new(PlanMenu { + markdown: markdown.clone(), + kind, + }), + ) + }) + }) +} + pub struct PlanPanel { store: Entity, /// Cached markdown state for the proposed-plan body (rebuilt when the text @@ -85,6 +126,7 @@ impl PlanPanel { plan_title(&markdown).unwrap_or_else(|| crate::tr!("plan.proposed_plan").into_owned()); let md_state = self.sync_markdown(&markdown, cx); let copied = self.copied; + let menu_items = plan_menu_items(markdown.clone()); let md_copy = markdown.clone(); let md_download = markdown.clone(); @@ -126,7 +168,11 @@ impl PlanPanel { .min_w_0() .text_size(px(13.)) .line_height(px(20.)) - .child(MarkdownView::new(&md_state).selectable(true)), + .child( + MarkdownView::new(&md_state) + .selectable(true) + .menu_extension(menu_items), + ), ) .child( h_flex() @@ -183,6 +229,26 @@ impl PlanPanel { .into_any_element() } + fn on_plan_menu(&mut self, action: &PlanMenu, _: &mut Window, cx: &mut Context) { + let markdown = action.markdown.clone(); + match action.kind { + PlanMenuKind::Copy => { + self.store + .update(cx, |store, _cx| store.copy_plan(markdown)); + self.mark_copied(cx); + } + PlanMenuKind::Download => { + let fallback = crate::tr!("plan.proposed_plan").into_owned(); + self.store + .update(cx, |store, _cx| store.download_plan(markdown, fallback)); + } + PlanMenuKind::Save => { + self.store + .update(cx, |store, _cx| store.save_plan_to_workspace(markdown)); + } + } + } + fn render_steps(&self, steps: &[PlanStep], cx: &mut Context) -> AnyElement { let muted = cx.theme().muted_foreground; let mut steps_col = v_flex().w_full().min_w_0().gap_1(); @@ -293,17 +359,20 @@ impl Render for PlanPanel { column = column.child(self.render_steps(&steps, cx)); } - v_flex().size_full().child(crate::scroll::page_viewport( - "plan-scroll-bounce", - crate::wheel_easing::Handle::Scroll(self.vscroll.clone()), - div() - .id("plan-scroll") - .flex_1() - .min_h_0() - .overflow_y_scroll() - .lock_scroll_axis() - .track_scroll(&self.vscroll) - .child(column), - )) + v_flex() + .size_full() + .on_action(cx.listener(Self::on_plan_menu)) + .child(crate::scroll::page_viewport( + "plan-scroll-bounce", + crate::wheel_easing::Handle::Scroll(self.vscroll.clone()), + div() + .id("plan-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .lock_scroll_axis() + .track_scroll(&self.vscroll) + .child(column), + )) } } diff --git a/crates/ui/src/provider_card.rs b/crates/ui/src/provider_card.rs index 3ab5f75e7..aac2b562a 100644 --- a/crates/ui/src/provider_card.rs +++ b/crates/ui/src/provider_card.rs @@ -6,6 +6,7 @@ //! settings dialog, a transactional modal form. use crate::icon::Icon; +use crate::widgets::menu::{ContextMenuExt as _, CopyText}; use crate::{ icon::IconName, overlay::OverlayExt as _, @@ -398,7 +399,16 @@ impl ProviderCard { copy.clone(), )); }), - ), + ) + .context_menu({ + let command = command.clone(); + move |menu, _, _| { + menu.menu( + crate::tr!("providers.copy_command").into_owned(), + Box::new(CopyText(command.clone())), + ) + } + }), ); } pane diff --git a/crates/ui/src/terminal_drawer.rs b/crates/ui/src/terminal_drawer.rs index b4d3da233..f6c2a923e 100644 --- a/crates/ui/src/terminal_drawer.rs +++ b/crates/ui/src/terminal_drawer.rs @@ -205,6 +205,23 @@ struct TerminalClear(u64); #[action(namespace = tcode_terminal, no_json)] struct TerminalAddContext(u64); +/// A tab's context-menu action. Restart and the splits act on the active +/// terminal, so the tab is activated first. +#[derive(Action, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[action(namespace = tcode_terminal, no_json)] +struct TerminalTabMenu { + id: u64, + kind: TerminalTabMenuKind, +} + +#[derive(Clone, Copy, PartialEq, Eq, serde::Deserialize)] +enum TerminalTabMenuKind { + Restart, + SplitHorizontal, + SplitVertical, + Close, +} + /// A drawer action that does not fit the compact toolbar row and lives in its /// overflow menu, which addresses items by action rather than by callback. #[derive(Action, Clone, Copy, PartialEq, Eq, serde::Deserialize)] @@ -643,6 +660,25 @@ impl TerminalDrawer { }); } + fn on_tab_menu(&mut self, action: &TerminalTabMenu, _: &mut Window, cx: &mut Context) { + let TerminalTabMenu { id, kind } = *action; + self.workspace_store.update(cx, |store, cx| match kind { + TerminalTabMenuKind::Restart => { + store.activate_terminal(id); + store.restart_terminal(); + } + TerminalTabMenuKind::SplitHorizontal => { + store.activate_terminal(id); + store.split_terminal(TerminalSplitDirection::Horizontal); + } + TerminalTabMenuKind::SplitVertical => { + store.activate_terminal(id); + store.split_terminal(TerminalSplitDirection::Vertical); + } + TerminalTabMenuKind::Close => store.close_terminal(id, cx), + }); + } + /// Apply the bell and OSC 52 notices carried by the latest grid deltas. fn drain_terminal_notices(&mut self, window: &mut Window, cx: &mut Context) { let notices = self @@ -1504,7 +1540,33 @@ impl Render for TerminalDrawer { this.workspace_store .update(cx, |store, cx| store.close_terminal(close_id, cx)); })), - ), + ) + .context_menu({ + let can_split = + tabs.len() < MAX_TERMINALS_PER_SESSION && active_split.is_none(); + move |menu, _, _| { + let item = |kind| Box::new(TerminalTabMenu { id, kind }); + menu.menu( + crate::tr!("terminal.restart").into_owned(), + item(TerminalTabMenuKind::Restart), + ) + .menu_with_enable( + crate::tr!("terminal.split_horizontal").into_owned(), + item(TerminalTabMenuKind::SplitHorizontal), + can_split, + ) + .menu_with_enable( + crate::tr!("terminal.split_vertical").into_owned(), + item(TerminalTabMenuKind::SplitVertical), + can_split, + ) + .separator() + .menu( + crate::tr!("terminal.close_tab").into_owned(), + item(TerminalTabMenuKind::Close), + ) + } + }), ); } @@ -1711,6 +1773,7 @@ impl Render for TerminalDrawer { .on_action(cx.listener(Self::on_terminal_clear)) .on_action(cx.listener(Self::on_terminal_add_context)) .on_action(cx.listener(Self::on_overflow)) + .on_action(cx.listener(Self::on_tab_menu)) .child(header) .child( crate::material::accessible_clickable( diff --git a/crates/ui/src/widgets/input.rs b/crates/ui/src/widgets/input.rs index c5b2df37e..a56ca99dc 100644 --- a/crates/ui/src/widgets/input.rs +++ b/crates/ui/src/widgets/input.rs @@ -5,14 +5,29 @@ use crate::{ sizing::{Sizable, Size}, theme::ActiveTheme as _, touch_selection::{EditMenuItem, TouchSelectionOverlay}, + widgets::menu::ContextMenuExt as _, }; use gpui::{ - Action, AnyElement, App, DefiniteLength, Edges, Entity, Focusable as _, IntoElement, - LongPressEvent, ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, - TextAlign, TouchPhase, Window, div, prelude::FluentBuilder as _, px, rems, + Action, AnyElement, App, DefiniteLength, Edges, Entity, Focusable as _, + InteractiveElement as _, IntoElement, LongPressEvent, ParentElement as _, RenderOnce, + SharedString, StyleRefinement, Styled, TextAlign, TouchPhase, Window, div, + prelude::FluentBuilder as _, px, rems, }; use gpui_base::StyledExt as _; use gpui_base::{InputBase, RoleOverride}; +use serde::Deserialize; + +/// A right-click menu item of a field. The menu takes focus while it is open, +/// so its choice is forwarded to the field's own focus handle, where the +/// editor's Cut, Copy, Paste and Select All handlers live. +#[derive(Action, Clone, Copy, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_input, no_json)] +enum InputMenu { + Cut, + Copy, + Paste, + SelectAll, +} pub use gpui_base::input::{Copy, InputEvent, InputState, Paste, SelectAll, TextareaState}; @@ -264,7 +279,18 @@ impl RenderOnce for Input { }; let input_entity = base.clone(); let focus = base.read(cx).focus_handle(cx); + let menu_focus = focus.clone(); + let menu_state = base.clone(); + let disabled = self.disabled; InputBase::new(("input", base.entity_id())) + .on_action(move |action: &InputMenu, window, cx| match action { + InputMenu::Cut => { + menu_focus.dispatch_action(&gpui_base::input::Cut, window, cx) + } + InputMenu::Copy => menu_focus.dispatch_action(&Copy, window, cx), + InputMenu::Paste => menu_focus.dispatch_action(&Paste, window, cx), + InputMenu::SelectAll => menu_focus.dispatch_action(&SelectAll, window, cx), + }) .focused(focused) .disabled(self.disabled) .role(self.role) @@ -346,6 +372,46 @@ impl RenderOnce for Input { // The handles float above the field and the menu above the // window; both are deferred, so they draw over the editor. .children(touch_selection) + // The editor already moved the caret to the press; what the + // menu offers follows what the field can do right now, as the + // touch edit menu does, with items disabled rather than left + // out so the menu keeps one shape. + .context_menu(move |menu, _, cx| { + if disabled { + return menu; + } + let (capabilities, has_text) = { + let base = menu_state.read(cx); + (base.context_menu_capabilities(), base.text().len() > 0) + }; + let editable = capabilities.is_editable(); + let copyable = capabilities.is_copyable(); + let has_selection = capabilities.has_selection(); + menu.menu_with_enable( + crate::tr!("edit_menu.cut").into_owned(), + Box::new(InputMenu::Cut), + editable && copyable && has_selection, + ) + .menu_with_enable( + crate::tr!("edit_menu.copy").into_owned(), + Box::new(InputMenu::Copy), + copyable && has_selection, + ) + // Offered whenever the text can change, without peeking + // at the clipboard: the synchronous read is always empty + // on the web, and an empty clipboard pastes nothing. + .menu_with_enable( + crate::tr!("edit_menu.paste").into_owned(), + Box::new(InputMenu::Paste), + editable, + ) + .separator() + .menu_with_enable( + crate::tr!("edit_menu.select_all").into_owned(), + Box::new(InputMenu::SelectAll), + has_text, + ) + }) .into_any_element() }) } diff --git a/crates/ui/src/widgets/menu.rs b/crates/ui/src/widgets/menu.rs index 7865f0982..2aabc7bc7 100644 --- a/crates/ui/src/widgets/menu.rs +++ b/crates/ui/src/widgets/menu.rs @@ -1,14 +1,17 @@ +use std::path::Path; use std::rc::Rc; use gpui::{ - Action, Anchor, AnyElement, App, AppContext as _, Context, DismissEvent, ElementId, Entity, - EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, MouseButton, - MouseDownEvent, ParentElement, Pixels, Point, Render, RenderOnce, Role, ScrollHandle, - SharedString, StatefulInteractiveElement as _, Styled, Subscription, Window, deferred, div, - prelude::FluentBuilder, px, + Action, Anchor, AnyElement, App, AppContext as _, ClipboardItem, Context, DismissEvent, + ElementId, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, + KeyBinding, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, RenderOnce, + Role, ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled, Subscription, + Window, deferred, div, prelude::FluentBuilder, px, }; use gpui_base::actions::{Cancel, Confirm, SelectDown, SelectUp}; +use serde::Deserialize; +use crate::overlay::{Notification, OverlayExt as _}; use crate::{ icon::{Icon, IconName}, scroll::ScrollableElement as _, @@ -18,6 +21,37 @@ use crate::{ const CONTEXT: &str = "TcodePopupMenu"; +/// The actions every context menu can offer without its surface wiring a +/// handler: each [`ContextMenu`] trigger handles them, so a menu item built +/// anywhere resolves at the nearest trigger above it. +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_menu, no_json)] +pub struct CopyText(pub String); +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_menu, no_json)] +pub struct OpenUrl(pub String); +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_menu, no_json)] +pub struct OpenPath(pub String); +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_menu, no_json)] +pub struct OpenPathInZed(pub String); +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_menu, no_json)] +pub struct RevealPath(pub String); + +/// Launching an editor is the client's own process work, so it is injected +/// through the client host rather than linked here. A client without one (a +/// phone, a browser) reports that plainly. +pub fn open_in_zed(path: &Path, window: &mut Window, cx: &mut App) { + if !matches!(crate::remote::open_in_editor(path, cx), Some(Ok(()))) { + window.push_notification( + Notification::error(crate::tr!("errors.zed_cli_missing")), + cx, + ); + } +} + pub fn init(cx: &mut App) { cx.bind_keys([ KeyBinding::new("escape", Cancel, Some(CONTEXT)), @@ -122,6 +156,56 @@ impl PopupMenu { } self } + /// What a file path offers wherever one appears: open it, open it in + /// Zed, reveal it, and copy it absolute or relative to `base_dir`. + pub fn path_items(self, path: &str, base_dir: Option<&Path>) -> Self { + let relative = base_dir + .map(|base_dir| crate::workspace_walk::relativize_to_workspace(path, base_dir)) + .filter(|relative| relative != path); + self.menu( + crate::tr!("chat.open").into_owned(), + Box::new(OpenPath(path.to_string())), + ) + .menu( + crate::tr!("chat.open_zed").into_owned(), + Box::new(OpenPathInZed(path.to_string())), + ) + .menu( + crate::tr!("chat.reveal_in_file_manager").into_owned(), + Box::new(RevealPath(path.to_string())), + ) + .separator() + .menu( + crate::tr!("chat.copy_path").into_owned(), + Box::new(CopyText(path.to_string())), + ) + .when_some(relative, |menu, relative| { + menu.menu( + crate::tr!("markdown.path_copy_relative").into_owned(), + Box::new(CopyText(relative)), + ) + }) + } + /// Copy and Select All for read-only text in the window selection. Copy + /// goes through the root's `Copy`, which reads the window selection, so + /// it is enabled only while something is selected; Select All dispatches + /// to the surface that owns the text and is offered when `select_all`. + pub fn selection_items(self, select_all: bool, window: &mut Window, cx: &mut App) -> Self { + let has_selection = !gpui_base::TextSelection::selected_text(window, cx) + .trim() + .is_empty(); + self.menu_with_enable( + crate::tr!("edit_menu.copy").into_owned(), + Box::new(gpui_base::input::Copy), + has_selection, + ) + .when(select_all, |menu| { + menu.menu( + crate::tr!("edit_menu.select_all").into_owned(), + Box::new(gpui_base::input::SelectAll), + ) + }) + } pub fn is_empty(&self) -> bool { self.items.is_empty() } @@ -457,14 +541,25 @@ impl Ren }); }, ); - let mut trigger = self.trigger.on_mouse_down(MouseButton::Right, { - let state = state.clone(); - let open = open.clone(); - move |event: &MouseDownEvent, window, cx| { - cx.stop_propagation(); - open(event.position, state.clone(), window, cx); - } - }); + let mut trigger = self + .trigger + .on_mouse_down(MouseButton::Right, { + let state = state.clone(); + let open = open.clone(); + move |event: &MouseDownEvent, window, cx| { + cx.stop_propagation(); + open(event.position, state.clone(), window, cx); + } + }) + .on_action(|action: &CopyText, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(action.0.clone())) + }) + .on_action(|action: &OpenUrl, _, cx| cx.open_url(&action.0)) + .on_action(|action: &OpenPath, _, cx| cx.open_with_system(Path::new(&action.0))) + .on_action(|action: &OpenPathInZed, window, cx| { + open_in_zed(Path::new(&action.0), window, cx) + }) + .on_action(|action: &RevealPath, _, cx| cx.reveal_path(Path::new(&action.0))); if touch { let state = state.clone(); trigger = trigger.child( diff --git a/locales/en.yml b/locales/en.yml index c44587e68..c07623bdd 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -79,6 +79,13 @@ chat: scroll_end: "Scroll to end" copy: "Copy" copied: "Copied!" + copy_message: "Copy message" + copy_title: "Copy title" + copy_command: "Copy command" + copy_output: "Copy output" + copy_input: "Copy input" + copy_text: "Copy text" + copy_error: "Copy error" limit_resume: resumes_in: "Resumes in %{countdown}" cancel: "Cancel" @@ -104,6 +111,8 @@ markdown: link_copy_address: "Copy link address" link_copy_text: "Copy link text" path_copy_relative: "Copy relative path" + code_copy: "Copy code" + image_copy_address: "Copy image address" sidebar: parent_unavailable: "(parent unavailable)" collapse: "Collapse sidebar" @@ -620,6 +629,9 @@ composer: steer_unsupported_tooltip: "%{agent} can't be steered — this message will be sent when the turn ends" steer_unsupported: "%{agent} can't be steered — message queued" drop_queued: "Remove from queue" + edit_queued: "Edit in composer" + remove_context: "Remove" + remove_image: "Remove image" loading: "Loading…" loading_models: "Loading models…" local_checkout: "Local checkout" @@ -737,6 +749,8 @@ diff: loading: "Loading diff…" truncated: "This diff exceeded 200 KB. The preview was truncated." add_comment: "Add comment" + copy_line: "Copy line" + copy_selected_lines: "Copy selected lines" submit_comment: "Add comment" comment_placeholder: "Leave a review comment…" plan: diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index a9eb8eefc..43d76eaca 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -79,6 +79,13 @@ chat: scroll_end: "滚动到底部" copy: "复制" copied: "已复制!" + copy_message: "复制消息" + copy_title: "复制标题" + copy_command: "复制命令" + copy_output: "复制输出" + copy_input: "复制输入" + copy_text: "复制文字" + copy_error: "复制错误信息" limit_resume: resumes_in: "将在 %{countdown} 后继续" cancel: "取消" @@ -104,6 +111,8 @@ markdown: link_copy_address: "复制链接地址" link_copy_text: "复制链接文字" path_copy_relative: "复制相对路径" + code_copy: "复制代码" + image_copy_address: "复制图片地址" sidebar: parent_unavailable: "(父对话不可用)" collapse: "折叠侧边栏" @@ -614,6 +623,9 @@ composer: steer_unsupported_tooltip: "%{agent} 不支持引导 — 该消息将在本轮结束后发送" steer_unsupported: "%{agent} 不支持引导 — 消息已加入队列" drop_queued: "从队列中移除" + edit_queued: "在输入框中编辑" + remove_context: "移除" + remove_image: "移除图片" loading: "正在加载…" loading_models: "正在加载模型…" local_checkout: "本地检出" @@ -731,6 +743,8 @@ diff: loading: "正在加载 diff…" truncated: "此 diff 超过 200 KB,预览已截断。" add_comment: "添加评论" + copy_line: "复制该行" + copy_selected_lines: "复制选中的行" submit_comment: "添加评论" comment_placeholder: "留下审查评论…" plan: