tlc: phase 23 — viewer FileNext / FilePrev (Ctrl-F / Ctrl-B)

Mirrors Midnight Commander's
MC src/viewer/actions_cmd.c::mcview_load_next_prev (CK_FileNext
/ CK_FilePrev).

Components:
  src/viewer/siblings.rs (new) — pure helper next_or_prev_sibling:
    reads current file's parent directory, filters out hidden
    files (dot-prefixed), sorts case-insensitive, locates current
    by file_name, returns next (direction=+1) or prev (direction=-1)
    entry. Returns None at directory boundaries or on I/O error.
    6 unit tests cover next/prev/last/first/hidden/no-parent.

  src/viewer/mod.rs — Viewer::open_next / Viewer::open_prev
    public methods that look up the sibling and reload viewer
    state via a private reload_at helper (mirrors MC's
    mcview_init/mcview_done pair around mcview_load). Source
    errors are converted to std::io::Error so the Result type
    matches the existing open() signature.

  src/viewer/mod.rs — Ctrl-F / Ctrl-B keybinds in handle_key.
    Each delegates to open_next / open_prev.

  PLAN.md §15d row 29 marked Done; status bumped to Phase 23.

Tests: 1150 passed (was 1141, +9: 6 siblings module tests +
3 viewer integration tests covering open_next, open_prev,
Ctrl-F/Ctrl-B keybinds). Release binaries build clean.
This commit is contained in:
vasilito
2026-06-20 21:52:12 +03:00
parent d209b64ce9
commit 0b0e65a643
3 changed files with 285 additions and 4 deletions
+4 -4
View File
@@ -1,9 +1,9 @@
# Twilight Commander (TLC) — Pure Rust Reimplementation Plan
**Status:** Architecture chosen. Implementation in progress. Phases 08 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
@@ -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<bool> {
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<bool> {
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);
}
}
@@ -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<PathBuf> {
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<Vec<std::ffi::OsString>> {
let read = fs::read_dir(dir).ok()?;
let mut names: Vec<std::ffi::OsString> = 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());
}
}