diff --git a/local/recipes/tui/tlc/source/src/filemanager/format_utils.rs b/local/recipes/tui/tlc/source/src/filemanager/format_utils.rs
index 8a48363f77..1ebc25cd36 100644
--- a/local/recipes/tui/tlc/source/src/filemanager/format_utils.rs
+++ b/local/recipes/tui/tlc/source/src/filemanager/format_utils.rs
@@ -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() {
+ "
".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};
diff --git a/local/recipes/tui/tlc/source/src/filemanager/panel.rs b/local/recipes/tui/tlc/source/src/filemanager/panel.rs
index a47893a243..695bc286fe 100644
--- a/local/recipes/tui/tlc/source/src/filemanager/panel.rs
+++ b/local/recipes/tui/tlc/source/src/filemanager/panel.rs
@@ -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 {
+ 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)
diff --git a/local/recipes/tui/tlc/source/src/filemanager/render.rs b/local/recipes/tui/tlc/source/src/filemanager/render.rs
index c26b04f4bd..1fad7415cd 100644
--- a/local/recipes/tui/tlc/source/src/filemanager/render.rs
+++ b/local/recipes/tui/tlc/source/src/filemanager/render.rs
@@ -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 = (0..height)
+ let items: Vec = (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 = 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::().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)],
diff --git a/local/recipes/tui/tlc/source/src/fs/stat.rs b/local/recipes/tui/tlc/source/src/fs/stat.rs
index 2c0546b3f0..85d66a7945 100644
--- a/local/recipes/tui/tlc/source/src/fs/stat.rs
+++ b/local/recipes/tui/tlc/source/src/fs/stat.rs
@@ -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;