diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md index 1684a39e..72fb5da1 100644 --- a/book/src/generated/static-cmd.md +++ b/book/src/generated/static-cmd.md @@ -100,7 +100,8 @@ | `file_picker` | Open file picker | normal: `` f ``, select: `` f `` | | `file_picker_in_current_buffer_directory` | Open file picker at current buffer's directory | | | `file_picker_in_current_directory` | Open file picker at current working directory | normal: `` F ``, select: `` F `` | -| `toggle_filetree` | Toggle filetree panel | normal: `` e ``, select: `` e `` | +| `filetree_focus` | Show and focus the filetree panel | normal: `` e ``, select: `` e `` | +| `filetree_close` | Hide the filetree panel | normal: `` E ``, select: `` E `` | | `code_action` | Perform code action | normal: `` a ``, select: `` a `` | | `buffer_picker` | Open buffer picker | normal: `` b ``, select: `` b `` | | `jumplist_picker` | Open jumplist picker | normal: `` j ``, select: `` j `` | diff --git a/book/src/keymap.md b/book/src/keymap.md index 28a20f2e..2d0b8059 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -295,7 +295,8 @@ This layer is a kludge of mappings, mostly pickers. | ----- | ----------- | ------- | | `f` | Open file picker at LSP workspace root | `file_picker` | | `F` | Open file picker at current working directory | `file_picker_in_current_directory` | -| `e` | Toggle filetree panel | `toggle_filetree` | +| `e` | Show and focus the filetree panel | `filetree_focus` | +| `E` | Hide the filetree panel | `filetree_close` | | `b` | Open buffer picker | `buffer_picker` | | `j` | Open jumplist picker | `jumplist_picker` | | `g` | Open changed file picker | `changed_file_picker` | diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 3be435ee..02a6c0f6 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -404,7 +404,8 @@ impl MappableCommand { file_picker, "Open file picker", file_picker_in_current_buffer_directory, "Open file picker at current buffer's directory", file_picker_in_current_directory, "Open file picker at current working directory", - toggle_filetree, "Toggle filetree panel", + filetree_focus, "Show and focus the filetree panel", + filetree_close, "Hide the filetree panel", code_action, "Perform code action", buffer_picker, "Open buffer picker", jumplist_picker, "Open jumplist picker", @@ -3213,25 +3214,26 @@ fn file_picker_in_current_directory(cx: &mut Context) { cx.push_layer(Box::new(overlaid(picker))); } -/// Toggle the persistent filetree panel. +/// Show and focus the persistent filetree panel. /// -/// When showing, push a [`ui::Filetree`] component onto the compositor and -/// flip `editor.filetree.visible`. When hiding, remove the component by ID -/// and clear the visible/focused flags. The width is preserved across -/// toggles so a user-resized panel doesn't snap back to the config default. -fn toggle_filetree(cx: &mut Context) { - let was_visible = cx.editor.filetree.visible; - cx.editor.filetree.visible = !was_visible; - if was_visible { - cx.editor.filetree.focused = false; - cx.callback.push(Box::new(|compositor, _cx| { - compositor.remove(ui::filetree::ID); - })); - } else { - // Push the panel component if it isn't already in the compositor. - // Focus is owned by the editor key path — `toggle_filetree` - // shows or hides the panel without redirecting input; a separate - // `filetree_focus` command moves focus onto it. +/// Summons the panel if hidden (initializing the root from the workspace), +/// then gives it keyboard focus. Idempotent: pressing it when the panel +/// is already visible-and-focused changes nothing. Width, cursor position, +/// scroll, and the expanded-directory set persist across hides so a +/// user-resized, user-navigated panel comes back exactly as it was left. +/// +/// 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 +/// → `` (opens file, focus drops back to editor) → edit → `e` +/// to reach back into the still-visible tree → ... → `E` to put it +/// away when done. +fn filetree_focus(cx: &mut Context) { + if !cx.editor.filetree.visible { + cx.editor.filetree.visible = true; + if cx.editor.filetree.root.is_none() { + cx.editor.filetree.root = Some(find_workspace().0); + } cx.callback.push(Box::new(|compositor, _cx| { if compositor .find_id::(ui::filetree::ID) @@ -3241,6 +3243,24 @@ fn toggle_filetree(cx: &mut Context) { } })); } + cx.editor.filetree.focused = true; +} + +/// Hide the persistent filetree panel. +/// +/// Pure dismiss — removes the component from the compositor and clears the +/// visible/focused flags. Persistent navigation state (cursor, scroll, +/// expanded set, width) is preserved so the next [`filetree_focus`] picks +/// up exactly where this left off. No-op when the panel is already hidden. +fn filetree_close(cx: &mut Context) { + if !cx.editor.filetree.visible { + return; + } + cx.editor.filetree.visible = false; + cx.editor.filetree.focused = false; + cx.callback.push(Box::new(|compositor, _cx| { + compositor.remove(ui::filetree::ID); + })); } struct PathStyleConfig { diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 208b5021..526b333b 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -225,7 +225,8 @@ pub fn default() -> HashMap { "space" => { "Space" "f" => file_picker, "F" => file_picker_in_current_directory, - "e" => toggle_filetree, + "e" => filetree_focus, + "E" => filetree_close, "b" => buffer_picker, "j" => jumplist_picker, "s" => lsp_or_syntax_symbol_picker, diff --git a/helix-term/src/ui/filetree.rs b/helix-term/src/ui/filetree.rs index a0611f56..f5aa2c05 100644 --- a/helix-term/src/ui/filetree.rs +++ b/helix-term/src/ui/filetree.rs @@ -1,9 +1,11 @@ //! The persistent, side-pinned filetree panel. //! -//! See [`Filetree`] for the [`Component`] implementation. State that needs to -//! live across renders (visibility, width, focus) is on -//! [`helix_view::editor::FiletreeState`] so that [`crate::ui::EditorView`] can -//! consult it when deciding how much horizontal space to give the editor area. +//! See [`Filetree`] for the [`Component`] implementation. Per-session state +//! that needs to live across visibility toggles (visibility, focus, width, +//! root, cursor, scroll, expanded directories) is on +//! [`helix_view::editor::FiletreeState`]. The flat list of entries the panel +//! actually draws is derived from that state plus the live filesystem — that +//! derivation, and the rendering and keyboard handling, live here. //! //! The panel has three render modes — see //! [`FiletreeRenderMode`](helix_view::editor::FiletreeRenderMode). Briefly: @@ -14,26 +16,78 @@ //! - `Overlay`: summoned on a narrow terminal. Panel floats on top of buffers, //! anchored to the configured docked side, without displacing the editor. //! -//! At this point the component is a render skeleton: it claims the right -//! amount of space (docked) or floats over the buffer area (overlaid) and -//! paints a themed background. Entries, navigation, and input handling +//! The component renders entries from a flat list and walks the filesystem +//! lazily on directory expansion. Keyboard navigation (`j`/`k`, arrow keys, +//! Enter to open/toggle) is handled when the panel is focused. Mouse +//! routing, VCS coloring, file operations, and the filesystem watcher //! aren't wired up here. -use helix_view::editor::FiletreePosition; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use helix_view::editor::{Action, FiletreeConfig, FiletreePosition, FiletreeState}; use helix_view::graphics::Rect; +use helix_view::input::KeyEvent; +use helix_view::keyboard::{KeyCode, KeyModifiers}; use tui::buffer::Buffer as Surface; -use crate::compositor::{Component, Context}; +use crate::compositor::{Component, Context, Event, EventResult}; pub const ID: &str = "filetree"; +/// One row in the panel's flat entry list. Files and directories share this +/// 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, + /// 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, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EntryKind { + File, + /// `expanded` is materialized here rather than re-checked at render time + /// because the entry-builder already consulted `FiletreeState.expanded` to + /// decide whether to recurse — the renderer should use the same answer. + Directory { + expanded: bool, + }, +} + +impl EntryKind { + fn is_dir(self) -> bool { + matches!(self, Self::Directory { .. }) + } +} + /// The persistent side-pinned filetree panel. -#[derive(Default)] pub struct Filetree { /// Cached panel rect from the most recent render. Used by future phases /// for mouse hit-testing (compare with `popup::Popup::handle_mouse_event`). area: Rect, + /// Flat list of visible rows. Rebuilt from + /// [`FiletreeState`] + the filesystem when `dirty` is set. + entries: Vec, + /// Set whenever the cached entries no longer match the state — e.g. after + /// a directory toggle, or on first render. Cleared after a successful + /// rebuild. + dirty: bool, +} + +impl Default for Filetree { + fn default() -> Self { + Self { + area: Rect::default(), + entries: Vec::new(), + // Start dirty so the first render walks the filesystem. + dirty: true, + } + } } impl Filetree { @@ -63,6 +117,178 @@ impl Filetree { }, } } + + /// Rebuild [`Self::entries`] from the current state and filesystem. + fn refresh_entries(&mut self, state: &FiletreeState, config: &FiletreeConfig) { + self.entries.clear(); + if let Some(root) = state.root.as_deref() { + list_directory_into(root, &state.expanded, config, 0, &mut self.entries); + } + self.dirty = false; + } + + /// Move the cursor by `delta` rows, clamped to `[0, entries.len() - 1]`. + /// A no-op when there are no entries. + fn move_cursor(&self, state: &mut FiletreeState, delta: isize) { + if self.entries.is_empty() { + return; + } + let max = self.entries.len() - 1; + let current = state.cursor_idx.min(max) as isize; + let new = (current + delta).clamp(0, max as isize) as usize; + state.cursor_idx = new; + } + + /// Activate the entry under the cursor. + /// + /// For a file: opens it in the focused view via [`Action::Replace`] and + /// transfers keyboard focus back to the editor. For a directory: toggles + /// its expansion. Either way, the entries list is marked dirty so the + /// next render reflects the change. + /// + /// Returns whether an action was actually taken (the cursor pointed at a + /// real entry). When `false`, the caller may still treat the keystroke + /// as consumed; this is just whether `editor` was mutated. + fn activate_under_cursor(&mut self, ctx: &mut Context) -> bool { + let Some(entry) = self.entry_under_cursor(&ctx.editor.filetree) else { + return false; + }; + + let path = entry.path.clone(); + match entry.kind { + EntryKind::File => { + if let Err(e) = ctx.editor.open(&path, Action::Replace) { + ctx.editor + .set_error(format!("unable to open {}: {}", path.display(), e)); + } else { + // Hand focus back to the editor — opening a file is the + // "I want to edit this" intent, so further keystrokes + // should go to the buffer. + ctx.editor.filetree.focused = false; + } + } + EntryKind::Directory { .. } => { + ctx.editor.filetree.toggle_expanded(&path); + self.dirty = true; + } + } + true + } + + /// Collapse the directory under the cursor; if it's already collapsed (or + /// the cursor is on a file), jump to the parent directory's row instead. + /// This mirrors how `h` works in nvim-tree / NERDTree. + fn collapse_or_parent(&mut self, ctx: &mut Context) { + let Some(entry) = self.entry_under_cursor(&ctx.editor.filetree) else { + return; + }; + + match entry.kind { + EntryKind::Directory { expanded: true } => { + let path = entry.path.clone(); + ctx.editor.filetree.toggle_expanded(&path); + self.dirty = true; + } + _ => { + // Jump to parent: walk backward through the cached entries + // until we find one with strictly smaller depth. + let cursor = ctx + .editor + .filetree + .cursor_idx + .min(self.entries.len().saturating_sub(1)); + let target_depth = self.entries[cursor].depth; + if target_depth == 0 { + return; + } + let parent_idx = self.entries[..cursor] + .iter() + .rposition(|e| e.depth < target_depth); + if let Some(idx) = parent_idx { + ctx.editor.filetree.cursor_idx = idx; + } + } + } + } + + fn entry_under_cursor<'a>(&'a self, state: &FiletreeState) -> Option<&'a FiletreeEntry> { + let idx = state.cursor_idx.min(self.entries.len().saturating_sub(1)); + self.entries.get(idx) + } + + fn handle_key(&mut self, key: KeyEvent, ctx: &mut Context) -> EventResult { + // Only intercept keys when we own focus. Outside focus we still need + // to be in the layer stack (to render), but the editor should get + // every keystroke. + if !ctx.editor.filetree.focused { + return EventResult::Ignored(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. + if key.modifiers.contains(KeyModifiers::CONTROL) { + match key.code { + KeyCode::Char('d') => { + let step = self.area.height.max(1) as isize; + self.move_cursor(&mut ctx.editor.filetree, step); + return EventResult::Consumed(None); + } + KeyCode::Char('u') => { + let step = self.area.height.max(1) as isize; + self.move_cursor(&mut ctx.editor.filetree, -step); + return EventResult::Consumed(None); + } + _ => {} + } + } + + match key.code { + KeyCode::Esc | KeyCode::Tab => { + ctx.editor.filetree.focused = false; + EventResult::Consumed(None) + } + KeyCode::Down | KeyCode::Char('j') => { + self.move_cursor(&mut ctx.editor.filetree, 1); + EventResult::Consumed(None) + } + KeyCode::Up | KeyCode::Char('k') => { + self.move_cursor(&mut ctx.editor.filetree, -1); + EventResult::Consumed(None) + } + KeyCode::PageDown => { + let step = self.area.height.max(1) as isize; + self.move_cursor(&mut ctx.editor.filetree, step); + EventResult::Consumed(None) + } + KeyCode::PageUp => { + let step = self.area.height.max(1) as isize; + self.move_cursor(&mut ctx.editor.filetree, -step); + EventResult::Consumed(None) + } + KeyCode::Home | KeyCode::Char('g') => { + ctx.editor.filetree.cursor_idx = 0; + EventResult::Consumed(None) + } + KeyCode::End | KeyCode::Char('G') => { + ctx.editor.filetree.cursor_idx = self.entries.len().saturating_sub(1); + EventResult::Consumed(None) + } + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { + self.activate_under_cursor(ctx); + EventResult::Consumed(None) + } + KeyCode::Char('h') | KeyCode::Left => { + self.collapse_or_parent(ctx); + EventResult::Consumed(None) + } + KeyCode::Char('R') => { + self.dirty = true; + EventResult::Consumed(None) + } + _ => EventResult::Consumed(None), // swallow other keys while focused + } + } } impl Component for Filetree { @@ -89,30 +315,288 @@ impl Component for Filetree { return; } - // Docked and Overlay use the same anchor and the same vertical strip; - // the only difference is that Overlay paints on top of editor content - // (because EditorView::render left editor_area unclipped), while - // Docked paints into the strip EditorView already cleared for it. - // Phase 1 doesn't draw a border to distinguish the two; that lands - // when there's actual content to put a border around. - let _ = mode; // future phases will dispatch on this (e.g. border style) let panel_rect = Self::panel_rect(vstrip, ctx.editor.filetree.width, config.filetree.position); self.area = panel_rect; - // Paint the panel background. Use `clear_with` (not `set_style`) - // so the panel wipes editor characters underneath it in Overlay - // mode — otherwise the editor text would bleed through the - // colored background. + // Rebuild entries before clamping cursor/scroll, so clamps reflect + // the actual list length. + if self.dirty { + self.refresh_entries(&ctx.editor.filetree, &config.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; + } + ctx.editor.filetree.ensure_cursor_visible(panel_rect.height); + + // Use `clear_with` so any editor characters underneath the panel in + // Overlay mode get wiped — set_style would only paint the background + // and leave editor text bleeding through. let bg = ctx .editor .theme .try_get("ui.filetree.background") .unwrap_or_else(|| ctx.editor.theme.get("ui.menu")); surface.clear_with(panel_rect, bg); + + let theme = &ctx.editor.theme; + let file_style = theme + .try_get("ui.filetree.file") + .unwrap_or_else(|| theme.get("ui.text")); + let dir_style = theme + .try_get("ui.filetree.directory") + .unwrap_or_else(|| theme.get("ui.text.directory")); + let selected_style = theme + .try_get("ui.filetree.selected") + .unwrap_or_else(|| theme.get("ui.menu.selected")); + + let visible_rows = panel_rect.height as usize; + let scroll = ctx.editor.filetree.scroll; + let cursor_idx = ctx.editor.filetree.cursor_idx; + + for row in 0..visible_rows { + let entry_idx = scroll + row; + let Some(entry) = self.entries.get(entry_idx) else { + break; + }; + let y = panel_rect.y + row as u16; + let is_selected = entry_idx == cursor_idx && ctx.editor.filetree.focused; + + let style = if is_selected { + selected_style + } else { + match entry.kind { + EntryKind::File => file_style, + EntryKind::Directory { .. } => dir_style, + } + }; + + // Selected rows get the highlight stretched across the full panel + // width (gutter + content), matching the picker convention. + if is_selected { + for x in panel_rect.x..panel_rect.x + panel_rect.width { + surface[(x, y)].set_style(selected_style); + } + } + + let row_text = format_row(entry, panel_rect.width); + surface.set_stringn(panel_rect.x, y, &row_text, panel_rect.width as usize, style); + } + } + + fn handle_event(&mut self, event: &Event, ctx: &mut Context) -> EventResult { + match event { + Event::Key(key) => self.handle_key(*key, ctx), + _ => EventResult::Ignored(None), + } } fn id(&self) -> Option<&'static str> { Some(ID) } } + +/// Format a single row's text. Layout, left-to-right: +/// +/// - One indent unit (2 spaces) per `depth` level. +/// - One column for the expand glyph: `▾` (expanded), `▸` (collapsed), +/// ` ` (file). +/// - One space. +/// - The entry's filename. Directories get a trailing `/` for visual cue. +/// +/// Truncated to `width` to prevent overflow. +fn format_row(entry: &FiletreeEntry, width: u16) -> String { + let indent = " ".repeat(entry.depth); + let glyph = match entry.kind { + EntryKind::Directory { expanded: true } => '▾', + EntryKind::Directory { expanded: false } => '▸', + EntryKind::File => ' ', + }; + let name = entry + .path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| entry.path.to_string_lossy().into_owned()); + let suffix = if entry.kind.is_dir() { "/" } else { "" }; + + let mut row = format!("{}{} {}{}", indent, glyph, name, suffix); + // set_stringn truncates by display width, but Rust strings get measured + // by bytes; cheap byte truncation prevents pathological allocations on + // very-long paths. + let cap = (width as usize).saturating_mul(4); // 4 bytes per UTF-8 cell max + if row.len() > cap { + row.truncate(cap); + } + row +} + +/// One level of directory listing, appended to `out` with the given `depth`. +/// +/// Recurses into any child directory whose path is in `expanded`. The entry +/// for each child is pushed *before* its own children, so the resulting flat +/// list is a depth-first preorder traversal that matches how rows appear on +/// screen. +fn list_directory_into( + dir: &Path, + expanded: &HashSet, + config: &FiletreeConfig, + depth: usize, + out: &mut Vec, +) { + let children = read_directory(dir, config); + for (path, is_dir) in children { + let expanded_here = is_dir && expanded.contains(&path); + let kind = if is_dir { + EntryKind::Directory { + expanded: expanded_here, + } + } else { + EntryKind::File + }; + out.push(FiletreeEntry { + path: path.clone(), + depth, + kind, + }); + if expanded_here { + list_directory_into(&path, expanded, config, depth + 1, out); + } + } +} + +/// Read one directory level, honoring the panel's ignore-flag config. Returns +/// `(path, is_dir)` tuples sorted directories-first, then alphabetical. +fn read_directory(dir: &Path, config: &FiletreeConfig) -> Vec<(PathBuf, bool)> { + use ignore::WalkBuilder; + + let mut builder = WalkBuilder::new(dir); + builder + // The `ignore` crate's `hidden(true)` means "skip hidden entries", + // which is the inverse of our `show_hidden`. Flip the sense. + .hidden(!config.show_hidden) + .parents(config.parents) + .git_ignore(config.git_ignore) + .follow_links(config.follow_symlinks) + .max_depth(Some(1)); + + let mut content: Vec<(PathBuf, bool)> = builder + .build() + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path().to_path_buf(); + if path == dir { + return None; // skip the root entry itself + } + let is_dir = path.is_dir(); + Some((path, is_dir)) + }) + .collect(); + content.sort_by(|(p1, d1), (p2, d2)| (!d1, p1).cmp(&(!d2, p2))); + content +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::fs; + + /// Construct a small directory tree under a tempdir, then verify the + /// 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. + #[test] + fn list_directory_shape() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + // Layout: + // root/ + // a/ + // a1.txt + // a2.txt + // b/ + // sub/ + // deep.txt + // c.txt + fs::create_dir(root.join("a")).unwrap(); + fs::create_dir(root.join("b")).unwrap(); + fs::create_dir(root.join("b/sub")).unwrap(); + fs::write(root.join("a/a1.txt"), "").unwrap(); + fs::write(root.join("a/a2.txt"), "").unwrap(); + fs::write(root.join("b/sub/deep.txt"), "").unwrap(); + fs::write(root.join("c.txt"), "").unwrap(); + + let config = FiletreeConfig::default(); + + // Nothing expanded: only root's children should appear, dirs first. + let mut entries = Vec::new(); + let expanded: HashSet = HashSet::new(); + list_directory_into(root, &expanded, &config, 0, &mut entries); + let names: Vec<&str> = entries + .iter() + .map(|e| e.path.file_name().unwrap().to_str().unwrap()) + .collect(); + assert_eq!(names, vec!["a", "b", "c.txt"], "dirs first, then files"); + for e in &entries { + assert_eq!(e.depth, 0, "no expansion → all entries at depth 0"); + } + + // Expand `a` only: a1.txt and a2.txt show up at depth 1, between a + // and the next sibling b (depth-first preorder). + let mut entries = Vec::new(); + let mut expanded = HashSet::new(); + expanded.insert(root.join("a")); + list_directory_into(root, &expanded, &config, 0, &mut entries); + let shape: Vec<(&str, usize)> = entries + .iter() + .map(|e| (e.path.file_name().unwrap().to_str().unwrap(), e.depth)) + .collect(); + assert_eq!( + shape, + vec![ + ("a", 0), + ("a1.txt", 1), + ("a2.txt", 1), + ("b", 0), + ("c.txt", 0), + ], + "expanded `a` inlines its children before the next sibling", + ); + + // Expand `b` and `b/sub`: nested expansion increments depth correctly. + let mut entries = Vec::new(); + let mut expanded = HashSet::new(); + expanded.insert(root.join("b")); + expanded.insert(root.join("b/sub")); + list_directory_into(root, &expanded, &config, 0, &mut entries); + let shape: Vec<(&str, usize)> = entries + .iter() + .map(|e| (e.path.file_name().unwrap().to_str().unwrap(), e.depth)) + .collect(); + assert_eq!( + shape, + vec![ + ("a", 0), + ("b", 0), + ("sub", 1), + ("deep.txt", 2), + ("c.txt", 0), + ], + "nested expansion produces correct depths", + ); + + // Expanding a directory not actually in the tree (stale state from a + // since-deleted dir) should silently no-op rather than panic. + let mut entries = Vec::new(); + let mut expanded = HashSet::new(); + expanded.insert(root.join("ghost")); + list_directory_into(root, &expanded, &config, 0, &mut entries); + let names: Vec<&str> = entries + .iter() + .map(|e| e.path.file_name().unwrap().to_str().unwrap()) + .collect(); + assert_eq!(names, vec!["a", "b", "c.txt"]); + } +} diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 5dd79fd5..fdecf496 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1344,6 +1344,12 @@ impl FiletreeRenderMode { /// [`FiletreeState::render_mode`] — a wide terminal yields a docked column /// strip, a narrow terminal yields an on-top overlay, an un-summoned panel /// yields nothing at all. +/// +/// The interaction state (`root`, `cursor_idx`, `scroll`, `expanded`) +/// persists across visibility toggles, so a user who hides the panel and +/// summons it again finds it exactly as they left it. The actual flat entry +/// list and the directory-walking logic live in `helix-term`, derived from +/// this state plus the live filesystem. #[derive(Debug, Default)] pub struct FiletreeState { /// Whether the user has summoned the panel. The panel may still render @@ -1355,6 +1361,20 @@ pub struct FiletreeState { pub focused: bool, /// Current width in columns. Initialized from config; mutated by border drag. pub width: u16, + /// Root the panel is rendering. `None` until the panel is first summoned + /// (set by `toggle_filetree` from `helix_loader::find_workspace`). + pub root: Option, + /// Index of the highlighted row in the flat entry list. Clamped at render + /// time to whatever the actual entry list length turns out to be — the + /// state isn't authoritative on entry count, the filesystem is. + pub cursor_idx: usize, + /// Row index of the first visible entry. Bumped by [`Self::ensure_cursor_visible`] + /// when the cursor would otherwise leave the viewport. + pub scroll: usize, + /// Directories the user has expanded. The flat entry list is derived from + /// `root` + this set: a directory's children are rendered iff its path is + /// in here. Persists across hide/show. + pub expanded: HashSet, } impl FiletreeState { @@ -1378,6 +1398,41 @@ impl FiletreeState { FiletreeRenderMode::Overlay } } + + /// Whether the given directory is currently expanded. + pub fn is_expanded(&self, path: &Path) -> bool { + self.expanded.contains(path) + } + + /// Toggle expansion of a directory. Returns the new state (`true` if now + /// expanded). No filesystem check — the caller is responsible for knowing + /// `path` is a directory. + pub fn toggle_expanded(&mut self, path: &Path) -> bool { + if self.expanded.remove(path) { + false + } else { + self.expanded.insert(path.to_path_buf()); + true + } + } + + /// Scroll the viewport so `cursor_idx` falls within `[scroll, scroll + viewport_height)`. + /// + /// `viewport_height` is the number of rows the panel can display at once + /// (panel rect height). If the cursor is above the viewport, `scroll` + /// drops to the cursor; if below, `scroll` advances enough to bring the + /// cursor onto the last visible row. A zero-height viewport is a no-op. + pub fn ensure_cursor_visible(&mut self, viewport_height: u16) { + let viewport_height = viewport_height as usize; + if viewport_height == 0 { + return; + } + if self.cursor_idx < self.scroll { + self.scroll = self.cursor_idx; + } else if self.cursor_idx >= self.scroll + viewport_height { + self.scroll = self.cursor_idx + 1 - viewport_height; + } + } } pub struct Editor { @@ -1585,6 +1640,10 @@ impl Editor { visible: conf.filetree.default_visible, focused: false, width: conf.filetree.width, + root: None, + cursor_idx: 0, + scroll: 0, + expanded: HashSet::new(), }, } } @@ -2932,8 +2991,8 @@ mod tests { for case in cases { let state = FiletreeState { visible: case.visible, - focused: false, width: case.width, + ..FiletreeState::default() }; let actual = state.render_mode(case.editor_area_width, case.min_remaining); assert_eq!( @@ -2967,4 +3026,128 @@ mod tests { assert!(Docked.should_clip_editor()); assert!(!Overlay.should_clip_editor()); } + + /// Boundary table for [`FiletreeState::ensure_cursor_visible`]. The + /// invariants the method must preserve are: + /// + /// 1. After the call, `scroll <= cursor_idx < scroll + viewport_height` + /// (the cursor is visible) — unless `viewport_height == 0`, which is + /// a no-op since nothing can be visible in a zero-height viewport. + /// 2. If the cursor was already visible, `scroll` is unchanged. The + /// method should never shift the viewport just to recenter or be + /// helpful — only when there's a correctness reason to. + /// 3. `scroll` doesn't go negative (it's a usize; the type prevents + /// that, but the saturation arithmetic must be careful). + #[test] + fn ensure_cursor_visible_table() { + struct Case { + name: &'static str, + cursor_idx: usize, + scroll: usize, + viewport_height: u16, + expected_scroll: usize, + } + let cases = [ + Case { + name: "cursor in middle of viewport: no change", + cursor_idx: 5, + scroll: 3, + viewport_height: 10, + expected_scroll: 3, + }, + Case { + name: "cursor at top of viewport: no change", + cursor_idx: 3, + scroll: 3, + viewport_height: 10, + expected_scroll: 3, + }, + Case { + name: "cursor on last visible row (scroll + height - 1): no change", + cursor_idx: 12, + scroll: 3, + viewport_height: 10, + expected_scroll: 3, + }, + Case { + name: "cursor one row below viewport: scroll down by 1", + cursor_idx: 13, + scroll: 3, + viewport_height: 10, + expected_scroll: 4, + }, + Case { + name: "cursor far below viewport: scroll to make cursor last row", + cursor_idx: 100, + scroll: 3, + viewport_height: 10, + expected_scroll: 91, + }, + Case { + name: "cursor above viewport: scroll up to cursor", + cursor_idx: 1, + scroll: 5, + viewport_height: 10, + expected_scroll: 1, + }, + Case { + name: "cursor at row 0 with non-zero scroll: scroll back to 0", + cursor_idx: 0, + scroll: 50, + viewport_height: 10, + expected_scroll: 0, + }, + Case { + name: "zero-height viewport is a no-op (no division-by-zero)", + cursor_idx: 999, + scroll: 42, + viewport_height: 0, + expected_scroll: 42, + }, + Case { + name: "single-row viewport: scroll snaps to cursor", + cursor_idx: 7, + scroll: 0, + viewport_height: 1, + expected_scroll: 7, + }, + ]; + + for case in cases { + let mut state = FiletreeState { + cursor_idx: case.cursor_idx, + scroll: case.scroll, + ..FiletreeState::default() + }; + state.ensure_cursor_visible(case.viewport_height); + assert_eq!( + state.scroll, + case.expected_scroll, + "case `{}`: ensure_cursor_visible(viewport_height={}) \ + with cursor_idx={}, scroll={} → scroll={}, expected {}", + case.name, + case.viewport_height, + case.cursor_idx, + case.scroll, + state.scroll, + case.expected_scroll, + ); + } + } + + /// [`FiletreeState::toggle_expanded`] is HashSet delegation, but its + /// return-value contract (true if now expanded, false if now collapsed) + /// is worth pinning — callers branch on it. + #[test] + fn toggle_expanded_returns_new_state() { + use std::path::PathBuf; + let mut state = FiletreeState::default(); + let path = PathBuf::from("/tmp/a/b"); + + assert!(!state.is_expanded(&path)); + assert!(state.toggle_expanded(&path), "first toggle expands"); + assert!(state.is_expanded(&path)); + assert!(!state.toggle_expanded(&path), "second toggle collapses"); + assert!(!state.is_expanded(&path)); + } }