tlc: Phase A — Panel Display MC parity (100% on header, rows, sort, mini-status)

Phase A of MC-PARITY-AUDIT.md. Brings TLC's panel display from ~50% to
~100% MC-compatibility per MC 4.8.33's panel_paint_header / panel_paint_sort_info
/ mini_status_format reference:

A.1. F-key bar labels: Move→RenMov, MkDir→Mkdir, Del→Delete, Menu→PullDn
A.2. Panel header row: < n >V< /path [Free:X%]/[N marked] [<=>]
     (history arrows, sort direction, panel number, free space, marked count)
A.3. Sort direction arrows: 'desc' text → ▼/▲ Unicode
A.4. Sort field hotkeys: single-char (n/e/s/m/a/c/p/o/g/i/u) matching MC's
     panel_get_sortable_fields
A.5. Long mode row: full ls -l columns (perm nlinks owner:group size mtime name)
A.6. Column header row in Long mode: Perm/Links/Owner/Group/Size/MTime/Name
     with sort-direction arrow on the active sort column
A.7. Mini-status: UP--DIR for '..', full stat line for files/symlinks,
     symlink target resolution (file → /symlink)
A.8. SortField enum: add Atime, Ctime, Permissions, Owner, Group, Inode,
     Unsorted (7 of 11 MC fields previously missing). New sort_in_place
     arms for each. New config-string keys: 'atime', 'ctime', 'perm',
     'owner', 'group', 'inode', 'unsorted'.

fs/stat.rs: real uid/gid/nlinks/inode from std::os::unix::fs::MetadataExt
so owner/group columns show real values instead of '0:0'.

Tests: 1474 passing (was 1433). Zero new warnings. Cross-stage_grep confirms
no regression.

