Skip to content

Commit 2d103de

Browse files
eatnugclaude
andcommitted
Add DiffPane improvements: auto-refresh, selection, keyboard nav, dock integration
- Auto-refresh DiffPane via background git poller (no main-thread blocking) - Only update when diff content actually changes (generation skip) - Preserve expanded/collapsed state across refreshes - Fix editor text selection off-by-one (gutter width 5→6) - Add DiffPane selection highlight rendering - Add DiffPane text selection and Cmd+C copy support - Add keyboard navigation (j/k, Enter/Space toggle) - Per-file horizontal scroll instead of global - Open DiffPane in dock instead of split - File header click to toggle expand/collapse with pointer cursor - Fix scroll overscroll clamping Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a2da493 commit 2d103de

21 files changed

Lines changed: 815 additions & 38 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ resolver = "2"
66

77
[workspace.package]
88
edition = "2021"
9-
version = "0.41.0"
9+
version = "0.42.0"
1010
license = "MIT"
1111
repository = "https://github.com/eatnug/tide"
1212
authors = ["eatnug"]

crates/tide-app/src/adapter/inward/click_adapter/hit_test.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,15 @@ pub(crate) fn compute_hover_target(
308308
);
309309
if content.contains(pos) {
310310
match ctx.pane(id) {
311-
Some(PaneKind::Terminal(_)) | Some(PaneKind::Editor(_)) | Some(PaneKind::Diff(_)) => {
311+
Some(PaneKind::Terminal(_)) | Some(PaneKind::Editor(_)) => {
312+
return Some(HoverTarget::PaneContent);
313+
}
314+
Some(PaneKind::Diff(dp)) => {
315+
let cell_size = ctx.cell_size();
316+
let visual_row = ((pos.y - content.y) / cell_size.height).floor() as usize;
317+
if dp.is_file_header_row(visual_row) {
318+
return Some(HoverTarget::DiffFileHeader);
319+
}
312320
return Some(HoverTarget::PaneContent);
313321
}
314322
_ => {}

crates/tide-app/src/adapter/inward/mouse_adapter/mod.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,37 @@ pub(crate) fn handle_mouse_down(ctx: &mut impl MousePorts, button: MouseButton,
252252
}
253253
}
254254

255+
// Diff pane file header click (toggle expand/collapse) — only on header rows
256+
if button == MouseButton::Left {
257+
let pos = ctx.last_cursor_pos();
258+
let cell_size = ctx.cell_size();
259+
let content_top = TAB_BAR_HEIGHT;
260+
let rects: Vec<_> = ctx.visual_pane_rects().to_vec();
261+
for &(id, rect) in &rects {
262+
let content = crate::tide_core::Rect::new(
263+
rect.x + PANE_PADDING,
264+
rect.y + content_top,
265+
rect.width - 2.0 * PANE_PADDING,
266+
rect.height - content_top - PANE_PADDING,
267+
);
268+
if content.contains(pos) {
269+
if let Some(crate::pane::PaneKind::Diff(dp)) = ctx.pane_mut(id) {
270+
let visual_row = ((pos.y - content.y) / cell_size.height).floor() as usize;
271+
if dp.is_file_header_row(visual_row) {
272+
dp.click_row(visual_row);
273+
ctx.focus_pane(id);
274+
ctx.set_focus_area(crate::state::FocusArea::Dock);
275+
ctx.request_redraw();
276+
return;
277+
}
278+
// Non-header: focus the pane but let text selection handle the rest
279+
ctx.focus_pane(id);
280+
ctx.set_focus_area(crate::state::FocusArea::Dock);
281+
}
282+
}
283+
}
284+
}
285+
255286
// Config page
256287
if button == MouseButton::Left && ctx.modal().config_page.is_some() {
257288
crate::adapter::inward::click_adapter::pane::handle_config_page_click(ctx, ctx.last_cursor_pos());

crates/tide-app/src/adapter/inward/mouse_adapter/selection.rs

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ pub(super) fn start_text_selection(ctx: &mut (impl AppCorePort + InputStatePort
4343
let editor_cell = {
4444
let cs = cell_size_cached;
4545
if let Some((_, rect)) = rects.iter().find(|(id, _)| *id == pid) {
46-
let gutter = 5.0 * cs.width;
46+
let gutter = crate::pane::editor::GUTTER_WIDTH_CELLS as f32 * cs.width;
4747
let cx = rect.x + PANE_PADDING + gutter;
4848
let cy = rect.y + content_top_offset;
4949
let rc = ((pos.x - cx) / cs.width).floor() as isize;
@@ -58,6 +58,30 @@ pub(super) fn start_text_selection(ctx: &mut (impl AppCorePort + InputStatePort
5858
}
5959
};
6060

61+
// Diff pane cell: virtual row (scroll + visual), col
62+
let diff_cell = {
63+
let cs = cell_size_cached;
64+
if let Some((_, rect)) = rects.iter().find(|(id, _)| *id == pid) {
65+
let cx = rect.x + PANE_PADDING;
66+
let cy = rect.y + content_top_offset;
67+
let rc = ((pos.x - cx) / cs.width).floor() as isize;
68+
let rr = ((pos.y - cy) / cs.height).floor() as isize;
69+
if rr >= 0 && rc >= 0 {
70+
// Convert visual row to virtual row using scroll offset
71+
if let Some(PaneKind::Diff(dp)) = ctx.pane(pid) {
72+
let virtual_row = dp.scroll as usize + rr as usize;
73+
Some((virtual_row, rc as usize))
74+
} else {
75+
None
76+
}
77+
} else {
78+
None
79+
}
80+
} else {
81+
None
82+
}
83+
};
84+
6185
// Shift+click: extend existing selection instead of starting a new one
6286
if shift_held {
6387
match ctx.pane_mut(pid) {
@@ -115,6 +139,12 @@ pub(super) fn start_text_selection(ctx: &mut (impl AppCorePort + InputStatePort
115139
}
116140
}
117141
}
142+
Some(PaneKind::Diff(dp)) => {
143+
if let (Some(ref mut sel), Some((vr, vc))) = (&mut dp.selection, diff_cell) {
144+
sel.end = (vr, vc);
145+
return true;
146+
}
147+
}
118148
_ => {}
119149
}
120150
// No existing selection to extend — fall through to create a new one
@@ -183,7 +213,14 @@ pub(super) fn start_text_selection(ctx: &mut (impl AppCorePort + InputStatePort
183213
});
184214
}
185215
}
186-
Some(PaneKind::Diff(_)) => {}
216+
Some(PaneKind::Diff(dp)) => {
217+
if let Some((vr, vc)) = diff_cell {
218+
dp.selection = Some(Selection {
219+
anchor: (vr, vc),
220+
end: (vr, vc),
221+
});
222+
}
223+
}
187224
Some(PaneKind::Launcher(_)) => {}
188225
None => {}
189226
}
@@ -272,7 +309,18 @@ pub(super) fn handle_selection_drag(ctx: &mut (impl AppCorePort + PaneAccessPort
272309
);
273310
}
274311
}
275-
Some(PaneKind::Diff(_)) => {}
312+
Some(PaneKind::Diff(dp)) => {
313+
let cx = rect.x + PANE_PADDING;
314+
let cy = rect.y + drag_top_offset;
315+
let rc = ((pos.x - cx) / cell_size.width).floor() as isize;
316+
let rr = ((pos.y - cy) / cell_size.height).floor() as isize;
317+
if rr >= 0 && rc >= 0 {
318+
let virtual_row = dp.scroll as usize + rr as usize;
319+
if let Some(ref mut sel) = dp.selection {
320+
sel.end = (virtual_row, rc as usize);
321+
}
322+
}
323+
}
276324
Some(PaneKind::Launcher(_)) => {}
277325
None => {}
278326
}

crates/tide-app/src/adapter/inward/scroll_adapter/mod.rs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,18 @@ pub(crate) fn handle_scroll(
139139
}
140140
Some(PaneKind::Diff(dp)) => {
141141
let delta = (editor_dx.abs() * 3.0).ceil() as usize;
142-
let vis_cols = {
143-
(rect.width / cs.width).floor() as usize
144-
};
145-
let max_h = dp.max_line_len().saturating_sub(vis_cols.saturating_sub(4));
146-
if editor_dx > 0.0 {
147-
dp.h_scroll = dp.h_scroll.saturating_sub(delta);
148-
} else {
149-
dp.h_scroll = (dp.h_scroll + delta).min(max_h);
142+
let vis_cols = (rect.width / cs.width).floor() as usize;
143+
let content_y = rect.y + scroll_top_off;
144+
let visual_row = ((cursor_pos.y - content_y) / cs.height).floor().max(0.0) as usize;
145+
if let Some(fi) = dp.file_index_at_row(visual_row) {
146+
let max_h = dp.max_line_len().saturating_sub(vis_cols.saturating_sub(4));
147+
let cur = dp.h_scroll.get(&fi).copied().unwrap_or(0);
148+
let new_val = if editor_dx > 0.0 {
149+
cur.saturating_sub(delta)
150+
} else {
151+
(cur + delta).min(max_h)
152+
};
153+
dp.h_scroll.insert(fi, new_val);
150154
}
151155
dp.generation = dp.generation.wrapping_add(1);
152156
}

crates/tide-app/src/adapter/outward/view/cursor.rs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,11 @@ pub(crate) fn render_cursor_and_highlights(
159159
let sb_hovered = matches!(app.interaction.hover_target, Some(crate::state::drag_types::HoverTarget::EditorScrollbar(hid)) if hid == id);
160160
pane.render_scrollbar(inner, renderer, pane.search.as_ref(), p, sb_hovered);
161161
}
162-
Some(PaneKind::Diff(_)) => {}
162+
Some(PaneKind::Diff(dp)) => {
163+
if let Some(ref sel) = dp.selection {
164+
render_diff_selection(dp, inner, renderer, p, sel);
165+
}
166+
}
163167
Some(PaneKind::Browser(_)) => {}
164168
Some(PaneKind::Launcher(_)) => {}
165169
None => {}
@@ -260,6 +264,52 @@ fn render_editor_search_highlights(
260264
}
261265
}
262266

267+
/// Render selection highlight for a diff pane.
268+
/// Selection coordinates use virtual rows (scroll offset + visual row) and columns.
269+
fn render_diff_selection(
270+
dp: &crate::pane::diff::DiffPane,
271+
inner: Rect,
272+
renderer: &mut crate::tide_renderer::WgpuRenderer,
273+
p: &ThemePalette,
274+
sel: &crate::pane::Selection,
275+
) {
276+
let cell_size = renderer.cell_size();
277+
let (start, end) = if sel.anchor <= sel.end {
278+
(sel.anchor, sel.end)
279+
} else {
280+
(sel.end, sel.anchor)
281+
};
282+
if start == end {
283+
return;
284+
}
285+
let sel_color = p.selection;
286+
let scroll = dp.scroll as usize;
287+
let visible_rows = (inner.height / cell_size.height).ceil() as usize;
288+
let visible_cols = (inner.width / cell_size.width).ceil() as usize;
289+
let flat = dp.flat_lines();
290+
291+
for row in start.0..=end.0 {
292+
if row < scroll || row >= scroll + visible_rows {
293+
continue;
294+
}
295+
let visual_row = row - scroll;
296+
let col_start = if row == start.0 { start.1 } else { 0 };
297+
let col_end = if row == end.0 {
298+
end.1
299+
} else {
300+
flat.get(row).map_or(visible_cols, |l| l.chars().count().max(visible_cols))
301+
};
302+
if col_start >= col_end {
303+
continue;
304+
}
305+
let vis_end = col_end.min(visible_cols);
306+
let rx = inner.x + col_start as f32 * cell_size.width;
307+
let ry = inner.y + visual_row as f32 * cell_size.height;
308+
let rw = (vis_end - col_start) as f32 * cell_size.width;
309+
renderer.draw_rect(Rect::new(rx, ry, rw, cell_size.height), sel_color);
310+
}
311+
}
312+
263313
/// Render selection highlight for a markdown preview pane.
264314
fn render_preview_selection(
265315
pane: &crate::pane::editor::EditorPane,

crates/tide-app/src/adapter/outward/view/hover.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ pub(crate) fn render_hover(
179179
crate::state::drag_types::HoverTarget::PaneContent => {
180180
// No visual overlay — only affects cursor icon (IBeam)
181181
}
182+
crate::state::drag_types::HoverTarget::DiffFileHeader => {
183+
// No visual overlay — only affects cursor icon (Pointer)
184+
}
182185
}
183186
}
184187
}

crates/tide-app/src/app.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,8 @@ impl crate::application::ports::inward::PaneAccessPort for App {
697697
match pane {
698698
PaneKind::Terminal(p) => p.selection = None,
699699
PaneKind::Editor(p) => p.selection = None,
700-
PaneKind::Diff(_) | PaneKind::Browser(_) | PaneKind::Launcher(_) => {}
700+
PaneKind::Diff(p) => p.selection = None,
701+
PaneKind::Browser(_) | PaneKind::Launcher(_) => {}
701702
}
702703
}
703704
}

0 commit comments

Comments
 (0)