tlc: §30 MC parity — cursor memory, display, file ops, hardlink optimization

13 sub-items closing genuine MC parity gaps identified in PLAN §14:

Panel display:
- Cursor memory: per-directory cursor save/restore via HashMap
- mtime column: MC dual-format dates (recent=Mon DD HH:MM, old=Mon DD YYYY)
- rwx permissions: 10-char -rwxr-xr-x in Long listing mode
- Type glyphs: MC-style / @ * | = # % suffixes on entries
- Free space: panel footer shows disk free/total via statvfs

Navigation/sort:
- Sort reverse: Ctrl-Alt-T toggle (was dispatched but unbound)
- Sort case sensitivity: Alt-C toggle between case-insensitive/sensitive
- Viewer next/prev: dispatch fixed from no-op stub to real open_next/prev

File operations:
- Same-file detection: OpsError::SameFile via canonicalize check
- Hardlink optimization: HardlinkTracker maps source (dev,ino) to first
  destination; when nlink>=2 and identity already copied, creates
  fs::hard_link instead of byte-for-byte copy

Viewer:
- Wrap toggle: Alt-W (field existed, key was unbound)
- Percent display: footer shows NN% based on line position

Editor:
- Date insert: Alt-D inserts YYYY-MM-DD HH:MM at cursor

Version: 1.0.0-beta → 0.2.5 (branch-aligned)
Tests: 1184 → 1204 (+20 new), 0 failures
Binary: tlc 5.3MB, tlcedit 3.9MB, tlcview 3.7MB
This commit is contained in:
2026-07-04 14:27:01 +03:00
parent 26ecd868f7
commit a2958e9b02
19 changed files with 748 additions and 62 deletions
+1 -1
View File
@@ -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
+24 -1
View File
@@ -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
+21 -3
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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"
@@ -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
@@ -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> = 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.
@@ -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<String> = 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)
@@ -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}"
);
}
}
@@ -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<PathBuf>,
/// 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<PathBuf, usize>,
}
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);
}
}
@@ -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() {
"<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();
}
@@ -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");
@@ -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]
+4 -4
View File
@@ -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");
+109 -20
View File
@@ -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);
}
}
@@ -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<crate::fs::StatError> 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<AtomicBool>`. Set to `true` to
/// request cancellation. The operation polls `is_cancelled()` between
/// files and bails out cleanly.
@@ -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(())
@@ -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(), "@");
}
}
+13 -1
View File
@@ -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;
}