From 81a9102b16f25e4c94bd61461ed95a42b06940fa Mon Sep 17 00:00:00 2001 From: Tryanks Date: Fri, 2 Oct 2026 03:36:45 +0800 Subject: [PATCH] feat(ui): right-click menus across inputs, Markdown, chat, diff, terminal and composer Every text field gets Cut / Copy / Paste / Select All on right-click, built in the Input wrapper from the editor's own capabilities. The Markdown view's menu no longer needs a link under the pointer: it offers Copy (with a window selection) and Select All, then the link, code block ("Copy code") or image under the pointer, then the hosting surface's own items through a new `menu_extension` hook. Shared menu actions (CopyText, OpenUrl, OpenPath, OpenPathInZed, RevealPath) are handled by every context-menu trigger, with `path_items` and `selection_items` as reusable groups; the two local `open_in_zed` helpers collapse into one. Surfaces gaining a menu: assistant and user messages (copy message, rewind modes), user attachments, tool-call and command rows (copy command / input / output / text), error cards, changed-file chips and live edit rows (view diff, path items), proposed plan cards and the plan panel (copy / download / save), the chat header title, diff file headers (path items) and code rows (copy line, copy selected lines, add comment), terminal tabs (restart, split, close), composer context chips, queued messages (copy, send now, edit, remove), pending images, and the provider update command. Co-Authored-By: Claude --- crates/ui/src/chat/components/activity.rs | 43 ++- crates/ui/src/chat/components/assistant.rs | 30 +- crates/ui/src/chat/components/bubble.rs | 58 +++- .../ui/src/chat/components/changed_files.rs | 28 +- crates/ui/src/chat/components/disclosure.rs | 7 +- crates/ui/src/chat/components/error_card.rs | 10 + crates/ui/src/chat/mod.rs | 99 +++++- crates/ui/src/composer/components/images.rs | 17 +- .../ui/src/composer/components/queue_strip.rs | 36 ++- crates/ui/src/composer/mod.rs | 62 ++++ crates/ui/src/diff/view.rs | 113 ++++++- crates/ui/src/gallery_support.rs | 1 + crates/ui/src/markdown/inline.rs | 12 +- crates/ui/src/markdown/inline_flow.rs | 24 +- crates/ui/src/markdown/mod.rs | 2 +- crates/ui/src/markdown/render.rs | 37 ++- crates/ui/src/markdown/state.rs | 21 +- crates/ui/src/markdown/view.rs | 285 ++++++++++-------- crates/ui/src/plan_panel.rs | 103 +++++-- crates/ui/src/provider_card.rs | 12 +- crates/ui/src/terminal_drawer.rs | 65 +++- crates/ui/src/widgets/input.rs | 72 ++++- crates/ui/src/widgets/menu.rs | 121 +++++++- locales/en.yml | 14 + locales/zh-CN.yml | 14 + 25 files changed, 1073 insertions(+), 213 deletions(-) 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: