From 61aea7f6ce99bf30f54eafa06eca082545a60d95 Mon Sep 17 00:00:00 2001 From: Daniel Kurzynski Date: Fri, 2 Oct 2026 17:39:18 +0200 Subject: [PATCH 1/2] Enable copy from the Review preview panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The right-side Review diff is painted by a custom DiffRows element inside a virtualized list, so its text was not selectable. Add whole-line selection: drag to select lines across a file's diff chunks, then copy with Cmd/Ctrl-C or the header Copy button (e.g. to paste code into the chat). - diff_rows: optional RowSelection on DiffRows — paints a selection highlight behind the glyphs and registers pointer listeners that map a position to a flat line index (base_line + row under the cursor). - diff_card: thread an optional selection through render_diff_chunk / render_diff_rows (tool-card diffs stay non-selectable). - review_view: hold the selection per file, begin/extend/end on pointer events via a weak handle, copy the joined lines, clear it when the file leaves the listing or the session changes. Tests cover the y->row mapping and cross-chunk line joining. --- .../main_screen/right_panel/review_view.rs | 291 +++++++++++++++++- crates/ui_gpui/src/tool_cards/diff_card.rs | 15 +- crates/ui_gpui/src/tool_cards/diff_rows.rs | 167 +++++++++- 3 files changed, 452 insertions(+), 21 deletions(-) diff --git a/crates/ui_gpui/src/main_screen/right_panel/review_view.rs b/crates/ui_gpui/src/main_screen/right_panel/review_view.rs index 66ac128a..090c181b 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/review_view.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/review_view.rs @@ -31,6 +31,7 @@ use super::review_rows::{ }; use crate::shared::file_icons; use crate::tool_cards::diff_card::{added_row_colors, deleted_row_colors, render_diff_chunk}; +use crate::tool_cards::diff_rows::{EndCallback, LineCallback, RowSelection}; use crate::{Gpui, PreparedReviewDiff, RepoReviewData}; use code_assistant_core::session::{ReviewMode, ReviewScanState}; use git::{ChangeStatus, ChangedFile}; @@ -41,11 +42,13 @@ use gpui_kit::component::{ v_flex, }; use gpui_kit::{ - AnimationExt, Context, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListAlignment, - ListState, Render, Subscription, Task, Window, div, list, prelude::*, px, rems, + AnimationExt, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, + KeyDownEvent, ListAlignment, ListState, Render, Subscription, Task, Window, div, list, + prelude::*, px, rems, }; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; +use std::rc::Rc; // --------------------------------------------------------------------------- // Compare-mode dropdown @@ -165,6 +168,24 @@ struct LoadedDiff { stamp: u64, } +/// A whole-line text selection within a single file's diff. `anchor` and +/// `head` are indices into that file's flattened hunk lines (the concatenation +/// of every hunk's lines, which is exactly what the diff chunks render); either +/// end may be the smaller. Selection never spans files. +#[derive(Clone)] +struct DiffSelection { + file: FileKey, + anchor: usize, + head: usize, +} + +impl DiffSelection { + /// Inclusive `(low, high)` line range. + fn range(&self) -> (usize, usize) { + (self.anchor.min(self.head), self.anchor.max(self.head)) + } +} + pub struct ReviewView { session_id: Option, mode_state: Entity>>, @@ -199,6 +220,12 @@ pub struct ReviewView { rows: Vec, list_state: ListState, + /// Current whole-line text selection in a file's diff, if any. Copying + /// (Cmd/Ctrl-C or the header button) reads from this. + selection: Option, + /// Whether the pointer button is down for an in-progress drag-select. + dragging: bool, + /// Filesystem watcher on the listed repos (keyed by their roots so a /// changed set restarts it). Dropping it stops watching. watcher: Option<(Vec, git::ChangeWatcher)>, @@ -246,6 +273,8 @@ impl ReviewView { next_diff_stamp: 0, rows: Vec::new(), list_state: ListState::new(0, ListAlignment::Top, REVIEW_LIST_OVERDRAW).measure_all(), + selection: None, + dragging: false, watcher: None, watch_task: None, listing_generation: GENERATION_UNSEEN, @@ -270,6 +299,8 @@ impl ReviewView { // Start the new session scrolled to the top. self.rows.clear(); self.list_state.reset(0); + self.selection = None; + self.dragging = false; // A new session lists its own repos; the watcher follows the listing. self.watcher = None; self.watch_task = None; @@ -520,6 +551,12 @@ impl ReviewView { { self.in_flight = None; } + if let Some(sel) = &self.selection + && !live.contains(&sel.file) + { + self.selection = None; + self.dragging = false; + } // Apply the persisted default base to any repo that has no explicit // override yet and whose resolved base differs. Seeding the override @@ -833,13 +870,14 @@ impl ReviewView { ReviewRow::Chunk { repo, file, chunk, .. } => { - let prepared = self.repos.get(repo).and_then(|section| { - let file = section.files.get(file)?; - let key = (section.repo_root.clone(), file.path.clone()); - Some(&self.file_diffs.get(&key)?.prepared) + let resolved = self.repos.get(repo).and_then(|section| { + let f = section.files.get(file)?; + let key = (section.repo_root.clone(), f.path.clone()); + let prepared = &self.file_diffs.get(&key)?.prepared; + Some((key, prepared)) }); - match prepared { - Some(prepared) => Self::render_chunk(prepared, chunk, window, cx), + match resolved { + Some((key, prepared)) => self.render_chunk(&key, prepared, chunk, window, cx), None => empty(), } } @@ -1056,9 +1094,79 @@ impl ReviewView { .into_any_element() } + /// Begin a new selection at flat line `line` in `file`, replacing any + /// previous one and marking a drag as in progress. + fn begin_selection(&mut self, file: FileKey, line: usize, cx: &mut Context) { + self.selection = Some(DiffSelection { + file, + anchor: line, + head: line, + }); + self.dragging = true; + cx.notify(); + } + + /// Extend the in-progress selection to flat line `line`, but only while a + /// drag that started in the same file is active. + fn extend_selection(&mut self, file: &FileKey, line: usize, cx: &mut Context) { + if !self.dragging { + return; + } + if let Some(sel) = &mut self.selection + && &sel.file == file + && sel.head != line + { + sel.head = line; + cx.notify(); + } + } + + /// End the current drag; the selection itself is kept for copying. + fn end_selection(&mut self) { + self.dragging = false; + } + + /// A file's diff lines flattened across its hunks in render order — the + /// same order the diff chunks (and thus selection indices) use. + fn file_lines(&self, key: &FileKey) -> Option> { + let loaded = self.file_diffs.get(key)?; + Some( + loaded + .prepared + .hunks + .iter() + .flat_map(|h| h.lines.iter().map(|l| l.text.to_string())) + .collect(), + ) + } + + /// The selected lines joined by newlines, or `None` when there is nothing + /// to copy (no selection, or the file's diff is no longer loaded). + fn selected_text(&self) -> Option { + let sel = self.selection.as_ref()?; + let lines = self.file_lines(&sel.file)?; + if lines.is_empty() { + return None; + } + let (lo, hi) = sel.range(); + let hi = hi.min(lines.len() - 1); + (lo <= hi).then(|| lines[lo..=hi].join("\n")) + } + + /// Copy the selected lines to the clipboard, joined by newlines. + fn copy_selection(&mut self, cx: &mut Context) { + if let Some(text) = self.selected_text() { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + } + /// One chunk of a file's diff body. The first and last chunk carry the - /// body's vertical padding, so the chunks read as one block. + /// body's vertical padding, so the chunks read as one block. The rows are + /// selectable: pointer events map to flat line indices in `key`'s diff and + /// drive the view's [`DiffSelection`]. fn render_chunk( + &self, + key: &FileKey, prepared: &PreparedReviewDiff, chunk_ix: usize, window: &Window, @@ -1077,6 +1185,71 @@ impl ReviewView { gpui_kit::hsla(0.0, 0.0, 0.97, 1.0) }; let line_height_px = rems(1.25).to_pixels(rem_size).round(); + + // Flat line index of this chunk's first row within the file's hunks, + // and the local rows (relative to this chunk) to paint as selected. + let base_line: usize = prepared.hunks[..chunk.hunk] + .iter() + .map(|h| h.lines.len()) + .sum::() + + chunk.lines.start; + let row_count = chunk.lines.len(); + let highlight = self + .selection + .as_ref() + .filter(|s| &s.file == key) + .map(|s| { + let (lo, hi) = s.range(); + let start = lo.max(base_line); + let end = hi.min(base_line + row_count.saturating_sub(1)); + if row_count > 0 && start <= end { + (start - base_line)..(end - base_line + 1) + } else { + 0..0 + } + }) + .unwrap_or(0..0); + + // Pointer callbacks reach the view through a weak handle, so the + // (static) closures held by the element never borrow it. + let entity = cx.entity().downgrade(); + let on_start: LineCallback = { + let entity = entity.clone(); + let focus = self.focus_handle.clone(); + let key = key.clone(); + Rc::new(move |line, window, cx: &mut gpui_kit::App| { + window.focus(&focus, cx); + if let Some(view) = entity.upgrade() { + view.update(cx, |this, cx| this.begin_selection(key.clone(), line, cx)); + } + }) + }; + let on_drag: LineCallback = { + let entity = entity.clone(); + let key = key.clone(); + Rc::new(move |line, _window, cx| { + if let Some(view) = entity.upgrade() { + view.update(cx, |this, cx| this.extend_selection(&key, line, cx)); + } + }) + }; + let on_end: EndCallback = { + Rc::new(move |_window, cx| { + if let Some(view) = entity.upgrade() { + view.update(cx, |this, _cx| this.end_selection()); + } + }) + }; + + let selection = RowSelection { + base_line, + highlight, + color: theme.selection, + on_start, + on_drag, + on_end, + }; + div() .w_full() .when(chunk_ix == 0, |d| d.pt_1()) @@ -1095,6 +1268,7 @@ impl ReviewView { prepared.syntax.as_deref(), theme, rem_size, + Some(selection), )) .into_any_element() } @@ -1144,7 +1318,31 @@ impl Render for ReviewView { .into_any_element(); } - // Header: compare-mode selector only (base selectors live per repo). + // A "Copy" button appears while there is a selection; it (and Cmd/Ctrl-C + // on the focused panel) copies the selected lines. + let copy_button = self.selection.is_some().then(|| { + let theme = cx.theme(); + div() + .id("review-copy-selection") + .flex() + .flex_row() + .items_center() + .gap_1() + .px_2() + .py_0p5() + .rounded(px(4.)) + .cursor_pointer() + .bg(theme.muted) + .hover(|s| s.bg(theme.border)) + .text_xs() + .text_color(theme.foreground) + .child("Copy") + .on_click(cx.listener(|this, _ev, _window, cx| this.copy_selection(cx))) + .into_any_element() + }); + + // Header: compare-mode selector (base selectors live per repo), plus + // the copy button when a selection exists. let header = div() .flex() .flex_row() @@ -1164,7 +1362,8 @@ impl Render for ReviewView { .text_color(muted), ) .min_w(px(130.)), - ); + ) + .children(copy_button); // The render callback only runs for rows in (or near) the viewport. self.sync_rows(); @@ -1176,6 +1375,14 @@ impl Render for ReviewView { v_flex() .size_full() + .track_focus(&self.focus_handle) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + let ks = &event.keystroke; + if ks.modifiers.secondary() && ks.key == "c" { + this.copy_selection(cx); + cx.stop_propagation(); + } + })) .child(header) .child( div() @@ -1330,4 +1537,66 @@ mod tests { ); }); } + + #[gpui_kit::test] + fn selected_text_joins_whole_lines_across_chunks(cx: &mut TestAppContext) { + let root = PathBuf::from("/repo"); + let key: FileKey = (root.clone(), "a.rs".into()); + let (view, cx) = view_with_files(vec![added_file("a.rs")], cx); + + // 100 added lines "line 0".."line 99", split into 40-line chunks, so a + // selection spanning a chunk boundary exercises the flat indexing. + view.update(cx, |view, cx| { + view.file_diffs.insert( + key.clone(), + LoadedDiff { + file: added_file("a.rs"), + prepared: prepared(100), + stamp: 1, + }, + ); + cx.notify(); + }); + cx.run_until_parked(); + + view.update(cx, |view, cx| { + view.begin_selection(key.clone(), 38, cx); + view.extend_selection(&key, 42, cx); + assert_eq!( + view.selected_text().as_deref(), + Some("line 38\nline 39\nline 40\nline 41\nline 42") + ); + + // A reversed drag (head before anchor) selects the same lines. + view.begin_selection(key.clone(), 42, cx); + view.extend_selection(&key, 40, cx); + assert_eq!( + view.selected_text().as_deref(), + Some("line 40\nline 41\nline 42") + ); + + // Extending only takes effect while a drag is active. + view.end_selection(); + view.extend_selection(&key, 10, cx); + assert_eq!( + view.selected_text().as_deref(), + Some("line 40\nline 41\nline 42") + ); + }); + } + + #[gpui_kit::test] + fn selected_text_is_none_without_a_loaded_diff(cx: &mut TestAppContext) { + let root = PathBuf::from("/repo"); + let key: FileKey = (root.clone(), "a.rs".into()); + let (view, cx) = view_with_files(vec![added_file("a.rs")], cx); + + // A selection whose file has no (longer a) loaded diff yields nothing + // to copy, so copying is a safe no-op. + view.update(cx, |view, cx| { + view.begin_selection(key.clone(), 1, cx); + assert!(view.selection.is_some()); + assert!(view.selected_text().is_none()); + }); + } } diff --git a/crates/ui_gpui/src/tool_cards/diff_card.rs b/crates/ui_gpui/src/tool_cards/diff_card.rs index 9a8421de..f577ad17 100644 --- a/crates/ui_gpui/src/tool_cards/diff_card.rs +++ b/crates/ui_gpui/src/tool_cards/diff_card.rs @@ -9,7 +9,7 @@ //! Replaces the old parameter-renderer-based rendering for these tools. use super::diff_prepare::SectionLines; -use super::diff_rows::{DiffRow, DiffRows, RowGeometry}; +use super::diff_rows::{DiffRow, DiffRows, RowGeometry, RowSelection}; use super::diff_syntax::DiffSyntax; use super::{CardRenderContext, ToolBlockRenderer, ToolBlockStyle, animated_card_body}; use crate::blocks::{BlockView, ToolUseBlock}; @@ -753,7 +753,8 @@ pub fn chunk_hunks(hunks: &[DiffHunk], max_lines: usize) -> ChunkedHunks { /// Render one chunk of already-computed hunks with real new-file line /// numbers, preceded by a slim "⋯" separator where a later hunk begins. -/// With `syntax`, rows are syntax highlighted. +/// With `syntax`, rows are syntax highlighted. `selection` makes the rows +/// selectable (the Review panel passes it; tool cards do not). pub(crate) fn render_diff_chunk( hunks: &[DiffHunk], chunk: &DiffChunk, @@ -761,6 +762,7 @@ pub(crate) fn render_diff_chunk( syntax: Option<&DiffSyntax>, theme: &gpui_kit::component::theme::Theme, rem_size: gpui_kit::Pixels, + selection: Option, ) -> gpui_kit::AnyElement { let Some(lines) = hunks .get(chunk.hunk) @@ -781,6 +783,7 @@ pub(crate) fn render_diff_chunk( new: chunk.new_start, }, }), + selection, ); if !chunk.starts_later_hunk() { return rows; @@ -832,6 +835,7 @@ pub(crate) fn render_diff_lines( syntax, start: LineCounter::default(), }), + None, ) } @@ -886,6 +890,7 @@ fn render_diff_rows( gutter_width: usize, rem_size: gpui_kit::Pixels, syntax: Option, + selection: Option, ) -> gpui_kit::AnyElement { let mut gutter_lines = LineCounter { old: 1, @@ -960,7 +965,11 @@ fn render_diff_rows( }) .collect(); - DiffRows::new(rows, geometry).into_any() + let element = DiffRows::new(rows, geometry); + match selection { + Some(selection) => element.selectable(selection).into_any(), + None => element.into_any(), + } } fn render_streaming_block( diff --git a/crates/ui_gpui/src/tool_cards/diff_rows.rs b/crates/ui_gpui/src/tool_cards/diff_rows.rs index f4a4309b..c518a18f 100644 --- a/crates/ui_gpui/src/tool_cards/diff_rows.rs +++ b/crates/ui_gpui/src/tool_cards/diff_rows.rs @@ -5,10 +5,10 @@ //! per chunk and the text system's line cache does the rest. use gpui_kit::{ - App, AvailableSpace, Bounds, Element, GlobalElementId, HighlightStyle, Hsla, - InspectorElementId, IntoElement, LayoutId, Length, Pixels, Point, ShapedLine, SharedString, - Size, Style, TextAlign, TextRun, TextStyle, Window, WrappedLine, fill, point, px, relative, - size, + App, AvailableSpace, Bounds, DispatchPhase, Element, GlobalElementId, HighlightStyle, Hsla, + InspectorElementId, IntoElement, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, Pixels, Point, ShapedLine, SharedString, Size, Style, TextAlign, TextRun, + TextStyle, Window, WrappedLine, fill, point, px, relative, size, }; use std::cell::RefCell; use std::ops::Range; @@ -43,11 +43,43 @@ impl RowGeometry { } } +/// Makes a [`DiffRows`] participate in whole-line text selection. The element +/// maps a pointer position to a flat line index (`base_line` plus the row +/// under the pointer) and reports it through the callbacks; the owner keeps +/// the actual selection and hands back the local rows (`highlight`) to paint +/// as selected. Selection is line-granular, which suits grabbing code to paste +/// elsewhere and keeps painting trivial over a wrapped, virtualized list. +/// Reports a flat line index (`base_line` + row) when selection starts or +/// extends over a row. +pub(crate) type LineCallback = Rc; + +/// Reports that a selection drag has ended. +pub(crate) type EndCallback = Rc; + +#[derive(Clone)] +pub(crate) struct RowSelection { + /// Flat line index of this element's first row (row 0). + pub base_line: usize, + /// Local rows (indices into `rows`) to paint as selected. + pub highlight: Range, + /// Highlight color (usually the theme's selection background). + pub color: Hsla, + /// Pointer pressed on a row: `base_line + row`. + pub on_start: LineCallback, + /// Pointer dragged over a row while the button is held. + pub on_drag: LineCallback, + /// Button released (anywhere) — ends the drag. + pub on_end: EndCallback, +} + pub(crate) struct DiffRows { rows: Rc>, geometry: RowGeometry, /// Filled by the measure closure, read by prepaint and paint. cell: LayoutCell, + /// Present when the rows are selectable (the Review panel); drives + /// highlight painting and pointer handling in [`DiffRows::paint`]. + selection: Option, } impl DiffRows { @@ -56,15 +88,37 @@ impl DiffRows { rows: Rc::new(rows), geometry, cell: Rc::default(), + selection: None, } } + /// Enable whole-line selection on these rows. + pub(crate) fn selectable(mut self, selection: RowSelection) -> Self { + self.selection = Some(selection); + self + } + #[cfg(test)] fn layout_cell(&self) -> LayoutCell { self.cell.clone() } } +/// Map an absolute `y` to a flat line index: `base` plus the row whose band +/// contains `y`, clamped to `[0, row_count)`. `tops` holds the absolute top of +/// each row plus a final bottom (so it has `row_count + 1` entries). +fn local_line(tops: &[Pixels], base: usize, row_count: usize, y: Pixels) -> usize { + if row_count == 0 { + return base; + } + let row = tops[..row_count] + .iter() + .rposition(|&top| y >= top) + .unwrap_or(0) + .min(row_count - 1); + base + row +} + /// Shaped rows for one width. #[derive(Default)] pub(crate) struct RowsLayout { @@ -206,12 +260,13 @@ fn paint_rows( layout: &RowsLayout, geometry: RowGeometry, bounds: Bounds, + selection: Option<(&Range, Hsla)>, window: &mut Window, cx: &mut App, ) { let line_height = layout.line_height; let mut y = bounds.origin.y; - for (row, row_layout) in rows.iter().zip(&layout.rows) { + for (ix, (row, row_layout)) in rows.iter().zip(&layout.rows).enumerate() { if let Some(background) = row.background { window.paint_quad(fill( Bounds::new( @@ -221,6 +276,19 @@ fn paint_rows( background, )); } + // Selection sits above the add/delete tint but below the glyphs, so + // the selected text stays readable. + if let Some((range, color)) = selection + && range.contains(&ix) + { + window.paint_quad(fill( + Bounds::new( + point(bounds.origin.x, y), + size(bounds.size.width, row_layout.height), + ), + color, + )); + } if let Some(gutter) = &row_layout.gutter { _ = gutter.paint( point(bounds.origin.x + geometry.gutter_left, y), @@ -321,8 +389,79 @@ impl Element for DiffRows { window: &mut Window, cx: &mut App, ) { - if let Some(layout) = cell.borrow().as_ref() { - paint_rows(&self.rows, layout, self.geometry, bounds, window, cx); + let borrow = cell.borrow(); + let Some(layout) = borrow.as_ref() else { + return; + }; + + let selection = self + .selection + .as_ref() + .map(|s| (s.highlight.clone(), s.color)); + paint_rows( + &self.rows, + layout, + self.geometry, + bounds, + selection.as_ref().map(|(range, color)| (range, *color)), + window, + cx, + ); + + // Pointer handling for selectable rows. The absolute top of each row + // (plus a final bottom) lets the listeners below map a pointer y to a + // row without re-reading the layout at event time. + if let Some(sel) = &self.selection { + let mut tops = Vec::with_capacity(layout.rows.len() + 1); + let mut y = bounds.origin.y; + for row in &layout.rows { + tops.push(y); + y += row.height; + } + tops.push(y); + let tops = Rc::new(tops); + let base = sel.base_line; + let row_count = layout.rows.len(); + let (on_start, on_drag, on_end) = ( + sel.on_start.clone(), + sel.on_drag.clone(), + sel.on_end.clone(), + ); + drop(borrow); + + let tops_down = tops.clone(); + window.on_mouse_event(move |e: &MouseDownEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble + && e.button == MouseButton::Left + && bounds.contains(&e.position) + { + on_start( + local_line(&tops_down, base, row_count, e.position.y), + window, + cx, + ); + } + }); + + let tops_move = tops.clone(); + window.on_mouse_event(move |e: &MouseMoveEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble + && e.pressed_button == Some(MouseButton::Left) + && bounds.contains(&e.position) + { + on_drag( + local_line(&tops_move, base, row_count, e.position.y), + window, + cx, + ); + } + }); + + window.on_mouse_event(move |e: &MouseUpEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble && e.button == MouseButton::Left { + on_end(window, cx); + } + }); } } } @@ -335,6 +474,20 @@ mod tests { red, white, }; + #[test] + fn local_line_maps_y_to_a_row_and_clamps() { + // Three rows at tops 0,10,20 with bottom 30; base 100. + let tops = [px(0.), px(10.), px(20.), px(30.)]; + assert_eq!(local_line(&tops, 100, 3, px(5.)), 100); + assert_eq!(local_line(&tops, 100, 3, px(10.)), 101); + assert_eq!(local_line(&tops, 100, 3, px(25.)), 102); + // Above the first row and below the last clamp to the ends. + assert_eq!(local_line(&tops, 100, 3, px(-5.)), 100); + assert_eq!(local_line(&tops, 100, 3, px(999.)), 102); + // No rows: the base line. + assert_eq!(local_line(&[px(0.)], 7, 0, px(5.)), 7); + } + #[test] fn row_runs_cover_the_text_exactly() { let style = TextStyle::default(); From a349a7c3782e55738fe6364985644ce76176a797 Mon Sep 17 00:00:00 2001 From: Daniel Kurzynski Date: Fri, 2 Oct 2026 22:02:48 +0200 Subject: [PATCH 2/2] Enable whole-line copy from diff cards in the chat thread Reuse the review panel's selectable-rows infrastructure for the edit/replace_in_file/write_file diff cards: drag-select whole lines, then copy via a header Copy button or Cmd/Ctrl-C. - BlockView owns the per-card selection (flat line indices across the card's diff sections) plus a focus handle for keyboard copy. - CardRenderContext carries the focus handle and current selection range. - render_prepared_diff/render_diff_lines thread a RowSelection per section, wired to the BlockView via a weak handle (mirrors ReviewView). - Header shows a Copy button while a selection exists; the card is focusable and handles Cmd/Ctrl-C. --- crates/ui_gpui/src/blocks/mod.rs | 158 ++++++++++++++++++++- crates/ui_gpui/src/blocks/render.rs | 2 + crates/ui_gpui/src/tool_cards/diff_card.rs | 158 +++++++++++++++++++-- crates/ui_gpui/src/tool_cards/mod.rs | 6 + 4 files changed, 310 insertions(+), 14 deletions(-) diff --git a/crates/ui_gpui/src/blocks/mod.rs b/crates/ui_gpui/src/blocks/mod.rs index 15e8b1b1..9c6bd43c 100644 --- a/crates/ui_gpui/src/blocks/mod.rs +++ b/crates/ui_gpui/src/blocks/mod.rs @@ -8,7 +8,7 @@ pub use data::*; use gpui_kit::component::ActiveTheme; use gpui_kit::component::text::{SelectionFormat, TextView, TextViewState}; use gpui_kit::prelude::*; -use gpui_kit::{Context, Entity, Pixels, Task, px}; +use gpui_kit::{ClipboardItem, Context, Entity, FocusHandle, Pixels, SharedString, Task, px}; use crate::tool_cards::diff_prepare::{DiffInput, PreparedDiff, SYNC_DIFF_MAX_BYTES}; use crate::tool_cards::diff_syntax::language_for_path; @@ -188,6 +188,23 @@ impl MarkdownSync { } } +/// A whole-line text selection within a diff card's body. `anchor` and `head` +/// are indices into the card's flattened section lines (the concatenation of +/// every section's diff lines, in render order), either end may be the +/// smaller. Copying (Cmd/Ctrl-C or the header button) reads from this. +#[derive(Clone, Copy)] +struct DiffSelection { + anchor: usize, + head: usize, +} + +impl DiffSelection { + /// Inclusive `(low, high)` line range. + fn range(&self) -> (usize, usize) { + (self.anchor.min(self.head), self.anchor.max(self.head)) + } +} + /// A diff card's [`PreparedDiff`] and what it was computed from. struct DiffCache { /// [`ToolUseBlock::revision`] last seen; the per-frame check. @@ -230,6 +247,15 @@ pub struct BlockView { /// Timer that clears [`Self::copied_feedback_until`] after the checkmark /// feedback window elapses. Dropped/replaced on each copy. copied_feedback_task: Option>, + /// Current whole-line selection within this block's diff card body, if + /// any. Drag-selecting rows sets it; the header's Copy button and + /// Cmd/Ctrl-C read from it. Only meaningful for file-editing tool blocks. + diff_selection: Option, + /// Whether the pointer button is down for an in-progress drag-select. + diff_dragging: bool, + /// Focus target so a drag-select in the diff card can focus the block and + /// Cmd/Ctrl-C reaches [`Self::copy_diff_selection`]. + focus_handle: FocusHandle, } impl BlockView { @@ -280,9 +306,23 @@ impl BlockView { write_file_diff_mode, copied_feedback_until: None, copied_feedback_task: None, + diff_selection: None, + diff_dragging: false, + focus_handle: _cx.focus_handle(), } } + /// The block's focus handle (used by the diff card for Cmd/Ctrl-C). + pub(crate) fn focus_handle(&self) -> FocusHandle { + self.focus_handle.clone() + } + + /// The current inclusive flat-line selection range in this block's diff + /// card, if any. + pub(crate) fn diff_selection_range(&self) -> Option<(usize, usize)> { + self.diff_selection.map(|s| s.range()) + } + /// Mutable access to the block. `render` has dropped its handle by the /// time anything mutates, so this does not copy. fn block_mut(&mut self) -> &mut BlockData { @@ -433,6 +473,78 @@ impl BlockView { cx.notify(); } + // ------------------------------------------------------------------ + // Diff-card whole-line selection + // ------------------------------------------------------------------ + + /// Begin a new selection at flat line `line`, replacing any previous one + /// and marking a drag as in progress. + pub(crate) fn begin_diff_selection(&mut self, line: usize, cx: &mut Context) { + self.diff_selection = Some(DiffSelection { + anchor: line, + head: line, + }); + self.diff_dragging = true; + cx.notify(); + } + + /// Extend the in-progress selection to flat line `line`, but only while a + /// drag is active. + pub(crate) fn extend_diff_selection(&mut self, line: usize, cx: &mut Context) { + if !self.diff_dragging { + return; + } + if let Some(sel) = &mut self.diff_selection + && sel.head != line + { + sel.head = line; + cx.notify(); + } + } + + /// End the current drag; the selection itself is kept for copying. + pub(crate) fn end_diff_selection(&mut self) { + self.diff_dragging = false; + } + + /// This block's diff lines flattened across its sections in render order — + /// the same order the rendered rows (and thus selection indices) use. + fn diff_lines_flat(&self) -> Option> { + let sections = self.diff_cache.as_ref()?.prepared.sections.as_ref()?; + Some( + sections + .iter() + .flat_map(|s| s.lines.iter().map(|l| l.text.clone())) + .collect(), + ) + } + + /// The selected lines joined by newlines, or `None` when there is nothing + /// to copy (no selection, or the diff is no longer prepared). + fn diff_selected_text(&self) -> Option { + let sel = self.diff_selection.as_ref()?; + let lines = self.diff_lines_flat()?; + if lines.is_empty() { + return None; + } + let (lo, hi) = sel.range(); + let hi = hi.min(lines.len() - 1); + (lo <= hi).then(|| { + lines[lo..=hi] + .iter() + .map(|s| s.as_ref()) + .collect::>() + .join("\n") + }) + } + + /// Copy the selected diff lines to the clipboard, joined by newlines. + pub(crate) fn copy_diff_selection(&mut self, cx: &mut Context) { + if let Some(text) = self.diff_selected_text().filter(|t| !t.is_empty()) { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + } + /// Check if this block is an image block pub fn is_image_block(&self) -> bool { matches!(*self.block, BlockData::ImageBlock(_)) @@ -504,6 +616,10 @@ impl BlockView { /// Toggle between diff view and plain new-file view for write_file tool blocks. pub fn toggle_write_file_diff_mode(&mut self, cx: &mut Context) { self.write_file_diff_mode = !self.write_file_diff_mode; + // The two views have different line counts, so flat selection indices + // from the old view no longer line up. + self.diff_selection = None; + self.diff_dragging = false; // Persist the new state if let (Some(session_id), Some(tool)) = (&self.session_id, self.block.as_tool()) @@ -1147,6 +1263,46 @@ mod tests { assert!(second.syntax.is_none(), "no grammar for .txt"); } + #[gpui_kit::test] + fn diff_selection_copies_whole_lines(cx: &mut TestAppContext) { + let view = diff_tool_view(RUST_EDIT, cx); + // Prepare the diff; a small edit is diffed synchronously, so its + // sections (and thus flat lines) are available right away. + view.update(cx, |view, cx| { + let _ = view.prepared_diff(cx); + }); + cx.run_until_parked(); + + view.update(cx, |view, cx| { + // Nothing selected yet → nothing to copy. + assert!(view.diff_selected_text().is_none()); + + // Drag-select both rows: the deletion and the insertion. + view.begin_diff_selection(0, cx); + view.extend_diff_selection(1, cx); + assert_eq!( + view.diff_selected_text().as_deref(), + Some("fn a() {}\nfn b() {}") + ); + + // A reversed drag selects the same lines. + view.begin_diff_selection(1, cx); + view.extend_diff_selection(0, cx); + assert_eq!( + view.diff_selected_text().as_deref(), + Some("fn a() {}\nfn b() {}") + ); + + // Extending only takes effect while a drag is active. + view.end_diff_selection(); + view.extend_diff_selection(0, cx); + assert_eq!( + view.diff_selected_text().as_deref(), + Some("fn a() {}\nfn b() {}") + ); + }); + } + #[gpui_kit::test] fn streaming_blocks_prepare_nothing(cx: &mut TestAppContext) { let view = diff_tool_view(RUST_EDIT, cx); diff --git a/crates/ui_gpui/src/blocks/render.rs b/crates/ui_gpui/src/blocks/render.rs index ec4a1df7..aad1383f 100644 --- a/crates/ui_gpui/src/blocks/render.rs +++ b/crates/ui_gpui/src/blocks/render.rs @@ -632,6 +632,8 @@ impl BlockView { write_file_diff_mode: self.write_file_diff_mode, markdown_state: Some(markdown_state), diff, + diff_focus_handle: self.focus_handle(), + diff_selection: self.diff_selection_range(), }; if let Some(element) = renderer.render( diff --git a/crates/ui_gpui/src/tool_cards/diff_card.rs b/crates/ui_gpui/src/tool_cards/diff_card.rs index f577ad17..c15759cf 100644 --- a/crates/ui_gpui/src/tool_cards/diff_card.rs +++ b/crates/ui_gpui/src/tool_cards/diff_card.rs @@ -9,7 +9,7 @@ //! Replaces the old parameter-renderer-based rendering for these tools. use super::diff_prepare::SectionLines; -use super::diff_rows::{DiffRow, DiffRows, RowGeometry, RowSelection}; +use super::diff_rows::{DiffRow, DiffRows, EndCallback, LineCallback, RowGeometry, RowSelection}; use super::diff_syntax::DiffSyntax; use super::{CardRenderContext, ToolBlockRenderer, ToolBlockStyle, animated_card_body}; use crate::blocks::{BlockView, ToolUseBlock}; @@ -17,10 +17,12 @@ use crate::shared::file_icons; use code_assistant_core::ui::ToolStatus; use gpui_kit::prelude::FluentBuilder; use gpui_kit::{ - ClickEvent, Context, Element, FontWeight, InteractiveElement, IntoElement, ParentElement, - SharedString, StatefulInteractiveElement, Styled, Window, div, px, rems, + App, ClickEvent, Context, Element, FocusHandle, FontWeight, Hsla, InteractiveElement, + IntoElement, KeyDownEvent, ParentElement, SharedString, StatefulInteractiveElement, Styled, + WeakEntity, Window, div, px, rems, }; use similar::{ChangeTag, TextDiff}; +use std::rc::Rc; // --------------------------------------------------------------------------- // DiffCardRenderer @@ -174,6 +176,31 @@ impl ToolBlockRenderer for DiffCardRenderer { .child(label), ); } + // Copy button — shown only while a selection exists in this card's + // diff. Copies the selected whole lines (Cmd/Ctrl-C does the same). + if card_ctx.diff_selection.is_some() { + header_right = header_right.child( + div() + .id(SharedString::from(format!("diff-copy-{}", tool.id))) + .flex_none() + .flex() + .items_center() + .justify_center() + .px_1p5() + .py(px(2.)) + .rounded(px(4.)) + .cursor_pointer() + .hover(|s| s.bg(header_text_color.opacity(0.1))) + .text_size(rems(0.6875)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.accent) + .on_click(cx.listener(move |view, _event: &ClickEvent, _window, cx| { + view.copy_diff_selection(cx); + cx.stop_propagation(); + })) + .child(SharedString::from("copy")), + ); + } // Chevron — highlights on header hover via group header_right = header_right.child( div() @@ -223,6 +250,16 @@ impl ToolBlockRenderer for DiffCardRenderer { if scale > 0.0 { let body_bg = diff_body_bg(theme); + // Selectable diff rows: wired to the owning BlockView so a + // drag-select drives its selection and Cmd/Ctrl-C / the Copy + // button read from it. + let selection = DiffCardSelection { + entity: cx.entity().downgrade(), + focus: card_ctx.diff_focus_handle.clone(), + range: card_ctx.diff_selection, + color: theme.selection, + }; + let body_content = match (tool.name.as_str(), &card_ctx.diff.sections) { ("delete_files", _) => render_delete_body(tool, theme), (_, Some(sections)) => Some(render_prepared_diff( @@ -230,6 +267,7 @@ impl ToolBlockRenderer for DiffCardRenderer { card_ctx.diff.syntax.as_deref().map(Vec::as_slice), theme, rem_size, + Some(&selection), )), // Still streaming, or a large diff is being computed. ("edit", None) => render_streaming_edit(tool, theme), @@ -288,10 +326,93 @@ impl ToolBlockRenderer for DiffCardRenderer { } } + // Focus + Cmd/Ctrl-C so a drag-selected diff can be copied from the + // keyboard (the drag's start focuses the card; see `DiffCardSelection`). + let card = card + .track_focus(&card_ctx.diff_focus_handle) + .on_key_down(cx.listener(|view, event: &KeyDownEvent, _window, cx| { + let ks = &event.keystroke; + if ks.modifiers.secondary() && ks.key == "c" { + view.copy_diff_selection(cx); + cx.stop_propagation(); + } + })); + Some(card.into_any_element()) } } +// --------------------------------------------------------------------------- +// Selectable diff rows (chat-thread diff cards) +// --------------------------------------------------------------------------- + +/// Wiring that makes a diff card's rows selectable: the owning `BlockView` +/// (via a weak handle so the row closures never borrow it), its focus handle, +/// the current selection range and the highlight color. Mirrors the Review +/// panel's selection, but indices are flat across the card's sections. +pub(crate) struct DiffCardSelection { + entity: WeakEntity, + focus: FocusHandle, + /// Inclusive flat-line range currently selected, if any. + range: Option<(usize, usize)>, + color: Hsla, +} + +impl DiffCardSelection { + /// The [`RowSelection`] for one section whose first row is flat line + /// `base_line` and which has `row_count` rows. + fn row_selection(&self, base_line: usize, row_count: usize) -> RowSelection { + let highlight = self + .range + .map(|(lo, hi)| { + let start = lo.max(base_line); + let end = hi.min(base_line + row_count.saturating_sub(1)); + if row_count > 0 && start <= end { + (start - base_line)..(end - base_line + 1) + } else { + 0..0 + } + }) + .unwrap_or(0..0); + + let entity = self.entity.clone(); + let on_start: LineCallback = { + let entity = entity.clone(); + let focus = self.focus.clone(); + Rc::new(move |line, window, cx: &mut App| { + window.focus(&focus, cx); + if let Some(view) = entity.upgrade() { + view.update(cx, |this, cx| this.begin_diff_selection(line, cx)); + } + }) + }; + let on_drag: LineCallback = { + let entity = entity.clone(); + Rc::new(move |line, _window, cx| { + if let Some(view) = entity.upgrade() { + view.update(cx, |this, cx| this.extend_diff_selection(line, cx)); + } + }) + }; + let on_end: EndCallback = { + Rc::new(move |_window, cx| { + if let Some(view) = entity.upgrade() { + view.update(cx, |this, _cx| this.end_diff_selection()); + } + }) + }; + + RowSelection { + base_line, + highlight, + color: self.color, + on_start, + on_drag, + on_end, + } + } +} + // --------------------------------------------------------------------------- // Per-tool body rendering // --------------------------------------------------------------------------- @@ -303,20 +424,30 @@ fn render_prepared_diff( syntax: Option<&[DiffSyntax]>, theme: &gpui_kit::component::theme::Theme, rem_size: gpui_kit::Pixels, + selection: Option<&DiffCardSelection>, ) -> gpui_kit::AnyElement { + // Flat line index of each section's first row: selection indices run + // across the whole card, so each section needs its own base. + let mut base_line = 0usize; + let mut children = Vec::with_capacity(sections.len()); + for (ix, section) in sections.iter().enumerate() { + let row_count = section.lines.len(); + let row_selection = selection.map(|sel| sel.row_selection(base_line, row_count)); + children.push(render_diff_lines( + §ion.lines, + theme, + section.start_line, + rem_size, + syntax.and_then(|syntax| syntax.get(ix)), + row_selection, + )); + base_line += row_count; + } div() .flex() .flex_col() .gap_1() - .children(sections.iter().enumerate().map(|(ix, section)| { - render_diff_lines( - §ion.lines, - theme, - section.start_line, - rem_size, - syntax.and_then(|syntax| syntax.get(ix)), - ) - })) + .children(children) .into_any() } @@ -813,6 +944,7 @@ pub(crate) fn render_diff_lines( start_line: Option, rem_size: gpui_kit::Pixels, syntax: Option<&DiffSyntax>, + selection: Option, ) -> gpui_kit::AnyElement { // Compute the gutter width (number of digits) based on new-file line numbers let gutter_width = if let Some(start) = start_line { @@ -835,7 +967,7 @@ pub(crate) fn render_diff_lines( syntax, start: LineCounter::default(), }), - None, + selection, ) } diff --git a/crates/ui_gpui/src/tool_cards/mod.rs b/crates/ui_gpui/src/tool_cards/mod.rs index cd393119..b2724c08 100644 --- a/crates/ui_gpui/src/tool_cards/mod.rs +++ b/crates/ui_gpui/src/tool_cards/mod.rs @@ -74,6 +74,12 @@ pub struct CardRenderContext { /// The block's cached diff; empty unless this is a finished file-editing /// tool (see `BlockView::prepared_diff`). pub diff: diff_prepare::PreparedDiff, + /// Focus handle of the owning `BlockView`. A drag-select in the diff card + /// focuses it so Cmd/Ctrl-C can copy the selected lines. + pub diff_focus_handle: gpui_kit::FocusHandle, + /// Current inclusive flat-line selection range in this card's diff, if + /// any. The rows paint it as selected and the header shows a Copy button. + pub diff_selection: Option<(usize, usize)>, } // ---------------------------------------------------------------------------