A post-modern development environment.
0

Configure Feed

Select the types of activity you want to include in your feed.

filetree: Add side-pinned panel (supersedes modal file_explorer)

Introduces a persistent filetree panel that can be opened on the left or
right side of the editor, with config, state, and a render skeleton in
place for subsequent features. Removes the modal file_explorer command
and key binding it replaces.

author
Isaac Corbrey
date (Jul 3, 2026, 7:06 PM -0400) commit e2d94068 parent 3e1458b2 change-id psnwlwms
+499 -199
+1 -3
book/src/generated/static-cmd.md
··· 100 100 | `file_picker` | Open file picker | normal: `` <space>f ``, select: `` <space>f `` | 101 101 | `file_picker_in_current_buffer_directory` | Open file picker at current buffer's directory | | 102 102 | `file_picker_in_current_directory` | Open file picker at current working directory | normal: `` <space>F ``, select: `` <space>F `` | 103 - | `file_explorer` | Open file explorer in workspace root | normal: `` <space>e ``, select: `` <space>e `` | 104 - | `file_explorer_in_current_buffer_directory` | Open file explorer at current buffer's directory | normal: `` <space>. ``, select: `` <space>. `` | 105 - | `file_explorer_in_current_directory` | Open file explorer at current working directory | | 103 + | `toggle_filetree` | Toggle filetree panel | normal: `` <space>e ``, select: `` <space>e `` | 106 104 | `code_action` | Perform code action | normal: `` <space>a ``, select: `` <space>a `` | 107 105 | `buffer_picker` | Open buffer picker | normal: `` <space>b ``, select: `` <space>b `` | 108 106 | `jumplist_picker` | Open jumplist picker | normal: `` <space>j ``, select: `` <space>j `` |
+1 -2
book/src/keymap.md
··· 295 295 | ----- | ----------- | ------- | 296 296 | `f` | Open file picker at LSP workspace root | `file_picker` | 297 297 | `F` | Open file picker at current working directory | `file_picker_in_current_directory` | 298 - | `e` | Open file explorer at workspace root | `file_explorer` | 299 - | `.` | Open file explorer at current buffer's directory | `file_explorer_in_current_buffer_directory`| 298 + | `e` | Toggle filetree panel | `toggle_filetree` | 300 299 | `b` | Open buffer picker | `buffer_picker` | 301 300 | `j` | Open jumplist picker | `jumplist_picker` | 302 301 | `g` | Open changed file picker | `changed_file_picker` |
+27 -51
helix-term/src/commands.rs
··· 404 404 file_picker, "Open file picker", 405 405 file_picker_in_current_buffer_directory, "Open file picker at current buffer's directory", 406 406 file_picker_in_current_directory, "Open file picker at current working directory", 407 - file_explorer, "Open file explorer in workspace root", 408 - file_explorer_in_current_buffer_directory, "Open file explorer at current buffer's directory", 409 - file_explorer_in_current_directory, "Open file explorer at current working directory", 407 + toggle_filetree, "Toggle filetree panel", 410 408 code_action, "Perform code action", 411 409 buffer_picker, "Open buffer picker", 412 410 jumplist_picker, "Open jumplist picker", ··· 3215 3213 cx.push_layer(Box::new(overlaid(picker))); 3216 3214 } 3217 3215 3218 - fn file_explorer(cx: &mut Context) { 3219 - let root = find_workspace().0; 3220 - if !root.exists() { 3221 - cx.editor.set_error("Workspace directory does not exist"); 3222 - return; 3223 - } 3224 - 3225 - if let Ok(picker) = ui::file_explorer(root, cx.editor) { 3226 - cx.push_layer(Box::new(overlaid(picker))); 3227 - } 3228 - } 3229 - 3230 - fn file_explorer_in_current_buffer_directory(cx: &mut Context) { 3231 - let doc_dir = doc!(cx.editor) 3232 - .path() 3233 - .and_then(|path| path.parent().map(|path| path.to_path_buf())); 3234 - 3235 - let path = match doc_dir { 3236 - Some(path) => path, 3237 - None => { 3238 - let cwd = helix_stdx::env::current_working_dir(); 3239 - if !cwd.exists() { 3240 - cx.editor.set_error( 3241 - "Current buffer has no parent and current working directory does not exist", 3242 - ); 3243 - return; 3216 + /// Toggle the persistent filetree panel. 3217 + /// 3218 + /// When showing, push a [`ui::Filetree`] component onto the compositor and 3219 + /// flip `editor.filetree.visible`. When hiding, remove the component by ID 3220 + /// and clear the visible/focused flags. The width is preserved across 3221 + /// toggles so a user-resized panel doesn't snap back to the config default. 3222 + fn toggle_filetree(cx: &mut Context) { 3223 + let was_visible = cx.editor.filetree.visible; 3224 + cx.editor.filetree.visible = !was_visible; 3225 + if was_visible { 3226 + cx.editor.filetree.focused = false; 3227 + cx.callback.push(Box::new(|compositor, _cx| { 3228 + compositor.remove(ui::filetree::ID); 3229 + })); 3230 + } else { 3231 + // Push the panel component if it isn't already in the compositor. 3232 + // Focus is owned by the editor key path — `toggle_filetree` 3233 + // shows or hides the panel without redirecting input; a separate 3234 + // `filetree_focus` command moves focus onto it. 3235 + cx.callback.push(Box::new(|compositor, _cx| { 3236 + if compositor 3237 + .find_id::<ui::Filetree>(ui::filetree::ID) 3238 + .is_none() 3239 + { 3240 + compositor.push(Box::new(ui::Filetree::new())); 3244 3241 } 3245 - cx.editor.set_error( 3246 - "Current buffer has no parent, opening file explorer in current working directory", 3247 - ); 3248 - cwd 3249 - } 3250 - }; 3251 - 3252 - if let Ok(picker) = ui::file_explorer(path, cx.editor) { 3253 - cx.push_layer(Box::new(overlaid(picker))); 3254 - } 3255 - } 3256 - 3257 - fn file_explorer_in_current_directory(cx: &mut Context) { 3258 - let cwd = helix_stdx::env::current_working_dir(); 3259 - if !cwd.exists() { 3260 - cx.editor 3261 - .set_error("Current working directory does not exist"); 3262 - return; 3263 - } 3264 - 3265 - if let Ok(picker) = ui::file_explorer(cwd, cx.editor) { 3266 - cx.push_layer(Box::new(overlaid(picker))); 3242 + })); 3267 3243 } 3268 3244 } 3269 3245
+1 -2
helix-term/src/keymap/default.rs
··· 225 225 "space" => { "Space" 226 226 "f" => file_picker, 227 227 "F" => file_picker_in_current_directory, 228 - "e" => file_explorer, 229 - "." => file_explorer_in_current_buffer_directory, 228 + "e" => toggle_filetree, 230 229 "b" => buffer_picker, 231 230 "j" => jumplist_picker, 232 231 "s" => lsp_or_syntax_symbol_picker,
+33 -1
helix-term/src/ui/editor.rs
··· 1634 1634 editor_area = editor_area.clip_top(1); 1635 1635 } 1636 1636 1637 + // If the filetree is docked, reclaim its column strip from 1638 + // editor_area. Overlay mode leaves editor_area alone (the panel 1639 + // floats on top of buffers without displacing them); Hidden mode 1640 + // does nothing. See `helix_view::editor::FiletreeState::render_mode`. 1641 + { 1642 + use helix_view::editor::FiletreePosition; 1643 + let filetree_config = &cx.editor.config().filetree; 1644 + let mode = cx.editor.filetree.render_mode( 1645 + editor_area.width, 1646 + filetree_config.min_remaining_editor_width, 1647 + ); 1648 + if mode.should_clip_editor() { 1649 + let panel_width = cx.editor.filetree.width; 1650 + editor_area = match filetree_config.position { 1651 + FiletreePosition::Left => editor_area.clip_left(panel_width), 1652 + FiletreePosition::Right => editor_area.clip_right(panel_width), 1653 + }; 1654 + } 1655 + } 1656 + 1637 1657 // if the terminal size suddenly changed, we need to trigger a resize 1638 1658 cx.editor.resize(editor_area); 1639 1659 1640 1660 if use_bufferline { 1641 - Self::render_bufferline(cx.editor, area.with_height(1), surface); 1661 + // The bufferline lives in the top row, but should only span the 1662 + // editor's horizontal extent — not the full terminal width. When 1663 + // the filetree is docked, the panel reclaims its column strip 1664 + // from `editor_area` above, so reading editor_area.x and .width 1665 + // here keeps the bufferline aligned with the editor it labels 1666 + // and out of the filetree column. 1667 + let bufferline_rect = Rect { 1668 + x: editor_area.x, 1669 + y: area.y, 1670 + width: editor_area.width, 1671 + height: 1, 1672 + }; 1673 + Self::render_bufferline(cx.editor, bufferline_rect, surface); 1642 1674 } 1643 1675 1644 1676 for (view, is_focused) in cx.editor.tree.views() {
+118
helix-term/src/ui/filetree.rs
··· 1 + //! The persistent, side-pinned filetree panel. 2 + //! 3 + //! See [`Filetree`] for the [`Component`] implementation. State that needs to 4 + //! live across renders (visibility, width, focus) is on 5 + //! [`helix_view::editor::FiletreeState`] so that [`crate::ui::EditorView`] can 6 + //! consult it when deciding how much horizontal space to give the editor area. 7 + //! 8 + //! The panel has three render modes — see 9 + //! [`FiletreeRenderMode`](helix_view::editor::FiletreeRenderMode). Briefly: 10 + //! 11 + //! - `Hidden`: not summoned. Editor uses the whole screen. 12 + //! - `Docked`: summoned on a wide terminal. Panel occupies a column strip; 13 + //! editor area is clipped to make room. 14 + //! - `Overlay`: summoned on a narrow terminal. Panel floats on top of buffers, 15 + //! anchored to the configured docked side, without displacing the editor. 16 + //! 17 + //! At this point the component is a render skeleton: it claims the right 18 + //! amount of space (docked) or floats over the buffer area (overlaid) and 19 + //! paints a themed background. Entries, navigation, and input handling 20 + //! aren't wired up here. 21 + 22 + use helix_view::editor::FiletreePosition; 23 + use helix_view::graphics::Rect; 24 + 25 + use tui::buffer::Buffer as Surface; 26 + 27 + use crate::compositor::{Component, Context}; 28 + 29 + pub const ID: &str = "filetree"; 30 + 31 + /// The persistent side-pinned filetree panel. 32 + #[derive(Default)] 33 + pub struct Filetree { 34 + /// Cached panel rect from the most recent render. Used by future phases 35 + /// for mouse hit-testing (compare with `popup::Popup::handle_mouse_event`). 36 + area: Rect, 37 + } 38 + 39 + impl Filetree { 40 + pub fn new() -> Self { 41 + Self::default() 42 + } 43 + 44 + /// Compute the panel rect inside a given vertical strip (`vstrip`). 45 + /// 46 + /// `vstrip` is the editor area's vertical extent — i.e. the screen area 47 + /// minus the commandline at the bottom and (when present) the bufferline 48 + /// at the top. The panel sits flush against the docked edge of that strip. 49 + fn panel_rect(vstrip: Rect, width: u16, position: FiletreePosition) -> Rect { 50 + let width = width.min(vstrip.width); 51 + match position { 52 + FiletreePosition::Left => Rect { 53 + x: vstrip.x, 54 + y: vstrip.y, 55 + width, 56 + height: vstrip.height, 57 + }, 58 + FiletreePosition::Right => Rect { 59 + x: vstrip.x + vstrip.width - width, 60 + y: vstrip.y, 61 + width, 62 + height: vstrip.height, 63 + }, 64 + } 65 + } 66 + } 67 + 68 + impl Component for Filetree { 69 + fn render(&mut self, area: Rect, surface: &mut Surface, ctx: &mut Context) { 70 + let config = ctx.editor.config(); 71 + 72 + // The panel runs from row 0 down to the commandline. We clip the 73 + // commandline row off the bottom because it spans the full terminal 74 + // width and would otherwise be clobbered, but we deliberately do 75 + // *not* clip a row for the bufferline at the top — EditorView already 76 + // clips the bufferline horizontally so it doesn't extend over the 77 + // panel column, and the filetree owns its column edge to edge. 78 + let vstrip = area.clip_bottom(1); 79 + 80 + let mode = ctx 81 + .editor 82 + .filetree 83 + .render_mode(vstrip.width, config.filetree.min_remaining_editor_width); 84 + 85 + if !mode.is_visible() { 86 + // Not summoned. Reset cached area so stale mouse hit-testing 87 + // can't fire while the panel is hidden. 88 + self.area = Rect::default(); 89 + return; 90 + } 91 + 92 + // Docked and Overlay use the same anchor and the same vertical strip; 93 + // the only difference is that Overlay paints on top of editor content 94 + // (because EditorView::render left editor_area unclipped), while 95 + // Docked paints into the strip EditorView already cleared for it. 96 + // Phase 1 doesn't draw a border to distinguish the two; that lands 97 + // when there's actual content to put a border around. 98 + let _ = mode; // future phases will dispatch on this (e.g. border style) 99 + let panel_rect = 100 + Self::panel_rect(vstrip, ctx.editor.filetree.width, config.filetree.position); 101 + self.area = panel_rect; 102 + 103 + // Paint the panel background. Use `clear_with` (not `set_style`) 104 + // so the panel wipes editor characters underneath it in Overlay 105 + // mode — otherwise the editor text would bleed through the 106 + // colored background. 107 + let bg = ctx 108 + .editor 109 + .theme 110 + .try_get("ui.filetree.background") 111 + .unwrap_or_else(|| ctx.editor.theme.get("ui.menu")); 112 + surface.clear_with(panel_rect, bg); 113 + } 114 + 115 + fn id(&self) -> Option<&'static str> { 116 + Some(ID) 117 + } 118 + }
+20 -107
helix-term/src/ui/mod.rs
··· 1 1 mod completion; 2 2 mod document; 3 3 pub(crate) mod editor; 4 + pub mod filetree; 4 5 mod info; 5 6 pub mod lsp; 6 7 mod markdown; ··· 20 21 use crate::job::{self, Callback}; 21 22 pub use completion::Completion; 22 23 pub use editor::EditorView; 24 + pub use filetree::Filetree; 23 25 use helix_stdx::rope; 24 26 use helix_view::theme::Style; 25 27 pub use markdown::Markdown; ··· 313 315 picker 314 316 } 315 317 316 - type FileExplorer = Picker<(PathBuf, bool), (PathBuf, Style)>; 317 - 318 - pub fn file_explorer(root: PathBuf, editor: &Editor) -> Result<FileExplorer, std::io::Error> { 319 - let directory_style = editor.theme.get("ui.text.directory"); 320 - let directory_content = directory_content(&root, editor)?; 321 - 322 - let columns = [PickerColumn::new( 323 - "path", 324 - |(path, is_dir): &(PathBuf, bool), (root, directory_style): &(PathBuf, Style)| { 325 - let name = path.strip_prefix(root).unwrap_or(path).to_string_lossy(); 326 - if *is_dir { 327 - Span::styled(format!("{}/", name), *directory_style).into() 328 - } else { 329 - name.into() 330 - } 331 - }, 332 - )]; 333 - let picker = Picker::new( 334 - columns, 335 - 0, 336 - directory_content, 337 - (root, directory_style), 338 - move |cx, (path, is_dir): &(PathBuf, bool), action| { 339 - if *is_dir { 340 - let new_root = helix_stdx::path::normalize(path); 341 - let callback = Box::pin(async move { 342 - let call: Callback = 343 - Callback::EditorCompositor(Box::new(move |editor, compositor| { 344 - if let Ok(picker) = file_explorer(new_root, editor) { 345 - compositor.push(Box::new(overlay::overlaid(picker))); 346 - } 347 - })); 348 - Ok(call) 349 - }); 350 - cx.jobs.callback(callback); 351 - } else if let Err(e) = cx.editor.open(path, action) { 352 - let err = if let Some(err) = e.source() { 353 - format!("{}", err) 354 - } else { 355 - format!("unable to open \"{}\"", path.display()) 356 - }; 357 - cx.editor.set_error(err); 358 - } 359 - }, 360 - ) 361 - .with_preview(|_editor, (path, _is_dir)| Some((path.as_path().into(), None))); 362 - 363 - Ok(picker) 364 - } 365 - 366 - fn directory_content(root: &Path, editor: &Editor) -> Result<Vec<(PathBuf, bool)>, std::io::Error> { 318 + /// One-level directory listing, used by the picker's directory-preview pane. 319 + /// 320 + /// Honors the same ignore flags as the file picker (since this is in service 321 + /// of the picker's preview), capped at depth 1. Entries are sorted directories 322 + /// first, then alphabetical; `root` itself is excluded. 323 + pub(crate) fn directory_content( 324 + root: &Path, 325 + editor: &Editor, 326 + ) -> Result<Vec<(PathBuf, bool)>, std::io::Error> { 367 327 use ignore::WalkBuilder; 368 328 369 329 let config = editor.config(); 370 330 371 - let mut walk_builder = WalkBuilder::new(root); 372 - 373 - let mut content: Vec<(PathBuf, bool)> = walk_builder 374 - .hidden(config.file_explorer.hidden) 375 - .parents(config.file_explorer.parents) 376 - .ignore(config.file_explorer.ignore) 377 - .follow_links(config.file_explorer.follow_symlinks) 378 - .git_ignore(config.file_explorer.git_ignore) 379 - .git_global(config.file_explorer.git_global) 380 - .git_exclude(config.file_explorer.git_exclude) 331 + let mut content: Vec<(PathBuf, bool)> = WalkBuilder::new(root) 332 + .hidden(config.file_picker.hidden) 333 + .parents(config.file_picker.parents) 334 + .ignore(config.file_picker.ignore) 335 + .follow_links(config.file_picker.follow_symlinks) 336 + .git_ignore(config.file_picker.git_ignore) 337 + .git_global(config.file_picker.git_global) 338 + .git_exclude(config.file_picker.git_exclude) 381 339 .max_depth(Some(1)) 382 340 .add_custom_ignore_filename(helix_loader::config_dir().join("ignore")) 383 341 .add_custom_ignore_filename(".helix/ignore") ··· 388 346 .map(|entry| { 389 347 let path = entry.path(); 390 348 let is_dir = path.is_dir(); 391 - let mut path = path.to_path_buf(); 392 - if is_dir && path != root && config.file_explorer.flatten_dirs { 393 - while let Some(single_child_directory) = get_child_if_single_dir(&path) { 394 - path = single_child_directory; 395 - } 396 - } 397 - (path, is_dir) 349 + (path.to_path_buf(), is_dir) 398 350 }) 399 351 .ok() 400 352 .filter(|entry| entry.0 != root) ··· 403 355 404 356 content.sort_by(|(path1, is_dir1), (path2, is_dir2)| (!is_dir1, path1).cmp(&(!is_dir2, path2))); 405 357 406 - if root.parent().is_some() { 407 - content.insert(0, (root.join(".."), true)); 408 - } 409 - 410 358 Ok(content) 411 - } 412 - 413 - fn get_child_if_single_dir(path: &Path) -> Option<PathBuf> { 414 - let mut entries = path.read_dir().ok()?; 415 - let entry = entries.next()?.ok()?; 416 - let entry_path = entry.path(); 417 - if entries.next().is_none() && entry_path.is_dir() { 418 - Some(entry_path) 419 - } else { 420 - None 421 - } 422 359 } 423 360 424 361 pub mod completers { ··· 797 734 completions 798 735 } 799 736 } 800 - 801 - #[cfg(test)] 802 - mod tests { 803 - use std::fs::{create_dir, File}; 804 - 805 - use super::*; 806 - 807 - #[test] 808 - fn test_get_child_if_single_dir() { 809 - let root = tempfile::tempdir().unwrap(); 810 - 811 - assert_eq!(get_child_if_single_dir(root.path()), None); 812 - 813 - let dir = root.path().join("dir1"); 814 - create_dir(&dir).unwrap(); 815 - 816 - assert_eq!(get_child_if_single_dir(root.path()), Some(dir)); 817 - 818 - let file = root.path().join("file"); 819 - File::create(file).unwrap(); 820 - 821 - assert_eq!(get_child_if_single_dir(root.path()), None); 822 - } 823 - }
+298 -33
helix-view/src/editor.rs
··· 223 223 } 224 224 } 225 225 226 + /// Which side of the screen the filetree panel docks to. 227 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 228 + #[serde(rename_all = "kebab-case")] 229 + pub enum FiletreePosition { 230 + Left, 231 + Right, 232 + } 233 + 234 + impl Default for FiletreePosition { 235 + fn default() -> Self { 236 + Self::Left 237 + } 238 + } 239 + 240 + /// Configuration for the persistent filetree panel. 226 241 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 227 242 #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] 228 - pub struct FileExplorerConfig { 229 - /// IgnoreOptions 230 - /// Enables ignoring hidden files. 231 - /// Whether to hide hidden files in file explorer and global search results. Defaults to false. 232 - pub hidden: bool, 233 - /// Enables following symlinks. 234 - /// Whether to follow symbolic links in file picker and file or directory completions. Defaults to false. 243 + pub struct FiletreeConfig { 244 + /// Whether the panel should be visible on startup. Defaults to false. 245 + pub default_visible: bool, 246 + /// Which side the panel docks to. Defaults to left. 247 + pub position: FiletreePosition, 248 + /// Default width in columns when docked. Defaults to 30. 249 + pub width: u16, 250 + /// If docking the panel would leave the editor area with fewer than this 251 + /// many columns, the panel renders as an overlay on top of the editor 252 + /// instead of being docked alongside it. The editor keeps its full width 253 + /// in overlay mode; the panel floats over buffers, anchored to the 254 + /// configured `position` side. Defaults to 80. 255 + pub min_remaining_editor_width: u16, 256 + /// Whether to expand and scroll to the active buffer when the focused 257 + /// document changes. Defaults to false. 258 + pub follow_active_buffer: bool, 259 + /// Whether to show hidden (dotfile) entries. Defaults to true. 260 + pub show_hidden: bool, 261 + /// Whether to honor `.gitignore`. Defaults to false. 262 + pub git_ignore: bool, 263 + /// Whether to recurse into parent directories when finding ignore files. 264 + /// Defaults to true. 265 + pub parents: bool, 266 + /// Whether to follow symbolic links. Defaults to true. 235 267 pub follow_symlinks: bool, 236 - /// Enables reading ignore files from parent directories. Defaults to false. 237 - pub parents: bool, 238 - /// Enables reading `.ignore` files. 239 - /// Whether to hide files listed in .ignore in file picker and global search results. Defaults to false. 240 - pub ignore: bool, 241 - /// Enables reading `.gitignore` files. 242 - /// Whether to hide files listed in .gitignore in file picker and global search results. Defaults to false. 243 - pub git_ignore: bool, 244 - /// Enables reading global .gitignore, whose path is specified in git's config: `core.excludefile` option. 245 - /// Whether to hide files listed in global .gitignore in file picker and global search results. Defaults to false. 246 - pub git_global: bool, 247 - /// Enables reading `.git/info/exclude` files. 248 - /// Whether to hide files listed in .git/info/exclude in file picker and global search results. Defaults to false. 249 - pub git_exclude: bool, 250 - /// Whether to flatten single-child directories in file explorer. Defaults to true. 251 - pub flatten_dirs: bool, 268 + /// Cap directory recursion depth. `None` means unbounded. Defaults to `None`. 269 + pub max_depth: Option<usize>, 270 + /// Whether to display git status indicators on entries. Defaults to true. 271 + pub show_git_status: bool, 272 + /// Whether to register a recursive filesystem watcher rooted at the 273 + /// workspace, refreshing the panel when files change. Defaults to true. 274 + pub watch_filesystem: bool, 252 275 } 253 276 254 - impl Default for FileExplorerConfig { 277 + impl Default for FiletreeConfig { 255 278 fn default() -> Self { 256 279 Self { 257 - hidden: false, 258 - follow_symlinks: false, 259 - parents: false, 260 - ignore: false, 280 + default_visible: false, 281 + position: FiletreePosition::Left, 282 + width: 30, 283 + min_remaining_editor_width: 80, 284 + follow_active_buffer: false, 285 + show_hidden: true, 261 286 git_ignore: false, 262 - git_global: false, 263 - git_exclude: false, 264 - flatten_dirs: true, 287 + parents: true, 288 + follow_symlinks: true, 289 + max_depth: None, 290 + show_git_status: true, 291 + watch_filesystem: true, 265 292 } 266 293 } 267 294 } ··· 365 392 /// Whether to display infoboxes. Defaults to true. 366 393 pub auto_info: bool, 367 394 pub file_picker: FilePickerConfig, 368 - pub file_explorer: FileExplorerConfig, 395 + pub filetree: FiletreeConfig, 369 396 /// Configuration of the statusline elements 370 397 pub statusline: StatusLineConfig, 371 398 /// Shape for cursor in each mode ··· 1202 1229 completion_trigger_len: 2, 1203 1230 auto_info: true, 1204 1231 file_picker: FilePickerConfig::default(), 1205 - file_explorer: FileExplorerConfig::default(), 1232 + filetree: FiletreeConfig::default(), 1206 1233 statusline: StatusLineConfig::default(), 1207 1234 cursor_shape: CursorShapeConfig::default(), 1208 1235 true_color: false, ··· 1270 1297 1271 1298 type Diagnostics = BTreeMap<Uri, Vec<(lsp::Diagnostic, DiagnosticProvider)>>; 1272 1299 1300 + /// How the filetree panel should be drawn on this frame, given the current 1301 + /// user intent (summoned or not) and the available editor width. 1302 + /// 1303 + /// This is a typed outcome rather than a bool because the call sites need to 1304 + /// distinguish two reasons the panel might claim screen space: docking 1305 + /// (which displaces the editor by clipping its area) versus overlay (which 1306 + /// floats on top of the editor without displacing it). Letting the consumer 1307 + /// dispatch on the variant means `EditorView::render` knows whether to clip 1308 + /// `editor_area`, and the [`crate::ui::filetree`] component knows whether to 1309 + /// render on top of buffers or alongside them. 1310 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 1311 + pub enum FiletreeRenderMode { 1312 + /// Not summoned. The panel is invisible; the editor keeps the full screen. 1313 + Hidden, 1314 + /// Summoned on a terminal wide enough to dock without crowding the editor. 1315 + /// The panel occupies a column strip; the editor area is clipped. 1316 + Docked, 1317 + /// Summoned on a terminal too narrow to dock. The panel renders as an 1318 + /// overlay anchored to the docked side, sitting on top of the editor area 1319 + /// rather than displacing it. 1320 + Overlay, 1321 + } 1322 + 1323 + impl FiletreeRenderMode { 1324 + /// Whether the panel should be visible on screen in this mode. 1325 + pub fn is_visible(self) -> bool { 1326 + !matches!(self, Self::Hidden) 1327 + } 1328 + 1329 + /// Whether the panel should reclaim screen space from the editor (i.e. 1330 + /// whether [`crate::Editor`]'s area should be clipped by the panel width). 1331 + pub fn should_clip_editor(self) -> bool { 1332 + matches!(self, Self::Docked) 1333 + } 1334 + } 1335 + 1336 + /// Runtime state of the persistent filetree panel. 1337 + /// 1338 + /// Configuration that doesn't change per-session lives on [`FiletreeConfig`]. 1339 + /// This struct holds state that mutates as the user interacts with the panel. 1340 + /// 1341 + /// `visible` is the *intent* flag: it records that the user summoned the 1342 + /// panel, not whether anything is currently on screen. Whether the panel 1343 + /// actually renders, and how, is decided by 1344 + /// [`FiletreeState::render_mode`] — a wide terminal yields a docked column 1345 + /// strip, a narrow terminal yields an on-top overlay, an un-summoned panel 1346 + /// yields nothing at all. 1347 + #[derive(Debug, Default)] 1348 + pub struct FiletreeState { 1349 + /// Whether the user has summoned the panel. The panel may still render 1350 + /// as an overlay (rather than docked) on a narrow terminal; see 1351 + /// [`Self::render_mode`]. 1352 + pub visible: bool, 1353 + /// Whether the panel currently owns keyboard focus. When `true`, keyboard 1354 + /// input is handled by the filetree component rather than the editor. 1355 + pub focused: bool, 1356 + /// Current width in columns. Initialized from config; mutated by border drag. 1357 + pub width: u16, 1358 + } 1359 + 1360 + impl FiletreeState { 1361 + /// Decide how the panel should render this frame. 1362 + /// 1363 + /// `editor_area_width` is the width of the screen area available *before* 1364 + /// any panel clip — i.e. the full terminal width minus any vertical chrome, 1365 + /// but with all horizontal columns still in play. `min_remaining` is the 1366 + /// configured floor for editor space after the panel is docked. 1367 + /// 1368 + /// An un-summoned panel is always [`FiletreeRenderMode::Hidden`]. A 1369 + /// summoned panel docks when there would be at least `min_remaining` 1370 + /// columns left for the editor, and overlays on top of the editor when 1371 + /// there wouldn't be. 1372 + pub fn render_mode(&self, editor_area_width: u16, min_remaining: u16) -> FiletreeRenderMode { 1373 + if !self.visible { 1374 + FiletreeRenderMode::Hidden 1375 + } else if editor_area_width.saturating_sub(self.width) >= min_remaining { 1376 + FiletreeRenderMode::Docked 1377 + } else { 1378 + FiletreeRenderMode::Overlay 1379 + } 1380 + } 1381 + } 1382 + 1273 1383 pub struct Editor { 1274 1384 /// Current editing mode. 1275 1385 pub mode: Mode, ··· 1343 1453 pub mouse_down_range: Option<Range>, 1344 1454 pub cursor_cache: CursorCache, 1345 1455 pub workspace_trust: WorkspaceTrust, 1456 + 1457 + /// Persistent filetree panel state. See [`FiletreeState`]. 1458 + pub filetree: FiletreeState, 1346 1459 } 1347 1460 1348 1461 pub type Motion = Box<dyn Fn(&mut Editor)>; ··· 1468 1581 cursor_cache: CursorCache::default(), 1469 1582 dir_stack: VecDeque::with_capacity(DIR_STACK_CAP), 1470 1583 workspace_trust, 1584 + filetree: FiletreeState { 1585 + visible: conf.filetree.default_visible, 1586 + focused: false, 1587 + width: conf.filetree.width, 1588 + }, 1471 1589 } 1472 1590 } 1473 1591 ··· 2703 2821 self.0.set(None) 2704 2822 } 2705 2823 } 2824 + 2825 + #[cfg(test)] 2826 + mod tests { 2827 + use super::{FiletreeRenderMode, FiletreeState}; 2828 + 2829 + /// Exhaustive table over the mode decision in 2830 + /// [`FiletreeState::render_mode`]. The interesting axes are: 2831 + /// 2832 + /// - `visible`: false always yields [`FiletreeRenderMode::Hidden`], 2833 + /// regardless of widths. 2834 + /// - The arithmetic boundary: when summoned, the panel docks iff 2835 + /// `editor_area_width - width >= min_remaining`. The comparison is 2836 + /// `>=`, not `>` — landing exactly on the threshold docks. Below the 2837 + /// threshold the panel overlays. 2838 + /// - Saturating subtraction: when the panel is wider than the editor 2839 + /// area, `editor_area_width - width` saturates at 0 instead of wrapping. 2840 + /// This is a degenerate-but-representable case the type system can't 2841 + /// prevent. 2842 + /// 2843 + /// Helper methods [`FiletreeRenderMode::is_visible`] and 2844 + /// [`FiletreeRenderMode::should_clip_editor`] are also exercised through 2845 + /// the expected modes — `Hidden` is the only non-visible variant, 2846 + /// `Docked` is the only editor-clipping variant. 2847 + #[test] 2848 + fn render_mode_table() { 2849 + struct Case { 2850 + name: &'static str, 2851 + visible: bool, 2852 + width: u16, 2853 + editor_area_width: u16, 2854 + min_remaining: u16, 2855 + expected: FiletreeRenderMode, 2856 + } 2857 + let cases = [ 2858 + Case { 2859 + name: "hidden when not summoned, even on wide terminal", 2860 + visible: false, 2861 + width: 30, 2862 + editor_area_width: 200, 2863 + min_remaining: 80, 2864 + expected: FiletreeRenderMode::Hidden, 2865 + }, 2866 + Case { 2867 + name: "hidden when not summoned on narrow terminal", 2868 + visible: false, 2869 + width: 30, 2870 + editor_area_width: 50, 2871 + min_remaining: 80, 2872 + expected: FiletreeRenderMode::Hidden, 2873 + }, 2874 + Case { 2875 + name: "docked with comfortable margin", 2876 + visible: true, 2877 + width: 30, 2878 + editor_area_width: 200, 2879 + min_remaining: 80, 2880 + expected: FiletreeRenderMode::Docked, 2881 + }, 2882 + Case { 2883 + name: "docked exactly at threshold (>=, not >)", 2884 + visible: true, 2885 + width: 30, 2886 + editor_area_width: 110, 2887 + min_remaining: 80, 2888 + expected: FiletreeRenderMode::Docked, 2889 + }, 2890 + Case { 2891 + name: "overlay one column below threshold", 2892 + visible: true, 2893 + width: 30, 2894 + editor_area_width: 109, 2895 + min_remaining: 80, 2896 + expected: FiletreeRenderMode::Overlay, 2897 + }, 2898 + Case { 2899 + name: "overlay on small terminal", 2900 + visible: true, 2901 + width: 30, 2902 + editor_area_width: 60, 2903 + min_remaining: 80, 2904 + expected: FiletreeRenderMode::Overlay, 2905 + }, 2906 + Case { 2907 + name: "min_remaining=0 always docks when summoned", 2908 + visible: true, 2909 + width: 30, 2910 + editor_area_width: 30, 2911 + min_remaining: 0, 2912 + expected: FiletreeRenderMode::Docked, 2913 + }, 2914 + Case { 2915 + name: "saturating sub: panel wider than area + min_remaining=0 docks", 2916 + visible: true, 2917 + width: 100, 2918 + editor_area_width: 50, 2919 + min_remaining: 0, 2920 + expected: FiletreeRenderMode::Docked, 2921 + }, 2922 + Case { 2923 + name: "saturating sub: panel wider than area + min_remaining>0 overlays", 2924 + visible: true, 2925 + width: 100, 2926 + editor_area_width: 50, 2927 + min_remaining: 1, 2928 + expected: FiletreeRenderMode::Overlay, 2929 + }, 2930 + ]; 2931 + 2932 + for case in cases { 2933 + let state = FiletreeState { 2934 + visible: case.visible, 2935 + focused: false, 2936 + width: case.width, 2937 + }; 2938 + let actual = state.render_mode(case.editor_area_width, case.min_remaining); 2939 + assert_eq!( 2940 + actual, 2941 + case.expected, 2942 + "case `{}`: render_mode(area_width={}, min_remaining={}) \ 2943 + with visible={}, width={} = {:?}, expected {:?}", 2944 + case.name, 2945 + case.editor_area_width, 2946 + case.min_remaining, 2947 + case.visible, 2948 + case.width, 2949 + actual, 2950 + case.expected, 2951 + ); 2952 + } 2953 + } 2954 + 2955 + /// The two helper methods on [`FiletreeRenderMode`] are themselves a 2956 + /// 3-element closed domain; enumerate them so a future fourth variant 2957 + /// can't be added without explicitly classifying it. 2958 + #[test] 2959 + fn render_mode_helpers() { 2960 + use FiletreeRenderMode::*; 2961 + // is_visible: only Hidden is invisible. 2962 + assert!(!Hidden.is_visible()); 2963 + assert!(Docked.is_visible()); 2964 + assert!(Overlay.is_visible()); 2965 + // should_clip_editor: only Docked claims editor space. 2966 + assert!(!Hidden.should_clip_editor()); 2967 + assert!(Docked.should_clip_editor()); 2968 + assert!(!Overlay.should_clip_editor()); 2969 + } 2970 + }