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:
@@ -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
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user