diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 02a6c0f6..1c9dcdc1 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -3222,6 +3222,14 @@ fn file_picker_in_current_directory(cx: &mut Context) { /// scroll, and the expanded-directory set persist across hides so a /// user-resized, user-navigated panel comes back exactly as it was left. /// +/// Whenever this runs and the focused view has a non-scratch buffer whose +/// path lives under the panel root, the tree expands all ancestor +/// directories and snaps the cursor to that file's row. This means +/// pressing `e` from a buffer always lands you at "this file" in +/// the tree, so the next `j`/`k` is relative to where you actually are. +/// Scratch buffers, untitled buffers, and files outside the workspace are +/// silently skipped — the panel just opens at wherever the cursor was. +/// /// Paired with [`filetree_close`]: `e` is "open or reach back into" /// the panel, `E` is "dismiss" it. `` from within the panel /// unfocuses without hiding, so a typical workflow is `e` → navigate @@ -3231,8 +3239,12 @@ fn file_picker_in_current_directory(cx: &mut Context) { fn filetree_focus(cx: &mut Context) { if !cx.editor.filetree.visible { cx.editor.filetree.visible = true; + // Canonicalize on first set so the root matches the paths + // `Document::path()` reports (which `Editor::open` canonicalizes). + // Otherwise reveal-the-active-buffer would silently miss when the + // workspace path includes `..` segments or a symlink. if cx.editor.filetree.root.is_none() { - cx.editor.filetree.root = Some(find_workspace().0); + cx.editor.filetree.root = Some(helix_stdx::path::canonicalize(find_workspace().0)); } cx.callback.push(Box::new(|compositor, _cx| { if compositor @@ -3244,6 +3256,41 @@ fn filetree_focus(cx: &mut Context) { })); } cx.editor.filetree.focused = true; + + // Try to surface the active buffer's path in the tree. Doc paths are + // canonical (set by Editor::open); the root we just stored is too; so + // a strip_prefix is enough to test "is this file under the root?". + let target = doc!(cx.editor) + .path() + .map(|p| p.to_path_buf()) + .and_then(|path| { + let root = cx.editor.filetree.root.as_deref()?; + path.strip_prefix(root).ok()?; + Some(path) + }); + + if let Some(target) = target { + let root = cx.editor.filetree.root.clone().expect("set above"); + // Expand every directory between the root (exclusive) and the file + // (exclusive). The walker handles the root itself; the file is a + // leaf, not a container. + let mut current = target.parent(); + while let Some(dir) = current { + if dir == root { + break; + } + cx.editor.filetree.expanded.insert(dir.to_path_buf()); + current = dir.parent(); + } + // Hand the path to the component for the next render. We do this + // through a callback rather than mutating editor state because the + // reveal cursor is component-local (the entries list lives there). + cx.callback.push(Box::new(move |compositor, _cx| { + if let Some(filetree) = compositor.find_id::(ui::filetree::ID) { + filetree.reveal_path(target); + } + })); + } } /// Hide the persistent filetree panel. diff --git a/helix-term/src/ui/filetree.rs b/helix-term/src/ui/filetree.rs index dcd6355f..59f8ed42 100644 --- a/helix-term/src/ui/filetree.rs +++ b/helix-term/src/ui/filetree.rs @@ -77,6 +77,12 @@ pub struct Filetree { /// a directory toggle, or on first render. Cleared after a successful /// rebuild. dirty: bool, + /// A path the next render should jump the cursor to once the entries are + /// rebuilt. Used by [`Self::reveal_path`] to surface the active buffer + /// without having to walk the filesystem from inside the command. Cleared + /// after a successful match (or silently dropped if the path isn't in + /// the visible flat list). + reveal_pending: Option, } impl Default for Filetree { @@ -86,6 +92,7 @@ impl Default for Filetree { entries: Vec::new(), // Start dirty so the first render walks the filesystem. dirty: true, + reveal_pending: None, } } } @@ -95,6 +102,33 @@ impl Filetree { Self::default() } + /// Mark `path` as the entry the next render should snap the cursor to. + /// + /// The caller is responsible for first ensuring all ancestor directories + /// between the root and `path` are in [`FiletreeState::expanded`] — this + /// method does not expand on its own. The render path rebuilds the flat + /// entries list (because we also flip `dirty`) and then searches it for + /// the matching entry; if found, `cursor_idx` and `scroll` are updated to + /// place it on screen. If `path` isn't in the rebuilt list (e.g. it lives + /// outside the root, or an ancestor wasn't expanded), the reveal is + /// silently dropped. + pub fn reveal_path(&mut self, path: PathBuf) { + self.reveal_pending = Some(path); + self.dirty = true; + } + + /// Consume any pending reveal: if a target path is queued and currently + /// lives in [`Self::entries`], update `state.cursor_idx` to its row. + /// Either way the pending target is cleared. + fn apply_reveal_pending(&mut self, state: &mut FiletreeState) { + let Some(target) = self.reveal_pending.take() else { + return; + }; + if let Some(idx) = self.entries.iter().position(|e| e.path == target) { + state.cursor_idx = idx; + } + } + /// Compute the panel rect inside a given vertical strip (`vstrip`). /// /// `vstrip` is the editor area's vertical extent — i.e. the screen area @@ -338,6 +372,11 @@ impl Component for Filetree { self.refresh_entries(&ctx.editor.filetree, &config.filetree); } + // If a path was queued for reveal (e.g. active buffer on focus), + // snap the cursor to it now that the entries reflect the latest + // expansion state. + self.apply_reveal_pending(&mut ctx.editor.filetree); + let max_idx = self.entries.len().saturating_sub(1); if ctx.editor.filetree.cursor_idx > max_idx { ctx.editor.filetree.cursor_idx = max_idx; @@ -612,4 +651,56 @@ mod tests { .collect(); assert_eq!(names, vec!["a", "b", "c.txt"]); } + + /// [`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 + /// entries list that already contains the target, the cursor lands on + /// its row — and that targets not in the list are silently dropped. + #[test] + fn apply_reveal_pending_snaps_to_target() { + let mut tree = Filetree::new(); + // Build a stub entries list by hand to keep the test off the + // filesystem. The depth/kind don't matter for the lookup, only path. + let make_entry = |p: &str| FiletreeEntry { + path: PathBuf::from(p), + depth: 0, + kind: EntryKind::File, + }; + tree.entries = vec![ + make_entry("/root/a"), + make_entry("/root/b"), + make_entry("/root/c"), + ]; + + // Hit: cursor moves to the matching entry. + let mut state = FiletreeState { + cursor_idx: 99, // deliberately stale + ..FiletreeState::default() + }; + tree.reveal_pending = Some(PathBuf::from("/root/b")); + tree.apply_reveal_pending(&mut state); + assert_eq!(state.cursor_idx, 1, "cursor snaps to /root/b at index 1"); + assert!(tree.reveal_pending.is_none(), "pending is consumed on hit"); + + // Miss: cursor unchanged, pending consumed. + state.cursor_idx = 0; + tree.reveal_pending = Some(PathBuf::from("/root/elsewhere")); + tree.apply_reveal_pending(&mut state); + assert_eq!( + state.cursor_idx, 0, + "missing target leaves cursor where it was" + ); + assert!( + tree.reveal_pending.is_none(), + "pending is consumed even on miss" + ); + + // None: total no-op. + state.cursor_idx = 2; + tree.reveal_pending = None; + tree.apply_reveal_pending(&mut state); + assert_eq!(state.cursor_idx, 2); + assert!(tree.reveal_pending.is_none()); + } }