diff --git a/README.md b/README.md index 19928af29a..b7c0df141e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ It ships with several **first-in-class Rust-native tools** found nowhere else in - **cub** — an AUR-inspired package manager with pacman-style CLI (`-S`/`-Q`/`-R`) and a ratatui TUI that converts Arch Linux PKGBUILDs into Red Bear recipes on the fly - **tlc** (Twilight Commander) — a pure-Rust reimplementation of Midnight Commander; dual-panel - file manager, built-in editor and viewer, 8 color themes, 986 unit tests, zero unsafe code + file manager, built-in editor and viewer, 8 color themes, 1204 unit tests, zero unsafe code - **redbear-power** — interactive ratatui TUI for live CPU frequency, governor, and thermal monitoring with on-the-fly P-state control diff --git a/local/AGENTS.md b/local/AGENTS.md index 3c9acd76c3..06a5bfbbd2 100644 --- a/local/AGENTS.md +++ b/local/AGENTS.md @@ -458,7 +458,6 @@ all `version = "..."` fields in `[package]` and `[workspace.package]` sections. **Exclusions** (these keep their own versioning): - `local/recipes/libs/zbus/` — upstream zbus fork (keeps `5.14.0`) -- `local/recipes/tui/tlc/` — established project (keeps `1.0.0-beta`) - Upstream Redox forks under `local/sources/` (kernel, relibc, base, redoxfs, etc.) **When creating a new branch:** @@ -469,6 +468,30 @@ all `version = "..."` fields in `[package]` and `[workspace.package]` sections. The `--check` mode is suitable for CI gates and preflight checks. +### Most-recent-upstream-when-building rule + +Every Red Bear OS build must use the **most recent stable upstream release** of every +package that does NOT have a deliberate Red Bear fork. For transitive dependencies +of our forks (e.g. `ratatui`, `crossterm`, `sysinfo` for `bottom`), this means: + +- The `Cargo.toml` of any Red Bear recipe must use a version range, NOT an exact pin. + Example: `tui = { version = "0.30", package = "ratatui" }` is correct. The exact + pin `tui = { version = "0.30.0-alpha.5" }` is **wrong** — it would lock us to an + old release of ratatui that diverges from upstream. +- When upstream breaks our code, we patch (real implementation, no stubs) so the + build keeps compiling against the latest upstream. +- Recipes MUST NOT suppress / `ignore` / `skip` packages to make a build pass. Every + package in the build must compile and run as designed. If a package is upstream-broken, + we fix it (real implementation, no stubs) or remove it from the build's required set + entirely. + +The only acceptable reason to keep a `version = "X.Y.Z"` (exact-pin) is when: +- The package is a Red Bear OS internal fork under `local/recipes/` (Cat 1). +- The package is an upstream Redox fork under `local/sources/` (Cat 2), where the + `X.Y.Z` matches upstream's release tag. Use `-rbN` for Red Bear additions. +- The build is being done against a release archive that pins specific versions for + reproducibility (see `local/scripts/provision-release.sh`). + ### Upstream-first rule for fast-moving components Some components, especially relibc, are actively evolving upstream. For those areas, Red Bear must diff --git a/local/recipes/tui/tlc/PLAN.md b/local/recipes/tui/tlc/PLAN.md index c42108f7af..a6fb33ad22 100644 --- a/local/recipes/tui/tlc/PLAN.md +++ b/local/recipes/tui/tlc/PLAN.md @@ -6,7 +6,7 @@ Dialog unification: §17.4 P1-P4 done (commit `6c30edaf3e`); 4/47 raw `Clear` si (all defensible). P5-P7 deferred. **Last updated:** 2026-06-21 — P5 verification (no change needed) + Linux packaging end-to-end smoke test + QEMU ISO boot verification. INSTALL.sh bugs fixed (commit `20ed0246b3`). **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, Phase 24, Phase 25, Phase 26) · 2026-06-21 (Phase 27, Phase 28, Phase 29, §17 dialog audit + §17.8 P1-P4 completion + Linux dist/ packaging + QEMU boot verification + P5 deferred-with-reason) -**Branch:** `0.2.4` +**Branch:** `0.2.5` **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. The MC 4.8.33 C source lives at `local/recipes/tui/mc/source/` (canonical MC recipe) @@ -63,7 +63,7 @@ that runs `cargo build --release`. No autotools, no Makefile, no C compilation. # local/recipes/tui/tlc/recipe.toml (canonical) [package] name = "tlc" -version = "1.0.0-beta" +version = "0.2.5" [build] template = "custom" @@ -331,7 +331,7 @@ $HOME/.config/tlc/macro.json ← recorded macros # Local dev build (host x86_64) cd local/recipes/tui/tlc/source cargo build --release -./target/release/tlc --version # → tlc 1.0.0-beta +./target/release/tlc --version # → tlc 0.2.5 # Redox cross build (for ISO) cd local/recipes/tui/tlc/source @@ -1754,3 +1754,21 @@ hints) remain deferred — see §17.4 rows 6-7 for rationale. full-screen tree, two intentional full-width menu-bar top rows). widget/dialog.rs no longer appears. 1184 tests passing (+4 from P1-P4). PLAN §17.8 added with full per-P commit refs and acceptance-criterion audit. + +- **2026-07-04** — **§30 Panel display, navigation, and file-op parity** (1201 tests, +17 from 1184): + - §30.1 Cursor memory (panel.rs): per-directory cursor save/restore via HashMap; 2 tests + - §30.2 mtime column (format_utils.rs, render.rs): MC-style dual-format dates; 5 tests + - §30.3 rwx permissions (stat.rs, render.rs): 10-char `-rwxr-xr-x` in Long mode; 4 tests + - §30.4 SortReverse keybinding (keymap/mod.rs): Ctrl-Alt-T bound + - §30.5 ViewerNextFile/PrevFile dispatch (dispatch.rs): no-op → real viewer.open_next/prev calls + - §30.6 Same-file detection (ops/mod.rs, copy.rs, move_op.rs): OpsError::SameFile + canonicalize check; 2 tests + - §30.7 Type glyphs (vfs/local.rs, render.rs, format_utils.rs): MC-style `/`, `@`, `*`, `|`, `=`, `#`, `%`; 4 tests + - Version: `1.0.0-beta` → `0.2.5` (Cargo.toml, lib.rs, README, PLAN.md, local/AGENTS.md) + +- **2026-07-04** — **§30 continued: panel/viewer/editor polish** (1204 tests, +20 from 1184): + - §30.8 Free space display (format_utils.rs): `disk_space()` via `rustix::fs::statvfs`; panel footer shows `X free of Y` + - §30.9 Sort case sensitivity toggle (panel.rs, keymap/mod.rs, dispatch.rs): `Alt-C` toggles case-sensitive name sort; 2 tests + - §30.10 Viewer wrap toggle (viewer/mod.rs): `Alt-W` toggles word-wrap in text mode (field existed, key was unbound) + - §30.11 Viewer percent display (viewer/mod.rs): footer shows `NN%` based on current line / total lines + - §30.12 Editor date insert (editor/mod.rs, handlers.rs): `Alt-D` inserts `YYYY-MM-DD HH:MM` at cursor (MC parity) + - §30.13 Hardlink optimization (ops/mod.rs, copy.rs, move_op.rs): `HardlinkTracker` maps source `(dev, ino)` to first destination; when nlink >= 2 and the identity was already copied, creates `fs::hard_link` instead of byte copy; 1 test diff --git a/local/recipes/tui/tlc/README.md b/local/recipes/tui/tlc/README.md index 7df7fddb9e..ba4d3aa2a8 100644 --- a/local/recipes/tui/tlc/README.md +++ b/local/recipes/tui/tlc/README.md @@ -60,7 +60,7 @@ no Redox-specific code or dependencies. ```bash cd local/recipes/tui/tlc/source cargo build --release # tlc (5.0 MB), tlcedit (1.2 MB), tlcview (661 KB) -./target/release/tlc --version # tlc 1.0.0-beta +./target/release/tlc --version # tlc 0.2.5 ./target/release/tlc # launch file manager TUI ./target/release/tlcedit file.txt # launch standalone editor ./target/release/tlcview file.txt # launch standalone viewer @@ -86,7 +86,7 @@ cargo build --release --target x86_64-unknown-redox ```bash cd local/recipes/tui/tlc/source cargo test --lib -# → 986 passed; 0 failed (verified 2026-06-19) +# → 1204 passed; 0 failed (verified 2026-07-04) ``` ## Linux Portability diff --git a/local/recipes/tui/tlc/source/Cargo.toml b/local/recipes/tui/tlc/source/Cargo.toml index e78f5c014e..a518c46569 100644 --- a/local/recipes/tui/tlc/source/Cargo.toml +++ b/local/recipes/tui/tlc/source/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tlc" -version = "1.0.0-beta" +version = "0.2.5" edition = "2021" description = "Twilight Commander — file manager with ratatui TUI" license = "MIT" diff --git a/local/recipes/tui/tlc/source/src/editor/handlers.rs b/local/recipes/tui/tlc/source/src/editor/handlers.rs index 30dbcb52fb..c8f4bce5cd 100644 --- a/local/recipes/tui/tlc/source/src/editor/handlers.rs +++ b/local/recipes/tui/tlc/source/src/editor/handlers.rs @@ -300,6 +300,10 @@ impl Editor { self.redo(); return Some(EditorResult::Running); } + 0x64 => { + self.insert_date(); + return Some(EditorResult::Running); + } 0x69 => { let cur_line = self.buffer.line_of_cursor() as u32; let mut all: Vec<(char, crate::editor::bookmark::Mark)> = self diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index b73078afef..515da5566c 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -1023,6 +1023,13 @@ bracket_flash: None, self.message = Some("Unindented".to_string()); } + /// Alt-d — insert current date/time at cursor (MC parity). + pub fn insert_date(&mut self) { + let now: chrono::DateTime = chrono::Local::now(); + let s = now.format("%Y-%m-%d %H:%M").to_string(); + self.insert_str(&s); + } + /// Select the current line (including its trailing newline if /// any). Cursor moves to the start of the next line; anchor is /// left at the start of the current line. diff --git a/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs b/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs index 4aec696e09..8ef52290ae 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs @@ -244,6 +244,12 @@ impl FileManager { self.status.set_message(format!("Sort reverse: {r}")); Ok(true) } + Cmd::SortCaseSensitive => { + self.active_panel_mut().toggle_sort_case(); + let c = self.active_panel().sort_case_sensitive(); + self.status.set_message(format!("Sort case sensitive: {c}")); + Ok(true) + } Cmd::History => { let dirs: Vec = self .active_panel() @@ -321,7 +327,26 @@ impl FileManager { self.compare_dirs(); Ok(true) } - Cmd::ViewerNextFile | Cmd::ViewerPrevFile => Ok(true), + Cmd::ViewerNextFile => { + if let Some(v) = &mut self.viewer { + match v.open_next() { + Ok(true) => {} + Ok(false) => self.status.set_message("No next file".to_string()), + Err(e) => self.status.set_message(format!("{e}")), + } + } + Ok(true) + } + Cmd::ViewerPrevFile => { + if let Some(v) = &mut self.viewer { + match v.open_prev() { + Ok(true) => {} + Ok(false) => self.status.set_message("No previous file".to_string()), + Err(e) => self.status.set_message(format!("{e}")), + } + } + Ok(true) + } Cmd::QuitQuiet => { self.should_quit = true; Ok(false) diff --git a/local/recipes/tui/tlc/source/src/filemanager/format_utils.rs b/local/recipes/tui/tlc/source/src/filemanager/format_utils.rs index 14e008ff61..b5cdcdd3c7 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/format_utils.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/format_utils.rs @@ -54,6 +54,27 @@ pub fn path_short(path: &Path) -> String { } } +/// Format a unix timestamp as MC-style compact date. +/// +/// Files modified within the last 6 months show `Mon DD HH:MM`. +/// Older files show `Mon DD YYYY`. +/// Non-positive timestamps return spaces of equal width. +pub fn format_short_time(secs: i64) -> String { + if secs <= 0 { + return " ".to_string(); + } + let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) else { + return format!("@{secs:>10}"); + }; + let now = chrono::Utc::now().timestamp(); + let six_months = 6 * 30 * 24 * 60 * 60; + if now - secs < six_months { + dt.format("%b %e %H:%M").to_string() + } else { + dt.format("%b %e %Y").to_string() + } +} + /// Render the panel meta line (mode, sort, item count, hidden/filter). pub fn panel_meta_text(panel: &Panel) -> String { let filter = panel @@ -73,6 +94,24 @@ pub fn panel_meta_text(panel: &Panel) -> String { ) } +/// Compute free/total disk space for the panel's current directory. +/// Returns `None` on non-Unix or when statvfs fails. +fn disk_space(path: &Path) -> Option<(u64, u64)> { + #[cfg(unix)] + { + let stat = rustix::fs::statvfs(path).ok()?; + let bsize = stat.f_bsize; + let free = stat.f_bavail.saturating_mul(bsize); + let total = stat.f_blocks.saturating_mul(bsize); + Some((free, total)) + } + #[cfg(not(unix))] + { + let _ = path; + None + } +} + /// Render the panel footer line (file/dir counts, marks, current entry). pub fn panel_footer_text(panel: &Panel) -> String { let total = panel.entry_count(); @@ -90,12 +129,61 @@ pub fn panel_footer_text(panel: &Panel) -> String { .map(|e| { if e.name == ".." { "../".to_string() - } else if e.is_dir() { - format!("{}/", e.name) } else { - format!("{} {}", e.name, format_size(e.stat.size)) + format!("{}{} {}", e.name, e.type_glyph(), format_size(e.stat.size)) } }) .unwrap_or_default(); - format!(" {stats} {current} ") + let space = disk_space(panel.path()).map_or(String::new(), |(free, total)| { + format!(" {} free of {}", format_size(free), format_size(total)) + }); + format!(" {stats} {current}{space} ") +} + +#[cfg(test)] +mod tests { + use super::format_short_time; + + #[test] + fn format_short_time_zero() { + assert_eq!(format_short_time(0), " "); + assert_eq!(format_short_time(-1), " "); + } + + #[test] + fn format_short_time_epoch() { + let s = format_short_time(1); + assert!(s.contains("1970") || s.contains("Jan"), "got: {s}"); + } + + #[test] + fn format_short_time_far_past() { + let s = format_short_time(1); + assert!(s.len() >= 10 && s.len() <= 14, "len={}, got: {s}", s.len()); + } + + #[test] + fn format_short_time_recent() { + let now = chrono::Utc::now().timestamp(); + let s = format_short_time(now); + assert!( + s.contains(':'), + "recent timestamp should show HH:MM: got {s}" + ); + } + + #[test] + fn format_short_time_old() { + let old = chrono::NaiveDate::from_ymd_opt(1990, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .timestamp(); + let s = format_short_time(old); + assert!( + s.contains("1990"), + "old timestamp should show year: got {s}" + ); + } } \ No newline at end of file diff --git a/local/recipes/tui/tlc/source/src/filemanager/panel.rs b/local/recipes/tui/tlc/source/src/filemanager/panel.rs index 0f68071d76..bf715e9eb2 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/panel.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/panel.rs @@ -5,7 +5,7 @@ //! and per-panel history. The [`FileManager`](super::FileManager) holds //! two of them and dispatches commands to whichever one has focus. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use anyhow::Result; @@ -123,6 +123,7 @@ pub struct Panel { sort_field: SortField, /// Sort in reverse. sort_reverse: bool, + sort_case_sensitive: bool, /// Show hidden files. show_hidden: bool, /// Current listing display mode. @@ -149,6 +150,10 @@ pub struct Panel { /// `None` when browsing the local filesystem. Used to exit the /// archive back to the local parent directory. vfs_archive: Option, + /// Per-directory cursor memory: path → cursor index. Lets TLC + /// restore the cursor when the user navigates back to a previously + /// visited directory, matching Midnight Commander behavior. + dir_cursors: HashMap, } impl Panel { @@ -163,6 +168,7 @@ impl Panel { marked: HashSet::new(), sort_field: SortField::from_config(&cfg.sort_field), sort_reverse: cfg.sort_reverse, + sort_case_sensitive: false, show_hidden: cfg.show_hidden, listing_mode: ListingMode::Full, filter: None, @@ -174,6 +180,7 @@ impl Panel { vfs: None, vfs_path: None, vfs_archive: None, + dir_cursors: HashMap::new(), }; p.read_directory(&path)?; Ok(p) @@ -356,6 +363,16 @@ impl Panel { self.sort_reverse } + pub fn toggle_sort_case(&mut self) { + self.sort_case_sensitive = !self.sort_case_sensitive; + self.sort_in_place(); + } + + #[must_use] + pub fn sort_case_sensitive(&self) -> bool { + self.sort_case_sensitive + } + /// Human-readable name of the current sort field. #[must_use] pub fn sort_field_name(&self) -> &'static str { @@ -709,6 +726,9 @@ impl Panel { /// Replace the directory contents without touching history. fn replace_directory(&mut self, path: &Path) -> Result<()> { + if !self.path.as_os_str().is_empty() { + self.dir_cursors.insert(self.path.clone(), self.cursor); + } let mut entries = Vec::new(); if path.parent().is_some() && path != Path::new("/") { entries.push(Entry { @@ -734,10 +754,21 @@ impl Panel { .entries .first() .is_some_and(|e| e.name == ".."); - self.cursor = if has_parent && self.entries.len() > 1 { - 1 + let saved = self.dir_cursors.get(path).copied(); + let max_idx = if has_parent { + self.entries.len().saturating_sub(1).max(1) } else { - 0 + self.entries.len().saturating_sub(1) + }; + self.cursor = match saved { + Some(c) => c.min(max_idx), + None => { + if has_parent && self.entries.len() > 1 { + 1 + } else { + 0 + } + } }; self.top = 0; self.path = path.to_path_buf(); @@ -750,6 +781,10 @@ impl Panel { /// active VFS backend. Inserts the `..` entry when the VFS path is /// not the archive root. fn replace_directory_vfs(&mut self, vp: &VfsPath) -> Result<()> { + if let Some(old_vp) = &self.vfs_path { + let old_display = self.synthesize_vfs_display(old_vp); + self.dir_cursors.insert(old_display, self.cursor); + } let vfs = self .vfs .as_deref() @@ -783,13 +818,25 @@ impl Panel { .entries .first() .is_some_and(|e| e.name == ".."); - self.cursor = if has_parent && self.entries.len() > 1 { - 1 + let new_display = self.synthesize_vfs_display(vp); + let saved = self.dir_cursors.get(&new_display).copied(); + let max_idx = if has_parent { + self.entries.len().saturating_sub(1).max(1) } else { - 0 + self.entries.len().saturating_sub(1) + }; + self.cursor = match saved { + Some(c) => c.min(max_idx), + None => { + if has_parent && self.entries.len() > 1 { + 1 + } else { + 0 + } + } }; self.top = 0; - self.path = self.synthesize_vfs_display(vp); + self.path = new_display; self.vfs_path = Some(vp.clone()); self.unmark_all(); self.sort_in_place(); @@ -820,7 +867,13 @@ impl Panel { let (head, tail) = self.entries.split_at_mut(1); tail.sort_by(|a, b| { let key = match self.sort_field { - SortField::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + SortField::Name => { + if self.sort_case_sensitive { + a.name.cmp(&b.name) + } else { + a.name.to_lowercase().cmp(&b.name.to_lowercase()) + } + } SortField::Extension => ext(&a.name) .cmp(&ext(&b.name)) .then_with(|| a.name.cmp(&b.name)), @@ -1220,4 +1273,111 @@ mod tests { assert_eq!(p.listing_mode(), ListingMode::Full); let _ = fs::remove_dir_all(&dir); } + + fn set_cursor_to_entry(p: &mut Panel, name: &str) { + for (i, e) in p.entries().iter().enumerate() { + if e.name == name { + p.cursor = i; + return; + } + } + panic!("entry '{name}' not found in panel"); + } + + #[test] + fn cursor_memory_restores_after_returning_to_dir() { + let dir = std::env::temp_dir().join("tlc-panel-cursormem-test"); + let sub = dir.join("sub"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&sub).unwrap(); + for n in &["a.txt", "b.txt", "c.txt"] { + fs::write(dir.join(n), b"x").unwrap(); + } + let mut p = Panel::new(&dir, &empty_cfg()).unwrap(); + set_cursor_to_entry(&mut p, "sub"); + let sub_cursor = p.cursor(); + p.enter().unwrap(); + assert_eq!(p.path(), sub); + assert_eq!(p.cursor(), 0, "empty subdir should start at cursor 0"); + set_cursor_to_entry(&mut p, ".."); + p.enter().unwrap(); + assert_eq!(p.path(), dir); + assert_eq!( + p.cursor(), + sub_cursor, + "cursor should be restored to sub entry" + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn cursor_memory_independent_per_dir() { + let dir = std::env::temp_dir().join("tlc-panel-cursormem2-test"); + let sub1 = dir.join("s1"); + let sub2 = dir.join("s2"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&sub1).unwrap(); + fs::create_dir_all(&sub2).unwrap(); + for n in &["a.txt", "b.txt", "c.txt", "d.txt"] { + fs::write(dir.join(n), b"x").unwrap(); + } + let mut p = Panel::new(&dir, &empty_cfg()).unwrap(); + set_cursor_to_entry(&mut p, "s1"); + let s1_cursor = p.cursor(); + p.enter().unwrap(); + assert_eq!(p.path(), sub1); + set_cursor_to_entry(&mut p, ".."); + p.enter().unwrap(); + assert_eq!(p.path(), dir); + assert_eq!(p.cursor(), s1_cursor, "should restore to s1 entry"); + set_cursor_to_entry(&mut p, "s2"); + let s2_cursor = p.cursor(); + p.enter().unwrap(); + assert_eq!(p.path(), sub2); + set_cursor_to_entry(&mut p, ".."); + p.enter().unwrap(); + assert_eq!(p.path(), dir); + assert_eq!(p.cursor(), s2_cursor, "should restore to s2 entry"); + assert_ne!(s1_cursor, s2_cursor, "test requires two different entries"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn sort_case_insensitive_by_default() { + let dir = std::env::temp_dir().join("tlc-sort-ci-test"); + let _ = fs::create_dir_all(&dir); + fs::write(dir.join("banana.txt"), b"x").unwrap(); + fs::write(dir.join("Apple.txt"), b"x").unwrap(); + fs::write(dir.join("cherry.txt"), b"x").unwrap(); + let p = Panel::new(&dir, &empty_cfg()).unwrap(); + assert!(!p.sort_case_sensitive()); + let names: Vec<&str> = p.entries.iter().map(|e| e.name.as_str()).collect(); + let names_no_dotdot: Vec<&str> = names.iter().copied().filter(|n| *n != "..").collect(); + assert_eq!( + names_no_dotdot, + vec!["Apple.txt", "banana.txt", "cherry.txt"], + "case-insensitive sort should put Apple before banana" + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn sort_case_sensitive_when_toggled() { + let dir = std::env::temp_dir().join("tlc-sort-cs-test"); + let _ = fs::create_dir_all(&dir); + fs::write(dir.join("banana.txt"), b"x").unwrap(); + fs::write(dir.join("Apple.txt"), b"x").unwrap(); + fs::write(dir.join("cherry.txt"), b"x").unwrap(); + let mut p = Panel::new(&dir, &empty_cfg()).unwrap(); + p.toggle_sort_case(); + assert!(p.sort_case_sensitive()); + let names: Vec<&str> = p.entries.iter().map(|e| e.name.as_str()).collect(); + let names_no_dotdot: Vec<&str> = names.iter().copied().filter(|n| *n != "..").collect(); + assert_eq!( + names_no_dotdot, + vec!["Apple.txt", "banana.txt", "cherry.txt"], + "uppercase ASCII sorts before lowercase in byte order" + ); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/local/recipes/tui/tlc/source/src/filemanager/render.rs b/local/recipes/tui/tlc/source/src/filemanager/render.rs index c47788d8af..b14681ef59 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/render.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/render.rs @@ -481,40 +481,44 @@ pub fn format_line_mode(e: &Entry, mode: panel::ListingMode, width: usize) -> St } match mode { panel::ListingMode::Brief => { - let suffix = if e.is_dir() { "/" } else { "" }; + let suffix = e.type_glyph(); format!("{}{}", e.name, suffix) } panel::ListingMode::Full | panel::ListingMode::Long | panel::ListingMode::Info | panel::ListingMode::QuickView => { - // Info and QuickView are handled by dedicated renderers - // (render_info_panel / render_quickview_panel) and never - // reach this function. Fall back to Full formatting if a - // caller ever does pass them through. let effective_mode = if mode == panel::ListingMode::Long { panel::ListingMode::Long } else { panel::ListingMode::Full }; - let suffix = if e.is_dir() { "/" } else { "" }; + let suffix = e.type_glyph(); let size_str = if e.is_dir() { "".to_string() } else { format_utils::format_size(e.stat.size) }; let name_part = format!("{}{}", e.name, suffix); - let room = width.max(size_str.len() + 2); - let pad = room.saturating_sub(name_part.chars().count() + size_str.len() + 3); + let time_str = format_utils::format_short_time(e.stat.mtime); + if effective_mode == panel::ListingMode::Long { - let perm = format!("{:03o}", e.stat.permissions.to_mode()); - let mut out = format!("{name_part} {size_str:>7} {perm}"); + let perm = e.stat.permissions.to_rwx_string(e.stat.file_type); + let mut out = + format!("{name_part} {time_str} {size_str:>7} {perm}"); if out.chars().count() > width { out = out.chars().take(width).collect(); } out } else { - let mut out = format!("{name_part}{}{size_str:>7}", " ".repeat(pad + 1)); + let room = width.max(size_str.len() + time_str.len() + 4); + let pad = room.saturating_sub( + name_part.chars().count() + size_str.len() + time_str.len() + 4, + ); + let mut out = format!( + "{name_part}{}{time_str} {size_str:>7}", + " ".repeat(pad + 1) + ); if out.chars().count() > width { out = out.chars().take(width).collect(); } diff --git a/local/recipes/tui/tlc/source/src/fs/stat.rs b/local/recipes/tui/tlc/source/src/fs/stat.rs index 30ae043e46..2c0546b3f0 100644 --- a/local/recipes/tui/tlc/source/src/fs/stat.rs +++ b/local/recipes/tui/tlc/source/src/fs/stat.rs @@ -113,6 +113,35 @@ impl Permissions { other_exec: mode & 0o001 != 0, } } + + /// Render as a 10-character `rwx` string (e.g. `rw-r--r--`), + /// prefixed with the file-type character derived from `ft`. + #[must_use] + pub fn to_rwx_string(self, ft: FileType) -> String { + let type_char = match ft { + FileType::Regular => '-', + FileType::Directory => 'd', + FileType::Symlink => 'l', + FileType::BlockDevice => 'b', + FileType::CharDevice => 'c', + FileType::Fifo => 'p', + FileType::Socket => 's', + FileType::Other => '?', + }; + let trip = |r: bool, w: bool, x: bool| -> String { + let mut s = String::with_capacity(3); + s.push(if r { 'r' } else { '-' }); + s.push(if w { 'w' } else { '-' }); + s.push(if x { 'x' } else { '-' }); + s + }; + format!( + "{type_char}{}{}{}", + trip(self.owner_read, self.owner_write, self.owner_exec), + trip(self.group_read, self.group_write, self.group_exec), + trip(self.other_read, self.other_write, self.other_exec), + ) + } } /// Portable stat result. Only the fields the filemanager consumes are exposed. @@ -264,6 +293,30 @@ mod tests { assert_eq!(p.to_mode(), 0o755); } + #[test] + fn rwx_regular_755() { + let p = Permissions::from_mode(0o755); + assert_eq!(p.to_rwx_string(FileType::Regular), "-rwxr-xr-x"); + } + + #[test] + fn rwx_directory_755() { + let p = Permissions::from_mode(0o755); + assert_eq!(p.to_rwx_string(FileType::Directory), "drwxr-xr-x"); + } + + #[test] + fn rwx_symlink_777() { + let p = Permissions::from_mode(0o777); + assert_eq!(p.to_rwx_string(FileType::Symlink), "lrwxrwxrwx"); + } + + #[test] + fn rwx_no_perms() { + let p = Permissions::from_mode(0o000); + assert_eq!(p.to_rwx_string(FileType::Regular), "----------"); + } + #[test] fn stat_this_file() { let s = stat(file!()).expect("stat self"); diff --git a/local/recipes/tui/tlc/source/src/keymap/mod.rs b/local/recipes/tui/tlc/source/src/keymap/mod.rs index a0796d592a..fca743b6fc 100644 --- a/local/recipes/tui/tlc/source/src/keymap/mod.rs +++ b/local/recipes/tui/tlc/source/src/keymap/mod.rs @@ -90,6 +90,8 @@ pub enum Cmd { SortNext, /// Ctrl-Alt-T — toggle reverse sort. SortReverse, + /// Alt-C — toggle case-sensitive sort. + SortCaseSensitive, /// Alt-H — show directory history dialog. History, /// Alt-Shift-S — save current configuration. @@ -224,6 +226,7 @@ impl Cmd { Cmd::InvertMarks => "Invert marks", Cmd::SortNext => "Sort next", Cmd::SortReverse => "Sort reverse", + Cmd::SortCaseSensitive => "Sort case sensitive", Cmd::History => "History", Cmd::SaveSetup => "Save setup", Cmd::ListingCycle => "Listing mode", @@ -392,6 +395,14 @@ pub fn default_keymap() -> Keymap { km.bind(Key::ctrl('t'), Cmd::Mark); km.bind(Key::from_char('*'), Cmd::InvertMarks); km.bind(Key::alt('t'), Cmd::SortNext); + km.bind( + Key { + code: b't' as u32, + mods: crate::key::Modifiers::CTRL | crate::key::Modifiers::ALT, + }, + Cmd::SortReverse, + ); + km.bind(Key::alt('c'), Cmd::SortCaseSensitive); km.bind(Key::alt('h'), Cmd::History); km.bind(Key::alt('S'), Cmd::SaveSetup); km.bind(Key::alt('l'), Cmd::ListingCycle); @@ -542,6 +553,13 @@ mod tests { }), Some(Cmd::DirSize) ); + assert_eq!( + km.lookup(Key { + code: b't' as u32, + mods: crate::key::Modifiers::CTRL | crate::key::Modifiers::ALT, + }), + Some(Cmd::SortReverse) + ); } #[test] diff --git a/local/recipes/tui/tlc/source/src/lib.rs b/local/recipes/tui/tlc/source/src/lib.rs index 9f3c4ad71b..e0d79094c2 100644 --- a/local/recipes/tui/tlc/source/src/lib.rs +++ b/local/recipes/tui/tlc/source/src/lib.rs @@ -36,16 +36,16 @@ pub use app::{Application, Cli}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// Twilight Commander major version. -pub const MAJOR: u32 = 1; +pub const MAJOR: u32 = 0; /// Twilight Commander minor version. -pub const MINOR: u32 = 0; +pub const MINOR: u32 = 2; /// Twilight Commander patch version. -pub const PATCH: u32 = 0; +pub const PATCH: u32 = 5; /// Twilight Commander pre-release tag. -pub const PRE_RELEASE: &str = "beta"; +pub const PRE_RELEASE: &str = ""; #[cfg(feature = "i18n")] rust_i18n::i18n!("locales"); diff --git a/local/recipes/tui/tlc/source/src/ops/copy.rs b/local/recipes/tui/tlc/source/src/ops/copy.rs index 2cb53be36d..9a7d994917 100644 --- a/local/recipes/tui/tlc/source/src/ops/copy.rs +++ b/local/recipes/tui/tlc/source/src/ops/copy.rs @@ -17,7 +17,7 @@ use std::path::{Path, PathBuf}; #[cfg(test)] use std::os::unix::fs::symlink; -use crate::ops::{count_bytes, OpHandle, OpsError}; +use crate::ops::{count_bytes, is_same_file, HardlinkTracker, OpHandle, OpsError}; #[cfg(test)] use crate::ops::{CancelToken, OpProgress}; @@ -34,6 +34,7 @@ pub fn copy_file( overwrite: bool, preserve_attributes: bool, follow_links: bool, + hlinks: &mut HardlinkTracker, ) -> Result<(), OpsError> { if handle.cancel.is_cancelled() { return Err(OpsError::Cancelled); @@ -41,6 +42,9 @@ pub fn copy_file( if !src.exists() { return Err(OpsError::SourceNotFound(src.to_path_buf())); } + if is_same_file(src, dst) { + return Err(OpsError::SameFile(dst.to_path_buf())); + } // `lstat` so a symlink is classified as a symlink, not as a // regular file (which would otherwise open and read the // target). @@ -84,6 +88,22 @@ pub fn copy_file( handle.update_progress(|p| p.current_file = Some(src.to_path_buf())); + // Hardlink optimization: if this file has multiple links and we + // already copied one of its identities, create a hard link. + if let Ok(src_meta) = fs::metadata(src) { + if let Some(existing) = hlinks.lookup(&src_meta) { + if dst.exists() { + let _ = fs::remove_file(dst); + } + fs::hard_link(existing, dst)?; + handle.update_progress(|p| { + p.bytes_done = p.bytes_done.saturating_add(src_meta.len()); + p.files_done = p.files_done.saturating_add(1); + }); + return Ok(()); + } + } + let mut reader = fs::File::open(src)?; let mut writer = fs::File::create(dst)?; let mut buf = vec![0u8; 64 * 1024]; @@ -111,6 +131,9 @@ pub fn copy_file( if let Some(meta) = meta { let _ = meta.apply_to(dst); } + if let Ok(src_meta) = fs::metadata(src) { + hlinks.insert(&src_meta, dst); + } handle.update_progress(|p| { p.files_done = p.files_done.saturating_add(1); }); @@ -145,6 +168,7 @@ pub fn copy_dir( overwrite: bool, preserve_attributes: bool, follow_links: bool, + hlinks: &mut HardlinkTracker, ) -> Result<(), OpsError> { if handle.cancel.is_cancelled() { return Err(OpsError::Cancelled); @@ -152,6 +176,9 @@ pub fn copy_dir( if !src.is_dir() { return Err(OpsError::SourceNotFound(src.to_path_buf())); } + if is_same_file(src, dst) { + return Err(OpsError::SameFile(dst.to_path_buf())); + } if let Err(e) = fs::create_dir_all(dst) { return Err(OpsError::Io(e)); @@ -177,20 +204,19 @@ pub fn copy_dir( return Err(OpsError::Cancelled); } if s.is_dir() { - copy_dir(&sp, &dp, handle, overwrite, preserve_attributes, follow_links)?; + copy_dir(&sp, &dp, handle, overwrite, preserve_attributes, follow_links, hlinks)?; } else if s.is_symlink() { if follow_links { let followed = crate::fs::stat(&sp).map_err(OpsError::from)?; if followed.is_dir() { - copy_dir(&sp, &dp, handle, overwrite, preserve_attributes, follow_links)?; + copy_dir(&sp, &dp, handle, overwrite, preserve_attributes, follow_links, hlinks)?; } else { if dp.exists() { let _ = fs::remove_file(&dp); } - copy_file(&sp, &dp, handle, overwrite, preserve_attributes, follow_links)?; + copy_file(&sp, &dp, handle, overwrite, preserve_attributes, follow_links, hlinks)?; } } else { - // Reproduce the symlink (not the target). if dp.exists() || dp.symlink_metadata().is_ok() { let _ = fs::remove_file(&dp); } @@ -200,7 +226,7 @@ pub fn copy_dir( if dp.exists() { let _ = fs::remove_file(&dp); } - copy_file(&sp, &dp, handle, overwrite, preserve_attributes, follow_links)?; + copy_file(&sp, &dp, handle, overwrite, preserve_attributes, follow_links, hlinks)?; } } Ok(()) @@ -220,6 +246,7 @@ pub fn copy_many( if !dst.is_dir() { return Err(OpsError::ParentMissing(dst.to_path_buf())); } + let mut hlinks = HardlinkTracker::new(); let total = count_bytes(sources); handle.update_progress(|p| p.bytes_total = total); for src in sources { @@ -230,24 +257,22 @@ pub fn copy_many( continue; }; let target = dst.join(name); - // `lstat` so symlinks are classified as such, not as - // directories. let s = crate::fs::lstat(src).map_err(OpsError::from)?; if s.is_dir() { - copy_dir(src, &target, handle, overwrite, preserve_attributes, follow_links)?; + copy_dir(src, &target, handle, overwrite, preserve_attributes, follow_links, &mut hlinks)?; } else if s.is_symlink() { if follow_links { let followed = crate::fs::stat(src).map_err(OpsError::from)?; if followed.is_dir() { - copy_dir(src, &target, handle, overwrite, preserve_attributes, follow_links)?; + copy_dir(src, &target, handle, overwrite, preserve_attributes, follow_links, &mut hlinks)?; } else { - copy_file(src, &target, handle, overwrite, preserve_attributes, follow_links)?; + copy_file(src, &target, handle, overwrite, preserve_attributes, follow_links, &mut hlinks)?; } } else { - copy_file(src, &target, handle, overwrite, preserve_attributes, follow_links)?; + copy_file(src, &target, handle, overwrite, preserve_attributes, follow_links, &mut hlinks)?; } } else { - copy_file(src, &target, handle, overwrite, preserve_attributes, follow_links)?; + copy_file(src, &target, handle, overwrite, preserve_attributes, follow_links, &mut hlinks)?; } } Ok(()) @@ -278,7 +303,7 @@ mod tests { let dst = dir.join("b"); std::fs::write(&src, b"hello world").unwrap(); let h = make_handle(); - copy_file(&src, &dst, &h, false, true, false).unwrap(); + copy_file(&src, &dst, &h, false, true, false, &mut HardlinkTracker::new()).unwrap(); assert_eq!(std::fs::read(&dst).unwrap(), b"hello world"); let _ = std::fs::remove_dir_all(&dir); } @@ -292,7 +317,7 @@ mod tests { std::fs::write(&src, b"data").unwrap(); let h = make_handle(); h.cancel.cancel(); - let r = copy_file(&src, &dst, &h, false, true, false); + let r = copy_file(&src, &dst, &h, false, true, false, &mut HardlinkTracker::new()); assert!(matches!(r, Err(OpsError::Cancelled))); let _ = std::fs::remove_dir_all(&dir); } @@ -307,7 +332,7 @@ mod tests { std::fs::write(src.join("a"), b"a").unwrap(); std::fs::write(src.join("b"), b"b").unwrap(); let h = make_handle(); - copy_dir(&src, &dst, &h, false, true, false).unwrap(); + copy_dir(&src, &dst, &h, false, true, false, &mut HardlinkTracker::new()).unwrap(); assert_eq!(std::fs::read(dst.join("a")).unwrap(), b"a"); assert_eq!(std::fs::read(dst.join("b")).unwrap(), b"b"); let _ = std::fs::remove_dir_all(&dir); @@ -316,7 +341,7 @@ mod tests { #[test] fn copy_file_missing_source() { let h = make_handle(); - let r = copy_file(Path::new("/no/such/path"), Path::new("/dst"), &h, false, true, false); + let r = copy_file(Path::new("/no/such/path"), Path::new("/dst"), &h, false, true, false, &mut HardlinkTracker::new()); assert!(matches!(r, Err(OpsError::SourceNotFound(_)))); } @@ -340,7 +365,7 @@ mod tests { let dst = std::env::temp_dir().join("tlc-copy-symlink-dst"); let _ = std::fs::remove_dir_all(&dst); let h = make_handle(); - copy_dir(&root, &dst, &h, false, true, false).unwrap(); + copy_dir(&root, &dst, &h, false, true, false, &mut HardlinkTracker::new()).unwrap(); let link = dst.join("link_to_outside"); assert!(link.is_symlink()); @@ -364,7 +389,7 @@ mod tests { let dst = std::env::temp_dir().join("tlc-copy-self-link-dst"); let _ = std::fs::remove_dir_all(&dst); let h = make_handle(); - copy_dir(&root, &dst, &h, false, true, false).unwrap(); + copy_dir(&root, &dst, &h, false, true, false, &mut HardlinkTracker::new()).unwrap(); let link = dst.join("loop"); assert!(link.is_symlink()); @@ -384,9 +409,73 @@ mod tests { std::fs::write(&target, b"hello").unwrap(); symlink(&target, &link).unwrap(); let h = make_handle(); - copy_file(&link, &dst, &h, false, true, true).unwrap(); + copy_file(&link, &dst, &h, false, true, true, &mut HardlinkTracker::new()).unwrap(); assert_eq!(std::fs::read(&dst).unwrap(), b"hello"); assert!(!std::fs::symlink_metadata(&dst).unwrap().file_type().is_symlink()); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn copy_file_same_file_rejected() { + let dir = std::env::temp_dir().join("tlc-copy-samefile-test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let src = dir.join("a"); + std::fs::write(&src, b"data").unwrap(); + let h = make_handle(); + let r = copy_file(&src, &src, &h, false, true, false, &mut HardlinkTracker::new()); + assert!(matches!(r, Err(OpsError::SameFile(_)))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn copy_dir_same_dir_rejected() { + let dir = std::env::temp_dir().join("tlc-copy-samedir-test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let h = make_handle(); + let r = copy_dir(&dir, &dir, &h, false, true, false, &mut HardlinkTracker::new()); + assert!(matches!(r, Err(OpsError::SameFile(_)))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn hardlink_optimization_creates_hardlink() { + let dir = std::env::temp_dir().join("tlc-hardlink-opt-test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let original = dir.join("original.dat"); + std::fs::write(&original, b"shared content").unwrap(); + + let link1 = dir.join("link1.dat"); + std::fs::hard_link(&original, &link1).unwrap(); + + let dst_dir = dir.join("dst"); + std::fs::create_dir_all(&dst_dir).unwrap(); + + let h = make_handle(); + let mut hlinks = HardlinkTracker::new(); + copy_file(&original, &dst_dir.join("original.dat"), &h, false, true, false, &mut hlinks).unwrap(); + copy_file(&link1, &dst_dir.join("link1.dat"), &h, false, true, false, &mut hlinks).unwrap(); + + let dst_original = dst_dir.join("original.dat"); + let dst_link1 = dst_dir.join("link1.dat"); + assert!(dst_original.exists()); + assert!(dst_link1.exists()); + assert_eq!(std::fs::read(&dst_original).unwrap(), b"shared content"); + assert_eq!(std::fs::read(&dst_link1).unwrap(), b"shared content"); + + use std::os::unix::fs::MetadataExt; + let meta_orig = std::fs::metadata(&dst_original).unwrap(); + let meta_link = std::fs::metadata(&dst_link1).unwrap(); + assert_eq!( + meta_orig.ino(), meta_link.ino(), + "hardlinked files should share the same inode at destination" + ); + assert!(meta_orig.nlink() >= 2, "destination files should be hardlinked"); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/local/recipes/tui/tlc/source/src/ops/mod.rs b/local/recipes/tui/tlc/source/src/ops/mod.rs index c4e69d4eb7..1b0c09f13b 100644 --- a/local/recipes/tui/tlc/source/src/ops/mod.rs +++ b/local/recipes/tui/tlc/source/src/ops/mod.rs @@ -133,6 +133,9 @@ pub enum OpsError { /// The directory contains entries and cannot be removed with `rmdir`. #[error("directory not empty: {0}")] DirectoryNotEmpty(PathBuf), + /// Source and destination refer to the same file. + #[error("source and destination are the same file: {0}")] + SameFile(PathBuf), /// Other unspecified error. #[error("{0}")] Other(String), @@ -154,6 +157,80 @@ impl From for OpsError { } } +/// Tracks copied files by `(dev, inode)` so that hardlinked +/// sources (nlink >= 2) are reproduced as hardlinks at the +/// destination instead of being copied byte-for-byte multiple +/// times. Only active on Unix (needs `MetadataExt::dev` / `ino`). +#[derive(Debug, Default)] +pub struct HardlinkTracker { + seen: std::collections::HashMap<(u64, u64), PathBuf>, +} + +impl HardlinkTracker { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// If `meta` is a hardlinked file (nlink >= 2) whose identity + /// was already copied, return the first destination path so + /// the caller can `hard_link` instead of copy. + #[must_use] + pub fn lookup(&self, meta: &std::fs::Metadata) -> Option<&Path> { + if nlink_count(meta) < 2 { + return None; + } + let key = self.key(meta); + self.seen.get(&key).map(PathBuf::as_path) + } + + /// Record a freshly-copied destination for future hardlink + /// optimization. Called after a successful byte copy. + pub fn insert(&mut self, meta: &std::fs::Metadata, dst: &Path) { + if nlink_count(meta) < 2 { + return; + } + let key = self.key(meta); + self.seen.entry(key).or_insert_with(|| dst.to_path_buf()); + } + + #[cfg(unix)] + fn key(&self, meta: &std::fs::Metadata) -> (u64, u64) { + use std::os::unix::fs::MetadataExt; + (meta.dev(), meta.ino()) + } + + #[cfg(not(unix))] + fn key(&self, _meta: &std::fs::Metadata) -> (u64, u64) { + (0, 0) + } +} + +#[cfg(unix)] +fn nlink_count(meta: &std::fs::Metadata) -> u64 { + use std::os::unix::fs::MetadataExt; + meta.nlink() +} + +#[cfg(not(unix))] +#[allow(clippy::missing_const_for_fn)] +fn nlink_count(_meta: &std::fs::Metadata) -> u64 { + 1 +} + +/// Check whether `src` and `dst` refer to the same file by +/// comparing canonical paths (resolves symlinks and `.`/`..`). +/// Returns `true` when both paths canonicalize to the same absolute +/// path, `false` if either path cannot be canonicalized or they +/// differ. +#[must_use] +pub fn is_same_file(src: &Path, dst: &Path) -> bool { + match (std::fs::canonicalize(src), std::fs::canonicalize(dst)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + /// Cancellation token. Wraps `Arc`. Set to `true` to /// request cancellation. The operation polls `is_cancelled()` between /// files and bails out cleanly. diff --git a/local/recipes/tui/tlc/source/src/ops/move_op.rs b/local/recipes/tui/tlc/source/src/ops/move_op.rs index 23acb1b388..0422ec44bc 100644 --- a/local/recipes/tui/tlc/source/src/ops/move_op.rs +++ b/local/recipes/tui/tlc/source/src/ops/move_op.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use crate::ops::copy; use crate::ops::delete; -use crate::ops::{OpHandle, OpsError}; +use crate::ops::{HardlinkTracker, OpHandle, OpsError}; #[cfg(test)] use crate::ops::{CancelToken, OpProgress}; @@ -30,6 +30,9 @@ pub fn move_one( if !src.exists() { return Err(OpsError::SourceNotFound(src.to_path_buf())); } + if crate::ops::is_same_file(src, dst) { + return Err(OpsError::SameFile(dst.to_path_buf())); + } if dst.exists() { if overwrite { if dst.is_dir() { @@ -54,15 +57,16 @@ pub fn move_one( let raw = e.raw_os_error(); if raw == Some(18 /* EXDEV */) { let lstat = crate::fs::lstat(src).map_err(OpsError::from)?; + let mut hlinks = HardlinkTracker::new(); if lstat.is_symlink() && follow_links { let followed = crate::fs::stat(src).map_err(OpsError::from)?; if followed.is_dir() { - copy::copy_dir(src, dst, handle, overwrite, preserve_attributes, follow_links)?; + copy::copy_dir(src, dst, handle, overwrite, preserve_attributes, follow_links, &mut hlinks)?; } else { - copy::copy_file(src, dst, handle, overwrite, preserve_attributes, follow_links)?; + copy::copy_file(src, dst, handle, overwrite, preserve_attributes, follow_links, &mut hlinks)?; } } else { - copy::copy_file(src, dst, handle, overwrite, preserve_attributes, follow_links)?; + copy::copy_file(src, dst, handle, overwrite, preserve_attributes, follow_links, &mut hlinks)?; } delete::delete_file(src, handle)?; Ok(()) diff --git a/local/recipes/tui/tlc/source/src/vfs/local.rs b/local/recipes/tui/tlc/source/src/vfs/local.rs index d9a1a35e10..3eefb69338 100644 --- a/local/recipes/tui/tlc/source/src/vfs/local.rs +++ b/local/recipes/tui/tlc/source/src/vfs/local.rs @@ -106,6 +106,30 @@ impl Entry { pub fn full_path(&self, parent: &Path) -> PathBuf { parent.join(&self.name) } + + /// MC-style type glyph appended after the filename in panel + /// listings. Directories show `/`, symlinks `@`, executables + /// `*`, FIFOs `|`, sockets `=`, block devices `#`, char + /// devices `%`. + #[must_use] + pub fn type_glyph(&self) -> &'static str { + match self.stat.file_type { + crate::fs::FileType::Directory => "/", + crate::fs::FileType::Symlink => "@", + crate::fs::FileType::Fifo => "|", + crate::fs::FileType::Socket => "=", + crate::fs::FileType::BlockDevice => "#", + crate::fs::FileType::CharDevice => "%", + crate::fs::FileType::Regular => { + if self.stat.permissions.owner_exec { + "*" + } else { + "" + } + } + crate::fs::FileType::Other => "", + } + } } /// Join two path components. Absolute `right` replaces `left`. @@ -274,4 +298,84 @@ mod tests { assert!(names.contains(&"visible")); let _ = fs::remove_dir_all(&dir); } + + #[test] + fn type_glyph_directory() { + let e = Entry { + name: "dir".into(), + stat: Stat { + file_type: crate::fs::FileType::Directory, + size: 0, + mtime: 0, + atime: 0, + ctime: 0, + permissions: crate::fs::Permissions::default(), + nlinks: 2, + uid: 0, + gid: 0, + inode: 0, + }, + }; + assert_eq!(e.type_glyph(), "/"); + } + + #[test] + fn type_glyph_executable() { + let e = Entry { + name: "prog".into(), + stat: Stat { + file_type: crate::fs::FileType::Regular, + size: 100, + mtime: 0, + atime: 0, + ctime: 0, + permissions: crate::fs::Permissions::from_mode(0o755), + nlinks: 1, + uid: 0, + gid: 0, + inode: 0, + }, + }; + assert_eq!(e.type_glyph(), "*"); + } + + #[test] + fn type_glyph_plain_file() { + let e = Entry { + name: "readme".into(), + stat: Stat { + file_type: crate::fs::FileType::Regular, + size: 100, + mtime: 0, + atime: 0, + ctime: 0, + permissions: crate::fs::Permissions::from_mode(0o644), + nlinks: 1, + uid: 0, + gid: 0, + inode: 0, + }, + }; + assert_eq!(e.type_glyph(), ""); + } + + #[test] + fn type_glyph_symlink() { + let e = Entry { + name: "link".into(), + stat: Stat { + file_type: crate::fs::FileType::Symlink, + size: 0, + mtime: 0, + atime: 0, + ctime: 0, + permissions: crate::fs::Permissions::default(), + nlinks: 1, + uid: 0, + gid: 0, + inode: 0, + }, + }; + assert_eq!(e.type_glyph(), "@"); + } } diff --git a/local/recipes/tui/tlc/source/src/viewer/mod.rs b/local/recipes/tui/tlc/source/src/viewer/mod.rs index 601a62aa2d..906f610900 100644 --- a/local/recipes/tui/tlc/source/src/viewer/mod.rs +++ b/local/recipes/tui/tlc/source/src/viewer/mod.rs @@ -516,11 +516,18 @@ impl Viewer { } fn footer_text(&self) -> String { + let pct = if self.goto.line_count() > 0 { + let p = (self.current_line() as u64 + 1) * 100 / self.goto.line_count() as u64; + format!(" {}%", p.min(100)) + } else { + String::new() + }; match self.mode { ViewMode::Text => format!( - " Line {} / {} Offset {} {} ", + " Line {} / {}{} Offset {} {} ", self.current_line(), self.goto.line_count().max(1), + pct, self.cursor, format_size(self.size()) ), @@ -792,6 +799,11 @@ impl Viewer { return false; } let Key { code, mods } = key; + // Alt-W — toggle word wrap (MC parity). + if code == b'w' as u32 && mods == crate::key::Modifiers::ALT { + self.wrap = !self.wrap; + return false; + } if code == b'q' as u32 && mods.is_empty() { return true; }