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