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;