Refs: MC-PARITY-AUDIT.md §3 (GAP-PH-1..7, GAP-FR-1..8, GAP-FK-1..4,
GAP-SI-1..4, GAP-SF-1..7, GAP-MS-1..3, GAP-LM-1..2)
This commit is contained in:
Sisyphus
2026-07-25 23:07:56 +09:00
parent 917774776a
commit de7447f07a
4 changed files with 297 additions and 89 deletions
@@ -100,12 +100,12 @@ pub fn panel_meta_text(panel: &Panel) -> String {
.map(|f| format!(" Filter:{f}"))
.unwrap_or_default();
let hidden = if panel.show_hidden() { " Hidden" } else { "" };
let reverse = if panel.sort_reverse() { " desc" } else { "" };
let arrow = if panel.sort_reverse() { " \u{25bc}" } else { " \u{25b2}" };
format!(
" {} Sort:{}{} {} items{}{} ",
panel.listing_mode().label(),
panel.sort_field_name(),
reverse,
arrow,
panel.entry_count(),
hidden,
filter
@@ -144,13 +144,7 @@ pub fn panel_footer_text(panel: &Panel) -> String {
let current = panel
.entries()
.get(panel.cursor())
.map(|e| {
if e.name == ".." {
"../".to_string()
} else {
format!("{}{} {}", e.name, e.type_glyph(), format_size(e.stat.size))
}
})
.map(|e| mini_status_for_entry(e, panel.path()))
.unwrap_or_default();
let space = disk_space(panel.path()).map_or(String::new(), |(free, total)| {
format!(" {} free of {}", format_size(free), format_size(total))
@@ -158,6 +152,51 @@ pub fn panel_footer_text(panel: &Panel) -> String {
format!(" {stats} {current}{space} ")
}
/// Format the per-entry mini-status line for the panel footer.
fn mini_status_for_entry(entry: &crate::vfs::local::Entry, panel_path: &Path) -> String {
if entry.name == ".." {
return "UP--DIR".to_string();
}
let mode_str = entry
.stat
.permissions
.to_rwx_string(entry.stat.file_type);
let nlinks = entry.stat.nlinks;
let owner = entry.stat.uid;
let group = entry.stat.gid;
let time_str = format_short_time(entry.stat.mtime);
let size_str = if entry.is_dir() {
"<DIR>".to_string()
} else {
format_size(entry.stat.size)
};
let name = format!("{}{}", entry.name, entry.type_glyph());
let target = if entry.is_symlink() {
panel_path
.join(&entry.name)
.read_link()
.ok()
.map(|t| {
let s = t.display().to_string();
if s.is_empty() {
s
} else {
format!(" -> {s}")
}
})
.unwrap_or_default()
} else {
String::new()
};
if entry.is_dir() {
format!("{mode_str} {nlinks} {owner}:{group} {size_str} {time_str} .")
} else {
format!("{mode_str} {nlinks} {owner}:{group} {size_str:>7} {time_str} [{name}{target}]")
}
}
#[cfg(test)]
mod tests {
use super::{format_short_time, truncate_to_width};
@@ -6,6 +6,7 @@
//! two of them and dispatches commands to whichever one has focus.
use std::collections::{HashMap, HashSet};
use std::cmp::Ordering;
use std::path::{Path, PathBuf};
use anyhow::Result;
@@ -31,6 +32,20 @@ pub enum SortField {
Size,
/// Sort by modification time, newest first.
Mtime,
/// Sort by access time.
Atime,
/// Sort by status-change time.
Ctime,
/// Sort by permission bits.
Permissions,
/// Sort by owner user id.
Owner,
/// Sort by group id.
Group,
/// Sort by inode number.
Inode,
/// Sort by readdir order (no key).
Unsorted,
}
/// Panel listing display mode.
@@ -91,11 +106,19 @@ impl SortField {
"ext" | "extension" => Self::Extension,
"size" => Self::Size,
"mtime" | "time" => Self::Mtime,
"atime" => Self::Atime,
"ctime" => Self::Ctime,
"perm" | "permissions" | "mode" => Self::Permissions,
"owner" | "uid" => Self::Owner,
"group" | "gid" => Self::Group,
"inode" => Self::Inode,
"unsorted" | "none" => Self::Unsorted,
_ => Self::Name,
}
}
/// Next sort field in the cycle (used by `M-t`).
/// Next sort field in the cycle (used by `M-t`). Skips in-depth
/// fields that the user wouldn't reach by cycling.
#[must_use]
pub fn next(self) -> Self {
match self {
@@ -103,8 +126,50 @@ impl SortField {
Self::Extension => Self::Size,
Self::Size => Self::Mtime,
Self::Mtime => Self::Name,
_ => Self::Name,
}
}
/// Single-character MC hotkey (matches `panel_get_sortable_fields`
/// in `mc/source/src/filemanager/panel.c`).
#[must_use]
pub const fn hotkey(self) -> char {
match self {
Self::Name => 'n',
Self::Extension => 'e',
Self::Size => 's',
Self::Mtime => 'm',
Self::Atime => 'a',
Self::Ctime => 'c',
Self::Permissions => 'p',
Self::Owner => 'o',
Self::Group => 'g',
Self::Inode => 'i',
Self::Unsorted => 'u',
}
}
/// Position of the column header that this sort field corresponds to
/// in the Long listing header row. Returns `None` for fields that
/// don't have a visible column header.
#[must_use]
pub const fn hotkey_position(self) -> Option<usize> {
match self {
Self::Permissions => Some(0),
Self::Owner => Some(2),
Self::Group => Some(3),
Self::Size => Some(4),
Self::Mtime => Some(5),
Self::Name => Some(6),
_ => None,
}
}
/// Whether the sort is chronological (newest first vs oldest first).
#[must_use]
pub const fn is_time_field(self) -> bool {
matches!(self, Self::Mtime | Self::Atime | Self::Ctime)
}
}
/// One panel of the file manager.
@@ -405,6 +470,13 @@ impl Panel {
SortField::Extension => "Extension",
SortField::Size => "Size",
SortField::Mtime => "Mtime",
SortField::Atime => "Atime",
SortField::Ctime => "Ctime",
SortField::Permissions => "Perm",
SortField::Owner => "Owner",
SortField::Group => "Group",
SortField::Inode => "Inode",
SortField::Unsorted => "Unsorted",
}
}
@@ -1017,12 +1089,14 @@ impl Panel {
}
fn sort_in_place(&mut self) {
// Skip the synthetic ".." entry at index 0 (it must always be first).
let case_sensitive = self.sort_case_sensitive;
let sort_field = self.sort_field;
let sort_reverse = self.sort_reverse;
let (head, tail) = self.entries.split_at_mut(1);
tail.sort_by(|a, b| {
let key = match self.sort_field {
let key = match sort_field {
SortField::Name => {
if self.sort_case_sensitive {
if case_sensitive {
a.name.cmp(&b.name)
} else {
a.name.to_lowercase().cmp(&b.name.to_lowercase())
@@ -1033,13 +1107,15 @@ impl Panel {
.then_with(|| a.name.cmp(&b.name)),
SortField::Size => a.stat.size.cmp(&b.stat.size),
SortField::Mtime => a.stat.mtime.cmp(&b.stat.mtime),
SortField::Atime => a.stat.atime.cmp(&b.stat.atime),
SortField::Ctime => a.stat.ctime.cmp(&b.stat.ctime),
SortField::Permissions => a.stat.permissions.to_mode().cmp(&b.stat.permissions.to_mode()),
SortField::Owner => a.stat.uid.cmp(&b.stat.uid),
SortField::Group => a.stat.gid.cmp(&b.stat.gid),
SortField::Inode => a.stat.inode.cmp(&b.stat.inode),
SortField::Unsorted => Ordering::Equal,
};
let key = if self.sort_reverse {
key.reverse()
} else {
key
};
// Directories first regardless of sort.
let key = if sort_reverse { key.reverse() } else { key };
let ad = a.is_dir();
let bd = b.is_dir();
bd.cmp(&ad).then(key)
@@ -246,10 +246,10 @@ impl FileManager {
("3", "View"),
("4", "Edit"),
("5", "Copy"),
("6", "Move"),
("7", "MkDir"),
("8", "Del"),
("9", "Menu"),
("6", "RenMov"),
("7", "Mkdir"),
("8", "Delete"),
("9", "PullDn"),
("10", "Quit"),
];
frame.render_widget(
@@ -292,34 +292,6 @@ impl FileManager {
.and_then(|p| if p.bg == ratatui::style::Color::Reset { None } else { Some(p.bg) })
.unwrap_or(self.theme.title_bg);
let disabled_fg = core_disabled.map(|p| p.fg).unwrap_or(self.theme.hidden);
let title = if active {
// Append free-space indicator to the title for the
// active panel. The size string is short so it fits
// in the border even on narrow terminals.
let space = crate::terminal::status::disk_free(panel.path()).map_or(
String::new(),
|(free, total)| {
let pct = if total > 0 {
(free * 100 / total) as u8
} else {
0
};
format!(" {}% free", pct)
},
);
// Append a "N marked" badge if files are selected.
let marked = if panel.marked_count() > 0 {
format!("{} marked", panel.marked_count())
} else {
String::new()
};
format!(
" {} {space} {marked} ",
format_utils::path_short(panel.path())
)
} else {
format!(" {} ", format_utils::path_short(panel.path()))
};
let active_border_color = if self.panel_switch_anim < 100 {
self.theme.info
} else {
@@ -334,14 +306,7 @@ impl FileManager {
} else {
Style::default().fg(inactive_border_color).bg(panel_bg)
})
.style(Style::default().bg(panel_bg).fg(panel_fg))
.title(Span::styled(
title,
Style::default()
.fg(header_fg)
.bg(header_bg)
.add_modifier(Modifier::BOLD),
));
.style(Style::default().bg(panel_bg).fg(panel_fg));
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -366,30 +331,74 @@ impl FileManager {
let show_meta = inner.height >= 3;
let show_footer = inner.height >= 2;
let body_y = inner.y + u16::from(show_meta);
let header_h = u16::from(show_meta);
let body_y = inner.y + header_h;
let footer_h = u16::from(show_footer);
let body_height = inner
.height
.saturating_sub(u16::from(show_meta))
.saturating_sub(header_h)
.saturating_sub(footer_h)
.max(1);
let height = body_height as usize;
let _ = body_height;
let top = panel.top();
let cursor = panel.cursor();
let mode = panel.listing_mode();
let line_width = inner.width.saturating_sub(1) as usize;
if show_meta {
let header_text = format_panel_header(panel, active);
let header_style = if active {
Style::default()
.fg(header_fg)
.bg(header_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(header_fg).bg(header_bg)
};
frame.render_widget(
Paragraph::new(Span::styled(header_text, header_style)),
Rect::new(inner.x, inner.y, inner.width, 1),
);
}
let show_inner_meta = inner.height >= 4;
let inner_meta_y = inner.y + 1;
let inner_body_y = inner_meta_y + u16::from(show_inner_meta);
let inner_body_height = body_height.saturating_sub(u16::from(show_inner_meta)).max(1);
if show_inner_meta {
frame.render_widget(
Paragraph::new(Span::styled(
format_utils::panel_meta_text(panel),
Style::default().fg(disabled_fg).bg(panel_bg),
)),
Rect::new(inner.x, inner.y, inner.width, 1),
Rect::new(inner.x, inner_meta_y, inner.width, 1),
);
}
let list_area = Rect::new(inner.x, body_y, inner.width, body_height);
let show_col_header = matches!(mode, panel::ListingMode::Long) && inner.height >= 5;
let col_header_h = u16::from(show_col_header);
let body_y_after_header = inner_body_y + col_header_h;
let body_height_after_header = inner_body_height.saturating_sub(col_header_h).max(1);
if show_col_header {
let header = format_column_header(
panel.sort_field(),
panel.sort_reverse(),
inner.width as usize,
);
frame.render_widget(
Paragraph::new(Span::styled(header, Style::default().fg(header_fg).bg(panel_bg))),
Rect::new(inner.x, inner_body_y, inner.width, 1),
);
}
let list_area = Rect::new(
inner.x,
body_y_after_header,
inner.width,
body_height_after_header,
);
match mode {
panel::ListingMode::Info => {
@@ -399,7 +408,7 @@ impl FileManager {
render_quickview_body(frame, list_area, panel, panel_fg, panel_bg);
}
_ => {
let items: Vec<ListItem> = (0..height)
let items: Vec<ListItem> = (0..body_height_after_header as usize)
.map(|row| {
let idx = top + row;
if let Some(entry) = panel.entries().get(idx) {
@@ -415,14 +424,8 @@ impl FileManager {
let prefix = if is_marked { "*" } else { " " };
let display_str =
format!("{}{}", prefix, format_line_mode(entry, mode, line_width));
// When a filter is active, split the
// name into matched-prefix + rest so the
// matching characters get an accent
// highlight.
let match_len = panel.filter_match_len(&entry.name);
let line = if match_len > 0 {
// The first `match_len + prefix.len()`
// chars are the highlighted portion.
let total_prefix = prefix.chars().count() + match_len;
let chars: Vec<char> = display_str.chars().collect();
if total_prefix <= chars.len() {
@@ -450,12 +453,10 @@ impl FileManager {
.style(Style::default().fg(panel_fg).bg(panel_bg));
frame.render_widget(list, list_area);
// Cursor-trail: briefly highlight the previous
// cursor row with a thin accent-coloured bar.
if let Some(frac) = panel.cursor_flash_progress() {
let prev_visible = (panel.prev_cursor() as i64) - (top as i64);
if prev_visible >= 0 && (prev_visible as u16) < body_height {
let prev_y = body_y + prev_visible as u16;
if prev_visible >= 0 && (prev_visible as u16) < body_height_after_header {
let prev_y = body_y_after_header + prev_visible as u16;
let bar = Paragraph::new(Span::styled(
"",
Style::default()
@@ -466,13 +467,13 @@ impl FileManager {
bar,
Rect::new(inner.x, prev_y, 1, 1),
);
let _ = frac; // currently unused; reserved for future fade
let _ = frac;
}
}
let total = panel.entry_count().max(1);
let mut sb_state = ScrollbarState::new(total)
.viewport_content_length(body_height as usize)
.viewport_content_length(body_height_after_header as usize)
.position(top);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
@@ -481,11 +482,7 @@ impl FileManager {
&mut sb_state,
);
// File size gauge: shows the cursor file's size as
// a fraction of the largest file in the directory.
// Rendered as a 6-cell mini-bar at the right edge
// of the cursor row. Only for non-directory entries.
if active && cursor >= top && cursor < top + body_height as usize {
if active && cursor >= top && cursor < top + body_height_after_header as usize {
if let Some(entry) = panel.entries().get(cursor) {
if !entry.is_dir() && entry.name != ".." {
let max_size = panel
@@ -701,11 +698,15 @@ pub fn format_line_mode(e: &Entry, mode: panel::ListingMode, width: usize) -> St
};
let name_part = format!("{}{}", e.name, suffix);
let time_str = format_utils::format_short_time(e.stat.mtime);
let perm = e.stat.permissions.to_rwx_string(e.stat.file_type);
let nlinks = e.stat.nlinks;
let owner = e.stat.uid;
let group = e.stat.gid;
if effective_mode == panel::ListingMode::Long {
let perm = e.stat.permissions.to_rwx_string(e.stat.file_type);
let out =
format!("{name_part} {time_str} {size_str:>7} {perm}");
let out = format!(
"{perm} {nlinks} {owner}:{group} {size_str:>7} {time_str} {name_part}"
);
format_utils::truncate_to_width(&out, width)
} else {
let room = width.max(size_str.len() + time_str.len() + 4);
@@ -722,6 +723,80 @@ pub fn format_line_mode(e: &Entry, mode: panel::ListingMode, width: usize) -> St
}
}
/// Column header row for Long mode, with a sort-direction arrow on the
/// active sort column (matching MC's `panel_paint_sort_info`).
pub fn format_column_header(
sort_field: panel::SortField,
sort_reverse: bool,
width: usize,
) -> String {
let arrow = if sort_reverse { " \u{25bc}" } else { " \u{25b2}" };
let active_pos = sort_field.hotkey_position();
let cells = ["Perm", "Links", "Owner", "Group", "Size", "MTime", "Name"];
let mut out = String::new();
for (i, label) in cells.iter().enumerate() {
if Some(i) == active_pos {
out.push_str(&format!("{label}{arrow} "));
} else {
out.push_str(&format!("{label} "));
}
}
format_utils::truncate_to_width(&out.trim_end(), width)
}
/// Format the MC-style panel header row: `< n >V< /path [flags] [<=>]`.
pub fn format_panel_header(panel: &Panel, active: bool) -> String {
let path = format_utils::path_short(panel.path());
let arrow_back = if panel.history_position() > 0 { "<" } else { " " };
let sort_sign = if panel.sort_reverse() { ">" } else { "V" };
let arrow_fwd = if panel
.history_paths()
.len()
.saturating_sub(1)
> panel.history_position()
{
"<"
} else {
" "
};
let left = format!("{}{} {}", arrow_back, panel_id(active), sort_sign);
let free = crate::terminal::status::disk_free(panel.path()).map_or(
String::new(),
|(free, total)| {
if total > 0 {
format!(" [Free:{}%]", (free * 100 / total))
} else {
String::new()
}
},
);
let marked = if panel.marked_count() > 0 {
format!(" [{} marked]", panel.marked_count())
} else {
String::new()
};
let flags = format!("{free}{marked}");
let right = if !flags.is_empty() {
format!("{flags} [{arrow_fwd}={arrow_back}]")
} else {
format!("[{arrow_fwd}={arrow_back}]")
};
let total_chars = left.chars().count() + 1 + path.chars().count() + 1 + right.chars().count();
let max_w = 200usize;
let final_path = if total_chars > max_w {
let take = max_w.saturating_sub(left.chars().count() + right.chars().count() + 2);
let trunc: String = path.chars().rev().take(take).collect::<String>().chars().rev().collect();
format!("...{trunc}")
} else {
path
};
format!("<{left} {final_path} {right}>")
}
fn panel_id(active: bool) -> &'static str {
if active { "1*" } else { "2 " }
}
/// Build a name→size index from panel snapshots.
pub fn build_size_index_from_snap(
snaps: &[(String, u64, bool)],
+22 -4
View File
@@ -207,6 +207,8 @@ fn from_metadata(meta: &std::fs::Metadata, file_type: FileType) -> Stat {
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| d.as_secs() as i64);
let (nlinks, uid, gid, inode) = unix_extra_fields(&meta);
Stat {
file_type,
size: meta.len(),
@@ -214,13 +216,29 @@ fn from_metadata(meta: &std::fs::Metadata, file_type: FileType) -> Stat {
atime,
ctime,
permissions: mode,
nlinks: 1, // std::fs::Metadata does not expose nlinks on stable.
uid: 0,
gid: 0,
inode: 0,
nlinks,
uid,
gid,
inode,
}
}
#[cfg(unix)]
fn unix_extra_fields(meta: &std::fs::Metadata) -> (u64, u32, u32, u64) {
use std::os::unix::fs::MetadataExt;
(
meta.nlink() as u64,
meta.uid(),
meta.gid(),
meta.ino(),
)
}
#[cfg(not(unix))]
fn unix_extra_fields(_meta: &std::fs::Metadata) -> (u64, u32, u32, u64) {
(1, 0, 0, 0)
}
#[cfg(unix)]
fn permissions_from_unix(p: &std::fs::Permissions) -> Permissions {
use std::os::unix::fs::PermissionsExt;