diff --git a/README.md b/README.md index 2ea0b5f..15f45c8 100644 --- a/README.md +++ b/README.md @@ -96,27 +96,26 @@ Space o opens a floating file picker, defaulting to **List**; Space e opens or f The dock defaults to the right of the editor workspace. Set `config.file_explorer_side = "left"` (or `"right"`, `"top"`, `"bottom"`) to choose another side. Status and command/search prompts remain full-width; editor text, gutter, highlights, and cursors use the remaining rectangle. Focused panes prioritize their query on tiny terminals; unfocused docks shrink or hide to preserve editing space. -Choose either view independently with `config.file_picker_view = "list"` and `config.file_explorer_view = "tree"` (both accept `"list"` or `"tree"`). Ctrl+t toggles the current focused instance without rescanning. List retains searchable full relative paths. Tree shows folders first, two-cell indentation, disclosure markers, and basename-only files. Maximal single-child directory chains share one row, such as `src/client/http`; a directory containing one file still gets its own row. - -New trees start in browsing focus. `/` enters search; typing and Backspace then filter full relative paths by case-insensitive whitespace tokens. Matching nodes appear with their ancestor context, temporarily revealing descendants without clearing saved expansion. Query changes select a direct match, not merely a contextual ancestor. Escape clears the temporary filter and restores pre-search browsing selection; the next Escape returns to editing or closes floating search. List always accepts query typing immediately. +Choose either view independently with `config.file_picker_view = "list"` and `config.file_explorer_view = "tree"` (both accept `"list"` or `"tree"`). Ctrl+t toggles the current focused instance without rescanning. List retains searchable full relative paths. Tree shows folders first, two-cell indentation, and basename-only files. Folder names end in `/` and use cyan text; files use the normal foreground color. Fold-state arrows are omitted. Maximal single-child directory chains share one row, such as `src/client/http/`; a directory containing one file still gets its own row. +New trees start in explorer **NORMAL** mode for navigation. Press `i` to enter explorer **INSERT** mode and filter; the `i` itself is not added to the query. Typing and Backspace then filter full relative paths by case-insensitive whitespace tokens. These are explorer-local modes, not changes to the file buffer's mode. Matching nodes appear with their ancestor context, temporarily revealing descendants without clearing saved expansion. Query changes select a direct match, not merely a contextual ancestor. Escape in explorer INSERT clears the temporary filter and restores explorer NORMAL and its previous selection, retaining explorer focus. Escape from explorer NORMAL returns focus to the buffer (or closes floating search). List always accepts query typing immediately. | File-view input | Behavior | | --- | --- | -| Type / Backspace | Filter in List/tree search / remove one whole grapheme; ignored in tree browsing | +| Type / Backspace | Filter in List/explorer INSERT / remove one whole grapheme; ignored in explorer NORMAL | | Ctrl+j/Down/Ctrl+n or Ctrl+k/Up | Wrap forward/backward through visible rows | -| Ctrl+l/Right (tree browse) | Expand a closed folder; select its first child when already open | -| Ctrl+h/Left (tree browse) | Collapse an open folder; otherwise select its visible parent | +| Ctrl+l/Right (explorer NORMAL) | Expand a closed folder; select its first child when already open | +| Ctrl+h/Left (explorer NORMAL) | Collapse an open folder; otherwise select its visible parent | | Enter | Toggle a folder, or open the selected file using existing buffer deduplication | -| `/` (tree browse) | Focus search without inserting the slash | -| Esc | Return from tree search to browsing; otherwise close floating search or blur a dock | +| `i` (explorer NORMAL) | Enter explorer INSERT/filter mode without inserting `i` | +| Esc | INSERT → explorer NORMAL, retaining focus; NORMAL → buffer focus or floating close | | Tab | Return to editor focus from a dock | | Ctrl+w | Close either presentation | | Ctrl+r | Rescan while retaining query, surviving expansion, and selection | | Ctrl+p | Open the command palette, closing floating search or blurring a dock | | Ctrl+t | Toggle List/Tree while retaining view state | -Opening a file from a dock leaves it visible and returns focus to editing. Space e refocuses it. Choosing a folder during search returns to browsing and toggles that folder; it never opens a text buffer for a directory. Selections use actual OS-path identities across filtering, view switches, and refresh; a removed or hidden selection falls back to the nearest visible ancestor, then the first row. Query input never edits the underlying buffer; bracketed paste is ignored while file search is focused. Cancellation preserves buffer mode, selections, and pending Insert history. Invalid, binary, unreadable, or disappeared file selections report an error and keep focus without changing the active buffer. +Opening a file from a dock leaves it visible and returns focus to the buffer. Space e refocuses it. Choosing a folder during filtering returns to explorer NORMAL and toggles that folder; it never opens a text buffer for a directory. Selections use actual OS-path identities across filtering, view switches, and refresh; a removed or hidden selection falls back to the nearest visible ancestor, then the first row. Query input never edits the underlying buffer; bracketed paste is ignored while file search is focused. Cancellation preserves buffer mode, selections, and pending Insert history. Invalid, binary, unreadable, or disappeared file selections report an error and keep focus without changing the active buffer. The project root is the working directory captured at startup. Scanning uses the `ignore` crate for nested `.gitignore` rules and negation; parent/global Git ignore rules are disabled. Hidden regular files and directories are included unless ignored, `.git`/`.jj` metadata is always excluded, and symlink files/directories are not followed. Files open using their original OS paths, not sanitized display labels. Root scan failures report an error; descendant/ignore errors retain reachable entries with an explicit incomplete-scan warning. A failed refresh keeps the prior catalog. There is no background watcher: refresh explicitly after external file changes. @@ -140,9 +139,9 @@ config.bind "ctrl+b", config.core.up, mode: config.modes.selection config.bind "ctrl+j", nil, mode: config.modes.selection ``` -Selection bindings take precedence while a file or command list is focused. Unbound printable keys filter in List/search, but not tree browsing; a disabled key is consumed without filtering or moving. Only `up`, `down`, and `nil` are accepted—no leader sequences, edits, or plugin commands. Invalid declarations leave existing routes unchanged. Navigation does not change text, carets, history, or the underlying mode. Both list footers show effective keys, including remaps and disabled directions. +Selection bindings take precedence while a file or command list is focused. Unbound printable keys filter in List/explorer INSERT, but not explorer NORMAL; a disabled key is consumed without filtering or moving. Only `up`, `down`, and `nil` are accepted—no leader sequences, edits, or plugin commands. Invalid declarations leave existing routes unchanged. Navigation does not change text, carets, history, or the buffer mode. Both list footers show effective keys, including remaps and disabled directions. -Tree browsing additionally uses `config.modes.tree`, accepting `up`, `down`, `left`, `right`, or `nil`. Its single-key routes override shared selection routes only during tree browsing; unmatched keys inherit the shared selection bindings. Defaults are Ctrl+h/Left for collapse/parent and Ctrl+l/Right for expand/child. For example: +Explorer NORMAL additionally uses `config.modes.tree`, accepting `up`, `down`, `left`, `right`, or `nil`. Its single-key routes override shared selection routes only during tree navigation; unmatched keys inherit the shared selection bindings. Defaults are Ctrl+h/Left for collapse/parent and Ctrl+l/Right for expand/child. For example: ```ruby config.bind "ctrl+f", config.core.right, mode: config.modes.tree @@ -181,7 +180,7 @@ Frontends use cached `selection_navigation_hint()` for command lists and `file_n Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned. Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned. Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer. -Frontends inspect `file_picker()` for query, presentation, view, tree-search subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. +Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active. diff --git a/src/editor.rs b/src/editor.rs index 57acf4f..0d2b005 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1454,7 +1454,7 @@ impl Editor { Input::Backspace if picker.view == FileView::List || picker.searching => { picker.backspace() } - Input::Char('/') if picker.view == FileView::Tree && !picker.searching => { + Input::Char('i') if picker.view == FileView::Tree && !picker.searching => { picker.start_search() } Input::Char(ch) @@ -3729,7 +3729,7 @@ mod tests { let directory = Directory::new(); fs::write(directory.path("keep.txt"), "text").unwrap(); let mut editor = project_editor(&directory); - type_text(&mut editor, " e/keep"); + type_text(&mut editor, " eikeep"); editor.handle(Input::Char('\t')); assert!(!editor.file_picker().unwrap().focused); fs::write(directory.path("keep new.txt"), "new").unwrap(); @@ -3774,7 +3774,7 @@ mod tests { let id = editor.buffer_id(); editor.execute(&format!("edit {}", b.display())); fs::write(&a, b"\xff").unwrap(); - type_text(&mut editor, " e/a space 界"); + type_text(&mut editor, " eia space 界"); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), id); assert_eq!(editor.buffer_count(), 3); @@ -3886,7 +3886,7 @@ mod tests { type_text(&mut editor, " o"); editor.execute("buffers"); assert!(editor.file_picker().is_none()); - type_text(&mut editor, " e/file"); + type_text(&mut editor, " eifile"); editor.execute("buffers"); assert!(!editor.file_picker().unwrap().focused); assert_eq!(editor.file_picker().unwrap().query, "file"); @@ -3903,7 +3903,7 @@ mod tests { editor .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) .unwrap(); - type_text(&mut editor, " e/file"); + type_text(&mut editor, " eifile"); editor.handle(Input::Char('\t')); type_text(&mut editor, "2j"); assert_eq!(editor.cursor().row, 2); @@ -4081,7 +4081,7 @@ mod tests { .entries() .map(|row| row.path.to_owned()) .collect::>(); - type_text(&mut editor, "/deep/"); + type_text(&mut editor, "ideep/"); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "deep/"); editor.handle(Input::Ctrl('H')); @@ -4172,7 +4172,7 @@ mod tests { assert_eq!(editor.file_picker().unwrap().selected, 1); editor.handle(Input::Ctrl('j')); assert_eq!(editor.file_picker().unwrap().selected, 0); - editor.handle(Input::Char('/')); + editor.handle(Input::Char('i')); assert_eq!( editor.file_navigation_hint(), editor.selection_navigation_hint() @@ -4271,7 +4271,7 @@ mod tests { let mut editor = project_editor(&directory); editor.set_file_picker_view("tree").unwrap(); editor.open_file_picker(presentation); - type_text(&mut editor, "/needle.txt"); + type_text(&mut editor, "ineedle.txt"); let picker = editor.file_picker().unwrap(); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, @@ -4286,7 +4286,7 @@ mod tests { editor.open_file_picker(presentation); editor.handle(Input::Ctrl('w')); editor.open_file_picker(presentation); - type_text(&mut editor, "/module"); + type_text(&mut editor, "imodule"); assert!( editor .file_picker() @@ -4332,7 +4332,7 @@ mod tests { editor.edit_buffer(b.clone(), false).unwrap(); fs::write(&a, b"\xff").unwrap(); editor.open_file_picker(presentation); - type_text(&mut editor, "/same"); + type_text(&mut editor, "isame"); let picker = editor.file_picker.as_mut().unwrap(); let labels = picker .entries() diff --git a/src/main.rs b/src/main.rs index 2ec41de..f4781be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix Normal leader: Space w saves; Space b n/p/l/d next/previous/list/close buffer Files: Space o floating list; Space e docked compact tree (right by default) Ctrl+j/k select; tree Ctrl+l/h expand/parent, Enter toggles/opens - / tree search, Esc browse/edit, Ctrl+t view, Ctrl+w close, Ctrl+r refresh + i filters in tree INSERT; Esc returns NORMAL, then editor; Ctrl+t view, Ctrl+w close, Ctrl+r refresh Normal: i/a insert, I/A insert at first nonblank/end, o/O new line h/j/k/l move, 0/$ line ends, x delete, J join lines w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs diff --git a/src/session.rs b/src/session.rs index 05b43c1..297fbc4 100644 --- a/src/session.rs +++ b/src/session.rs @@ -250,7 +250,7 @@ end crate::files::FileView::Tree ); assert!(!session.editor().file_picker().unwrap().searching); - session.handle(Input::Char('/')); + session.handle(Input::Char('i')); for ch in "dir e\u{301}".chars() { session.handle(Input::Char(ch)); } @@ -321,7 +321,7 @@ end assert!(!session.editor().file_picker().unwrap().searching); session.handle(Input::Char('x')); assert_eq!(session.editor().file_picker().unwrap().query, ""); - session.handle(Input::Char('/')); + session.handle(Input::Char('i')); session.handle(Input::Char('x')); session.handle(Input::Ctrl('h')); assert_eq!(session.editor().file_picker().unwrap().query, "x"); diff --git a/src/tui.rs b/src/tui.rs index 3794a2a..c7353cf 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -496,10 +496,10 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio let title = match (picker.presentation, picker.view, browsing) { (FilePresentation::Floating, FileView::List, _) => " Files · List ", (FilePresentation::Docked, FileView::List, _) => " Explorer · List ", - (FilePresentation::Floating, FileView::Tree, true) => " Files · Tree browse ", - (FilePresentation::Docked, FileView::Tree, true) => " Explorer · Tree browse ", - (FilePresentation::Floating, FileView::Tree, false) => " Files · Tree search ", - (FilePresentation::Docked, FileView::Tree, false) => " Explorer · Tree search ", + (FilePresentation::Floating, FileView::Tree, true) => " Files · Tree NORMAL ", + (FilePresentation::Docked, FileView::Tree, true) => " Explorer · Tree NORMAL ", + (FilePresentation::Floating, FileView::Tree, false) => " Files · Tree INSERT ", + (FilePresentation::Docked, FileView::Tree, false) => " Explorer · Tree INSERT ", }; let block = Block::default() .borders(if bordered { @@ -525,7 +525,7 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio ]) .areas(content); let query = if browsing { - "browse · / search · ^T view".into() + "NORMAL · i filter · ^T view".into() } else { format!("> {}", display_text(&picker.query)) }; @@ -584,31 +584,30 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio .take(count) .map(|(index, entry)| { let selected = index == selected_index; - let style = if selected { + let mut style = if entry.is_dir { + Style::default().fg(Color::Cyan) + } else { Style::default() + }; + if selected { + style = style .bg(if picker.focused { Color::Blue } else { Color::DarkGray }) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - }; + .add_modifier(Modifier::BOLD); + } let label = match picker.view { FileView::List => { format!("{} {}", if selected { ">" } else { " " }, entry.label) } FileView::Tree => format!( - "{} {:indent$}{} {}", + "{} {:indent$}{}{}", if selected { ">" } else { " " }, "", - if entry.is_dir { - if entry.expanded { "▾" } else { "▸" } - } else { - " " - }, entry.label, + if entry.is_dir { "/" } else { "" }, indent = entry.depth * 2, ), }; @@ -624,16 +623,14 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio } } let help = if picker.focused { - let controls = match picker.presentation { - FilePresentation::Floating => "Enter · Esc/^W close · ^R scan", - FilePresentation::Docked => "Enter · Esc/Tab edit · ^W · ^R", + let controls = match (picker.presentation, browsing) { + (FilePresentation::Floating, true) => "Enter · i filter · Esc/^W close · ^R · ^T", + (FilePresentation::Docked, true) => "Enter · i filter · Esc/Tab edit · ^W · ^R · ^T", + (_, false) if picker.view == FileView::Tree => "Enter · Esc NORMAL · ^W · ^R · ^T", + (FilePresentation::Floating, false) => "Enter · Esc/^W close · ^R · ^T", + (FilePresentation::Docked, false) => "Enter · Esc/Tab edit · ^W · ^R · ^T", }; - let view_controls = if picker.view == FileView::Tree { - "/ search · ^T view" - } else { - "^T view" - }; - format!("{navigation_hint} · {view_controls} · {controls}") + format!("{navigation_hint} · {controls}") } else { "Editor focus · invoke explorer to browse".into() }; @@ -1011,7 +1008,6 @@ mod tests { .draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint())) .unwrap(); let text = screen_text(&terminal); - assert!(text.contains("Tree browse"), "{text}"); assert!(text.contains("No project files"), "{text}"); assert_eq!( terminal.get_cursor_position().unwrap(), @@ -1155,20 +1151,32 @@ mod tests { .unwrap(); let text = screen_text(&terminal); for label in [ - " ▾ chain/a/b", - " leaf.txt", - " ▸ empty", - " ▾ mixed", - " ▾ nested", - " inner.txt", - " sibling.txt", - "> ▾ single", - " only.txt", - " root.txt", - "/ search · ^T view", + " chain/a/b/", + " leaf.txt", + " empty/", + " mixed/", + " nested/", + " inner.txt", + " sibling.txt", + "> single/", + " only.txt", + " root.txt", ] { assert!(text.contains(label), "{label}: {text}"); } + assert!(!text.contains('▸') && !text.contains('▾'), "{text}"); + let buffer = terminal.backend().buffer(); + for (index, entry) in picker.entries().enumerate() { + let y = area.y + 3 + index as u16; + assert_eq!( + buffer[(area.x + 1, y)].fg, + if entry.is_dir { + Color::Cyan + } else { + Color::Reset + }, + ); + } assert!(!text.contains("single/only.txt"), "{text}"); assert!(!text.contains("mixed/nested/inner.txt"), "{text}"); let cursor = terminal.get_cursor_position().unwrap(); @@ -1185,7 +1193,6 @@ mod tests { .draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint())) .unwrap(); let text = screen_text(&terminal); - assert!(text.contains("List"), "{text}"); assert!(text.contains("chain/a/b/leaf.txt"), "{text}"); assert!(text.contains("single/only.txt"), "{text}"); assert!(!text.contains("▸"), "{text}"); @@ -1236,7 +1243,7 @@ mod tests { } if width == 100 { let text = screen_text(&terminal); - assert!(text.contains("> file.txt"), "{text}"); + assert!(text.contains("> file.txt"), "{text}"); assert!(!text.contains("dir-00"), "{text}"); } picker.start_search(); @@ -1295,7 +1302,7 @@ mod tests { .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); let browse_cursor = terminal.get_cursor_position().unwrap(); - editor.handle(Input::Char('/')); + editor.handle(Input::Char('i')); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, ""); assert!( @@ -1325,7 +1332,6 @@ mod tests { .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); let text = screen_text(&terminal); - assert!(text.contains("Tree search"), "{text}"); assert!(text.contains("needle.txt"), "{text}"); assert_eq!(terminal.get_cursor_position().unwrap().y, 1); editor.handle(Input::Escape); @@ -1353,7 +1359,7 @@ mod tests { assert_eq!(editor.lines(), ["unchanged"]); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); - editor.handle(Input::Char('/')); + editor.handle(Input::Char('i')); editor.handle(Input::Char('n')); editor.handle(Input::Char('\t')); assert!(!editor.file_picker().unwrap().focused); @@ -1376,7 +1382,6 @@ mod tests { terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); - assert!(screen_text(&terminal).contains("Tree search")); assert_eq!(terminal.get_cursor_position().unwrap().y, 1); editor.handle(Input::Escape); assert!(editor.file_picker().unwrap().focused); @@ -1423,22 +1428,12 @@ mod tests { .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); let text = screen_text(&terminal); - assert!( - text.contains(if view == FileView::Tree { - "Tree browse" - } else { - "List" - }), - "{text}" - ); - assert!(text.contains("^T view"), "{text}"); assert!(text.contains(editor.file_navigation_hint()), "{text}"); if view == FileView::Tree { assert_ne!( editor.file_navigation_hint(), editor.selection_navigation_hint() ); - assert!(text.contains("/ search"), "{text}"); let cursor = terminal.get_cursor_position().unwrap(); assert_eq!( terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),