From b3c8ecbf93c502d8d73dede63d6995ede3a9ab80 Mon Sep 17 00:00:00 2001 From: Isaac Corbrey Date: Thu, 11 Jun 2026 21:42:24 -0500 Subject: [PATCH] filetree: Drag-and-drop entry moves Press and hold on a row, drag onto a directory (or a file row that resolves to its parent dir), release to `std::fs::rename`. The dragged source row is dimmed during the gesture and the drop target is highlighted, with files-row-resolves-to-parent-dir for the "drop onto a file to land in its directory" UX. Drag, click, and resize gestures cluster cleanly enough at this point to earn a `gestures` submodule: `DragState` and the typed outcomes `ClickOutcome` / `MoveValidity` live there alongside the pure helpers (`classify_click`, `compute_resize_width`, `validate_move`, `resolve_drop_target`) so the boundary cases are unit-tested without faking a `Context`. --- helix-term/src/ui/filetree.rs | 653 ++++++++++++++----------- helix-term/src/ui/filetree/gestures.rs | 534 ++++++++++++++++++++ 2 files changed, 914 insertions(+), 273 deletions(-) create mode 100644 helix-term/src/ui/filetree/gestures.rs diff --git a/helix-term/src/ui/filetree.rs b/helix-term/src/ui/filetree.rs index 8e00adeb..142a10e7 100644 --- a/helix-term/src/ui/filetree.rs +++ b/helix-term/src/ui/filetree.rs @@ -23,6 +23,7 @@ //! aren't wired up here. mod context_menu; +mod gestures; pub mod vcs; mod watcher; @@ -30,113 +31,10 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; -/// Maximum time between two clicks on the same row to count as a double-click. -/// Matches the de-facto desktop convention (~500ms); keeping it here as a -/// constant rather than a config knob until someone surfaces a real reason -/// to make it user-tunable. -const DOUBLE_CLICK_THRESHOLD: Duration = Duration::from_millis(500); - -/// Minimum panel width during a drag-resize. Tight enough to be useful for -/// users who want the panel out of the way, wide enough that the indicator -/// + 1ch margin (which need 3 cols total) and at least one character of an -/// entry name still fit. -const MIN_DRAG_WIDTH: u16 = 4; - -/// In-flight mouse-down/drag state. Lives on the component so the various -/// `handle_mouse` branches can coordinate — a Down can plant a drag-source, -/// a subsequent Drag can act on it, and the eventual Up tears it down. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DragState { - None, - /// A mouse-down landed on the panel's resize handle (the column flush - /// against the seam with the editor). `start_col` is the absolute screen - /// column the press hit, `start_width` is what the panel was before the - /// drag began. The delta between current event column and `start_col` - /// translates straight into a width change. - ResizingBorder { - start_col: u16, - start_width: u16, - }, -} - -/// Pure resize-math, factored out for testing the boundary cases (delta -/// direction by docked side, underflow, MIN_DRAG_WIDTH clamp). Saturates at -/// `MIN_DRAG_WIDTH` on the low end; the high end is left to render-time -/// `panel_rect.width.min(vstrip.width)` so an over-dragged panel just visually -/// clamps to the terminal without state.width having to know terminal width. -fn compute_resize_width( - start_width: u16, - start_col: u16, - current_col: u16, - position: FiletreePosition, -) -> u16 { - let raw_delta = current_col as i32 - start_col as i32; - let signed_delta = match position { - FiletreePosition::Left => raw_delta, - // A right-docked panel grows when the user drags left; flip the sign - // so a more-negative `raw_delta` increases the panel width. - FiletreePosition::Right => -raw_delta, - }; - let new_width = (start_width as i32).saturating_add(signed_delta); - // Clamp to [MIN_DRAG_WIDTH, u16::MAX] *before* the cast so a huge positive - // delta saturates at u16::MAX rather than wrapping back into a tiny value - // via the truncating `as u16`. - new_width.clamp(MIN_DRAG_WIDTH as i32, u16::MAX as i32) as u16 -} - -/// What a click (mouse press-then-release) should do, given where the press -/// landed, where the release landed, and whether the last completed click was -/// recent enough on the same row to promote this one to a double-click. -/// -/// Lives as a typed outcome rather than imperative branching inside the mouse -/// handler so the decision can be unit-tested against [`ClickOutcome`] without -/// needing to fake a [`Context`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ClickOutcome { - /// Drag-out (release on a different row), release outside the entry list, - /// or press happened outside the panel. Single + double tracking should - /// be reset; no entry-level action fires. - Cancel, - /// Single click on row `entry_idx` — selects only. The entry is recorded - /// as a potential first half of a double-click. - Select { entry_idx: usize }, - /// Double click on `entry_idx` — opens the file or toggles the directory. - /// The double-click recorder is cleared so a third tap doesn't fire a - /// second activation. - Activate { entry_idx: usize }, -} - -/// Pure click-classification, separated out so the boundary cases are -/// testable. `now` and `threshold` are parameters rather than [`Instant::now`] -/// + the module constant so tests can fix time without messing with the -/// real clock. -fn classify_click( - pending: Option, - released_idx: Option, - last_click: Option<(usize, Instant)>, - now: Instant, - threshold: Duration, -) -> ClickOutcome { - let Some(pending) = pending else { - return ClickOutcome::Cancel; - }; - let Some(released) = released_idx else { - return ClickOutcome::Cancel; - }; - if pending != released { - return ClickOutcome::Cancel; - } - if let Some((prev_idx, prev_when)) = last_click { - if prev_idx == released && now.duration_since(prev_when) <= threshold { - return ClickOutcome::Activate { - entry_idx: released, - }; - } - } - ClickOutcome::Select { - entry_idx: released, - } -} +use gestures::{ + classify_click, compute_resize_width, resolve_drop_target, validate_move, ClickOutcome, + DragState, MoveValidity, DOUBLE_CLICK_THRESHOLD, +}; use helix_view::editor::{Action, FiletreeConfig, FiletreePosition, FiletreeState}; use helix_view::graphics::Rect; @@ -153,16 +51,16 @@ pub const ID: &str = "filetree"; /// shape; the `kind` discriminates and carries the per-row state the renderer /// needs (the expand glyph for directories). #[derive(Debug, Clone)] -struct FiletreeEntry { - path: PathBuf, +pub(super) struct FiletreeEntry { + pub(super) path: PathBuf, /// Indent level. The root's direct children are depth 0; their children /// (if their parent is expanded) are depth 1; and so on. - depth: usize, - kind: EntryKind, + pub(super) depth: usize, + pub(super) kind: EntryKind, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EntryKind { +pub(super) enum EntryKind { File, /// `expanded` is materialized here rather than re-checked at render time /// because the entry-builder already consulted `FiletreeState.expanded` to @@ -224,6 +122,22 @@ pub struct Filetree { /// handle becomes visually present and the drag-to-resize affordance is /// discoverable. hovered_handle: bool, + /// During [`DragState::MovingEntry`], the entry row the renderer + /// should highlight as the drop target. For a cursor on a directory + /// row, this is that directory's row. For a cursor on a file row, it + /// resolves to the *parent* directory's row (so the user sees where + /// the drop will land, not the row they're physically hovering); the + /// resolution lives in [`resolve_drop_target`]. `None` whenever + /// there's no row to highlight (cursor in gutter, outside the + /// panel, or the resolved parent isn't present in `entries` because + /// it's the panel root). + drop_target_row: Option, + /// During [`DragState::MovingEntry`], the directory path the move + /// will land in. Tracked separately from [`drop_target_row`] so + /// drops onto file rows whose parent is the panel root (not an + /// entry) still finalize correctly — the highlight goes empty but + /// this stays set. + drop_target_dir: Option, /// Recursive filesystem watcher rooted at the panel root, or `None` /// when no root is set yet or the OS refused to register a watcher /// (capacity limits on Linux, etc.). Created/recreated lazily in @@ -275,6 +189,8 @@ impl Default for Filetree { drag_state: DragState::None, hovered_row: None, hovered_handle: false, + drop_target_row: None, + drop_target_dir: None, fs_watcher: None, fs_watcher_failed: None, last_vcs_refresh: None, @@ -459,6 +375,126 @@ impl Filetree { /// In narrow mode, dropping focus is also what makes the panel /// auto-hide — see [`FiletreeState::render_mode`] — so a "file opened on /// overlay" implicitly dismisses the panel without any explicit close. + /// Conclude an in-flight [`DragState::MovingEntry`] gesture. Called + /// from the `Up(Left)` arm of the drag pre-check. Resets all + /// drag-related state up front so any early return below leaves the + /// component in a clean post-drag state. + /// + /// Behaviour by case: + /// + /// - **No drop target** (released outside the panel or on a non-dir + /// row): silent cancel. The user explicitly moved off a valid + /// target — no error, just nothing happens. + /// - [`MoveValidity::SameLocation`]: silent no-op. The user dropped + /// onto the directory the entry already lives in, which usually + /// means "actually I changed my mind" rather than "I want to do + /// something." + /// - [`MoveValidity::IntoSelf`] / [`MoveValidity::IntoOwnSubtree`]: + /// shown via `set_error` because these are user-visible mistakes + /// that benefit from explicit feedback. + /// - [`MoveValidity::Ok`] but destination filename already exists on + /// disk: refuse rather than clobber, surface via `set_error`. + /// - [`MoveValidity::Ok`] and rename fails (cross-device, permissions): + /// surface the IO error via `set_error`. + /// - [`MoveValidity::Ok`] and rename succeeds: mark dirty so the + /// panel rebuilds with the new layout on the next render (the + /// watcher will also fire — both paths are belt-and-suspenders), + /// and queue a reveal at the new path so the cursor follows the + /// moved entry. + /// Cancel whatever drag gesture is in flight. Reverts any state + /// the drag has been live-mutating and clears all drag-related + /// fields so the next mouse event starts from a clean slate. + /// + /// `width` is the only `FiletreeState` field this needs to touch — + /// during a resize, the `Drag(Left)` arm writes + /// `compute_resize_width` straight into it as the cursor moves — + /// so we take just that as `&mut u16` instead of the whole state. + /// Keeps the test free of any other state-struct setup. + /// + /// - **`ResizingBorder`**: snap width back to `start_width` so the + /// cancellation visibly undoes the resize. + /// - **`MovingEntry`**: no IO has happened yet (rename only fires + /// from `finalize_move` on `Up(Left)`), so there's nothing to + /// undo on disk — just reset the drop-target highlight. + /// + /// `pending_click_row` and `last_click` get cleared too so the + /// drag's originating Down can't accidentally pair with a future + /// Up as if it had been a click. + fn cancel_drag(&mut self, width: &mut u16) { + let drag = std::mem::replace(&mut self.drag_state, DragState::None); + match drag { + DragState::None => {} + DragState::ResizingBorder { start_width, .. } => { + *width = start_width; + } + DragState::MovingEntry { .. } => { + // No IO to roll back. + } + } + self.drop_target_row = None; + self.drop_target_dir = None; + self.pending_click_row = None; + self.last_click = None; + } + + fn finalize_move(&mut self, ctx: &mut Context) { + let drag = std::mem::replace(&mut self.drag_state, DragState::None); + let target_dir = self.drop_target_dir.take(); + self.drop_target_row = None; + self.pending_click_row = None; + self.last_click = None; + + let DragState::MovingEntry { source_path, .. } = drag else { + return; + }; + let Some(target_dir) = target_dir else { + // Released without a valid target (cursor off-panel or in + // the gutter when Up fired). Silent cancel. + return; + }; + + match validate_move(&source_path, &target_dir) { + MoveValidity::SameLocation => { + // No-op: dropped on the dir we started in. Don't shout. + } + MoveValidity::IntoSelf => { + ctx.editor + .set_error("filetree: cannot move an entry onto itself"); + } + MoveValidity::IntoOwnSubtree => { + ctx.editor + .set_error("filetree: cannot move a directory into its own subtree"); + } + MoveValidity::Ok => { + let Some(file_name) = source_path.file_name() else { + ctx.editor.set_error(format!( + "filetree: source path has no file name: {}", + source_path.display() + )); + return; + }; + let new_path = target_dir.join(file_name); + if new_path.exists() { + ctx.editor.set_error(format!( + "filetree: refusing to move {} — {} already exists", + source_path.display(), + new_path.display(), + )); + return; + } + if let Err(e) = std::fs::rename(&source_path, &new_path) { + ctx.editor.set_error(format!("filetree: move failed: {e}")); + return; + } + self.dirty = true; + // Bring the cursor along with the moved entry on the next + // render — `apply_reveal_pending` will expand ancestors + // and snap the cursor to the new location. + self.reveal_pending = Some(new_path); + } + } + } + fn activate_under_cursor(&mut self, ctx: &mut Context) { let Some(entry) = self.entry_under_cursor(&ctx.editor.filetree) else { return; @@ -534,6 +570,21 @@ impl Filetree { return EventResult::Ignored(None); } + // Drag-in-flight: Esc cancels the gesture. Mouse-down on a + // panel row already grabbed focus, so the keypress lands here + // rather than at the editor. Cancellation reverts the panel + // width (for a resize) and clears all drag-related state. We + // handle this *before* the unfocus arm below so cancelling a + // drag doesn't also drop focus — the user interrupted a + // gesture, not the panel session. + if !matches!(self.drag_state, DragState::None) + && key.code == KeyCode::Esc + && key.modifiers.is_empty() + { + self.cancel_drag(&mut ctx.editor.filetree.width); + return EventResult::Consumed(None); + } + // First, intercept the chorded keys (Ctrl-d/u for page nav) — they // have their own arm because the `code` alone doesn't discriminate // them from plain d/u. @@ -684,9 +735,41 @@ impl Filetree { } fn handle_mouse(&mut self, event: &MouseEvent, ctx: &mut Context) -> EventResult { - // A resize in progress claims every subsequent mouse event until Up, - // even if the cursor drifts outside the panel. Branching here first - // means `within` checks below don't accidentally drop drag events. + // A drag in progress (resize or entry-move) claims every subsequent + // mouse event until Up, even if the cursor drifts outside the panel. + // Branching here first means `within` checks below don't accidentally + // drop drag events. + if matches!(self.drag_state, DragState::MovingEntry { .. }) { + let within = self.contains(event.column, event.row); + match event.kind { + MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Moved => { + // Drops are allowed on both file and directory rows + // — `resolve_drop_target` maps a file-row hover to + // the file's parent dir (highlights the parent row + // when present, falls back to "no highlight, but + // the resolved target dir still stands"). Drops + // outside the panel resolve to no target so the + // gesture releases as a silent cancel. + let cursor_idx = if within { + self.row_to_entry_idx(event.row, &ctx.editor.filetree) + } else { + None + }; + let (highlight, target_dir) = resolve_drop_target(cursor_idx, &self.entries); + self.drop_target_row = highlight; + self.drop_target_dir = target_dir; + return EventResult::Consumed(None); + } + MouseEventKind::Up(MouseButton::Left) => { + self.finalize_move(ctx); + return EventResult::Consumed(None); + } + // Swallow other events (Drag(Right), scroll, etc.) while + // we own the drag — letting them through could let the + // editor misinterpret them. + _ => return EventResult::Consumed(None), + } + } if let DragState::ResizingBorder { start_col, start_width, @@ -802,6 +885,48 @@ impl Filetree { ); }))) } + // Drag-with-button-down inside the panel and we *don't* already + // own a drag (the resize / move pre-checks above handle the + // in-flight case). Promote the pending click into a + // `DragState::MovingEntry` if it landed on an entry row — the + // user is dragging that entry to a new location. From the + // next event onwards the pre-check above takes over. + // + // Returning Ignored when the source isn't a valid entry lets + // the editor's own click-drag selection extend across the + // panel area on accidental drag-outs (e.g. dragging a + // selection off the panel into the editor). + MouseEventKind::Drag(MouseButton::Left) => { + let Some(idx) = self.pending_click_row else { + return EventResult::Ignored(None); + }; + let Some(entry) = self.entries.get(idx) else { + return EventResult::Ignored(None); + }; + let source_path = entry.path.clone(); + self.drag_state = DragState::MovingEntry { + source_idx: idx, + source_path, + }; + // A drag invalidates the click chain (single + pending + // double) — the gesture is now a move, not a click. + self.pending_click_row = None; + self.last_click = None; + // Seed the drop target from the current position so the + // visuals appear immediately on the first Drag event, + // not just after the *next* motion event. Uses the same + // resolution as the steady-state pre-check so file-row + // hovers map to the file's parent dir. + let cursor_idx = if within { + self.row_to_entry_idx(event.row, &ctx.editor.filetree) + } else { + None + }; + let (highlight, target_dir) = resolve_drop_target(cursor_idx, &self.entries); + self.drop_target_row = highlight; + self.drop_target_dir = target_dir; + EventResult::Consumed(None) + } // Mouse-up inside the panel. // // Single-click semantics: when the press and release land on the @@ -1102,6 +1227,40 @@ impl Component for Filetree { let hover_handle_style = theme .try_get_exact("ui.filetree.resize-handle.hover") .unwrap_or(unfocused_cursor_style); + // Drag-and-drop visual feedback. Source row reads as "ghosted in + // motion"; the drop-target dir row reads as a loud full-row + // banner so the user can see where the entry will land before + // they release. + // + // Fallbacks chosen for legibility on themes that haven't been + // updated for the panel: + // + // - **Source**: `DIM + ITALIC` — DIM alone is too subtle on + // many themes (often a 10–20% brightness drop); pairing it + // with ITALIC adds a second distinguishing dimension so the + // source still reads even on terminals that swallow DIM. + // Both modifiers degrade gracefully on terminals that don't + // support them. + // - **Target**: `REVERSED` — swaps fg/bg of every cell in the + // row, which is universally supported and creates a much + // stronger visual contrast against the rest of the panel + // than borrowing the cursor's style would. Picking it over + // `unfocused_cursor_style` matters when the user is dragging + // *near* the cursor row: they need to tell drop-target from + // cursor at a glance. + let drag_source_style = theme + .try_get_exact("ui.filetree.drag.source") + .unwrap_or_else(|| { + helix_view::graphics::Style::default() + .add_modifier(helix_view::graphics::Modifier::DIM) + .add_modifier(helix_view::graphics::Modifier::ITALIC) + }); + let drag_target_style = theme + .try_get_exact("ui.filetree.drag.target") + .unwrap_or_else(|| { + helix_view::graphics::Style::default() + .add_modifier(helix_view::graphics::Modifier::REVERSED) + }); // Set of every path currently open as a buffer. Directories show the // indicator when *any* of their descendants is open, so callers will @@ -1157,6 +1316,17 @@ impl Component for Filetree { }; let is_hovered = hovered_row == Some(entry_idx); + // Drag-state per-row flags. When `in_drag`, exactly one row + // in the panel is the source (where the drag started), and + // zero-or-one rows is the current drop target (a directory + // the user could drop onto). We compute these here so the + // style cascade below can layer the drag emphases on top of + // the row's normal style. + let is_drag_source = matches!( + &self.drag_state, + DragState::MovingEntry { source_idx, .. } if *source_idx == entry_idx + ); + let is_drop_target = in_drag && self.drop_target_row == Some(entry_idx); // VCS lookup. Files key directly on their path; directories // surface the most severe status of any descendant via // `rollup_under` so a collapsed parent dir lights up when its @@ -1170,6 +1340,10 @@ impl Component for Filetree { let vcs_patch = vcs_status.and_then(vcs_style_for); // Row text style, in priority order: + // - drop target : loud full-row highlight, beats + // cursor because the drag gesture + // owns the user's attention for + // its duration // - cursor row + focused : focused selected style // - cursor row + unfocused: bufferline-active style (themed // chain above) @@ -1178,11 +1352,14 @@ impl Component for Filetree { // - VCS-dirty row : base patched with the per-status // VCS style // - everything else : the entry's base file/dir style - // Cursor wins over everything because it's sticky state; hover - // is mouse-position state; VCS is per-file state, lowest - // priority for the *text* but it still tints unhighlighted - // rows. - let style = if is_cursor_row { + // Cursor wins over everything except drag because cursor is + // sticky state; drag is in-flight gesture state, transient + // but commanding. We then patch the drag-source dim on top + // *last* so the dragged row still reads as "the thing in + // motion" even when it happens to be the cursor row. + let style_base = if is_drop_target { + drag_target_style + } else if is_cursor_row { if focused { selected_style } else { @@ -1195,15 +1372,21 @@ impl Component for Filetree { } else { base }; + let style = if is_drag_source { + style_base.patch(drag_source_style) + } else { + style_base + }; - // Stretch the bg highlight across the full panel width for *both* - // focused and unfocused cursor rows, so the highlight reads as a - // continuous band like the bufferline's active tab rather than a - // patch around just the entry text. Pre-fill before set_stringn - // so the text characters' style stays intact (set_stringn paints - // cells with the text style; pre-fill covers the rest of the - // row with the same style so bg/fg/mods are uniform). - if is_cursor_row { + // Stretch the bg highlight across the full panel width for the + // cursor row and the drop target row, so each reads as a + // continuous band like the bufferline's active tab rather than + // a patch around just the entry text. Pre-fill before + // set_stringn so the text characters' style stays intact + // (set_stringn paints cells with the text style; pre-fill + // covers the rest of the row with the same style so bg/fg/mods + // are uniform). + if is_cursor_row || is_drop_target { for x in panel_rect.x..panel_rect.x + panel_rect.width { surface[(x, y)].set_style(style); } @@ -1550,151 +1733,6 @@ mod tests { assert!(!tree.is_resize_handle(0, 0, FiletreePosition::Right)); } - /// [`compute_resize_width`] does the absolute-column → new-width math. - /// Closed domain: docked side × delta sign × clamp boundary. - #[test] - fn compute_resize_width_table() { - use FiletreePosition::*; - - // Left-docked panel: dragging the handle right grows the panel. - assert_eq!( - compute_resize_width(30, 29, 35, Left), - 36, - "left-dock + drag right = grow" - ); - assert_eq!( - compute_resize_width(30, 29, 20, Left), - 21, - "left-dock + drag left = shrink" - ); - - // Right-docked: signs flip. Dragging left grows the panel. - assert_eq!( - compute_resize_width(30, 50, 40, Right), - 40, - "right-dock + drag left = grow" - ); - assert_eq!( - compute_resize_width(30, 50, 60, Right), - 20, - "right-dock + drag right = shrink" - ); - - // No movement: width unchanged. - assert_eq!(compute_resize_width(30, 29, 29, Left), 30); - assert_eq!(compute_resize_width(30, 50, 50, Right), 30); - - // MIN_DRAG_WIDTH clamp. Dragging way below 4 saturates at 4. - assert_eq!( - compute_resize_width(30, 29, 0, Left), - MIN_DRAG_WIDTH, - "clamp to MIN_DRAG_WIDTH" - ); - assert_eq!( - compute_resize_width(5, 29, 25, Left), - MIN_DRAG_WIDTH, - "would-be 1 clamps to MIN_DRAG_WIDTH" - ); - - // Saturating add: huge positive delta doesn't wrap. - assert_eq!( - compute_resize_width(30, 0, u16::MAX, Left), - u16::MAX, - "huge delta saturates at u16::MAX, not wraps to 0" - ); - } - - /// [`classify_click`] is the small state machine that decides - /// `Cancel`/`Select`/`Activate` from press, release, and history. Closed - /// input domain: every combination of (pending, released, last_click, - /// elapsed-vs-threshold) is enumerable. - #[test] - fn classify_click_table() { - let now = Instant::now(); - let threshold = Duration::from_millis(500); - let earlier = now - Duration::from_millis(100); // within threshold - let much_earlier = now - Duration::from_millis(1_000); // past threshold - - use ClickOutcome::*; - - // No pending: drag arrived at the panel from outside, or we already - // reset state. Cancel regardless of release / history. - assert_eq!( - classify_click(None, Some(3), None, now, threshold), - Cancel, - "no pending press" - ); - assert_eq!( - classify_click(None, Some(3), Some((3, earlier)), now, threshold), - Cancel, - "no pending press, even with recent history" - ); - - // No release on an entry: cursor left the panel before releasing. - assert_eq!( - classify_click(Some(2), None, None, now, threshold), - Cancel, - "release outside entry list" - ); - - // Drag-out: press on N, release on M ≠ N. - assert_eq!( - classify_click(Some(2), Some(5), None, now, threshold), - Cancel, - "drag-out" - ); - assert_eq!( - classify_click(Some(2), Some(5), Some((2, earlier)), now, threshold), - Cancel, - "drag-out clears even a recent same-row history" - ); - - // Same-row press+release with no recent history → single click. - assert_eq!( - classify_click(Some(4), Some(4), None, now, threshold), - Select { entry_idx: 4 }, - "first click on a row" - ); - - // Recent same-row history → double-click. - assert_eq!( - classify_click(Some(4), Some(4), Some((4, earlier)), now, threshold), - Activate { entry_idx: 4 }, - "second click on same row within threshold → activate" - ); - - // Same row but stale history (past threshold) → treated as a fresh - // single click. Important boundary: this is what prevents - // accidental opens after long pauses between clicks. - assert_eq!( - classify_click(Some(4), Some(4), Some((4, much_earlier)), now, threshold), - Select { entry_idx: 4 }, - "stale history past threshold → select, not activate" - ); - - // Same row but history points at a different row → fresh single - // click. - assert_eq!( - classify_click(Some(4), Some(4), Some((9, earlier)), now, threshold), - Select { entry_idx: 4 }, - "history on a different row doesn't promote" - ); - - // Exactly at the threshold: still counts (we use `<=`). - let exactly_at_threshold = now - threshold; - assert_eq!( - classify_click( - Some(1), - Some(1), - Some((1, exactly_at_threshold)), - now, - threshold, - ), - Activate { entry_idx: 1 }, - "exactly at threshold still activates (boundary uses <=)" - ); - } - /// flat-list shape is correct under a few different expansion patterns. /// This is a closed-domain test: the inputs (`expanded` set choices over a /// known tree) are small and enumerable. @@ -1862,6 +1900,75 @@ mod tests { ); } + /// [`Filetree::cancel_drag`] reverts a drag-in-flight cleanly: the + /// resize variant snaps the panel width back to its pre-drag + /// value, the move variant just clears state (no IO), and both + /// wipe all drag-related fields so the next gesture starts fresh. + /// + /// Closed input domain: three drag states (None / ResizingBorder + /// / MovingEntry) × the observable fields they touch. Pin each + /// branch so a future refactor of the cancel logic can't quietly + /// skip a field. `cancel_drag` takes only `&mut u16` for the width + /// (the single state field it touches), so this test stays free + /// of `FiletreeState` setup boilerplate. + #[test] + fn cancel_drag_reverts_each_kind() { + // None: no-op. Width unchanged, drag_state stays None, the + // drag-related fields stay None too. + { + let mut tree = Filetree::new(); + let mut width = 30u16; + tree.cancel_drag(&mut width); + assert!(matches!(tree.drag_state, DragState::None)); + assert_eq!(width, 30, "no drag → width unchanged"); + assert!(tree.drop_target_row.is_none()); + assert!(tree.drop_target_dir.is_none()); + assert!(tree.pending_click_row.is_none()); + assert!(tree.last_click.is_none()); + } + + // ResizingBorder: width snaps back to start_width, all drag + // fields cleared. We seed `pending_click_row` and `last_click` + // to verify the cancel sweeps them too — the originating Down + // shouldn't be able to pair with a future Up as if it were a + // click. + { + let mut tree = Filetree::new(); + tree.drag_state = DragState::ResizingBorder { + start_col: 100, + start_width: 30, + }; + tree.pending_click_row = Some(5); + tree.last_click = Some((5, Instant::now())); + let mut width = 50u16; // mid-drag width + tree.cancel_drag(&mut width); + assert!(matches!(tree.drag_state, DragState::None)); + assert_eq!(width, 30, "resize cancel → width reverts"); + assert!(tree.pending_click_row.is_none()); + assert!(tree.last_click.is_none()); + } + + // MovingEntry: no width change (the move doesn't touch width + // state), drop-target fields cleared, drag state reset. + { + let mut tree = Filetree::new(); + tree.drag_state = DragState::MovingEntry { + source_idx: 3, + source_path: PathBuf::from("/root/foo.rs"), + }; + tree.drop_target_row = Some(2); + tree.drop_target_dir = Some(PathBuf::from("/root/src")); + tree.pending_click_row = Some(3); + let mut width = 30u16; + tree.cancel_drag(&mut width); + assert!(matches!(tree.drag_state, DragState::None)); + assert_eq!(width, 30, "move cancel → width unchanged"); + assert!(tree.drop_target_row.is_none()); + assert!(tree.drop_target_dir.is_none()); + assert!(tree.pending_click_row.is_none()); + } + } + /// [`Filetree::apply_reveal_pending`] is the cursor-snap half of the /// reveal-active-buffer feature. The ancestor-expansion half is done by /// `filetree_focus` in `commands.rs`; here we just check that given an diff --git a/helix-term/src/ui/filetree/gestures.rs b/helix-term/src/ui/filetree/gestures.rs new file mode 100644 index 00000000..c7284c1a --- /dev/null +++ b/helix-term/src/ui/filetree/gestures.rs @@ -0,0 +1,534 @@ +//! Typed outcomes and pure validation helpers for mouse-driven gestures. +//! +//! This module owns the "what should the panel decide" layer for click +//! /drag/move interactions, keeping the decisions independent of the +//! `Filetree` component and its IO. The component's `handle_mouse` +//! dispatcher reads from here; nothing here reads from there. +//! +//! - [`DragState`]: in-flight drag mode (none / resizing the border / +//! moving an entry). +//! - [`compute_resize_width`]: pure border-drag math. +//! - [`ClickOutcome`] + [`classify_click`]: the click state machine +//! (`Cancel` / `Select` / `Activate`). +//! - [`MoveValidity`] + [`validate_move`]: the rule set behind +//! drag-and-drop validation (`IntoSelf`, `IntoOwnSubtree`, +//! `SameLocation`). +//! - [`resolve_drop_target`]: cursor row → drop directory + highlight +//! row, with file-row-resolves-to-parent semantics. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use helix_view::editor::FiletreePosition; + +use super::{EntryKind, FiletreeEntry}; + +/// Maximum time between two clicks on the same row to count as a double-click. +/// Matches the de-facto desktop convention (~500ms); keeping it here as a +/// constant rather than a config knob until someone surfaces a real reason +/// to make it user-tunable. +pub(super) const DOUBLE_CLICK_THRESHOLD: Duration = Duration::from_millis(500); + +/// Minimum panel width during a drag-resize. Tight enough to be useful for +/// users who want the panel out of the way, wide enough that the indicator +/// + 1ch margin (which need 3 cols total) and at least one character of an +/// entry name still fit. +pub(super) const MIN_DRAG_WIDTH: u16 = 4; + +/// In-flight mouse-down/drag state. Lives on the component so the various +/// `handle_mouse` branches can coordinate — a Down can plant a drag-source, +/// a subsequent Drag can act on it, and the eventual Up tears it down. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum DragState { + None, + /// A mouse-down landed on the panel's resize handle (the column flush + /// against the seam with the editor). `start_col` is the absolute screen + /// column the press hit, `start_width` is what the panel was before the + /// drag began. The delta between current event column and `start_col` + /// translates straight into a width change. + ResizingBorder { + start_col: u16, + start_width: u16, + }, + /// A mouse-down on an entry row was followed by a Drag — the user is + /// dragging the entry to a new location. `source_idx` is the row index + /// at drag-start (used for the dimmed-source visual; remains stable for + /// the duration of the drag even if the cursor wanders off-row). + /// `source_path` is the on-disk path the eventual IO acts on. + /// + /// Both file and directory rows can be sources: `std::fs::rename` + /// works on either, and `validate_move` rejects the structurally + /// invalid cases (into self, into own subtree) before we attempt + /// the IO. + MovingEntry { + source_idx: usize, + source_path: PathBuf, + }, +} + +/// Pure resize-math, factored out for testing the boundary cases (delta +/// direction by docked side, underflow, MIN_DRAG_WIDTH clamp). Saturates at +/// `MIN_DRAG_WIDTH` on the low end; the high end is left to render-time +/// `panel_rect.width.min(vstrip.width)` so an over-dragged panel just visually +/// clamps to the terminal without state.width having to know terminal width. +pub(super) fn compute_resize_width( + start_width: u16, + start_col: u16, + current_col: u16, + position: FiletreePosition, +) -> u16 { + let raw_delta = current_col as i32 - start_col as i32; + let signed_delta = match position { + FiletreePosition::Left => raw_delta, + // A right-docked panel grows when the user drags left; flip the sign + // so a more-negative `raw_delta` increases the panel width. + FiletreePosition::Right => -raw_delta, + }; + let new_width = (start_width as i32).saturating_add(signed_delta); + // Clamp to [MIN_DRAG_WIDTH, u16::MAX] *before* the cast so a huge positive + // delta saturates at u16::MAX rather than wrapping back into a tiny value + // via the truncating `as u16`. + new_width.clamp(MIN_DRAG_WIDTH as i32, u16::MAX as i32) as u16 +} + +/// What a click (mouse press-then-release) should do, given where the press +/// landed, where the release landed, and whether the last completed click was +/// recent enough on the same row to promote this one to a double-click. +/// +/// Lives as a typed outcome rather than imperative branching inside the mouse +/// handler so the decision can be unit-tested against [`ClickOutcome`] without +/// needing to fake a [`crate::compositor::Context`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ClickOutcome { + /// Drag-out (release on a different row), release outside the entry list, + /// or press happened outside the panel. Single + double tracking should + /// be reset; no entry-level action fires. + Cancel, + /// Single click on row `entry_idx` — selects only. The entry is recorded + /// as a potential first half of a double-click. + Select { entry_idx: usize }, + /// Double click on `entry_idx` — opens the file or toggles the directory. + /// The double-click recorder is cleared so a third tap doesn't fire a + /// second activation. + Activate { entry_idx: usize }, +} + +/// Pure click-classification, separated out so the boundary cases are +/// testable. `now` and `threshold` are parameters rather than [`Instant::now`] +/// + the module constant so tests can fix time without messing with the +/// real clock. +pub(super) fn classify_click( + pending: Option, + released_idx: Option, + last_click: Option<(usize, Instant)>, + now: Instant, + threshold: Duration, +) -> ClickOutcome { + let Some(pending) = pending else { + return ClickOutcome::Cancel; + }; + let Some(released) = released_idx else { + return ClickOutcome::Cancel; + }; + if pending != released { + return ClickOutcome::Cancel; + } + if let Some((prev_idx, prev_when)) = last_click { + if prev_idx == released && now.duration_since(prev_when) <= threshold { + return ClickOutcome::Activate { + entry_idx: released, + }; + } + } + ClickOutcome::Select { + entry_idx: released, + } +} + +/// Why a proposed drag-and-drop move is or isn't allowed. The Ok variant +/// means "structurally fine, go check the filesystem and try the rename"; +/// the rest are reasons to short-circuit without touching disk. Lives as a +/// typed outcome so the rule set is testable on path tuples alone, without +/// faking a tree or `Context`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum MoveValidity { + /// Structurally allowed. Caller still has to attempt the `rename` — + /// the target file might exist on disk, or the rename might fail for + /// IO reasons (cross-device, permissions). Those errors are not the + /// concern of this function. + Ok, + /// `source` already lives directly inside `target_dir` (its parent is + /// the target). Treated as a no-op rather than an error because it's + /// usually the user releasing on the same dir they dragged from. + SameLocation, + /// `source` and `target_dir` are the same path. Common when the user + /// drops an entry on top of itself. + IntoSelf, + /// `target_dir` is a descendant of `source`. Only meaningful when + /// `source` is a directory (and the IO would orphan everything + /// underneath), but the rule is applied uniformly because it's + /// always nonsense. + IntoOwnSubtree, +} + +/// Decide whether moving `source` into `target_dir` is structurally +/// allowed. Pure: examines the path components, does not touch the +/// filesystem. +/// +/// Priority of rejection (the order matters when multiple rules +/// theoretically apply, e.g. `IntoSelf` is a degenerate case of +/// `IntoOwnSubtree`): +/// +/// 1. `source == target_dir` → [`MoveValidity::IntoSelf`] +/// 2. `target_dir` starts with `source` → [`MoveValidity::IntoOwnSubtree`] +/// 3. `source.parent() == Some(target_dir)` → [`MoveValidity::SameLocation`] +/// 4. otherwise → [`MoveValidity::Ok`] +/// +/// `starts_with` is the component-aware path version (e.g. `/ab` does +/// *not* start with `/a`), so substring confusions don't matter. +pub(super) fn validate_move(source: &Path, target_dir: &Path) -> MoveValidity { + if source == target_dir { + return MoveValidity::IntoSelf; + } + if target_dir.starts_with(source) { + return MoveValidity::IntoOwnSubtree; + } + if source.parent() == Some(target_dir) { + return MoveValidity::SameLocation; + } + MoveValidity::Ok +} + +/// Given the entry the cursor is currently over during a drag, decide +/// which directory the drop should land in and which row the renderer +/// should highlight. The two outputs differ when the cursor is on a file +/// row whose parent dir is the panel root (not represented as an entry): +/// the move still resolves to the root, but there's no row to highlight. +/// +/// - Cursor on a **directory** row → drop into that directory, highlight +/// that row. +/// - Cursor on a **file** row → drop into the file's parent directory. +/// Highlight the parent dir's row if it's present in `entries`, +/// else no highlight (parent is the panel root). +/// - Cursor on **nothing** (gutter, outside panel) → neither, drop is a +/// no-op when the gesture releases. +/// +/// Pure (path-and-index arithmetic, no IO) so the resolution rules are +/// table-tested. The highlight index lookup is `O(entries.len())`, which +/// is fine for the panel's small visible-row count. +pub(super) fn resolve_drop_target( + cursor_row_idx: Option, + entries: &[FiletreeEntry], +) -> (Option, Option) { + let Some(idx) = cursor_row_idx else { + return (None, None); + }; + let Some(entry) = entries.get(idx) else { + return (None, None); + }; + match &entry.kind { + EntryKind::Directory { .. } => (Some(idx), Some(entry.path.clone())), + EntryKind::File => { + let parent = entry.path.parent().map(PathBuf::from); + let highlight = parent + .as_ref() + .and_then(|p| entries.iter().position(|e| &e.path == p)); + (highlight, parent) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// [`compute_resize_width`] does the absolute-column → new-width math. + /// Closed domain: docked side × delta sign × clamp boundary. + #[test] + fn compute_resize_width_table() { + use FiletreePosition::*; + + // Left-docked panel: dragging the handle right grows the panel. + assert_eq!( + compute_resize_width(30, 29, 35, Left), + 36, + "left-dock + drag right = grow" + ); + assert_eq!( + compute_resize_width(30, 29, 20, Left), + 21, + "left-dock + drag left = shrink" + ); + + // Right-docked: signs flip. Dragging left grows the panel. + assert_eq!( + compute_resize_width(30, 50, 40, Right), + 40, + "right-dock + drag left = grow" + ); + assert_eq!( + compute_resize_width(30, 50, 60, Right), + 20, + "right-dock + drag right = shrink" + ); + + // No movement: width unchanged. + assert_eq!(compute_resize_width(30, 29, 29, Left), 30); + assert_eq!(compute_resize_width(30, 50, 50, Right), 30); + + // MIN_DRAG_WIDTH clamp. Dragging way below 4 saturates at 4. + assert_eq!( + compute_resize_width(30, 29, 0, Left), + MIN_DRAG_WIDTH, + "clamp to MIN_DRAG_WIDTH" + ); + assert_eq!( + compute_resize_width(5, 29, 25, Left), + MIN_DRAG_WIDTH, + "would-be 1 clamps to MIN_DRAG_WIDTH" + ); + + // Saturating add: huge positive delta doesn't wrap. + assert_eq!( + compute_resize_width(30, 0, u16::MAX, Left), + u16::MAX, + "huge delta saturates at u16::MAX, not wraps to 0" + ); + } + + /// [`classify_click`] is the small state machine that decides + /// `Cancel`/`Select`/`Activate` from press, release, and history. Closed + /// input domain: every combination of (pending, released, last_click, + /// elapsed-vs-threshold) is enumerable. + #[test] + fn classify_click_table() { + let now = Instant::now(); + let threshold = Duration::from_millis(500); + let earlier = now - Duration::from_millis(100); // within threshold + let much_earlier = now - Duration::from_millis(1_000); // past threshold + + use ClickOutcome::*; + + // No pending: drag arrived at the panel from outside, or we already + // reset state. Cancel regardless of release / history. + assert_eq!( + classify_click(None, Some(3), None, now, threshold), + Cancel, + "no pending press" + ); + assert_eq!( + classify_click(None, Some(3), Some((3, earlier)), now, threshold), + Cancel, + "no pending press, even with recent history" + ); + + // No release on an entry: cursor left the panel before releasing. + assert_eq!( + classify_click(Some(2), None, None, now, threshold), + Cancel, + "release outside entry list" + ); + + // Drag-out: press on N, release on M ≠ N. + assert_eq!( + classify_click(Some(2), Some(5), None, now, threshold), + Cancel, + "drag-out" + ); + assert_eq!( + classify_click(Some(2), Some(5), Some((2, earlier)), now, threshold), + Cancel, + "drag-out clears even a recent same-row history" + ); + + // Same-row press+release with no recent history → single click. + assert_eq!( + classify_click(Some(4), Some(4), None, now, threshold), + Select { entry_idx: 4 }, + "first click on a row" + ); + + // Recent same-row history → double-click. + assert_eq!( + classify_click(Some(4), Some(4), Some((4, earlier)), now, threshold), + Activate { entry_idx: 4 }, + "second click on same row within threshold → activate" + ); + + // Same row but stale history (past threshold) → treated as a fresh + // single click. Important boundary: this is what prevents + // accidental opens after long pauses between clicks. + assert_eq!( + classify_click(Some(4), Some(4), Some((4, much_earlier)), now, threshold), + Select { entry_idx: 4 }, + "stale history past threshold → select, not activate" + ); + + // Same row but history points at a different row → fresh single + // click. + assert_eq!( + classify_click(Some(4), Some(4), Some((9, earlier)), now, threshold), + Select { entry_idx: 4 }, + "history on a different row doesn't promote" + ); + + // Exactly at the threshold: still counts (we use `<=`). + let exactly_at_threshold = now - threshold; + assert_eq!( + classify_click( + Some(1), + Some(1), + Some((1, exactly_at_threshold)), + now, + threshold, + ), + Activate { entry_idx: 1 }, + "exactly at threshold still activates (boundary uses <=)" + ); + } + + /// [`validate_move`] is the pure rule set behind drag-and-drop file + /// moves. Closed input domain (two paths); enumerate the rejection + /// rules and a representative Ok case. The rule priorities matter + /// when rules overlap: `IntoSelf` is technically a degenerate case + /// of `IntoOwnSubtree`, so the order in the function (self before + /// subtree) is asserted by the `source == target_dir → IntoSelf` + /// row. + #[test] + fn validate_move_table() { + use MoveValidity::*; + // (source, target_dir, expected, label) + let cases: &[(&str, &str, MoveValidity, &str)] = &[ + ("/a/b.txt", "/c", Ok, "into an unrelated sibling dir"), + ( + "/a/b.txt", + "/a", + SameLocation, + "into the source's own parent dir is a no-op", + ), + ("/a", "/a", IntoSelf, "exact same path: self"), + ("/a", "/a/b", IntoOwnSubtree, "into own immediate child dir"), + ("/a", "/a/b/c/d", IntoOwnSubtree, "into own deep descendant"), + ( + "/a/b", + "/a/b/c", + IntoOwnSubtree, + "dir-source into its own subtree", + ), + ("/a/b", "/c/d", Ok, "different subtree, multi-segment paths"), + ( + "/repo/src/main.rs", + "/repo/tests", + Ok, + "moving a file to a sibling top-level dir", + ), + ( + "/repo/src/sub/file.rs", + "/repo/src/sub", + SameLocation, + "deep file dropped on its own directory parent", + ), + // Component-vs-substring boundary: /ab is not under /a. + ( + "/a", + "/ab/foo", + Ok, + "/ab is NOT under /a (component-based, not substring)", + ), + ]; + + for (source, target, expected, label) in cases { + let source = Path::new(source); + let target = Path::new(target); + assert_eq!( + validate_move(source, target), + *expected, + "{label}: source={source:?} target={target:?}", + ); + } + } + + /// [`resolve_drop_target`] decides what the renderer highlights and + /// what `finalize_move` acts on as the cursor passes over each row + /// during a drag. Closed input domain (cursor row + entries list); + /// boundary cases that matter: + /// + /// - cursor on a directory row → drop into it, highlight that row + /// - cursor on a file row whose parent dir IS in entries → drop + /// into the parent, highlight that parent's row (this is the + /// "drop on a file to land in its directory" UX) + /// - cursor on a file row whose parent is NOT in entries → drop + /// into the parent path anyway, but no row to highlight (the + /// parent IS the panel root, not represented as an entry) + /// - cursor over nothing → no target, no highlight + #[test] + fn resolve_drop_target_table() { + // Build a small entry list mirroring a real expanded tree under + // the panel root `/r`: + // 0 /r/dir_a/ (dir) + // 1 /r/dir_a/f.txt (file) + // 2 /r/dir_b/ (dir) + // 3 /r/loose.txt (file at panel-root level; parent /r isn't an entry) + let entries: Vec = vec![ + FiletreeEntry { + path: PathBuf::from("/r/dir_a"), + depth: 0, + kind: EntryKind::Directory { expanded: true }, + }, + FiletreeEntry { + path: PathBuf::from("/r/dir_a/f.txt"), + depth: 1, + kind: EntryKind::File, + }, + FiletreeEntry { + path: PathBuf::from("/r/dir_b"), + depth: 0, + kind: EntryKind::Directory { expanded: false }, + }, + FiletreeEntry { + path: PathBuf::from("/r/loose.txt"), + depth: 0, + kind: EntryKind::File, + }, + ]; + + let cases: &[(Option, Option, Option<&str>, &str)] = &[ + ( + Some(0), + Some(0), + Some("/r/dir_a"), + "cursor on a dir row → itself", + ), + ( + Some(1), + Some(0), + Some("/r/dir_a"), + "cursor on a file row → highlight its parent dir row", + ), + ( + Some(2), + Some(2), + Some("/r/dir_b"), + "cursor on a collapsed dir → still itself", + ), + ( + Some(3), + None, + Some("/r"), + "cursor on a file whose parent is the panel root → no highlight, target is root", + ), + (None, None, None, "no cursor row → no target, no highlight"), + (Some(99), None, None, "cursor row out of range → no target"), + ]; + + for (cursor, expected_hl, expected_dir, label) in cases { + let (hl, dir) = resolve_drop_target(*cursor, &entries); + assert_eq!(hl, *expected_hl, "{label} (highlight): cursor={cursor:?}"); + assert_eq!( + dir.as_deref().and_then(|p| p.to_str()), + *expected_dir, + "{label} (target_dir): cursor={cursor:?}", + ); + } + } +} -- 2.51.2