feat(tui): add Copy to the sidebar right-click context menu
Removing Paste from the sidebar menu (#3065) left rows with no copy path at all — sidebar text can't be mouse-selected. Right-clicking a sidebar row now offers Copy, which writes the row's untruncated text plus its hover detail to the clipboard via a new ContextMenuAction::CopyText. Run stays first for clickable rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -466,6 +466,35 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a right-click in the sidebar to the hovered row's full copyable
|
||||||
|
/// text: the row's untruncated text plus its hover detail when present.
|
||||||
|
fn sidebar_row_copy_text(app: &App, mouse: MouseEvent) -> Option<String> {
|
||||||
|
for section in &app.sidebar_hover.sections {
|
||||||
|
if !mouse_hits_rect(mouse, Some(section.content_area)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(row) = section.rows.iter().find(|row| row.row_y == mouse.row) {
|
||||||
|
let mut text = row.full_text.clone();
|
||||||
|
if let Some(detail) = row.detail.as_deref()
|
||||||
|
&& !detail.trim().is_empty()
|
||||||
|
{
|
||||||
|
text.push('\n');
|
||||||
|
text.push_str(detail);
|
||||||
|
}
|
||||||
|
return Some(text).filter(|text| !text.trim().is_empty());
|
||||||
|
}
|
||||||
|
let line_idx = (mouse.row.saturating_sub(section.content_area.y)) as usize;
|
||||||
|
if let Some(full) = section.lines.get(line_idx) {
|
||||||
|
return Some(full.clone()).filter(|text| !text.trim().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_line(text: &str) -> &str {
|
||||||
|
text.lines().next().unwrap_or(text)
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a left-click in the sidebar to a slash command, if the clicked
|
/// Resolve a left-click in the sidebar to a slash command, if the clicked
|
||||||
/// row has a click_action assigned (#3028).
|
/// row has a click_action assigned (#3028).
|
||||||
fn sidebar_click_action(app: &App, mouse: MouseEvent) -> Option<String> {
|
fn sidebar_click_action(app: &App, mouse: MouseEvent) -> Option<String> {
|
||||||
@@ -676,6 +705,15 @@ pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec<Co
|
|||||||
action: ContextMenuAction::ExecuteCommand { command },
|
action: ContextMenuAction::ExecuteCommand { command },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Copy the hovered row's full text (sidebar rows can't be
|
||||||
|
// mouse-selected, so the menu is the only copy path).
|
||||||
|
if let Some(text) = sidebar_row_copy_text(app, mouse) {
|
||||||
|
entries.push(ContextMenuEntry {
|
||||||
|
label: "Copy".to_string(),
|
||||||
|
description: truncate_line_to_width(first_line(&text), 28),
|
||||||
|
action: ContextMenuAction::CopyText { text },
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Paste first — the most common action when right-clicking in the
|
// Paste first — the most common action when right-clicking in the
|
||||||
// composer or transcript after copying text from the output area.
|
// composer or transcript after copying text from the output area.
|
||||||
@@ -811,6 +849,13 @@ pub(crate) fn handle_context_menu_action(app: &mut App, action: ContextMenuActio
|
|||||||
app.status_message = Some("Command staged in composer".to_string());
|
app.status_message = Some("Command staged in composer".to_string());
|
||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
}
|
}
|
||||||
|
ContextMenuAction::CopyText { text } => {
|
||||||
|
if app.clipboard.write_text(&text).is_ok() {
|
||||||
|
app.status_message = Some("Copied".to_string());
|
||||||
|
} else {
|
||||||
|
app.status_message = Some("Copy failed".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
ContextMenuAction::OpenCommandPalette => {
|
ContextMenuAction::OpenCommandPalette => {
|
||||||
app.view_stack
|
app.view_stack
|
||||||
.push(CommandPaletteView::new(build_command_palette_entries(
|
.push(CommandPaletteView::new(build_command_palette_entries(
|
||||||
@@ -1211,6 +1256,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidebar_context_menu_offers_copy_of_hovered_row() {
|
||||||
|
let mut app = create_test_app();
|
||||||
|
app.viewport.last_sidebar_area = Some(Rect::new(60, 4, 20, 6));
|
||||||
|
app.sidebar_hover.sections.push(SidebarHoverSection {
|
||||||
|
content_area: Rect::new(60, 4, 20, 6),
|
||||||
|
lines: vec!["agent row".to_string()],
|
||||||
|
rows: vec![SidebarHoverRow {
|
||||||
|
row_y: 4,
|
||||||
|
display_text: "[~] worker doc-che…".to_string(),
|
||||||
|
full_text: "[~] worker doc-checker".to_string(),
|
||||||
|
detail: Some("id: agent_123 · 2 step(s)".to_string()),
|
||||||
|
is_truncated: true,
|
||||||
|
click_action: None,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let entries = build_context_menu_entries(&app, right_click(65, 4));
|
||||||
|
|
||||||
|
let copy = entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| matches!(entry.action, ContextMenuAction::CopyText { .. }))
|
||||||
|
.expect("sidebar row should offer Copy");
|
||||||
|
assert_eq!(copy.label, "Copy");
|
||||||
|
assert!(matches!(
|
||||||
|
©.action,
|
||||||
|
ContextMenuAction::CopyText { text }
|
||||||
|
if text == "[~] worker doc-checker\nid: agent_123 · 2 step(s)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidebar_click_outside_section_resolves_to_none() {
|
fn sidebar_click_outside_section_resolves_to_none() {
|
||||||
let mut app = create_test_app();
|
let mut app = create_test_app();
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ pub enum ContextMenuAction {
|
|||||||
ExecuteCommand {
|
ExecuteCommand {
|
||||||
command: String,
|
command: String,
|
||||||
},
|
},
|
||||||
|
/// Copy a pre-resolved text payload (e.g. a sidebar row's full text)
|
||||||
|
/// to the clipboard.
|
||||||
|
CopyText {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
Reference in New Issue
Block a user