diff --git a/local/recipes/tui/tlc/PLAN.md b/local/recipes/tui/tlc/PLAN.md index c8dfda01f5..9d9a991112 100644 --- a/local/recipes/tui/tlc/PLAN.md +++ b/local/recipes/tui/tlc/PLAN.md @@ -1,9 +1,9 @@ # Twilight Commander (TLC) — Pure Rust Reimplementation Plan **Status:** Architecture chosen. Implementation in progress. Phases 0–8 substantially complete. -Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e, 16, 17, 18, 19, 20, 21, 22 substantially complete. -**Last updated:** 2026-06-20 — Phase 22 F11 user-menu execute path complete (selection stash → %expand → sh -c → stdout insert → status; 1141 tests pass). -**Date:** 2026-06-12 (initial) · 2026-06-13 (rename + comprehensive review + audit fixes) · 2026-06-19 (bug fixes, standalone binaries, syntax highlighter, parity audit reconciliation) · 2026-06-20 (Phase 16, Phase 17, Phase 18, Phase 19, Phase 20, Phase 21, Phase 22) +Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e, 16, 17, 18, 19, 20, 21, 22, 23 substantially complete. +**Last updated:** 2026-06-20 — Phase 23 viewer FileNext/FilePrev (Ctrl-F/Ctrl-B navigates to lexically next/prev sibling in the current file's directory; 1150 tests pass). +**Date:** 2026-06-12 (initial) · 2026-06-13 (rename + comprehensive review + audit fixes) · 2026-06-19 (bug fixes, standalone binaries, syntax highlighter, parity audit reconciliation) · 2026-06-20 (Phase 16, Phase 17, Phase 18, Phase 19, Phase 20, Phase 21, Phase 22, Phase 23) **Branch:** `0.2.4` **Decision authority:** User selected Option A (Pure Rust TLC) on 2026-06-12. **Scope:** Reimplement ALL of Midnight Commander (MC 4.8.33) in pure Rust. @@ -1280,7 +1280,7 @@ dispatcher): | 26 | Match bracket (Alt-B) | ✅ Done | `editor::match_bracket`; finds `()[]{}` enclosing cursor or next unmatched pair; smart-jump | | 27 | Viewer hex edit (F4 + F2 in hex) | 🚧 Partial | Read-only hex view exists; F4 toggle between text and hex works; mutable hex mode + write-back path still TBD | | 28 | Viewer magic mode | 🚧 Partial | `magic` module present; mc.ext preprocessing pipeline still TBD | -| 29 | File next/prev (Ctrl-F / Ctrl-B) | ❌ Not started | `--on-view-exit {next,prev,quit}` semantics; scan current dir for siblings, open in current viewer | +| 29 | File next/prev (Ctrl-F / Ctrl-B) | ✅ Done (Phase 23) | `viewer::siblings::next_or_prev_sibling` filters dir entries (skip dotfiles, sort case-insensitive); `Viewer::open_next/open_prev` reload viewer state via private `reload_at` (mirrors `mcview_load_next_prev` init/teardown); Ctrl-F / Ctrl-B keybinds; 9 tests cover sibling lookup + viewer integration | #### Phase 15e — Advanced / Subshell (~4 weeks) 🚧 PARTIAL diff --git a/local/recipes/tui/tlc/source/src/viewer/mod.rs b/local/recipes/tui/tlc/source/src/viewer/mod.rs index f692792dfc..ca2a72d26f 100644 --- a/local/recipes/tui/tlc/source/src/viewer/mod.rs +++ b/local/recipes/tui/tlc/source/src/viewer/mod.rs @@ -13,6 +13,7 @@ pub mod hex; pub mod magic; pub mod nroff; pub mod search; +pub mod siblings; pub mod source; pub mod text; @@ -623,6 +624,17 @@ impl Viewer { let _ = self.search_prev(); return false; } + // Ctrl-F / Ctrl-B — File next / prev (MC `CK_FileNext` / + // `CK_FilePrev`). Navigate to the lexically next/prev + // sibling in the current file's directory. + if key == Key::ctrl('f') { + let _ = self.open_next(); + return false; + } + if key == Key::ctrl('b') { + let _ = self.open_prev(); + return false; + } let Key { code, mods } = key; if code == b'q' as u32 && mods.is_empty() { return true; @@ -784,6 +796,66 @@ impl Viewer { } false } + + /// Open the next sibling (by name) of the current file. + /// Returns `Ok(true)` if the viewer reloaded a new path, + /// `Ok(false)` if there was no next sibling, and `Err` on + /// I/O failure. + pub fn open_next(&mut self) -> std::io::Result { + let Some(next) = crate::viewer::siblings::next_or_prev_sibling(&self.path, 1) else { + return Ok(false); + }; + self.reload_at(next)?; + Ok(true) + } + + /// Open the previous sibling (by name) of the current file. + /// Returns `Ok(true)` if the viewer reloaded a new path, + /// `Ok(false)` if there was no previous sibling. + pub fn open_prev(&mut self) -> std::io::Result { + let Some(prev) = crate::viewer::siblings::next_or_prev_sibling(&self.path, -1) else { + return Ok(false); + }; + self.reload_at(prev)?; + Ok(true) + } + + /// Reload the viewer state to point at `new_path`. + /// Used by `open_next` / `open_prev` after the sibling lookup. + fn reload_at(&mut self, new_path: PathBuf) -> std::io::Result<()> { + let src = source::FileSource::open(&new_path) + .map_err(|e| match e { + source::SourceError::Io(io) => io, + other => std::io::Error::new(std::io::ErrorKind::InvalidData, other.to_string()), + })?; + let content = match &src { + source::FileSource::Inline { bytes } => bytes.clone(), + source::FileSource::Compressed { bytes, .. } => bytes.clone(), + source::FileSource::Chunked { .. } => Vec::new(), + }; + let size = src.size(); + self.path = new_path; + self.source = src; + self.mode = ViewMode::Text; + self.magic_mode = true; + self.wrap = true; + self.top = 0; + self.cursor = 0; + self.search = search::Search::new(); + self.goto = goto::Goto::build(&content); + self.text_view = text::TextView::new(String::from_utf8_lossy(&content).into_owned()); + self.growing = false; + self.last_size = size; + self.prompt = None; + self.prompt_input.clear(); + self.loading = size >= crate::viewer::source::INLINE_THRESHOLD; + #[cfg(feature = "syntect")] + { + self.highlighter = crate::editor::syntax::Highlighter::new(&self.path); + self.last_render_top = 0; + } + Ok(()) + } } /// Backwards-compat shim: `open_file` was the Phase 0 stub. @@ -1006,4 +1078,62 @@ mod tests { assert!(!v.is_growing()); let _ = std::fs::remove_file(&p); } + + #[test] + fn open_next_advances_to_lexically_next_sibling() { + let dir = std::env::temp_dir().join(format!( + "tlc_open_next_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("alpha.txt"), b"alpha body").unwrap(); + std::fs::write(dir.join("beta.txt"), b"beta body").unwrap(); + let mut v = Viewer::open(dir.join("alpha.txt")).unwrap(); + assert!(v.open_next().unwrap()); + assert_eq!(v.path.file_name().unwrap(), "beta.txt"); + // No further next — should report false. + assert!(!v.open_next().unwrap()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn open_prev_retreats_to_lexically_previous_sibling() { + let dir = std::env::temp_dir().join(format!( + "tlc_open_prev_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("alpha.txt"), b"alpha body").unwrap(); + std::fs::write(dir.join("beta.txt"), b"beta body").unwrap(); + let mut v = Viewer::open(dir.join("beta.txt")).unwrap(); + assert!(v.open_prev().unwrap()); + assert_eq!(v.path.file_name().unwrap(), "alpha.txt"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn ctrl_f_and_ctrl_b_keybindings_trigger_open_next_prev() { + let dir = std::env::temp_dir().join(format!( + "tlc_ctrl_fb_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("first.txt"), b"1").unwrap(); + std::fs::write(dir.join("second.txt"), b"2").unwrap(); + let mut v = Viewer::open(dir.join("first.txt")).unwrap(); + v.handle_key(Key::ctrl('f')); + assert_eq!(v.path.file_name().unwrap(), "second.txt"); + v.handle_key(Key::ctrl('b')); + assert_eq!(v.path.file_name().unwrap(), "first.txt"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/local/recipes/tui/tlc/source/src/viewer/siblings.rs b/local/recipes/tui/tlc/source/src/viewer/siblings.rs new file mode 100644 index 0000000000..54e462f9f4 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/viewer/siblings.rs @@ -0,0 +1,151 @@ +//! File next/previous navigation for the viewer. +//! +//! Mirrors Midnight Commander's `MC src/viewer/actions_cmd.c::mcview_load_next_prev`: +//! on Ctrl-F (FileNext) / Ctrl-B (FilePrev), the viewer scans the +//! current file's parent directory for the next (or previous) +//! sibling in name-sorted order and reopens the viewer on that +//! path. Hidden files (dotfiles) are skipped by default to match +//! MC's filemanager-style listing. +//! +//! The function is pure: it takes a path + direction and returns +//! the resolved next/prev path (or `None` if no sibling exists). +//! The Viewer holds the actual file I/O and re-render logic. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// Find the next sibling after `current` in its parent directory +/// (positive direction) or the previous sibling (negative +/// direction). Hidden files are skipped. Returns `None` when +/// `current` has no parent directory, the parent cannot be read, +/// or there is no sibling in the requested direction. +#[must_use] +pub fn next_or_prev_sibling(current: &Path, direction: i32) -> Option { + let parent = current.parent()?; + let cur_name = current.file_name()?; + let entries = read_sorted_entries(parent)?; + // Locate current in the visible list. If missing, fall back to + // the first / last entry in the requested direction. + let cur_idx = entries + .iter() + .position(|e| e == cur_name); + let next_idx = match cur_idx { + Some(i) => { + if direction > 0 { + i.checked_add(1)? + } else { + i.checked_sub(1)? + } + } + None => { + if direction > 0 { + 0 + } else { + entries.len().checked_sub(1)? + } + } + }; + entries.get(next_idx).map(|n| parent.join(n)) +} + +/// Read the parent directory's entries, filter out `.`, `..`, and +/// hidden files (dot-prefixed), sort by name (case-insensitive). +/// Returns `None` on I/O error. +fn read_sorted_entries(dir: &Path) -> Option> { + let read = fs::read_dir(dir).ok()?; + let mut names: Vec = read + .filter_map(Result::ok) + .map(|e| e.file_name()) + // Drop hidden files. Mirror the filemanager's default + // "Show hidden = false" behaviour. + .filter(|name| { + name.to_str() + .map(|s| !s.starts_with('.')) + .unwrap_or(true) + }) + .collect(); + names.sort_by(|a, b| { + let a = a.to_string_lossy().to_lowercase(); + let b = b.to_string_lossy().to_lowercase(); + a.cmp(&b) + }); + Some(names) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn unique_dir(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let p = std::env::temp_dir().join(format!("tlc_siblings_{label}_{nanos}")); + fs::create_dir_all(&p).unwrap(); + p + } + + #[test] + fn next_sibling_returns_lexically_next_file() { + let dir = unique_dir("next"); + fs::write(dir.join("alpha.txt"), "a").unwrap(); + fs::write(dir.join("beta.txt"), "b").unwrap(); + fs::write(dir.join("gamma.txt"), "g").unwrap(); + let cur = dir.join("alpha.txt"); + let next = next_or_prev_sibling(&cur, 1).unwrap(); + assert_eq!(next.file_name().unwrap(), "beta.txt"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn prev_sibling_returns_lexically_previous_file() { + let dir = unique_dir("prev"); + fs::write(dir.join("alpha.txt"), "a").unwrap(); + fs::write(dir.join("beta.txt"), "b").unwrap(); + fs::write(dir.join("gamma.txt"), "g").unwrap(); + let cur = dir.join("gamma.txt"); + let prev = next_or_prev_sibling(&cur, -1).unwrap(); + assert_eq!(prev.file_name().unwrap(), "beta.txt"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn next_at_last_returns_none() { + let dir = unique_dir("last"); + fs::write(dir.join("only.txt"), "x").unwrap(); + let cur = dir.join("only.txt"); + assert!(next_or_prev_sibling(&cur, 1).is_none()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn prev_at_first_returns_none() { + let dir = unique_dir("first"); + fs::write(dir.join("only.txt"), "x").unwrap(); + let cur = dir.join("only.txt"); + assert!(next_or_prev_sibling(&cur, -1).is_none()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn hidden_files_are_skipped() { + let dir = unique_dir("hidden"); + fs::write(dir.join("alpha.txt"), "a").unwrap(); + fs::write(dir.join(".hidden"), "h").unwrap(); + fs::write(dir.join("beta.txt"), "b").unwrap(); + // Next from alpha should jump to beta, skipping .hidden. + let cur = dir.join("alpha.txt"); + let next = next_or_prev_sibling(&cur, 1).unwrap(); + assert_eq!(next.file_name().unwrap(), "beta.txt"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn no_parent_returns_none() { + // A bare filename with no parent component. + let cur = Path::new("only.txt"); + assert!(next_or_prev_sibling(cur, 1).is_none()); + } +} \ No newline at end of file