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
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
//! Up(Left) dispatch — extracted from `mouse/mod.rs` (T-7 of the
//! file-split refactor, 2026-06-29). Mouse-up is the
//! "drag completed; commit the drop" event: ends in-flight drags
//! (scrollbar, tree-edge, panel-edge, dock-widget, divider),
//! handles drop targets for tree-drag and bufferline-tab drag,
//! and clears all the drag-state rects.
//!
//! Public surface: `handle_up_left(app, x, y)`.
use crate::app::App;
use crate::pane::Pane;
pub(super) fn handle_up_left(app: &mut App, x: u16, y: u16) {
app.end_scrollbar_drag();
app.end_tree_edge_drag();
app.end_right_panel_edge_drag();
app.end_git_graph_detail_drag();
app.end_divider_drag();
app.drag_select = None;
app.dragging_tab_page = None;
// Pty drag-select — extract the text between origin and current
// cell, copy to clipboard, clear the state. Only copies if the
// range has size (single click just arms + releases without a
// drag doesn't copy). mouse-round-9 SEV-2 2026-07-11.
if let Some((pid, origin, cur)) = app.pty_drag_select.take()
&& origin != cur
{
app.copy_pty_selection_to_clipboard(pid, origin, cur);
}
// Dock widget drag — resolve the final cursor position.
//
// Magnetic snap first: if the cursor is near another
// widget's body, place the dragged widget in that
// widget's corner + reorder it adjacent in the vec
// (above/below based on cursor Y vs target center).
//
// Fallback: existing quadrant-of-editor-body logic.
// Sessions panel drag — released over another session
// tab swaps the two panes in `app.panes` so the
// visible order matches the drop position.
if let Some(src_pid) = app.session_drag_pid.take()
&& let Some(&(_, dst_pid)) = app
.rects
.session_tabs
.iter()
.find(|(r, _)| crate::app::dispatch::contains(*r, x, y))
&& src_pid != dst_pid
&& src_pid < app.panes.len()
&& dst_pid < app.panes.len()
{
app.panes.swap(src_pid, dst_pid);
// The active pane id stays pointing at the same
// physical pane (now at the dst index, since we
// swapped). Re-route active so it follows the
// drag.
if app.active == Some(src_pid) {
app.active = Some(dst_pid);
} else if app.active == Some(dst_pid) {
app.active = Some(src_pid);
}
}
if let Some(drag_id) = app.dock_drag_id.take()
&& let Some(body) = app.rects.body
&& body.width > 0
&& body.height > 0
{
const SNAP_DIST: u32 = 8;
// Find the closest non-self widget body rect by
// Manhattan distance to its center.
let snap_target = app
.rects
.dock_widget_bodies
.iter()
.filter(|(_, id)| *id != drag_id)
.map(|(r, id)| {
let cx = r.x + r.width / 2;
let cy = r.y + r.height / 2;
let dx = (cx as i32 - x as i32).unsigned_abs();
let dy = (cy as i32 - y as i32).unsigned_abs();
(dx + dy, *id, *r)
})
.min_by_key(|(d, _, _)| *d);
if let Some((dist, target_id, target_rect)) = snap_target
&& dist <= SNAP_DIST
{
// Inherit target's corner + reorder so the
// dragged widget sits adjacent to the target.
let target_corner = app
.dock_widgets
.iter()
.find(|w| w.id == target_id)
.map(|w| w.corner);
if let Some(corner) = target_corner {
if let Some(w) = app.dock_widgets.iter_mut().find(|w| w.id == drag_id) {
w.corner = corner;
}
// Move the dragged widget in the vec to sit
// either just before or just after the
// target based on the cursor's side.
let target_mid_y = target_rect.y + target_rect.height / 2;
let put_before = y < target_mid_y;
if let Some(src_idx) = app.dock_widgets.iter().position(|w| w.id == drag_id) {
let dragged = app.dock_widgets.remove(src_idx);
// Re-locate the target after removal.
let target_idx = app
.dock_widgets
.iter()
.position(|w| w.id == target_id)
.unwrap_or(app.dock_widgets.len());
let insert_at = if put_before {
target_idx
} else {
(target_idx + 1).min(app.dock_widgets.len())
};
app.dock_widgets.insert(insert_at, dragged);
}
}
} else {
let mid_x = body.x + body.width / 2;
let mid_y = body.y + body.height / 2;
let new_corner = match (x < mid_x, y < mid_y) {
(true, true) => crate::dock::DockCorner::TopLeft,
(false, true) => crate::dock::DockCorner::TopRight,
(true, false) => crate::dock::DockCorner::BottomLeft,
(false, false) => crate::dock::DockCorner::BottomRight,
};
if let Some(w) = app.dock_widgets.iter_mut().find(|w| w.id == drag_id) {
w.corner = new_corner;
}
}
app.dock_drag_cursor = None;
}
// Rail section drag-resize release. If the pointer never
// moved, treat as a click → toggle the section's
// collapse state. If it did move, commit the new
// `*_user_max_h` (already updated on each drag tick).
if let Some(drag) = app.rail_section_drag.take()
&& !drag.moved
{
match drag.kind {
crate::app::RailSectionKind::Integrations => {
app.integration_section_expanded = !app.integration_section_expanded;
}
crate::app::RailSectionKind::Git => {
app.toggle_git_section_expanded();
}
}
}
// Tree drag-drop release. Three outcomes:
// 1. over a pane body + the source is a FILE → drag-to-split:
// open the file in a split / move it into that pane.
// 2. over the tree → complete a file/dir MOVE if the drag armed;
// otherwise it was a plain click on a file → the DEFERRED open
// (preview, or a permanent tab on double-click).
// 3. released anywhere else → cancel.
if let Some(drag) = app.tree_drag.as_ref() {
let src_path = drag.src_path.clone();
let src_is_dir = drag.src_is_dir;
let armed = drag.armed;
let over_body = app
.rects
.pane_bodies
.iter()
.any(|(r, _)| crate::app::dispatch::contains(*r, x, y));
let tree_rect = app
.rects
.tree
.filter(|tr| crate::app::dispatch::contains(*tr, x, y));
// 2026-06-22 — when no editor pane is open
// (`pane_bodies` is empty), a drop anywhere
// outside the tree should still open the file.
// drop_tree_file_on_pane already falls back to
// open_path when there's no pane under the
// cursor; we just need to call it.
let empty_editor = app.rects.pane_bodies.is_empty() && tree_rect.is_none();
if (over_body || empty_editor) && !src_is_dir {
app.tree_drag = None;
if armed {
// Actual drag-and-drop: create / place in a split.
app.drop_tree_file_on_pane(src_path, x, y);
} else {
// Plain click that happens to land on a pane body —
// (a fast double-click may bounce out of the tree row).
// Treat as an open, not a drop. Preserves layout when
// the file is already open in a split (issue #1).
let permanent = matches!(app.last_click, Some((_, _, _, c)) if c >= 2);
if permanent {
app.open_path(&src_path);
} else {
app.open_path_preview(&src_path);
}
}
} else if let Some(tr) = tree_rect {
let idx = (y - tr.y) as usize + app.rects.tree_scroll;
let target = (idx < app.tree.visible_rows().len()).then_some(idx);
app.end_tree_drag(target); // moves if armed; no-op otherwise
if !armed && !src_is_dir {
// Plain click on a file → the deferred open.
let permanent = matches!(app.last_click, Some((_, _, _, c)) if c >= 2);
let was_tree_focus = matches!(app.focus, crate::focus::Focus::Tree);
if permanent {
app.open_path(&src_path);
} else {
app.open_path_preview(&src_path);
}
// qa-feature 2026-07-02 — preserve tree focus across
// tree-driven file opens (both preview + double-click
// promote). Was: `open_path*` → `reveal_pane` set
// Focus::Pane, so the user's next arrow moved the
// editor's cursor instead of continuing tree
// browsing.
if was_tree_focus {
app.focus_tree();
}
}
} else {
// Released in limbo (e.g. over chrome) → cancel.
app.tree_drag = None;
}
}
// Bufferline tab release. If it ended over a pane body, split that
// pane (edge zone) or move the dragged pane into it (center zone).
// Otherwise it was a plain click / a reorder release on the tab
// strip → reveal the tab (deferred buffer-switch).
//
// 2026-06-21 — VS Code-style: double-click on a tab
// promotes a preview tab to a regular tab (the italic
// becomes plain). Single click just reveals.
if let Some(src) = app.rects.bufferline_drag_tab {
// Clear visuals first.
app.rects.bufferline_drag_ghost = None;
app.rects.tab_insert_hint = None;
// 2026-07-24 — jitter guard. Ghostty (and most terminals)
// fire Up at a slightly-shifted cell from Down even for a
// pure click if the pointer drifts between press+release.
// The down_left handler ALREADY switched to this tab; if
// the mouse hasn't moved (or moved ≤1 cell), we don't
// want to also fire any drop handlers — those all run
// `remove_leaf(src)` + reinsert, which under some subtle
// pane-storage state ends up ORPHANING the tab (video
// repro from user 2026-07-24). Just clear the drag arm.
// 2026-07-24 (v2): use `mouse_down_at` not `click_echo`.
// click_echo is cleared by the renderer after 120ms so a
// >120ms hold falsely reported "no down" here, letting
// the drop path run and orphan the tab. mouse_down_at
// has no expiry — it's cleanly overwritten on next Down.
let jitter_only = matches!(
app.mouse_down_at,
Some((dx, dy))
if x.abs_diff(dx) <= 1 && y.abs_diff(dy) <= 1
);
if jitter_only {
app.rects.bufferline_drag_tab = None;
app.rects.tab_drop_target = None;
return;
}
// Released on a per-leaf tab strip → insert at the
// computed position. Tries this BEFORE other drop
// handlers so the strip area wins over the pane
// body just below it (drag-to-pane-body would
// otherwise split unintentionally).
if app.drop_tab_on_strip(src, x, y) {
app.rects.bufferline_drag_tab = None;
app.rects.tab_drop_target = None;
return;
}
let over_body = app
.rects
.pane_bodies
.iter()
.any(|(r, _)| crate::app::dispatch::contains(*r, x, y));
// Released over a different bufferline tab → swap
// (kept as fallback for the single-leaf bufferline
// strip; per-leaf strips go through drop_tab_on_strip
// above which is positional).
let dst_tab = app
.rects
.bufferline_tabs
.iter()
.find(|(r, _)| crate::app::dispatch::contains(*r, x, y))
.map(|(_, pid)| *pid);
if let Some(dst) = dst_tab
&& dst != src
{
app.splice_bufferline_tabs(src, dst);
app.rects.bufferline_drag_tab = None;
app.rects.tab_drop_target = None;
return;
}
// vscode-user 2026-06-28 SEV-2: drag released past
// the last tab on the bufferline row → drop on the
// rightmost tab so the user gets a "move to end"
// gesture. Without this, dragging slightly past
// the rightmost tab fell through to reveal_pane
// (click semantics), making drag-to-reorder feel
// broken.
if let Some(&(rect, rightmost_pid)) = app
.rects
.bufferline_tabs
.iter()
.filter(|(r, _)| r.y <= y && y < r.y + r.height)
.max_by_key(|(r, _)| r.x + r.width)
&& x >= rect.x + rect.width
&& rightmost_pid != src
{
app.swap_bufferline_tabs(src, rightmost_pid);
app.rects.bufferline_drag_tab = None;
app.rects.tab_drop_target = None;
return;
}
// The earlier jitter-guard already returned; if we're
// here, the pointer moved > 1 cell — a genuine drag.
if over_body {
app.drop_tab_on_pane(src, x, y);
} else {
// Detect double-click on the same tab rect.
let now = std::time::Instant::now();
let is_double = matches!(
app.last_click,
Some((prev, px, py, _))
if px == x
&& py == y
&& now.duration_since(prev) < std::time::Duration::from_millis(450)
);
app.last_click = Some((now, x, y, if is_double { 2 } else { 1 }));
if is_double && let Some(Pane::Editor(b)) = app.panes.get_mut(src) {
b.is_preview = false;
}
// qa-feature 2026-07-02 — preserve tree focus across a
// tab double-click promote. `reveal_pane` shifts focus
// to Pane; if the user was arrow-browsing the tree,
// that stole their next arrow. Restore Focus::Tree when
// that was the pre-click state so keyboard browsing
// survives the "commit to this file" gesture.
let was_tree_focus = matches!(app.focus, crate::focus::Focus::Tree);
app.reveal_pane(src);
if was_tree_focus {
app.focus_tree();
}
}
}
app.rects.tab_drop_target = None;
// Mouse-up always clears the bufferline-tab drag arm + ghost.
app.rects.bufferline_drag_tab = None;
app.rects.bufferline_drag_ghost = None;
}