1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
//! Mouse-drag interactions on `App` — tree-panel edge resize,
//! right-panel edge resize, editor scrollbar drag (vertical +
//! horizontal), and the shared `set_pane_scroll` writer that every
//! scrollbar-hit routes through.
//!
//! Extracted from `app/mod.rs` (file-split refactor — Task #963).
//! Pure non-destructive move; no API change.
use super::*;
impl App {
// ─── branches / worktrees ───────────────────────────────────────
/// If `(x, y)` is on the rail's right-edge handle, start a tree-width drag.
/// Returns true if so. (The drag continues with [`Self::drag_tree_edge_to`]
/// + ends with [`Self::end_tree_edge_drag`].)
pub fn begin_tree_edge_drag(&mut self, x: u16, y: u16) -> bool {
// A registered click chip wins over the drag handle when
// they overlap. The drag zone is wide (3 cells) for trackpad
// discoverability, so it commonly overlaps small right-
// aligned chips like the `+` workspace-add button. Without
// this check, the chip was unclickable (the drag handle
// swallowed the click first). 2026-06-19 user-reported.
let on_chip = self
.rects
.tree_icon_buttons
.iter()
.any(|(r, _)| crate::app::dispatch::contains(*r, x, y));
if on_chip {
return false;
}
if let Some(r) = self.rects.tree_edge
&& x >= r.x
&& x < r.x + r.width
&& y >= r.y
&& y < r.y + r.height
{
self.dragging_tree_edge = true;
return true;
}
false
}
/// Continue a tree-width drag: set the rail's width to the column under
/// the pointer, clamped to `[16, min(80, screen_width - 20)]`. Bounds
/// match the Settings schema's `ui.tree_width` clamp so a runtime drag
/// can't put the tree into a state the config path forbids.
/// mouse-round-11 SEV-2 2026-07-12 (min), mouse-round-16 F2 2026-07-17 (max).
pub fn drag_tree_edge_to(&mut self, x: u16, screen_width: u16) {
if !self.dragging_tree_edge {
return;
}
const TREE_WIDTH_MIN: u16 = 16;
const TREE_WIDTH_MAX: u16 = 80;
let max = screen_width
.saturating_sub(20)
.clamp(TREE_WIDTH_MIN, TREE_WIDTH_MAX);
let new = x.clamp(TREE_WIDTH_MIN, max);
self.tree_width = new;
}
pub fn end_tree_edge_drag(&mut self) {
self.dragging_tree_edge = false;
}
/// vscode-user-mouse SEV-1 — mirror of maybe-tree-edge-drag for
/// the right panel. Returns true if the click landed on the
/// panel's left-edge grip and a drag was started.
pub fn maybe_start_right_panel_edge_drag(&mut self, x: u16, y: u16) -> bool {
if let Some(r) = self.rects.right_panel_edge
&& x >= r.x
&& x < r.x + r.width
&& y >= r.y
&& y < r.y + r.height
{
self.dragging_right_panel_edge = true;
return true;
}
false
}
pub fn end_right_panel_edge_drag(&mut self) {
self.dragging_right_panel_edge = false;
}
/// If `(x, y)` lands on any rendered scrollbar, start a scrollbar
/// drag + jump-scroll to the click position. Returns true on hit.
/// Walks `rects.scrollbars` in reverse so a scrollbar painted over
/// an earlier one (rare — embedded-diff over the graph's body)
/// wins. Subsequent `Drag(Left)` events route to
/// [`Self::drag_scrollbar_to`]; mouse-up clears via
/// [`Self::end_scrollbar_drag`].
pub fn begin_scrollbar_drag(&mut self, x: u16, y: u16) -> bool {
for hit in self.rects.scrollbars.iter().rev().copied() {
let r = hit.area;
if x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height {
self.dragging_scrollbar = Some(hit);
self.apply_scrollbar_to(hit, x, y);
return true;
}
}
false
}
/// Continue a scrollbar drag — maps the current pointer position
/// (X for horizontal bars, Y for vertical) to a proportional
/// scroll offset and updates the underlying pane.
pub fn drag_scrollbar_to(&mut self, x: u16, y: u16) -> bool {
let Some(hit) = self.dragging_scrollbar else {
return false;
};
self.apply_scrollbar_to(hit, x, y);
true
}
pub fn end_scrollbar_drag(&mut self) {
self.dragging_scrollbar = None;
}
/// Map `y` (a screen row) onto a new scroll value for the pane the
/// `hit` references, then assign it. Used by both the initial
/// click and the per-tick drag continuation.
fn apply_scrollbar_to(&mut self, hit: ScrollbarHit, x: u16, y: u16) {
let horizontal = hit.kind.is_horizontal();
let span_cells = if horizontal {
hit.area.width
} else {
hit.area.height
};
if hit.total <= hit.viewport || span_cells == 0 {
return;
}
let cells = span_cells as usize;
// Position the viewport so the clicked cell maps proportionally
// into the document. Horizontal bars track X, vertical track Y.
let (pos, origin) = if horizontal {
(x, hit.area.x)
} else {
(y, hit.area.y)
};
let rel = pos
.saturating_sub(origin)
.min(cells.saturating_sub(1) as u16) as usize;
let max_scroll = hit.total - hit.viewport;
// Anchor the *middle* of the visible range to the click row
// so big viewports don't snap to the very top when the click
// is near the bottom edge.
let half_vp_cells = (hit.viewport * cells / hit.total).max(1) / 2;
let anchor = rel.saturating_sub(half_vp_cells);
let max_anchor = cells.saturating_sub((hit.viewport * cells / hit.total).max(1));
let new_scroll = if max_anchor == 0 {
0
} else {
(anchor * max_scroll)
.div_ceil(max_anchor.max(1))
.min(max_scroll)
};
self.set_pane_scroll(hit.pane_id, hit.kind, new_scroll);
}
/// Dispatch a new scroll value into whichever pane field the kind
/// names. No-op when the pane is gone or the variant doesn't match
/// (the rect was painted last frame; the user could have closed
/// the pane in between).
pub fn set_pane_scroll(&mut self, pane_id: PaneId, kind: ScrollbarKind, scroll: usize) {
// The file tree + agents panel aren't panes — their scroll lives on
// dedicated App fields.
// qa-feature 2026-07-01 — tree scrollbars also snap the
// CURSOR to the new scroll top. Without this the per-frame
// "keep cursor in view" logic in tree_view immediately
// reverted scroll back to whatever row cursor pointed at,
// so drag felt like it did nothing.
if matches!(kind, ScrollbarKind::Tree) {
self.tree.scroll = scroll;
self.tree.set_cursor(scroll);
return;
}
if let ScrollbarKind::ExtraTree(ws_idx) = kind {
if let Some(w) = self.extra_workspaces.get_mut(ws_idx) {
w.tree.scroll = scroll;
w.tree.set_cursor(scroll);
}
return;
}
if matches!(kind, ScrollbarKind::AgentsPanel) {
self.agents_panel_scroll = scroll;
return;
}
// Resolved up-front: a scrollbar drag follows the same policy
// as the mouse wheel (see `Self::cursor_follows_wheel`). Read
// before the &mut borrow on `self.panes` below.
let follows_cursor = matches!(kind, ScrollbarKind::Editor | ScrollbarKind::EditorHScroll)
&& self.cursor_follows_wheel();
match (kind, self.panes.get_mut(pane_id)) {
(ScrollbarKind::Editor, Some(Pane::Editor(b))) => {
if follows_cursor {
// Drag cursor along — same as the editor wheel in
// cursor-follows mode. Renderer's keep-cursor-in-
// view will hold the scroll where the cursor is.
b.editor.place_cursor(scroll, 0);
} else {
b.scroll = scroll;
b.scroll_pinned = true;
}
}
(ScrollbarKind::EditorHScroll, Some(Pane::Editor(b))) => {
b.h_scroll = scroll;
}
(ScrollbarKind::Diff, Some(Pane::Diff(d))) => {
d.scroll = scroll;
}
(ScrollbarKind::EmbeddedDiff, Some(Pane::GitGraph(g))) => {
if let Some(d) = g.embedded_diff.as_mut() {
d.scroll = scroll;
}
}
(ScrollbarKind::GitGraphCommits, Some(Pane::GitGraph(g))) => {
// Snap selection to the new scroll position so the
// per-frame keep-selected-on-screen math (in
// `git_graph_view::draw`) doesn't immediately fight
// the scrollbar back to the old position.
let total = g.total_rows();
if total > 0 {
let new_scroll = scroll.min(total - 1);
g.scroll = new_scroll;
if g.selected != new_scroll {
g.selected = new_scroll;
g.reload_detail();
}
}
}
// List panes — pull selection along with scroll for the
// same reason: the per-frame keep-selected-on-screen math
// in each renderer would otherwise snap scroll back.
(ScrollbarKind::Tests, Some(Pane::Tests(p))) => {
p.scroll = scroll;
p.selected = scroll;
}
(ScrollbarKind::Flaky, Some(Pane::Flaky(p))) => {
p.scroll = scroll;
p.selected = scroll;
}
(ScrollbarKind::Diagnostics, Some(Pane::Diagnostics(p))) => {
p.scroll = scroll;
p.selected = scroll;
}
(ScrollbarKind::Outline, Some(Pane::Outline(p))) => {
p.scroll = scroll;
p.selected = scroll;
}
(ScrollbarKind::Grep, Some(Pane::Grep(p)))
| (ScrollbarKind::Quickfix, Some(Pane::Quickfix(p))) => {
p.scroll = scroll;
p.selected = scroll;
}
(ScrollbarKind::GitStatus, Some(Pane::GitStatus(p))) => {
p.scroll = scroll;
p.selected = scroll;
}
(ScrollbarKind::CmdlineHistory, Some(Pane::CmdlineHistory(p))) => {
p.scroll = scroll;
p.selected = scroll;
}
_ => {}
}
}
}