From 940e5f55c585f12561371617428d5c0a5d0d997f Mon Sep 17 00:00:00 2001 From: vasilito Date: Fri, 19 Jun 2026 17:30:28 +0300 Subject: [PATCH] tlc: split filemanager/mod.rs into submodules + comment out mc in configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split filemanager/mod.rs into dispatch.rs, render.rs, dialog_ops.rs, format_utils.rs (3567→~1200 lines in mod.rs) - Comment out mc in redbear-mini.toml and redbear-full.toml per user request - Enable tlc in redbear-full.toml (was temporarily disabled) - 1022 tests pass, zero behavior changes --- config/redbear-full.toml | 2 +- config/redbear-mini.toml | 2 +- .../tlc/source/src/filemanager/dialog_ops.rs | 1016 +++++++ .../tlc/source/src/filemanager/dispatch.rs | 692 +++++ .../source/src/filemanager/format_utils.rs | 101 + .../tui/tlc/source/src/filemanager/mod.rs | 2324 +---------------- .../tui/tlc/source/src/filemanager/render.rs | 669 +++++ 7 files changed, 2506 insertions(+), 2300 deletions(-) create mode 100644 local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs create mode 100644 local/recipes/tui/tlc/source/src/filemanager/dispatch.rs create mode 100644 local/recipes/tui/tlc/source/src/filemanager/format_utils.rs create mode 100644 local/recipes/tui/tlc/source/src/filemanager/render.rs diff --git a/config/redbear-full.toml b/config/redbear-full.toml index df6cfb13ea..1464341897 100644 --- a/config/redbear-full.toml +++ b/config/redbear-full.toml @@ -160,7 +160,7 @@ cosmic-icons = "ignore" cosmic-term = "ignore" curl = "ignore" git = "ignore" -mc = {} +#mc = {} #curl = "ignore" # suppressed: cascade rebuild #git = "ignore" # suppressed: cascade rebuild #konsole = {} # WIP: recipe exists, not yet built — blocked by libiconv fetch diff --git a/config/redbear-mini.toml b/config/redbear-mini.toml index aba8d072f7..530b68bf0a 100644 --- a/config/redbear-mini.toml +++ b/config/redbear-mini.toml @@ -46,7 +46,7 @@ redbear-wifictl = {} # Diagnostics and shell-side utilities. tlc = {} -mc = {} +#mc = {} redbear-info = {} # Keep package builder utility in live environment. diff --git a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs new file mode 100644 index 0000000000..5ff302ab1f --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs @@ -0,0 +1,1016 @@ +//! Dialog-opening and dialog-outcome helpers for the file manager. +//! +//! Houses the `open_*_dialog` constructors (Info, Permission, Owner, +//! Link, MkDir, Copy, Move, Delete, Find, Hotlist, Tree, UserMenu, +//! Help, Skin) and the corresponding `apply_*_outcome` handlers that +//! the dispatcher calls when the user confirms, cancels, or +//! triggers an action inside one of the modal dialogs. The +//! `apply_finished_dialog` helper covers the dialogs that close +//! themselves via a `confirmed`/`cancelled` flag. + +use anyhow::Result; + +use crate::filemanager::{ + config_dialog, edit_history, external_panelize, filtered_view, find, help, hotlist, + layout_dialog, link, overwrite_dialog, panel_options, percent, render, screen_list, + skin_dialog, tree, usermenu, vfs_list, DialogState, FileManager, LinkDialog, PendingFileOp, +}; +use crate::filemanager::link::LinkKind; +use crate::config::Config; +use crate::terminal::color::Theme; + +use super::copy_dialog::CopyDialog; +use super::delete_dialog::DeleteDialog; +use super::info::InfoDialog; +use super::mkdir_dialog::MkDirDialog; +use super::move_dialog::MoveDialog; +use super::owner::OwnerDialog; +use super::permission::PermissionDialog; + +impl FileManager { + /// Compare directories using size-only mode (MC parity). + pub fn compare_dirs(&mut self) { + let snapshots: Vec<(String, u64, bool)> = self.other_panel().file_snapshots(); + let index = render::build_size_index_from_snap(&snapshots); + let count = render::mark_differing_from_snap(self.active_panel_mut(), &index); + self.status.set_message(format!( + "Compare: {} file(s) marked as different", + count + )); + } + + /// Open a dialog to edit an existing symlink's target. + pub fn edit_symlink_target(&mut self) { + let cursor = self.active_panel().cursor_path(); + match std::fs::read_link(&cursor) { + Ok(existing_target) => { + let dlg = link::LinkDialog::for_editing(cursor, existing_target); + self.dialog = Some(DialogState::Link(Box::new(dlg))); + } + Err(_) => { + self.status.set_message("Not a symlink".to_string()); + } + } + } + + /// Snapshot the currently-active screens (overlays that are open + /// on top of the panel pair) for the Alt-` screen list dialog. + pub fn collect_active_screens(&self) -> Vec { + let mut screens = Vec::new(); + if let Some(ed) = &self.editor { + let detail = ed + .path() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + screens.push(screen_list::ActiveScreen::new("Editor", detail)); + } + if let Some(v) = &self.viewer { + screens.push(screen_list::ActiveScreen::new( + "Viewer", + v.path.display().to_string(), + )); + } + if let Some(x) = &self.exec { + screens.push(screen_list::ActiveScreen::new( + "Exec", + x.command().to_string(), + )); + } + if self.menubar.is_some() { + screens.push(screen_list::ActiveScreen::new("Menu", String::new())); + } + if let Some(d) = &self.dialog { + screens.push(screen_list::ActiveScreen::new("Dialog", dialog_label(d))); + } + screens + } + + /// Open the F11 file-info dialog for the cursor entry. + pub fn open_info_dialog(&mut self) -> Result<()> { + let p = self.active_panel().cursor_path(); + match crate::ops::info::FileInfo::for_path(&p) { + Ok(info) => { + self.dialog = Some(DialogState::Info(Box::new(InfoDialog::new(p, info)))); + } + Err(e) => { + self.status.set_message(format!("info: {e}")); + } + } + Ok(()) + } + + /// Open the C-x c permission dialog for the cursor entry. + pub fn open_permission_dialog(&mut self) -> Result<()> { + let p = self.active_panel().cursor_path(); + let mode = match crate::fs::stat(&p) { + Ok(s) => s.permissions.to_mode(), + Err(e) => { + self.status.set_message(format!("chmod: {e}")); + return Ok(()); + } + }; + self.dialog = Some(DialogState::Permission(Box::new(PermissionDialog::new( + p, mode, + )))); + Ok(()) + } + + /// Open the C-x o owner dialog for the cursor entry. + pub fn open_owner_dialog(&mut self) -> Result<()> { + let p = self.active_panel().cursor_path(); + let s = match crate::fs::stat(&p) { + Ok(s) => s, + Err(e) => { + self.status.set_message(format!("chown: {e}")); + return Ok(()); + } + }; + self.dialog = Some(DialogState::Owner(Box::new(OwnerDialog::new( + p, s.uid, s.gid, + )))); + Ok(()) + } + + /// Open the C-x l (or C-x s) link dialog for the cursor entry. + pub fn open_link_dialog(&mut self, kind: LinkKind) -> Result<()> { + let p = self.active_panel().cursor_path(); + if p.as_os_str().is_empty() { + self.status.set_message("link: no path selected"); + return Ok(()); + } + self.dialog = Some(DialogState::Link(Box::new(LinkDialog::with_kind(p, kind)))); + Ok(()) + } + + /// Open the F7 mkdir dialog for the active panel's current path. + pub fn open_mkdir_dialog(&mut self) -> Result<()> { + let p = self.active_panel().path().to_path_buf(); + self.dialog = Some(DialogState::MkDir(Box::new(MkDirDialog::new(p)))); + Ok(()) + } + + /// Open the F5 copy dialog for the cursor file or all marked files. + pub fn open_copy_dialog(&mut self) -> Result<()> { + let panel = self.active_panel(); + let dst = self.other_panel().path().to_path_buf(); + let sources: Vec = if panel.marked_count() > 0 { + panel + .marked_names() + .into_iter() + .map(|n| panel.path().join(n)) + .collect() + } else { + vec![panel.cursor_path()] + }; + if sources.is_empty() { + self.status.set_message("copy: no source selected"); + return Ok(()); + } + self.dialog = Some(DialogState::Copy(Box::new( + CopyDialog::new_with_dst(sources, dst), + ))); + Ok(()) + } + + /// Open the F6 move dialog for the cursor file or all marked files. + pub fn open_move_dialog(&mut self) -> Result<()> { + let panel = self.active_panel(); + let dst = self.other_panel().path().to_path_buf(); + let sources: Vec = if panel.marked_count() > 0 { + panel + .marked_names() + .into_iter() + .map(|n| panel.path().join(n)) + .collect() + } else { + vec![panel.cursor_path()] + }; + if sources.is_empty() { + self.status.set_message("move: no source selected"); + return Ok(()); + } + self.dialog = Some(DialogState::Move(Box::new( + MoveDialog::new_with_dst(sources, dst), + ))); + Ok(()) + } + + /// Open the F8 delete confirmation dialog for the cursor file or + /// all marked files. + pub fn open_delete_dialog(&mut self) -> Result<()> { + let panel = self.active_panel(); + let paths: Vec = if panel.marked_count() > 0 { + panel + .marked_names() + .into_iter() + .map(|n| panel.path().join(n)) + .collect() + } else { + vec![panel.cursor_path()] + }; + if paths.is_empty() { + self.status.set_message("delete: no source selected"); + return Ok(()); + } + self.dialog = Some(DialogState::Delete(Box::new(DeleteDialog::new(paths)))); + Ok(()) + } + + /// Run `rmdir` on the cursor entry. + pub fn run_rmdir_on_cursor(&mut self) -> Result<()> { + let p = self.active_panel().cursor_path(); + let handle = self + .ops_manager + .begin(crate::ops::OpKind::Delete, vec![p.clone()], None); + match crate::ops::rmdir::rmdir(&p, &handle) { + Ok(()) => { + self.status + .set_message(format!("Removed directory {}", p.display())); + self.ops_manager.finish(); + self.active_panel_mut().refresh()?; + } + Err(crate::ops::OpsError::DirectoryNotEmpty(_)) => { + self.status.set_message("rmdir: directory not empty"); + } + Err(crate::ops::OpsError::NotADirectory(_)) => { + self.status.set_message("rmdir: not a directory"); + } + Err(crate::ops::OpsError::SourceNotFound(_)) => { + self.status.set_message("rmdir: not found"); + } + Err(e) => { + self.status.set_message(format!("rmdir: {e}")); + } + } + Ok(()) + } + + /// Open the F4 editor for the cursor entry. + pub fn open_editor_for_cursor(&mut self) -> Result<()> { + let p = self.active_panel().cursor_path(); + if p.as_os_str().is_empty() { + self.status.set_message("edit: no path selected"); + return Ok(()); + } + self.editor = Some(crate::editor::Editor::open(&p)); + self.status.set_message(format!("Editing {}", p.display())); + Ok(()) + } + + /// Open the F3 viewer for the cursor entry. + pub fn open_viewer_for_cursor(&mut self) -> Result<()> { + let p = self.active_panel().cursor_path(); + if p.as_os_str().is_empty() { + self.status.set_message("view: no path selected"); + return Ok(()); + } + match crate::viewer::Viewer::open(&p) { + Ok(v) => { + self.viewer = Some(v); + self.status.set_message(format!("Viewing {}", p.display())); + } + Err(e) => { + self.status.set_message(format!("view: {e}")); + } + } + Ok(()) + } + + /// Open the M-? Find dialog. + pub fn open_find_dialog(&mut self) -> Result<()> { + let start = self.active_panel().path().to_path_buf(); + self.dialog = Some(DialogState::Find(Box::new(find::FindDialog::new(start)))); + Ok(()) + } + + /// Open the `\` Hotlist dialog. + pub fn open_hotlist_dialog(&mut self) -> Result<()> { + let mut dlg = hotlist::HotlistDialog::new(); + dlg.set_current_path(self.active_panel().path().to_path_buf()); + self.dialog = Some(DialogState::Hotlist(Box::new(dlg))); + Ok(()) + } + + /// Open the C-\ Tree dialog. + pub fn open_tree_dialog(&mut self) -> Result<()> { + let start = self.active_panel().path().to_path_buf(); + self.dialog = Some(DialogState::Tree(Box::new(tree::TreeDialog::new(start)))); + Ok(()) + } + + /// Open the F2 User Menu dialog. + pub fn open_user_menu_dialog(&mut self) -> Result<()> { + let file = self.active_panel().cursor_path(); + if file.as_os_str().is_empty() { + self.status.set_message("user menu: no path selected"); + return Ok(()); + } + let condition = "view"; + let mut dlg = usermenu::UserMenuDialog::new(file, condition); + // Build a `PercentCtx` snapshot of the file manager so the + // user-menu executor can expand all 17 MC percent escapes + // (`%f`, `%p`, `%x`, `%s`, `%t`, `%u`, `%c`, `%cd`, …). + let active = self.active_panel(); + let other = self.other_panel(); + let mut ctx = percent::PercentCtx::for_file(active.cursor_path(), active.path()); + ctx.other_dir = other.path().to_path_buf(); + ctx.selected_count = active.marked_count(); + ctx.tagged = active.marked_names(); + ctx.menu_path = usermenu::UserMenu::new().storage_path; + dlg.set_context(ctx); + self.dialog = Some(DialogState::UserMenu(Box::new(dlg))); + Ok(()) + } + + /// Open the F1 Help dialog: a modal overlay listing every key + /// binding from the default keymap. + pub fn open_help_dialog(&mut self) -> Result<()> { + self.dialog = Some(DialogState::Help(Box::new(help::HelpDialog::new( + self.theme, + )))); + Ok(()) + } + + /// Open the Ctrl-S Skin selection dialog: a modal overlay + /// listing every available skin (built-in presets + user + /// TOML skins). + pub fn open_skin_dialog(&mut self) { + let current = self.skin_name.clone(); + self.dialog = Some(DialogState::Skin(Box::new( + skin_dialog::SkinDialog::new(¤t), + ))); + } + + /// Apply a confirmed skin selection: switch the active theme, + /// persist the new name to the user config, post a status. + pub fn apply_skin_selection(&mut self, name: String) { + self.theme = Theme::by_name(&name); + self.skin_name = name.clone(); + if let Err(e) = self.persist_skin_name(&name) { + self.status + .set_message(format!("skin: {e}")); + } else { + self.status + .set_message(format!("Skin: {name}")); + } + self.dialog = None; + } + + /// Write just the skin name to the user config file. Other + /// sections of the config are preserved by reading the file + /// first, replacing the `[skin] name` field, and writing it + /// back. A missing config file is created with a minimal + /// `Config::default()` skeleton. + pub fn persist_skin_name(&self, name: &str) -> anyhow::Result<()> { + let mut cfg = Config::load(None).unwrap_or_default(); + cfg.skin.name = name.to_string(); + cfg.save(None) + } + + /// Apply a tree dialog outcome (Cd path → switch active panel; Cancel → close). + pub fn apply_tree_outcome(&mut self, o: tree::TreeOutcome) { + use tree::TreeOutcome; + match o { + TreeOutcome::Cd(path) => { + self.status.set_message(format!("cd {}", path.display())); + if let Ok(()) = self.active_panel_mut().set_path(&path) { + let _ = self.active_panel_mut().refresh(); + } + } + TreeOutcome::Cancel | TreeOutcome::Running => {} + } + self.dialog = None; + } + + /// Apply a layout-dialog outcome by writing its settings into the + /// runtime config and clearing the dialog. + pub fn apply_layout_outcome(&mut self, o: layout_dialog::LayoutResult) { + use layout_dialog::LayoutResult; + match o { + LayoutResult::Confirm(settings) => { + self.runtime.equal_split = Some(settings.equal_split); + self.runtime.show_menubar = Some(settings.show_menubar); + self.runtime.show_keybar = Some(settings.show_keybar); + self.runtime.show_hintbar = Some(settings.show_hintbar); + self.runtime.show_cmdline = Some(settings.show_cmdline); + self.status.set_message("Layout options updated"); + if self.runtime.auto_save_setup() { + let _ = self.save_config(); + } + } + LayoutResult::Cancel | LayoutResult::Running => {} + } + self.dialog = None; + } + + /// Apply a panel-options outcome. + pub fn apply_panel_options_outcome(&mut self, o: panel_options::PanelOptionsResult) { + use panel_options::PanelOptionsResult; + match o { + PanelOptionsResult::Confirm(settings) => { + self.runtime.mix_all_files = Some(settings.mix_all_files); + self.runtime.mark_moves_down = Some(settings.mark_moves_down); + self.runtime.show_mini_status = Some(settings.show_mini_status); + if self.left.show_hidden() != settings.show_hidden { + let _ = self.left.toggle_hidden(); + } + if self.right.show_hidden() != settings.show_hidden { + let _ = self.right.toggle_hidden(); + } + self.cfg.show_hidden = settings.show_hidden; + self.status.set_message("Panel options updated"); + if self.runtime.auto_save_setup() { + let _ = self.save_config(); + } + } + PanelOptionsResult::Cancel | PanelOptionsResult::Running => {} + } + self.dialog = None; + } + + /// Apply a config-dialog outcome. + pub fn apply_config_outcome(&mut self, o: config_dialog::ConfigResult) { + use config_dialog::ConfigResult; + match o { + ConfigResult::Confirm(settings) => { + self.runtime.esc_exit_mode = Some(settings.esc_exit_mode); + self.runtime.verbose_ops = Some(settings.verbose_ops); + self.runtime.auto_save_setup = Some(settings.auto_save_setup); + self.runtime.safe_delete = Some(settings.safe_delete); + self.runtime.pause_after_run = Some(settings.pause_after_run); + self.status.set_message("Configuration updated"); + if self.runtime.auto_save_setup() { + let _ = self.save_config(); + } + } + ConfigResult::Cancel | ConfigResult::Running => {} + } + self.dialog = None; + } + + /// Apply an external-panelize dialog outcome. + /// + /// `Apply` is what the user wanted — the command produced one or + /// more paths. The dialog is closed and the path count is shown + /// on the status line. The active panel is not replaced yet: + /// `Panel::set_external_listing` is a future integration point + /// (the parsed paths are kept inside the dialog's + /// `ExternalPanelizeOutcome::Apply(Vec)` until the + /// panel-side support lands). + /// + /// `Empty` keeps the dialog open so the user can edit the + /// command; the dialog itself records the error in its + /// `last_error` field. `Cancel` (Esc) closes the dialog. + /// `Running` is the no-op case where the dialog stays open and + /// keeps consuming keys. + pub fn apply_external_panelize_outcome( + &mut self, + o: external_panelize::ExternalPanelizeOutcome, + ) { + use external_panelize::ExternalPanelizeOutcome; + match o { + ExternalPanelizeOutcome::Apply(paths) => { + self.status + .set_message(format!("panelize: {} path(s) captured", paths.len())); + self.dialog = None; + } + ExternalPanelizeOutcome::Cancel => { + self.dialog = None; + } + ExternalPanelizeOutcome::Empty | ExternalPanelizeOutcome::Running => {} + } + } + + /// Apply a VFS-list dialog outcome. The dialog is read-only — + /// `Cancel` (Esc) closes the dialog; `Running` (any other key) + /// leaves the dialog open and continues to consume keys. A future + /// enhancement may add a `Mount`/`Unmount` action that re-routes + /// the active panel. + pub fn apply_vfs_list_outcome(&mut self, o: vfs_list::VfsListOutcome) { + use vfs_list::VfsListOutcome; + match o { + VfsListOutcome::Cancel => { + self.dialog = None; + } + VfsListOutcome::Running => {} + } + } + + /// Apply a screen-list dialog outcome (no-op — closes on Esc). + pub fn apply_screen_list_outcome(&mut self, _o: screen_list::ScreenListOutcome) { + // ScreenList just closes on Esc — no action needed + } + + /// Apply an edit-history dialog outcome. + pub fn apply_edit_history_outcome(&mut self, o: edit_history::EditHistoryOutcome) { + use edit_history::EditHistoryOutcome; + match o { + EditHistoryOutcome::Navigate(path) => { + if let Err(e) = self.active_panel_mut().set_path(&path) { + self.status.set_message(format!("cd failed: {}", e)); + } + let _ = self.active_panel_mut().refresh(); + } + EditHistoryOutcome::Cancel | EditHistoryOutcome::Running => {} + } + self.dialog = None; + } + + /// Apply a filtered-view dialog outcome. + pub fn apply_filtered_view_outcome(&mut self, o: filtered_view::FilteredViewOutcome) { + use filtered_view::FilteredViewOutcome; + match o { + FilteredViewOutcome::Apply { + stdout, + stderr, + source_path, + } => { + use crate::viewer::source::FileSource; + let src = FileSource::Inline { bytes: stdout }; + let viewer = crate::viewer::Viewer::from_source(source_path.clone(), src); + self.viewer = Some(viewer); + if !stderr.is_empty() { + self.status.set_message(format!("filter: {}", stderr.trim_end())); + } else { + self.status.set_message(format!("filtered: {}", source_path.display())); + } + } + FilteredViewOutcome::Cancel => { + self.dialog = None; + } + FilteredViewOutcome::Running => {} + } + } + + /// Apply a find dialog outcome (Open/View/Edit/Cd path). + pub fn apply_find_outcome(&mut self, o: find::FindOutcome) { + use find::FindOutcome; + match o { + FindOutcome::Open(path) => { + self.open_file_via_path(&path); + } + FindOutcome::View(path) => match crate::viewer::Viewer::open(&path) { + Ok(v) => { + self.viewer = Some(v); + self.status + .set_message(format!("Viewing {}", path.display())); + } + Err(e) => self.status.set_message(format!("view: {e}")), + }, + FindOutcome::Edit(path) => { + self.editor = Some(crate::editor::Editor::open(&path)); + self.status + .set_message(format!("Editing {}", path.display())); + } + FindOutcome::Cancel | FindOutcome::Running => {} + } + self.dialog = None; + } + + /// Apply a hotlist dialog outcome (Cd path). + pub fn apply_hotlist_outcome(&mut self, o: hotlist::HotlistOutcome) { + use hotlist::HotlistOutcome; + match o { + HotlistOutcome::Cd(path) => { + if let Ok(()) = self.active_panel_mut().set_path(&path) { + let _ = self.active_panel_mut().refresh(); + } + self.status.set_message(format!("cd {}", path.display())); + } + HotlistOutcome::AddCurrent(path) => { + let label = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()); + let mut data = hotlist::load_hotlist(); + data.entries.push(hotlist::HotlistEntry { + label: label.clone(), + path: path.clone(), + }); + match hotlist::save_hotlist(&data) { + Ok(()) => { + self.status + .set_message(format!("Added to hotlist: {label}")); + } + Err(e) => { + self.status + .set_message(format!("Hotlist save failed: {e}")); + } + } + } + HotlistOutcome::Cancel | HotlistOutcome::Running => {} + } + self.dialog = None; + } + + /// Apply a user menu dialog outcome (Execute expanded command). + pub fn apply_user_menu_outcome(&mut self, o: usermenu::UserMenuOutcome) { + use usermenu::UserMenuOutcome; + match o { + UserMenuOutcome::Execute(cmd) => { + let cwd = self.active_panel().path().to_path_buf(); + self.start_exec(cmd, &cwd); + } + UserMenuOutcome::Cancel | UserMenuOutcome::Running => {} + } + self.dialog = None; + } + + /// Dispatch a [`skin_dialog::SkinDialogOutcome`] to either + /// `apply_skin_selection` (on `Selected`) or to a simple + /// dialog dismissal (on `Cancelled`). + pub fn apply_skin_outcome_or_close(&mut self, o: skin_dialog::SkinDialogOutcome) { + use skin_dialog::SkinDialogOutcome; + match o { + SkinDialogOutcome::Selected(name) => { + self.apply_skin_selection(name); + } + SkinDialogOutcome::Cancelled => { + self.dialog = None; + } + } + } + + /// Open a file at `path` by cd-ing the active panel into its parent. + pub fn open_file_via_path(&mut self, path: &std::path::Path) { + if let Some(parent) = path.parent() { + if let Ok(()) = self.active_panel_mut().set_path(parent) { + let _ = self.active_panel_mut().refresh(); + } + } + self.status.set_message(format!("Found {}", path.display())); + } + + /// Apply the result of a finished dialog (chmod, chown, link) and + /// then clear `self.dialog`. + pub fn apply_finished_dialog(&mut self) { + // Pull the dialog out (mem::replace with None) so we can + // destructure and apply without borrow conflicts. + let dlg = self.dialog.take(); + match dlg { + // The 4 new dialogs (Find/Hotlist/Tree/UserMenu) are + // closed by their own apply_*_outcome helpers before + // this function is called; they should never appear here. + Some(DialogState::Find(_)) + | Some(DialogState::Hotlist(_)) + | Some(DialogState::Tree(_)) + | Some(DialogState::UserMenu(_)) + | Some(DialogState::Layout(_)) + | Some(DialogState::PanelOptions(_)) + | Some(DialogState::Config(_)) + | Some(DialogState::Jobs(_)) + | Some(DialogState::ExternalPanelize(_)) + | Some(DialogState::VfsList(_)) + | Some(DialogState::ScreenList(_)) + | Some(DialogState::EditHistory(_)) + | Some(DialogState::FilteredView(_)) + | Some(DialogState::Compare(_)) => { + // No-op: those dialogs clear themselves. + } + // The Help dialog also clears itself in `handle_dialog_key` + // when the user presses a close key. + Some(DialogState::Help(_)) => {} + // The Skin dialog clears itself in `apply_skin_selection` + // and `apply_skin_outcome_or_close`; it should never + // reach this function. + Some(DialogState::Skin(_)) => {} + Some(DialogState::Permission(d)) => { + if let Some(new_mode) = d.result() { + let path = d.path.clone(); + match crate::fs::perm::chmod(&path, new_mode) { + Ok(()) => { + self.status.set_message(format!( + "chmod {:o} {}", + new_mode, + path.display() + )); + let _ = self.active_panel_mut().refresh(); + } + Err(e) => { + self.status.set_message(format!("chmod: {e}")); + } + } + } + } + Some(DialogState::Owner(d)) => { + if let Some((uid, gid)) = d.result() { + let path = d.path.clone(); + match crate::fs::perm::chown(&path, uid, gid) { + Ok(()) => { + self.status.set_message(format!( + "chown {}:{} {}", + uid, + gid, + path.display() + )); + let _ = self.active_panel_mut().refresh(); + } + Err(e) => { + self.status.set_message(format!("chown: {e}")); + } + } + } + } + Some(DialogState::Link(d)) => { + if let Some((src, target)) = d.result() { + let kind = d.kind; + let r = match kind { + LinkKind::Hard => crate::ops::link::hardlink(&src, &target), + LinkKind::Sym | LinkKind::SymRelative => { + // For SymRelative, rewrite `src` to a path + // relative to the link's directory. The + // link lives in `target.parent()`. + let effective_src = match kind { + LinkKind::SymRelative => { + let link_dir = target.parent().unwrap_or_else(|| { + std::path::Path::new(".") + }); + render::relpath_from(&src, link_dir) + } + _ => src.clone(), + }; + crate::ops::link::symlink(&effective_src, &target) + } + }; + match r { + Ok(()) => { + self.status.set_message(format!( + "{} {} -> {}", + kind.label(), + src.display(), + target.display() + )); + let _ = self.active_panel_mut().refresh(); + } + Err(e) => { + self.status.set_message(format!("{}: {e}", kind.label())); + } + } + } + } + Some(DialogState::MkDir(d)) => { + if let Some(new_path) = d.result() { + let handle = self.ops_manager.begin( + crate::ops::OpKind::MkDir, + vec![new_path.clone()], + None, + ); + match crate::ops::mkdir::mkdir(&new_path, false, &handle) { + Ok(()) => { + self.status + .set_message(format!("Created {}", new_path.display())); + self.ops_manager.finish(); + let _ = self.active_panel_mut().refresh(); + } + Err(e) => { + self.status.set_message(format!("mkdir: {e}")); + } + } + } + } + Some(DialogState::Copy(d)) => { + if let Some(dst) = d.result() { + let sources = d.src.clone(); + let preserve_attributes = d.preserve_attributes; + let follow_links = d.follow_links; + let handle = self.ops_manager.begin( + crate::ops::OpKind::Copy, + sources.clone(), + Some(dst.clone()), + ); + match crate::ops::copy::copy_many( + &sources, + &dst, + &handle, + false, + preserve_attributes, + follow_links, + ) { + Ok(()) => { + self.status.set_message(format!( + "Copied {} item(s) to {}", + sources.len(), + dst.display() + )); + self.ops_manager.finish(); + let _ = self.active_panel_mut().refresh(); + } + Err(crate::ops::OpsError::DestExists(p)) => { + self.pending_op = Some(PendingFileOp { + is_move: false, + sources, + dst, + preserve_attributes, + follow_links, + }); + self.dialog = Some(DialogState::Overwrite(Box::new( + overwrite_dialog::OverwriteDialog::new_copy( + p.display().to_string(), + ), + ))); + } + Err(e) => { + self.status.set_message(format!("copy: {e}")); + } + } + } + } + Some(DialogState::Move(d)) => { + if let Some(dst) = d.result() { + let sources = d.src.clone(); + let preserve_attributes = d.preserve_attributes; + let follow_links = d.follow_links; + let handle = self.ops_manager.begin( + crate::ops::OpKind::Move, + sources.clone(), + Some(dst.clone()), + ); + match crate::ops::move_op::move_many( + &sources, + &dst, + &handle, + false, + preserve_attributes, + follow_links, + ) { + Ok(()) => { + self.status.set_message(format!( + "Moved {} item(s) to {}", + sources.len(), + dst.display() + )); + self.ops_manager.finish(); + let _ = self.active_panel_mut().refresh(); + } + Err(crate::ops::OpsError::DestExists(p)) => { + self.pending_op = Some(PendingFileOp { + is_move: true, + sources, + dst, + preserve_attributes, + follow_links, + }); + self.dialog = Some(DialogState::Overwrite(Box::new( + overwrite_dialog::OverwriteDialog::new_move( + p.display().to_string(), + ), + ))); + } + Err(e) => { + self.status.set_message(format!("move: {e}")); + } + } + } + } + Some(DialogState::Delete(d)) => { + #[allow(clippy::collapsible_match, reason = "guard would change fallthrough semantics")] + if d.is_confirmed() { + let paths = d.paths.clone(); + let handle = + self.ops_manager + .begin(crate::ops::OpKind::Delete, paths.clone(), None); + match crate::ops::delete::delete_many(&paths, &handle) { + Ok(()) => { + self.status + .set_message(format!("Deleted {} item(s)", paths.len())); + self.ops_manager.finish(); + let _ = self.active_panel_mut().refresh(); + } + Err(e) => { + self.status.set_message(format!("delete: {e}")); + } + } + } + } + // Info dialog: just close. + Some(DialogState::Info(_)) => {} + Some(DialogState::Quit(d)) => { + #[allow(clippy::collapsible_match, reason = "guard would change fallthrough semantics")] + if d.confirmed { + self.should_quit = true; + } + } + Some(DialogState::SelectGroup(d)) => { + if let Some(pat) = d.result() { + self.active_panel_mut().mark_pattern(pat); + self.status.set_message(format!("Select group: {pat}")); + } + } + Some(DialogState::UnselectGroup(d)) => { + if let Some(pat) = d.result() { + self.active_panel_mut().unmark_pattern(pat); + self.status.set_message(format!("Unselect group: {pat}")); + } + } + Some(DialogState::QuickCd(d)) => { + if let Some(ref path) = d.confirmed_path { + if let Ok(()) = self.active_panel_mut().set_path(path) { + let _ = self.active_panel_mut().refresh(); + } + self.status.set_message(format!("cd {}", path.display())); + } + } + Some(DialogState::Overwrite(d)) => { + use overwrite_dialog::OverwriteOutcome; + if let Some(op) = self.pending_op.take() { + match d.outcome { + OverwriteOutcome::Yes | OverwriteOutcome::YesAll => { + let handle = self.ops_manager.begin( + if op.is_move { + crate::ops::OpKind::Move + } else { + crate::ops::OpKind::Copy + }, + op.sources.clone(), + Some(op.dst.clone()), + ); + let result = if op.is_move { + crate::ops::move_op::move_many( + &op.sources, + &op.dst, + &handle, + true, + op.preserve_attributes, + op.follow_links, + ) + } else { + crate::ops::copy::copy_many( + &op.sources, + &op.dst, + &handle, + true, + op.preserve_attributes, + op.follow_links, + ) + }; + match result { + Ok(()) => { + let verb = if op.is_move { "Moved" } else { "Copied" }; + self.status.set_message(format!( + "{} {} item(s) to {}", + verb, + op.sources.len(), + op.dst.display() + )); + self.ops_manager.finish(); + let _ = self.active_panel_mut().refresh(); + } + Err(e) => { + self.status.set_message(format!( + "{}: {e}", + if op.is_move { "move" } else { "copy" } + )); + } + } + } + OverwriteOutcome::No + | OverwriteOutcome::NoAll + | OverwriteOutcome::Abort => { + self.status.set_message("Operation skipped.".to_string()); + } + OverwriteOutcome::Running => { + self.pending_op = Some(op); + } + } + } + } + None => {} + } + } +} + +/// Short human label for the currently-open dialog (used by the +/// screen-list overlay). +fn dialog_label(d: &DialogState) -> String { + match d { + DialogState::Info(_) => "Info".to_string(), + DialogState::Permission(_) => "Chmod".to_string(), + DialogState::Owner(_) => "Chown".to_string(), + DialogState::Link(_) => "Link".to_string(), + DialogState::MkDir(_) => "Mkdir".to_string(), + DialogState::Copy(_) => "Copy".to_string(), + DialogState::Move(_) => "Move".to_string(), + DialogState::Delete(_) => "Delete".to_string(), + DialogState::Find(_) => "Find".to_string(), + DialogState::Hotlist(_) => "Hotlist".to_string(), + DialogState::Tree(_) => "Tree".to_string(), + DialogState::UserMenu(_) => "User menu".to_string(), + DialogState::Help(_) => "Help".to_string(), + DialogState::Skin(_) => "Skin".to_string(), + DialogState::Quit(_) => "Quit".to_string(), + DialogState::SelectGroup(_) => "Select group".to_string(), + DialogState::UnselectGroup(_) => "Unselect group".to_string(), + DialogState::QuickCd(_) => "Quick cd".to_string(), + DialogState::Overwrite(_) => "Overwrite".to_string(), + DialogState::Layout(_) => "Layout".to_string(), + DialogState::PanelOptions(_) => "Panel options".to_string(), + DialogState::Config(_) => "Configuration".to_string(), + DialogState::Jobs(_) => "Jobs".to_string(), + DialogState::ExternalPanelize(_) => "External panelize".to_string(), + DialogState::VfsList(_) => "VFS list".to_string(), + DialogState::ScreenList(_) => "Screen list".to_string(), + DialogState::EditHistory(_) => "Directory history".to_string(), + DialogState::FilteredView(_) => "Filtered view".to_string(), + DialogState::Compare(_) => "Compare files".to_string(), + } +} \ No newline at end of file diff --git a/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs b/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs new file mode 100644 index 0000000000..86588b4787 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs @@ -0,0 +1,692 @@ +//! Key dispatch and event handling for the file manager. +//! +//! The dispatcher receives a [`Cmd`] (a parsed keymap action) and +//! routes it to the matching [`FileManager`] method. When a modal +//! dialog is open, the [`handle_dialog_key`] fallback forwards the +//! raw key into the dialog state machine and applies any returned +//! outcome. The Ctrl-X chord entry point, [`dispatch_ctrl_x_followup`], +//! resolves the second key and re-dispatches the chosen [`Cmd`]. + +use anyhow::Result; + +use crate::filemanager::{ + compare, config_dialog, edit_history, exec, external_panelize, filter_dialog, filtered_view, + find, format_utils, help, hotlist, jobs, layout_dialog, link, menubar, move_dialog, + overwrite_dialog, panel_options, percent, quickcd_dialog, render, screen_list, skin_dialog, + tree, usermenu, vfs_list, Cmd, DialogState, FileManager, PendingFileOp, +}; +use crate::filemanager::link::LinkKind; +use crate::key::Key; + +use super::copy_dialog::CopyDialog; +use super::delete_dialog::DeleteDialog; +use super::info::InfoDialog; +use super::layout_dialog::LayoutDialog; +use super::panel::ListingMode; +use super::panel_options::PanelOptionsDialog; +use super::permission::PermissionDialog; +use super::config_dialog::ConfigDialog; + +impl FileManager { + /// Apply a [`Cmd`] to the active panel. Returns `Ok(true)` if the + /// command was handled, `Ok(false)` if the application should + /// process the key itself (e.g. quit). + pub fn dispatch(&mut self, cmd: Cmd) -> Result { + // If a dialog is open, only Esc and Quit close it; everything + // else is forwarded to the dialog via `handle_dialog_key`. + if self.dialog.is_some() && !matches!(cmd, Cmd::Quit) { + return Ok(true); + } + match cmd { + Cmd::Tab => { + self.switch_focus(); + Ok(true) + } + Cmd::SwapPanels => { + self.swap_panels(); + Ok(true) + } + Cmd::ToggleLayout => { + self.toggle_layout(); + Ok(true) + } + Cmd::Reload => { + self.active_panel_mut().refresh()?; + self.status.set_message("Refreshed"); + Ok(true) + } + Cmd::ToggleHidden => { + self.active_panel_mut().toggle_hidden()?; + self.status + .set_message(if self.active_panel().show_hidden() { + "Hidden: shown" + } else { + "Hidden: hidden" + }); + Ok(true) + } + Cmd::EnterDir => { + // ENTER on the cursor: descend into directory, or open + // a file in the editor. This preserves MC's historical + // "press Enter to navigate into" behavior. + let p = self.active_panel().cursor_path(); + if p.is_dir() { + self.active_panel_mut().enter()?; + } else { + self.open_editor_for_cursor()?; + } + Ok(true) + } + Cmd::MkDir => { + self.open_mkdir_dialog()?; + Ok(true) + } + Cmd::Copy => { + self.open_copy_dialog()?; + Ok(true) + } + Cmd::Move => { + self.open_move_dialog()?; + Ok(true) + } + Cmd::Delete => { + self.open_delete_dialog()?; + Ok(true) + } + Cmd::Edit => { + self.open_editor_for_cursor()?; + Ok(true) + } + Cmd::View => { + self.open_viewer_for_cursor()?; + Ok(true) + } + Cmd::UserMenu => { + self.open_user_menu_dialog()?; + Ok(true) + } + Cmd::HotList => { + self.open_hotlist_dialog()?; + Ok(true) + } + Cmd::Tree => { + self.open_tree_dialog()?; + Ok(true) + } + Cmd::Find => { + self.open_find_dialog()?; + Ok(true) + } + Cmd::Cmdline => { + self.cmdline.activate(); + self.status.set_message("Command:"); + Ok(true) + } + Cmd::Help => { + self.open_help_dialog()?; + Ok(true) + } + Cmd::Info => { + self.open_info_dialog()?; + Ok(true) + } + Cmd::Permission => { + self.open_permission_dialog()?; + Ok(true) + } + Cmd::Owner => { + self.open_owner_dialog()?; + Ok(true) + } + Cmd::Link => { + self.open_link_dialog(LinkKind::Hard)?; + Ok(true) + } + Cmd::Symlink => { + self.open_link_dialog(LinkKind::Sym)?; + Ok(true) + } + Cmd::Rmdir => { + self.run_rmdir_on_cursor()?; + Ok(true) + } + Cmd::SkinSelect => { + self.open_skin_dialog(); + Ok(true) + } + Cmd::Search => { + self.search = Some(String::new()); + self.status.set_message("Search:"); + Ok(true) + } + Cmd::Quit => { + if self.should_quit { + Ok(false) + } else { + self.dialog = Some(DialogState::Quit(Box::default())); + Ok(true) + } + } + Cmd::MenuBar => { + if self.menubar.is_some() { + self.menubar = None; + } else { + self.menubar = Some(menubar::MenuBar::new()); + } + Ok(true) + } + Cmd::SelectGroup => { + self.dialog = Some(DialogState::SelectGroup(Box::new( + super::pattern_dialog::PatternDialog::new_select(), + ))); + Ok(true) + } + Cmd::UnselectGroup => { + self.dialog = Some(DialogState::UnselectGroup(Box::new( + super::pattern_dialog::PatternDialog::new_unselect(), + ))); + Ok(true) + } + Cmd::QuickCd => { + let history: Vec = self + .active_panel() + .history_paths() + .iter() + .map(|p| p.display().to_string()) + .collect(); + self.dialog = Some(DialogState::QuickCd(Box::new( + quickcd_dialog::QuickCdDialog::new(&history), + ))); + Ok(true) + } + Cmd::TogglePanels => { + self.panels_visible = !self.panels_visible; + if !self.panels_visible { + self.status.set_message("Panels hidden."); + } else { + self.status.set_message("Panels visible."); + } + Ok(true) + } + Cmd::SubShell => { + self.want_subshell = true; + Ok(true) + } + Cmd::Mark => { + self.active_panel_mut().toggle_mark(); + self.active_panel_mut().cursor_down(); + Ok(true) + } + Cmd::MarkDown => { + self.active_panel_mut().toggle_mark(); + self.active_panel_mut().cursor_down(); + Ok(true) + } + Cmd::MarkUp => { + self.active_panel_mut().toggle_mark(); + self.active_panel_mut().cursor_up(); + Ok(true) + } + Cmd::InvertMarks => { + self.active_panel_mut().reverse_marks(); + let n = self.active_panel().marked_count(); + self.status.set_message(format!("Inverted marks ({n} marked)")); + Ok(true) + } + Cmd::SortNext => { + self.active_panel_mut().cycle_sort(); + let sf = self.active_panel().sort_field_name(); + self.status.set_message(format!("Sort: {sf}")); + Ok(true) + } + Cmd::SortReverse => { + self.active_panel_mut().toggle_sort_reverse(); + let r = self.active_panel().sort_reverse(); + self.status.set_message(format!("Sort reverse: {r}")); + Ok(true) + } + Cmd::History => { + let dirs: Vec = self + .active_panel() + .history_paths() + .iter() + .rev() + .map(|p| p.display().to_string()) + .collect(); + self.status.set_message(format!( + "History: {} dirs (Alt-Y/Alt-U to navigate)", + dirs.len() + )); + Ok(true) + } + Cmd::SaveSetup => { + match self.save_config() { + Ok(()) => { + self.status.set_message("Configuration saved."); + } + Err(e) => { + self.status.set_message(format!("Save failed: {e}")); + } + } + Ok(true) + } + Cmd::ListingCycle => { + self.active_panel_mut().cycle_listing_mode(); + let mode = self.active_panel().listing_mode(); + self.status.set_message(format!("Listing: {}", mode.label())); + Ok(true) + } + Cmd::HistoryBack => { + match self.active_panel_mut().history_back() { + Ok(()) => { + self.status + .set_message("Back".to_string()); + } + Err(e) => { + self.status.set_message(format!("{e}")); + } + } + Ok(true) + } + Cmd::HistoryForward => { + match self.active_panel_mut().history_forward() { + Ok(()) => { + self.status + .set_message("Forward".to_string()); + } + Err(e) => { + self.status.set_message(format!("{e}")); + } + } + Ok(true) + } + Cmd::LayoutDialog => { + self.dialog = Some(DialogState::Layout(Box::new( + LayoutDialog::from_runtime_config(&self.runtime), + ))); + Ok(true) + } + Cmd::PanelOptionsDialog => { + self.dialog = Some(DialogState::PanelOptions(Box::new( + PanelOptionsDialog::from_runtime_config(&self.runtime), + ))); + Ok(true) + } + Cmd::ConfigDialog => { + self.dialog = Some(DialogState::Config(Box::new( + ConfigDialog::from_runtime_config(&self.runtime), + ))); + Ok(true) + } + Cmd::CompareDirs => { + self.compare_dirs(); + Ok(true) + } + Cmd::ViewerNextFile | Cmd::ViewerPrevFile => Ok(true), + Cmd::QuitQuiet => { + self.should_quit = true; + Ok(false) + } + Cmd::EqualSplit => { + self.runtime.equal_split = Some(true); + self.split_ratio = 50; + Ok(true) + } + Cmd::DirSize => { + let p = self.active_panel().cursor_path(); + if p.is_dir() { + let bytes = crate::ops::count_bytes(std::slice::from_ref(&p)); + self.status + .set_message(format!("{}: {}", p.display(), format_utils::format_size(bytes))); + } else { + self.status.set_message("Not a directory".to_string()); + } + Ok(true) + } + Cmd::InsertCurPath => { + let p = self.active_panel().path().to_string_lossy().to_string(); + self.cmdline.insert_text(&p); + Ok(true) + } + Cmd::InsertOtherPath => { + let p = self.other_panel().path().to_string_lossy().to_string(); + self.cmdline.insert_text(&p); + Ok(true) + } + Cmd::InsertCurFile => { + let name = self + .active_panel() + .cursor_path() + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + self.cmdline.insert_text(&name); + Ok(true) + } + Cmd::SplitLess => { + self.runtime.equal_split = Some(false); + self.split_ratio = self.split_ratio.saturating_sub(5).max(10); + Ok(true) + } + Cmd::SplitMore => { + self.runtime.equal_split = Some(false); + self.split_ratio = self.split_ratio.saturating_add(5).min(90); + Ok(true) + } + Cmd::Jobs => { + let dlg = jobs::JobsDialog::new(Arc::clone(&self.jobs)); + self.dialog = Some(DialogState::Jobs(Box::new(dlg))); + Ok(true) + } + Cmd::Panelize => { + self.dialog = Some(DialogState::ExternalPanelize(Box::default())); + Ok(true) + } + Cmd::VfsList => { + self.dialog = Some(DialogState::VfsList(Box::default())); + Ok(true) + } + Cmd::SymlinkRelative => { + let cursor = self.active_panel().cursor_path(); + if let Some(dlg) = link::LinkDialog::for_relative(cursor) { + self.dialog = Some(DialogState::Link(Box::new(dlg))); + } + Ok(true) + } + Cmd::SymlinkEdit => { + self.edit_symlink_target(); + Ok(true) + } + Cmd::ScreenList => { + let dlg = screen_list::ScreenListDialog::with_screens(self.collect_active_screens()); + self.dialog = Some(DialogState::ScreenList(Box::new(dlg))); + Ok(true) + } + Cmd::EditHistory => { + let dlg = edit_history::EditHistoryDialog::from_history( + self.active_panel().history_paths().to_vec(), + ); + self.dialog = Some(DialogState::EditHistory(Box::new(dlg))); + Ok(true) + } + Cmd::FilteredView => { + let cursor = self.active_panel().cursor_path(); + self.dialog = Some(DialogState::FilteredView(Box::new( + filtered_view::FilteredViewDialog::new(cursor), + ))); + Ok(true) + } + Cmd::CompareFiles => { + let left = self.active_panel().cursor_path(); + let right = self.other_panel().cursor_path(); + if left.is_file() && right.is_file() { + self.dialog = Some(DialogState::Compare(Box::new( + compare::CompareDialog::new(&left, &right), + ))); + Ok(true) + } else { + self.status.set_message( + "Compare: both panels must have a regular file under cursor".to_string(), + ); + Ok(true) + } + } + Cmd::Suspend => { + self.status + .set_message("Suspend: use Ctrl-O to drop to a shell".to_string()); + Ok(true) + } + Cmd::PanelInfo => { + let was_info = matches!( + self.other_panel().listing_mode(), + ListingMode::Info + ); + self.other_panel_mut().toggle_info_mode(); + self.status + .set_message(if was_info { "Info off" } else { "Info on" }); + Ok(true) + } + Cmd::PanelQuickView => { + let was_qv = matches!( + self.other_panel().listing_mode(), + ListingMode::QuickView + ); + self.other_panel_mut().toggle_quickview_mode(); + self.status + .set_message(if was_qv { "Quick view off" } else { "Quick view on" }); + Ok(true) + } + } + } + + /// Dispatch a follow-up key after the user pressed Ctrl-X. + /// + /// The Ctrl-X prefix activates a two-key chord for commands that + /// don't have a single-key binding. When `pending_ctrl_x` is true, + /// the next key is routed here instead of the normal keymap. + pub fn dispatch_ctrl_x_followup(&mut self, key: crate::key::Key) -> Result { + self.pending_ctrl_x = false; + // Translate the key into a (char) so we can match on the + // follow-up letter. We ignore the modifier bits for the + // chord's second key — MC's chord semantics are letter-only. + let ch = char::from_u32(key.code); + let cmd = match ch { + Some('d') => Some(Cmd::CompareDirs), + Some('j') => Some(Cmd::Jobs), + Some('c') => Some(Cmd::Permission), + Some('o') => Some(Cmd::Owner), + Some('l') => Some(Cmd::Symlink), + Some('s') => Some(Cmd::SymlinkRelative), + Some('v') => Some(Cmd::SymlinkEdit), + Some('a') => Some(Cmd::VfsList), + Some('!') => Some(Cmd::Panelize), + Some('i') => Some(Cmd::PanelInfo), + Some('q') => Some(Cmd::PanelQuickView), + _ => None, + }; + match cmd { + Some(c) => self.dispatch(c).map_err(|e| e.to_string()), + None => { + self.status.set_message("Ctrl-X: unknown follow-up key".to_string()); + Ok(true) + } + } + } + + /// Forward a key to the active modal dialog (if any) and apply any + /// returned outcome. Returns `true` if the active dialog consumed + /// the key, `false` if there was no dialog to handle it. + pub fn handle_dialog_key(&mut self, key: Key) -> bool { + // The 4 new dialogs (find/hotlist/tree/usermenu) return + // an `Outcome` enum from `handle_key`; we capture and + // apply the result here. The 4 ops dialogs (MkDir/Copy/ + // Move/Delete) and the 4 metadata dialogs (Info/Permission/ + // Owner/Link) have a self-contained `confirmed` flag on + // the dialog itself, so they go through the default + // `handle_key` -> bool path. + let mut consumed = false; + let mut tree_outcome: Option = None; + let mut find_outcome: Option = None; + let mut hot_outcome: Option = None; + let mut menu_outcome: Option = None; + let mut skin_outcome: Option = None; + let mut layout_outcome: Option = None; + let mut panel_outcome: Option = None; + let mut config_outcome: Option = None; + let mut jobs_should_close = false; + let mut panelize_outcome: Option = None; + let mut vfs_outcome: Option = None; + let mut screen_list_outcome: Option = None; + let mut edit_history_outcome: Option = None; + let mut filtered_view_outcome: Option = None; + match &mut self.dialog { + Some(DialogState::Info(_d)) => { + // Info dialog consumes Enter and Esc (close). + if matches!(key, Key::ENTER | Key::ESCAPE) { + consumed = true; + } + } + Some(DialogState::Permission(d)) => { + consumed = d.handle_key(key); + } + Some(DialogState::Owner(d)) => { + consumed = d.handle_key(key); + } + Some(DialogState::Link(d)) => { + consumed = d.handle_key(key); + } + Some(DialogState::MkDir(d)) => { + consumed = d.handle_key(key); + } + Some(DialogState::Copy(d)) => { + consumed = d.handle_key(key); + } + Some(DialogState::Move(d)) => { + consumed = d.handle_key(key); + } + Some(DialogState::Delete(d)) => { + consumed = d.handle_key(key); + } + Some(DialogState::Find(d)) => { + find_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::Hotlist(d)) => { + hot_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::Tree(d)) => { + tree_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::UserMenu(d)) => { + menu_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::Help(d)) => { + if d.handle_key(key) { + self.dialog = None; + } + consumed = true; + } + Some(DialogState::Skin(d)) => { + skin_outcome = d.handle_key(key); + consumed = true; + } + Some(DialogState::Quit(d)) => { + consumed = d.handle_key(key); + } + Some(DialogState::SelectGroup(d)) => { + d.handle_key(key); + consumed = true; + } + Some(DialogState::UnselectGroup(d)) => { + d.handle_key(key); + consumed = true; + } + Some(DialogState::QuickCd(d)) => { + d.handle_key(key); + consumed = true; + } + Some(DialogState::Overwrite(d)) => { + d.handle_key(key); + consumed = true; + } + Some(DialogState::Layout(d)) => { + layout_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::PanelOptions(d)) => { + panel_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::Config(d)) => { + config_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::Jobs(d)) => { + jobs_should_close = d.handle_key(key); + consumed = true; + } + Some(DialogState::ExternalPanelize(d)) => { + panelize_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::VfsList(d)) => { + vfs_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::ScreenList(d)) => { + screen_list_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::EditHistory(d)) => { + edit_history_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::FilteredView(d)) => { + filtered_view_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::Compare(d)) => { + let o = d.handle_key(key); + if matches!(o, compare::CompareOutcome::Close) { + self.dialog = None; + } + consumed = true; + } + None => return false, + } + // Apply captured outcomes. + if let Some(o) = tree_outcome { + self.apply_tree_outcome(o); + } + if let Some(o) = find_outcome { + self.apply_find_outcome(o); + } + if let Some(o) = hot_outcome { + self.apply_hotlist_outcome(o); + } + if let Some(o) = menu_outcome { + self.apply_user_menu_outcome(o); + } + if let Some(o) = skin_outcome { + self.apply_skin_outcome_or_close(o); + } + if let Some(o) = layout_outcome { + self.apply_layout_outcome(o); + } + if let Some(o) = panel_outcome { + self.apply_panel_options_outcome(o); + } + if let Some(o) = config_outcome { + self.apply_config_outcome(o); + } + if jobs_should_close { + self.dialog = None; + } + if let Some(o) = panelize_outcome { + self.apply_external_panelize_outcome(o); + } + if let Some(o) = vfs_outcome { + self.apply_vfs_list_outcome(o); + } + if let Some(o) = screen_list_outcome { + self.apply_screen_list_outcome(o); + } + if let Some(o) = edit_history_outcome { + self.apply_edit_history_outcome(o); + } + if let Some(o) = filtered_view_outcome { + self.apply_filtered_view_outcome(o); + } + if let Some(d) = &self.dialog { + if d.is_finished() { + self.apply_finished_dialog(); + } + } + consumed + } +} + +use std::sync::Arc; \ No newline at end of file diff --git a/local/recipes/tui/tlc/source/src/filemanager/format_utils.rs b/local/recipes/tui/tlc/source/src/filemanager/format_utils.rs new file mode 100644 index 0000000000..14e008ff61 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/format_utils.rs @@ -0,0 +1,101 @@ +//! Formatting helpers shared across the file manager. +//! +//! These free functions format common primitives (sizes, timestamps, +//! permission triples) and produce the human-readable labels the file +//! manager uses in panel rows, dialog titles, and overlay screens. + +use std::path::Path; + +use crate::filemanager::panel::Panel; + +/// Format a file size in bytes using B/K/M/G suffixes. +pub fn format_size(bytes: u64) -> String { + if bytes < 1024 { + format!("{bytes}B") + } else if bytes < 1024 * 1024 { + format!("{:.1}K", bytes as f64 / 1024.0) + } else if bytes < 1024 * 1024 * 1024 { + format!("{:.1}M", bytes as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.1}G", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + } +} + +/// Format a unix timestamp (seconds since epoch) as a human-readable +/// string. Returns `"—"` for non-positive values. +pub fn info_format_time(secs: i64) -> String { + if secs <= 0 { + return "—".to_string(); + } + match chrono::DateTime::from_timestamp(secs, 0) { + Some(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(), + None => format!("@{secs}"), + } +} + +/// Short, ellipsised path used in panel titles. +/// +/// Paths longer than 50 chars collapse to `".../"`. +pub fn path_short(path: &Path) -> String { + let s = path.display().to_string(); + if s.len() > 50 { + // Show ".../". + let tail: String = s + .chars() + .rev() + .take(47) + .collect::() + .chars() + .rev() + .collect(); + format!(".../{tail}") + } else { + s + } +} + +/// Render the panel meta line (mode, sort, item count, hidden/filter). +pub fn panel_meta_text(panel: &Panel) -> String { + let filter = panel + .filter() + .map(|f| format!(" Filter:{f}")) + .unwrap_or_default(); + let hidden = if panel.show_hidden() { " Hidden" } else { "" }; + let reverse = if panel.sort_reverse() { " desc" } else { "" }; + format!( + " {} Sort:{}{} {} items{}{} ", + panel.listing_mode().label(), + panel.sort_field_name(), + reverse, + panel.entry_count(), + hidden, + filter + ) +} + +/// Render the panel footer line (file/dir counts, marks, current entry). +pub fn panel_footer_text(panel: &Panel) -> String { + let total = panel.entry_count(); + let dirs = panel.dir_count(); + let files = total.saturating_sub(dirs); + let marked = panel.marked_count(); + let stats = if marked > 0 { + format!("{files}f {dirs}d {marked} marked") + } else { + format!("{files}f {dirs}d") + }; + let current = panel + .entries() + .get(panel.cursor()) + .map(|e| { + if e.name == ".." { + "../".to_string() + } else if e.is_dir() { + format!("{}/", e.name) + } else { + format!("{} {}", e.name, format_size(e.stat.size)) + } + }) + .unwrap_or_default(); + format!(" {stats} {current} ") +} \ No newline at end of file diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index 5d38d2ec61..e6b12626a4 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -13,6 +13,8 @@ pub mod connection_dialog; pub mod connection_manager; pub mod copy_dialog; pub mod delete_dialog; +pub mod dialog_ops; +pub mod dispatch; pub mod edit_history; pub mod encoding_dialog; pub mod exec; @@ -21,6 +23,7 @@ pub mod filehighlight; pub mod filter_dialog; pub mod filtered_view; pub mod find; +pub mod format_utils; pub mod help; pub mod hotlist; pub mod info; @@ -40,6 +43,7 @@ pub mod permission; pub mod quit_dialog; pub mod quickcd_dialog; pub mod rename; +pub mod render; pub mod screen_list; pub mod skin_dialog; pub mod sort_dialog; @@ -52,17 +56,13 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use anyhow::Result; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; +use ratatui::layout::Rect; use ratatui::Frame; use crate::config::{Config, FilemanagerConfig}; use crate::key::Key; use crate::keymap::Cmd; use crate::terminal::color::Theme; -use crate::terminal::mc_skin; use crate::terminal::status::StatusLine; use self::config_dialog::ConfigDialog; @@ -425,528 +425,6 @@ impl FileManager { } } - /// Apply a [`Cmd`] to the active panel. Returns `Ok(true)` if the - /// command was handled, `Ok(false)` if the application should - /// process the key itself (e.g. quit). - pub fn dispatch(&mut self, cmd: Cmd) -> Result { - // If a dialog is open, only Esc and Quit close it; everything - // else is forwarded to the dialog via `handle_dialog_key`. - if self.dialog.is_some() && !matches!(cmd, Cmd::Quit) { - return Ok(true); - } - match cmd { - Cmd::Tab => { - self.switch_focus(); - Ok(true) - } - Cmd::SwapPanels => { - self.swap_panels(); - Ok(true) - } - Cmd::ToggleLayout => { - self.toggle_layout(); - Ok(true) - } - Cmd::Reload => { - self.active_panel_mut().refresh()?; - self.status.set_message("Refreshed"); - Ok(true) - } - Cmd::ToggleHidden => { - self.active_panel_mut().toggle_hidden()?; - self.status - .set_message(if self.active_panel().show_hidden() { - "Hidden: shown" - } else { - "Hidden: hidden" - }); - Ok(true) - } - Cmd::EnterDir => { - // ENTER on the cursor: descend into directory, or open - // a file in the editor. This preserves MC's historical - // "press Enter to navigate into" behavior. - let p = self.active_panel().cursor_path(); - if p.is_dir() { - self.active_panel_mut().enter()?; - } else { - self.open_editor_for_cursor()?; - } - Ok(true) - } - Cmd::MkDir => { - self.open_mkdir_dialog()?; - Ok(true) - } - Cmd::Copy => { - self.open_copy_dialog()?; - Ok(true) - } - Cmd::Move => { - self.open_move_dialog()?; - Ok(true) - } - Cmd::Delete => { - self.open_delete_dialog()?; - Ok(true) - } - Cmd::Edit => { - self.open_editor_for_cursor()?; - Ok(true) - } - Cmd::View => { - self.open_viewer_for_cursor()?; - Ok(true) - } - Cmd::UserMenu => { - self.open_user_menu_dialog()?; - Ok(true) - } - Cmd::HotList => { - self.open_hotlist_dialog()?; - Ok(true) - } - Cmd::Tree => { - self.open_tree_dialog()?; - Ok(true) - } - Cmd::Find => { - self.open_find_dialog()?; - Ok(true) - } - Cmd::Cmdline => { - self.cmdline.activate(); - self.status.set_message("Command:"); - Ok(true) - } - Cmd::Help => { - self.open_help_dialog()?; - Ok(true) - } - Cmd::Info => { - self.open_info_dialog()?; - Ok(true) - } - Cmd::Permission => { - self.open_permission_dialog()?; - Ok(true) - } - Cmd::Owner => { - self.open_owner_dialog()?; - Ok(true) - } - Cmd::Link => { - self.open_link_dialog(LinkKind::Hard)?; - Ok(true) - } - Cmd::Symlink => { - self.open_link_dialog(LinkKind::Sym)?; - Ok(true) - } - Cmd::Rmdir => { - self.run_rmdir_on_cursor()?; - Ok(true) - } - Cmd::SkinSelect => { - self.open_skin_dialog(); - Ok(true) - } - Cmd::Search => { - self.search = Some(String::new()); - self.status.set_message("Search:"); - Ok(true) - } - Cmd::Quit => { - if self.should_quit { - Ok(false) - } else { - self.dialog = Some(DialogState::Quit(Box::default())); - Ok(true) - } - } - Cmd::MenuBar => { - if self.menubar.is_some() { - self.menubar = None; - } else { - self.menubar = Some(menubar::MenuBar::new()); - } - Ok(true) - } - Cmd::SelectGroup => { - self.dialog = Some(DialogState::SelectGroup(Box::new( - pattern_dialog::PatternDialog::new_select(), - ))); - Ok(true) - } - Cmd::UnselectGroup => { - self.dialog = Some(DialogState::UnselectGroup(Box::new( - pattern_dialog::PatternDialog::new_unselect(), - ))); - Ok(true) - } - Cmd::QuickCd => { - let history: Vec = self - .active_panel() - .history_paths() - .iter() - .map(|p| p.display().to_string()) - .collect(); - self.dialog = Some(DialogState::QuickCd(Box::new( - quickcd_dialog::QuickCdDialog::new(&history), - ))); - Ok(true) - } - Cmd::TogglePanels => { - self.panels_visible = !self.panels_visible; - if !self.panels_visible { - self.status.set_message("Panels hidden."); - } else { - self.status.set_message("Panels visible."); - } - Ok(true) - } - Cmd::SubShell => { - self.want_subshell = true; - Ok(true) - } - Cmd::Mark => { - self.active_panel_mut().toggle_mark(); - self.active_panel_mut().cursor_down(); - Ok(true) - } - Cmd::MarkDown => { - self.active_panel_mut().toggle_mark(); - self.active_panel_mut().cursor_down(); - Ok(true) - } - Cmd::MarkUp => { - self.active_panel_mut().toggle_mark(); - self.active_panel_mut().cursor_up(); - Ok(true) - } - Cmd::InvertMarks => { - self.active_panel_mut().reverse_marks(); - let n = self.active_panel().marked_count(); - self.status.set_message(format!("Inverted marks ({n} marked)")); - Ok(true) - } - Cmd::SortNext => { - self.active_panel_mut().cycle_sort(); - let sf = self.active_panel().sort_field_name(); - self.status.set_message(format!("Sort: {sf}")); - Ok(true) - } - Cmd::SortReverse => { - self.active_panel_mut().toggle_sort_reverse(); - let r = self.active_panel().sort_reverse(); - self.status.set_message(format!("Sort reverse: {r}")); - Ok(true) - } - Cmd::History => { - let dirs: Vec = self - .active_panel() - .history_paths() - .iter() - .rev() - .map(|p| p.display().to_string()) - .collect(); - self.status.set_message(format!( - "History: {} dirs (Alt-Y/Alt-U to navigate)", - dirs.len() - )); - Ok(true) - } - Cmd::SaveSetup => { - match self.save_config() { - Ok(()) => { - self.status.set_message("Configuration saved."); - } - Err(e) => { - self.status.set_message(format!("Save failed: {e}")); - } - } - Ok(true) - } - Cmd::ListingCycle => { - self.active_panel_mut().cycle_listing_mode(); - let mode = self.active_panel().listing_mode(); - self.status.set_message(format!("Listing: {}", mode.label())); - Ok(true) - } - Cmd::HistoryBack => { - match self.active_panel_mut().history_back() { - Ok(()) => { - self.status - .set_message("Back".to_string()); - } - Err(e) => { - self.status.set_message(format!("{e}")); - } - } - Ok(true) - } - Cmd::HistoryForward => { - match self.active_panel_mut().history_forward() { - Ok(()) => { - self.status - .set_message("Forward".to_string()); - } - Err(e) => { - self.status.set_message(format!("{e}")); - } - } - Ok(true) - } - Cmd::LayoutDialog => { - self.dialog = Some(DialogState::Layout(Box::new( - LayoutDialog::from_runtime_config(&self.runtime), - ))); - Ok(true) - } - Cmd::PanelOptionsDialog => { - self.dialog = Some(DialogState::PanelOptions(Box::new( - PanelOptionsDialog::from_runtime_config(&self.runtime), - ))); - Ok(true) - } - Cmd::ConfigDialog => { - self.dialog = Some(DialogState::Config(Box::new( - ConfigDialog::from_runtime_config(&self.runtime), - ))); - Ok(true) - } - Cmd::CompareDirs => { - self.compare_dirs(); - Ok(true) - } - Cmd::ViewerNextFile | Cmd::ViewerPrevFile => Ok(true), - Cmd::QuitQuiet => { - self.should_quit = true; - Ok(false) - } - Cmd::EqualSplit => { - self.runtime.equal_split = Some(true); - self.split_ratio = 50; - Ok(true) - } - Cmd::DirSize => { - let p = self.active_panel().cursor_path(); - if p.is_dir() { - let bytes = crate::ops::count_bytes(std::slice::from_ref(&p)); - self.status - .set_message(format!("{}: {}", p.display(), format_size(bytes))); - } else { - self.status.set_message("Not a directory".to_string()); - } - Ok(true) - } - Cmd::InsertCurPath => { - let p = self.active_panel().path().to_string_lossy().to_string(); - self.cmdline.insert_text(&p); - Ok(true) - } - Cmd::InsertOtherPath => { - let p = self.other_panel().path().to_string_lossy().to_string(); - self.cmdline.insert_text(&p); - Ok(true) - } - Cmd::InsertCurFile => { - let name = self - .active_panel() - .cursor_path() - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - self.cmdline.insert_text(&name); - Ok(true) - } - Cmd::SplitLess => { - self.runtime.equal_split = Some(false); - self.split_ratio = self.split_ratio.saturating_sub(5).max(10); - Ok(true) - } - Cmd::SplitMore => { - self.runtime.equal_split = Some(false); - self.split_ratio = self.split_ratio.saturating_add(5).min(90); - Ok(true) - } - Cmd::Jobs => { - let dlg = jobs::JobsDialog::new(Arc::clone(&self.jobs)); - self.dialog = Some(DialogState::Jobs(Box::new(dlg))); - Ok(true) - } - Cmd::Panelize => { - self.dialog = Some(DialogState::ExternalPanelize(Box::default())); - Ok(true) - } - Cmd::VfsList => { - self.dialog = Some(DialogState::VfsList(Box::default())); - Ok(true) - } - Cmd::SymlinkRelative => { - let cursor = self.active_panel().cursor_path(); - if let Some(dlg) = link::LinkDialog::for_relative(cursor) { - self.dialog = Some(DialogState::Link(Box::new(dlg))); - } - Ok(true) - } - Cmd::SymlinkEdit => { - self.edit_symlink_target(); - Ok(true) - } - Cmd::ScreenList => { - let dlg = screen_list::ScreenListDialog::with_screens(self.collect_active_screens()); - self.dialog = Some(DialogState::ScreenList(Box::new(dlg))); - Ok(true) - } - Cmd::EditHistory => { - let dlg = edit_history::EditHistoryDialog::from_history( - self.active_panel().history_paths().to_vec(), - ); - self.dialog = Some(DialogState::EditHistory(Box::new(dlg))); - Ok(true) - } - Cmd::FilteredView => { - let cursor = self.active_panel().cursor_path(); - self.dialog = Some(DialogState::FilteredView(Box::new( - filtered_view::FilteredViewDialog::new(cursor), - ))); - Ok(true) - } - Cmd::CompareFiles => { - let left = self.active_panel().cursor_path(); - let right = self.other_panel().cursor_path(); - if left.is_file() && right.is_file() { - self.dialog = Some(DialogState::Compare(Box::new( - compare::CompareDialog::new(&left, &right), - ))); - Ok(true) - } else { - self.status.set_message( - "Compare: both panels must have a regular file under cursor".to_string(), - ); - Ok(true) - } - } - Cmd::Suspend => { - self.status - .set_message("Suspend: use Ctrl-O to drop to a shell".to_string()); - Ok(true) - } - Cmd::PanelInfo => { - let was_info = matches!( - self.other_panel().listing_mode(), - panel::ListingMode::Info - ); - self.other_panel_mut().toggle_info_mode(); - self.status - .set_message(if was_info { "Info off" } else { "Info on" }); - Ok(true) - } - Cmd::PanelQuickView => { - let was_qv = matches!( - self.other_panel().listing_mode(), - panel::ListingMode::QuickView - ); - self.other_panel_mut().toggle_quickview_mode(); - self.status - .set_message(if was_qv { "Quick view off" } else { "Quick view on" }); - Ok(true) - } - } - } - - /// Dispatch a follow-up key after the user pressed Ctrl-X. - /// - /// The Ctrl-X prefix activates a two-key chord for commands that - /// don't have a single-key binding. When `pending_ctrl_x` is true, - /// the next key is routed here instead of the normal keymap. - pub fn dispatch_ctrl_x_followup(&mut self, key: crate::key::Key) -> Result { - self.pending_ctrl_x = false; - // Translate the key into a (char) so we can match on the - // follow-up letter. We ignore the modifier bits for the - // chord's second key — MC's chord semantics are letter-only. - let ch = char::from_u32(key.code); - let cmd = match ch { - Some('d') => Some(Cmd::CompareDirs), - Some('j') => Some(Cmd::Jobs), - Some('c') => Some(Cmd::Permission), - Some('o') => Some(Cmd::Owner), - Some('l') => Some(Cmd::Symlink), - Some('s') => Some(Cmd::SymlinkRelative), - Some('v') => Some(Cmd::SymlinkEdit), - Some('a') => Some(Cmd::VfsList), - Some('!') => Some(Cmd::Panelize), - Some('i') => Some(Cmd::PanelInfo), - Some('q') => Some(Cmd::PanelQuickView), - _ => None, - }; - match cmd { - Some(c) => self.dispatch(c).map_err(|e| e.to_string()), - None => { - self.status.set_message("Ctrl-X: unknown follow-up key".to_string()); - Ok(true) - } - } - } - - /// Compare directories using size-only mode (MC parity). - fn compare_dirs(&mut self) { - let snapshots: Vec<(String, u64, bool)> = self.other_panel().file_snapshots(); - let index = build_size_index_from_snap(&snapshots); - let count = mark_differing_from_snap(self.active_panel_mut(), &index); - self.status.set_message(format!( - "Compare: {} file(s) marked as different", - count - )); - } - - /// Open a dialog to edit an existing symlink's target. - fn edit_symlink_target(&mut self) { - let cursor = self.active_panel().cursor_path(); - match std::fs::read_link(&cursor) { - Ok(existing_target) => { - let dlg = link::LinkDialog::for_editing(cursor, existing_target); - self.dialog = Some(DialogState::Link(Box::new(dlg))); - } - Err(_) => { - self.status.set_message("Not a symlink".to_string()); - } - } - } - - /// Snapshot the currently-active screens (overlays that are open - /// on top of the panel pair) for the Alt-` screen list dialog. - fn collect_active_screens(&self) -> Vec { - let mut screens = Vec::new(); - if let Some(ed) = &self.editor { - let detail = ed - .path() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - screens.push(screen_list::ActiveScreen::new("Editor", detail)); - } - if let Some(v) = &self.viewer { - screens.push(screen_list::ActiveScreen::new( - "Viewer", - v.path.display().to_string(), - )); - } - if let Some(x) = &self.exec { - screens.push(screen_list::ActiveScreen::new( - "Exec", - x.command().to_string(), - )); - } - if self.menubar.is_some() { - screens.push(screen_list::ActiveScreen::new("Menu", String::new())); - } - if let Some(d) = &self.dialog { - screens.push(screen_list::ActiveScreen::new("Dialog", dialog_label(d))); - } - screens - } - /// Persist the current configuration to `~/.config/tlc/config.toml`. pub fn save_config(&self) -> Result<()> { let cfg = crate::config::Config { @@ -963,288 +441,6 @@ impl FileManager { cfg.save(None) } - /// Open the F11 file-info dialog for the cursor entry. - fn open_info_dialog(&mut self) -> Result<()> { - let p = self.active_panel().cursor_path(); - match crate::ops::info::FileInfo::for_path(&p) { - Ok(info) => { - self.dialog = Some(DialogState::Info(Box::new(InfoDialog::new(p, info)))); - } - Err(e) => { - self.status.set_message(format!("info: {e}")); - } - } - Ok(()) - } - - /// Open the C-x c permission dialog for the cursor entry. - fn open_permission_dialog(&mut self) -> Result<()> { - let p = self.active_panel().cursor_path(); - let mode = match crate::fs::stat(&p) { - Ok(s) => s.permissions.to_mode(), - Err(e) => { - self.status.set_message(format!("chmod: {e}")); - return Ok(()); - } - }; - self.dialog = Some(DialogState::Permission(Box::new(PermissionDialog::new( - p, mode, - )))); - Ok(()) - } - - /// Open the C-x o owner dialog for the cursor entry. - fn open_owner_dialog(&mut self) -> Result<()> { - let p = self.active_panel().cursor_path(); - let s = match crate::fs::stat(&p) { - Ok(s) => s, - Err(e) => { - self.status.set_message(format!("chown: {e}")); - return Ok(()); - } - }; - self.dialog = Some(DialogState::Owner(Box::new(OwnerDialog::new( - p, s.uid, s.gid, - )))); - Ok(()) - } - - /// Open the C-x l (or C-x s) link dialog for the cursor entry. - fn open_link_dialog(&mut self, kind: LinkKind) -> Result<()> { - let p = self.active_panel().cursor_path(); - if p.as_os_str().is_empty() { - self.status.set_message("link: no path selected"); - return Ok(()); - } - self.dialog = Some(DialogState::Link(Box::new(LinkDialog::with_kind(p, kind)))); - Ok(()) - } - - /// Open the F7 mkdir dialog for the active panel's current path. - fn open_mkdir_dialog(&mut self) -> Result<()> { - let p = self.active_panel().path().to_path_buf(); - self.dialog = Some(DialogState::MkDir(Box::new(MkDirDialog::new(p)))); - Ok(()) - } - - /// Open the F5 copy dialog for the cursor file or all marked files. - fn open_copy_dialog(&mut self) -> Result<()> { - let panel = self.active_panel(); - let dst = self.other_panel().path().to_path_buf(); - let sources: Vec = if panel.marked_count() > 0 { - panel - .marked_names() - .into_iter() - .map(|n| panel.path().join(n)) - .collect() - } else { - vec![panel.cursor_path()] - }; - if sources.is_empty() { - self.status.set_message("copy: no source selected"); - return Ok(()); - } - self.dialog = Some(DialogState::Copy(Box::new( - CopyDialog::new_with_dst(sources, dst), - ))); - Ok(()) - } - - /// Open the F6 move dialog for the cursor file or all marked files. - fn open_move_dialog(&mut self) -> Result<()> { - let panel = self.active_panel(); - let dst = self.other_panel().path().to_path_buf(); - let sources: Vec = if panel.marked_count() > 0 { - panel - .marked_names() - .into_iter() - .map(|n| panel.path().join(n)) - .collect() - } else { - vec![panel.cursor_path()] - }; - if sources.is_empty() { - self.status.set_message("move: no source selected"); - return Ok(()); - } - self.dialog = Some(DialogState::Move(Box::new( - MoveDialog::new_with_dst(sources, dst), - ))); - Ok(()) - } - - /// Open the F8 delete confirmation dialog for the cursor file or - /// all marked files. - fn open_delete_dialog(&mut self) -> Result<()> { - let panel = self.active_panel(); - let paths: Vec = if panel.marked_count() > 0 { - panel - .marked_names() - .into_iter() - .map(|n| panel.path().join(n)) - .collect() - } else { - vec![panel.cursor_path()] - }; - if paths.is_empty() { - self.status.set_message("delete: no source selected"); - return Ok(()); - } - self.dialog = Some(DialogState::Delete(Box::new(DeleteDialog::new(paths)))); - Ok(()) - } - - /// Run `rmdir` on the cursor entry. - fn run_rmdir_on_cursor(&mut self) -> Result<()> { - let p = self.active_panel().cursor_path(); - let handle = self - .ops_manager - .begin(crate::ops::OpKind::Delete, vec![p.clone()], None); - match crate::ops::rmdir::rmdir(&p, &handle) { - Ok(()) => { - self.status - .set_message(format!("Removed directory {}", p.display())); - self.ops_manager.finish(); - self.active_panel_mut().refresh()?; - } - Err(crate::ops::OpsError::DirectoryNotEmpty(_)) => { - self.status.set_message("rmdir: directory not empty"); - } - Err(crate::ops::OpsError::NotADirectory(_)) => { - self.status.set_message("rmdir: not a directory"); - } - Err(crate::ops::OpsError::SourceNotFound(_)) => { - self.status.set_message("rmdir: not found"); - } - Err(e) => { - self.status.set_message(format!("rmdir: {e}")); - } - } - Ok(()) - } - - /// Open the F4 editor for the cursor entry. - fn open_editor_for_cursor(&mut self) -> Result<()> { - let p = self.active_panel().cursor_path(); - if p.as_os_str().is_empty() { - self.status.set_message("edit: no path selected"); - return Ok(()); - } - self.editor = Some(crate::editor::Editor::open(&p)); - self.status.set_message(format!("Editing {}", p.display())); - Ok(()) - } - - /// Open the F3 viewer for the cursor entry. - fn open_viewer_for_cursor(&mut self) -> Result<()> { - let p = self.active_panel().cursor_path(); - if p.as_os_str().is_empty() { - self.status.set_message("view: no path selected"); - return Ok(()); - } - match crate::viewer::Viewer::open(&p) { - Ok(v) => { - self.viewer = Some(v); - self.status.set_message(format!("Viewing {}", p.display())); - } - Err(e) => { - self.status.set_message(format!("view: {e}")); - } - } - Ok(()) - } - - /// Open the M-? Find dialog. - fn open_find_dialog(&mut self) -> Result<()> { - let start = self.active_panel().path().to_path_buf(); - self.dialog = Some(DialogState::Find(Box::new(find::FindDialog::new(start)))); - Ok(()) - } - - /// Open the `\` Hotlist dialog. - fn open_hotlist_dialog(&mut self) -> Result<()> { - let mut dlg = hotlist::HotlistDialog::new(); - dlg.set_current_path(self.active_panel().path().to_path_buf()); - self.dialog = Some(DialogState::Hotlist(Box::new(dlg))); - Ok(()) - } - - /// Open the C-\ Tree dialog. - fn open_tree_dialog(&mut self) -> Result<()> { - let start = self.active_panel().path().to_path_buf(); - self.dialog = Some(DialogState::Tree(Box::new(tree::TreeDialog::new(start)))); - Ok(()) - } - - /// Open the F2 User Menu dialog. - fn open_user_menu_dialog(&mut self) -> Result<()> { - let file = self.active_panel().cursor_path(); - if file.as_os_str().is_empty() { - self.status.set_message("user menu: no path selected"); - return Ok(()); - } - let condition = "view"; - let mut dlg = usermenu::UserMenuDialog::new(file, condition); - // Build a `PercentCtx` snapshot of the file manager so the - // user-menu executor can expand all 17 MC percent escapes - // (`%f`, `%p`, `%x`, `%s`, `%t`, `%u`, `%c`, `%cd`, …). - let active = self.active_panel(); - let other = self.other_panel(); - let mut ctx = percent::PercentCtx::for_file(active.cursor_path(), active.path()); - ctx.other_dir = other.path().to_path_buf(); - ctx.selected_count = active.marked_count(); - ctx.tagged = active.marked_names(); - ctx.menu_path = usermenu::UserMenu::new().storage_path; - dlg.set_context(ctx); - self.dialog = Some(DialogState::UserMenu(Box::new(dlg))); - Ok(()) - } - - /// Open the F1 Help dialog: a modal overlay listing every key - /// binding from the default keymap. - fn open_help_dialog(&mut self) -> Result<()> { - self.dialog = Some(DialogState::Help(Box::new(help::HelpDialog::new( - self.theme, - )))); - Ok(()) - } - - /// Open the Ctrl-S Skin selection dialog: a modal overlay - /// listing every available skin (built-in presets + user - /// TOML skins). - fn open_skin_dialog(&mut self) { - let current = self.skin_name.clone(); - self.dialog = Some(DialogState::Skin(Box::new( - skin_dialog::SkinDialog::new(¤t), - ))); - } - - /// Apply a confirmed skin selection: switch the active theme, - /// persist the new name to the user config, post a status. - fn apply_skin_selection(&mut self, name: String) { - self.theme = Theme::by_name(&name); - self.skin_name = name.clone(); - if let Err(e) = self.persist_skin_name(&name) { - self.status - .set_message(format!("skin: {e}")); - } else { - self.status - .set_message(format!("Skin: {name}")); - } - self.dialog = None; - } - - /// Write just the skin name to the user config file. Other - /// sections of the config are preserved by reading the file - /// first, replacing the `[skin] name` field, and writing it - /// back. A missing config file is created with a minimal - /// `Config::default()` skeleton. - fn persist_skin_name(&self, name: &str) -> anyhow::Result<()> { - let mut cfg = Config::load(None).unwrap_or_default(); - cfg.skin.name = name.to_string(); - cfg.save(None) - } - /// Handle a key while the editor is open. Returns `true` if the /// editor was closed (caller should clear `self.editor`). pub fn handle_editor_key(&mut self, key: Key) -> bool { @@ -1341,804 +537,6 @@ impl FileManager { } - /// dialog consumed it. Closes (and applies, for confirmation - /// dialogs) the dialog if it has finished. - pub fn handle_dialog_key(&mut self, key: Key) -> bool { - // The 4 new dialogs (find/hotlist/tree/usermenu) return - // an `Outcome` enum from `handle_key`; we capture and - // apply the result here. The 4 ops dialogs (MkDir/Copy/ - // Move/Delete) and the 4 metadata dialogs (Info/Permission/ - // Owner/Link) have a self-contained `confirmed` flag on - // the dialog itself, so they go through the default - // `handle_key` -> bool path. - let mut consumed = false; - let mut tree_outcome: Option = None; - let mut find_outcome: Option = None; - let mut hot_outcome: Option = None; - let mut menu_outcome: Option = None; - let mut skin_outcome: Option = None; - let mut layout_outcome: Option = None; - let mut panel_outcome: Option = None; - let mut config_outcome: Option = None; - let mut jobs_should_close = false; - let mut panelize_outcome: Option = None; - let mut vfs_outcome: Option = None; - let mut screen_list_outcome: Option = None; - let mut edit_history_outcome: Option = None; - let mut filtered_view_outcome: Option = None; - match &mut self.dialog { - Some(DialogState::Info(_d)) => { - // Info dialog consumes Enter and Esc (close). - if matches!(key, Key::ENTER | Key::ESCAPE) { - consumed = true; - } - } - Some(DialogState::Permission(d)) => { - consumed = d.handle_key(key); - } - Some(DialogState::Owner(d)) => { - consumed = d.handle_key(key); - } - Some(DialogState::Link(d)) => { - consumed = d.handle_key(key); - } - Some(DialogState::MkDir(d)) => { - consumed = d.handle_key(key); - } - Some(DialogState::Copy(d)) => { - consumed = d.handle_key(key); - } - Some(DialogState::Move(d)) => { - consumed = d.handle_key(key); - } - Some(DialogState::Delete(d)) => { - consumed = d.handle_key(key); - } - Some(DialogState::Find(d)) => { - find_outcome = Some(d.handle_key(key)); - consumed = true; - } - Some(DialogState::Hotlist(d)) => { - hot_outcome = Some(d.handle_key(key)); - consumed = true; - } - Some(DialogState::Tree(d)) => { - tree_outcome = Some(d.handle_key(key)); - consumed = true; - } - Some(DialogState::UserMenu(d)) => { - menu_outcome = Some(d.handle_key(key)); - consumed = true; - } - Some(DialogState::Help(d)) => { - if d.handle_key(key) { - self.dialog = None; - } - consumed = true; - } - Some(DialogState::Skin(d)) => { - skin_outcome = d.handle_key(key); - consumed = true; - } - Some(DialogState::Quit(d)) => { - consumed = d.handle_key(key); - } - Some(DialogState::SelectGroup(d)) => { - d.handle_key(key); - consumed = true; - } - Some(DialogState::UnselectGroup(d)) => { - d.handle_key(key); - consumed = true; - } - Some(DialogState::QuickCd(d)) => { - d.handle_key(key); - consumed = true; - } - Some(DialogState::Overwrite(d)) => { - d.handle_key(key); - consumed = true; - } - Some(DialogState::Layout(d)) => { - layout_outcome = Some(d.handle_key(key)); - consumed = true; - } - Some(DialogState::PanelOptions(d)) => { - panel_outcome = Some(d.handle_key(key)); - consumed = true; - } - Some(DialogState::Config(d)) => { - config_outcome = Some(d.handle_key(key)); - consumed = true; - } - Some(DialogState::Jobs(d)) => { - jobs_should_close = d.handle_key(key); - consumed = true; - } - Some(DialogState::ExternalPanelize(d)) => { - panelize_outcome = Some(d.handle_key(key)); - consumed = true; - } - Some(DialogState::VfsList(d)) => { - vfs_outcome = Some(d.handle_key(key)); - consumed = true; - } - Some(DialogState::ScreenList(d)) => { - screen_list_outcome = Some(d.handle_key(key)); - consumed = true; - } - Some(DialogState::EditHistory(d)) => { - edit_history_outcome = Some(d.handle_key(key)); - consumed = true; - } - Some(DialogState::FilteredView(d)) => { - filtered_view_outcome = Some(d.handle_key(key)); - consumed = true; - } - Some(DialogState::Compare(d)) => { - let o = d.handle_key(key); - if matches!(o, compare::CompareOutcome::Close) { - self.dialog = None; - } - consumed = true; - } - None => return false, - } - // Apply captured outcomes. - if let Some(o) = tree_outcome { - self.apply_tree_outcome(o); - } - if let Some(o) = find_outcome { - self.apply_find_outcome(o); - } - if let Some(o) = hot_outcome { - self.apply_hotlist_outcome(o); - } - if let Some(o) = menu_outcome { - self.apply_user_menu_outcome(o); - } - if let Some(o) = skin_outcome { - self.apply_skin_outcome_or_close(o); - } - if let Some(o) = layout_outcome { - self.apply_layout_outcome(o); - } - if let Some(o) = panel_outcome { - self.apply_panel_options_outcome(o); - } - if let Some(o) = config_outcome { - self.apply_config_outcome(o); - } - if jobs_should_close { - self.dialog = None; - } - if let Some(o) = panelize_outcome { - self.apply_external_panelize_outcome(o); - } - if let Some(o) = vfs_outcome { - self.apply_vfs_list_outcome(o); - } - if let Some(o) = screen_list_outcome { - self.apply_screen_list_outcome(o); - } - if let Some(o) = edit_history_outcome { - self.apply_edit_history_outcome(o); - } - if let Some(o) = filtered_view_outcome { - self.apply_filtered_view_outcome(o); - } - if let Some(d) = &self.dialog { - if d.is_finished() { - self.apply_finished_dialog(); - } - } - consumed - } - - /// Apply a tree dialog outcome (Cd path → switch active panel; Cancel → close). - fn apply_tree_outcome(&mut self, o: tree::TreeOutcome) { - use tree::TreeOutcome; - match o { - TreeOutcome::Cd(path) => { - self.status.set_message(format!("cd {}", path.display())); - if let Ok(()) = self.active_panel_mut().set_path(&path) { - let _ = self.active_panel_mut().refresh(); - } - } - TreeOutcome::Cancel | TreeOutcome::Running => {} - } - self.dialog = None; - } - - fn apply_layout_outcome(&mut self, o: layout_dialog::LayoutResult) { - use layout_dialog::LayoutResult; - match o { - LayoutResult::Confirm(settings) => { - self.runtime.equal_split = Some(settings.equal_split); - self.runtime.show_menubar = Some(settings.show_menubar); - self.runtime.show_keybar = Some(settings.show_keybar); - self.runtime.show_hintbar = Some(settings.show_hintbar); - self.runtime.show_cmdline = Some(settings.show_cmdline); - self.status.set_message("Layout options updated"); - if self.runtime.auto_save_setup() { - let _ = self.save_config(); - } - } - LayoutResult::Cancel | LayoutResult::Running => {} - } - self.dialog = None; - } - - fn apply_panel_options_outcome(&mut self, o: panel_options::PanelOptionsResult) { - use panel_options::PanelOptionsResult; - match o { - PanelOptionsResult::Confirm(settings) => { - self.runtime.mix_all_files = Some(settings.mix_all_files); - self.runtime.mark_moves_down = Some(settings.mark_moves_down); - self.runtime.show_mini_status = Some(settings.show_mini_status); - if self.left.show_hidden() != settings.show_hidden { - let _ = self.left.toggle_hidden(); - } - if self.right.show_hidden() != settings.show_hidden { - let _ = self.right.toggle_hidden(); - } - self.cfg.show_hidden = settings.show_hidden; - self.status.set_message("Panel options updated"); - if self.runtime.auto_save_setup() { - let _ = self.save_config(); - } - } - PanelOptionsResult::Cancel | PanelOptionsResult::Running => {} - } - self.dialog = None; - } - - fn apply_config_outcome(&mut self, o: config_dialog::ConfigResult) { - use config_dialog::ConfigResult; - match o { - ConfigResult::Confirm(settings) => { - self.runtime.esc_exit_mode = Some(settings.esc_exit_mode); - self.runtime.verbose_ops = Some(settings.verbose_ops); - self.runtime.auto_save_setup = Some(settings.auto_save_setup); - self.runtime.safe_delete = Some(settings.safe_delete); - self.runtime.pause_after_run = Some(settings.pause_after_run); - self.status.set_message("Configuration updated"); - if self.runtime.auto_save_setup() { - let _ = self.save_config(); - } - } - ConfigResult::Cancel | ConfigResult::Running => {} - } - self.dialog = None; - } - - /// Apply an external-panelize dialog outcome. - /// - /// `Apply` is what the user wanted — the command produced one or - /// more paths. The dialog is closed and the path count is shown - /// on the status line. The active panel is not replaced yet: - /// `Panel::set_external_listing` is a future integration point - /// (the parsed paths are kept inside the dialog's - /// `ExternalPanelizeOutcome::Apply(Vec)` until the - /// panel-side support lands). - /// - /// `Empty` keeps the dialog open so the user can edit the - /// command; the dialog itself records the error in its - /// `last_error` field. `Cancel` (Esc) closes the dialog. - /// `Running` is the no-op case where the dialog stays open and - /// keeps consuming keys. - fn apply_external_panelize_outcome( - &mut self, - o: external_panelize::ExternalPanelizeOutcome, - ) { - use external_panelize::ExternalPanelizeOutcome; - match o { - ExternalPanelizeOutcome::Apply(paths) => { - self.status - .set_message(format!("panelize: {} path(s) captured", paths.len())); - self.dialog = None; - } - ExternalPanelizeOutcome::Cancel => { - self.dialog = None; - } - ExternalPanelizeOutcome::Empty | ExternalPanelizeOutcome::Running => {} - } - } - - /// Apply a VFS-list dialog outcome. The dialog is read-only — - /// `Cancel` (Esc) closes the dialog; `Running` (any other key) - /// leaves the dialog open and continues to consume keys. A future - /// enhancement may add a `Mount`/`Unmount` action that re-routes - /// the active panel. - fn apply_vfs_list_outcome(&mut self, o: vfs_list::VfsListOutcome) { - use vfs_list::VfsListOutcome; - match o { - VfsListOutcome::Cancel => { - self.dialog = None; - } - VfsListOutcome::Running => {} - } - } - - fn apply_screen_list_outcome(&mut self, _o: screen_list::ScreenListOutcome) { - // ScreenList just closes on Esc — no action needed - } - - fn apply_edit_history_outcome(&mut self, o: edit_history::EditHistoryOutcome) { - use edit_history::EditHistoryOutcome; - match o { - EditHistoryOutcome::Navigate(path) => { - if let Err(e) = self.active_panel_mut().set_path(&path) { - self.status.set_message(format!("cd failed: {}", e)); - } - let _ = self.active_panel_mut().refresh(); - } - EditHistoryOutcome::Cancel | EditHistoryOutcome::Running => {} - } - self.dialog = None; - } - - fn apply_filtered_view_outcome(&mut self, o: filtered_view::FilteredViewOutcome) { - use filtered_view::FilteredViewOutcome; - match o { - FilteredViewOutcome::Apply { - stdout, - stderr, - source_path, - } => { - use crate::viewer::source::FileSource; - let src = FileSource::Inline { bytes: stdout }; - let viewer = crate::viewer::Viewer::from_source(source_path.clone(), src); - self.viewer = Some(viewer); - if !stderr.is_empty() { - self.status.set_message(format!("filter: {}", stderr.trim_end())); - } else { - self.status.set_message(format!("filtered: {}", source_path.display())); - } - } - FilteredViewOutcome::Cancel => { - self.dialog = None; - } - FilteredViewOutcome::Running => {} - } - } - - /// Apply a find dialog outcome (Open/View/Edit/Cd path). - fn apply_find_outcome(&mut self, o: find::FindOutcome) { - use find::FindOutcome; - match o { - FindOutcome::Open(path) => { - self.open_file_via_path(&path); - } - FindOutcome::View(path) => match crate::viewer::Viewer::open(&path) { - Ok(v) => { - self.viewer = Some(v); - self.status - .set_message(format!("Viewing {}", path.display())); - } - Err(e) => self.status.set_message(format!("view: {e}")), - }, - FindOutcome::Edit(path) => { - self.editor = Some(crate::editor::Editor::open(&path)); - self.status - .set_message(format!("Editing {}", path.display())); - } - FindOutcome::Cancel | FindOutcome::Running => {} - } - self.dialog = None; - } - - /// Apply a hotlist dialog outcome (Cd path). - fn apply_hotlist_outcome(&mut self, o: hotlist::HotlistOutcome) { - use hotlist::HotlistOutcome; - match o { - HotlistOutcome::Cd(path) => { - if let Ok(()) = self.active_panel_mut().set_path(&path) { - let _ = self.active_panel_mut().refresh(); - } - self.status.set_message(format!("cd {}", path.display())); - } - HotlistOutcome::AddCurrent(path) => { - let label = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| path.display().to_string()); - let mut data = hotlist::load_hotlist(); - data.entries.push(hotlist::HotlistEntry { - label: label.clone(), - path: path.clone(), - }); - match hotlist::save_hotlist(&data) { - Ok(()) => { - self.status - .set_message(format!("Added to hotlist: {label}")); - } - Err(e) => { - self.status - .set_message(format!("Hotlist save failed: {e}")); - } - } - } - HotlistOutcome::Cancel | HotlistOutcome::Running => {} - } - self.dialog = None; - } - - /// Apply a user menu dialog outcome (Execute expanded command). - fn apply_user_menu_outcome(&mut self, o: usermenu::UserMenuOutcome) { - use usermenu::UserMenuOutcome; - match o { - UserMenuOutcome::Execute(cmd) => { - let cwd = self.active_panel().path().to_path_buf(); - self.start_exec(cmd, &cwd); - } - UserMenuOutcome::Cancel | UserMenuOutcome::Running => {} - } - self.dialog = None; - } - - /// Dispatch a [`skin_dialog::SkinDialogOutcome`] to either - /// `apply_skin_selection` (on `Selected`) or to a simple - /// dialog dismissal (on `Cancelled`). - fn apply_skin_outcome_or_close(&mut self, o: skin_dialog::SkinDialogOutcome) { - use skin_dialog::SkinDialogOutcome; - match o { - SkinDialogOutcome::Selected(name) => { - self.apply_skin_selection(name); - } - SkinDialogOutcome::Cancelled => { - self.dialog = None; - } - } - } - - /// Open a file at `path` by cd-ing the active panel into its parent. - fn open_file_via_path(&mut self, path: &std::path::Path) { - if let Some(parent) = path.parent() { - if let Ok(()) = self.active_panel_mut().set_path(parent) { - let _ = self.active_panel_mut().refresh(); - } - } - self.status.set_message(format!("Found {}", path.display())); - } - - /// Apply the result of a finished dialog (chmod, chown, link) and - /// then clear `self.dialog`. - fn apply_finished_dialog(&mut self) { - // Pull the dialog out (mem::replace with None) so we can - // destructure and apply without borrow conflicts. - let dlg = self.dialog.take(); - match dlg { - // The 4 new dialogs (Find/Hotlist/Tree/UserMenu) are - // closed by their own apply_*_outcome helpers before - // this function is called; they should never appear here. - Some(DialogState::Find(_)) - | Some(DialogState::Hotlist(_)) - | Some(DialogState::Tree(_)) - | Some(DialogState::UserMenu(_)) - | Some(DialogState::Layout(_)) - | Some(DialogState::PanelOptions(_)) - | Some(DialogState::Config(_)) - | Some(DialogState::Jobs(_)) - | Some(DialogState::ExternalPanelize(_)) - | Some(DialogState::VfsList(_)) - | Some(DialogState::ScreenList(_)) - | Some(DialogState::EditHistory(_)) - | Some(DialogState::FilteredView(_)) - | Some(DialogState::Compare(_)) => { - // No-op: those dialogs clear themselves. - } - // The Help dialog also clears itself in `handle_dialog_key` - // when the user presses a close key. - Some(DialogState::Help(_)) => {} - // The Skin dialog clears itself in `apply_skin_selection` - // and `apply_skin_outcome_or_close`; it should never - // reach this function. - Some(DialogState::Skin(_)) => {} - Some(DialogState::Permission(d)) => { - if let Some(new_mode) = d.result() { - let path = d.path.clone(); - match crate::fs::perm::chmod(&path, new_mode) { - Ok(()) => { - self.status.set_message(format!( - "chmod {:o} {}", - new_mode, - path.display() - )); - let _ = self.active_panel_mut().refresh(); - } - Err(e) => { - self.status.set_message(format!("chmod: {e}")); - } - } - } - } - Some(DialogState::Owner(d)) => { - if let Some((uid, gid)) = d.result() { - let path = d.path.clone(); - match crate::fs::perm::chown(&path, uid, gid) { - Ok(()) => { - self.status.set_message(format!( - "chown {}:{} {}", - uid, - gid, - path.display() - )); - let _ = self.active_panel_mut().refresh(); - } - Err(e) => { - self.status.set_message(format!("chown: {e}")); - } - } - } - } - Some(DialogState::Link(d)) => { - if let Some((src, target)) = d.result() { - let kind = d.kind; - let r = match kind { - LinkKind::Hard => crate::ops::link::hardlink(&src, &target), - LinkKind::Sym | LinkKind::SymRelative => { - // For SymRelative, rewrite `src` to a path - // relative to the link's directory. The - // link lives in `target.parent()`. - let effective_src = match kind { - LinkKind::SymRelative => { - let link_dir = target.parent().unwrap_or_else(|| { - std::path::Path::new(".") - }); - relpath_from(&src, link_dir) - } - _ => src.clone(), - }; - crate::ops::link::symlink(&effective_src, &target) - } - }; - match r { - Ok(()) => { - self.status.set_message(format!( - "{} {} -> {}", - kind.label(), - src.display(), - target.display() - )); - let _ = self.active_panel_mut().refresh(); - } - Err(e) => { - self.status.set_message(format!("{}: {e}", kind.label())); - } - } - } - } - Some(DialogState::MkDir(d)) => { - if let Some(new_path) = d.result() { - let handle = self.ops_manager.begin( - crate::ops::OpKind::MkDir, - vec![new_path.clone()], - None, - ); - match crate::ops::mkdir::mkdir(&new_path, false, &handle) { - Ok(()) => { - self.status - .set_message(format!("Created {}", new_path.display())); - self.ops_manager.finish(); - let _ = self.active_panel_mut().refresh(); - } - Err(e) => { - self.status.set_message(format!("mkdir: {e}")); - } - } - } - } - Some(DialogState::Copy(d)) => { - if let Some(dst) = d.result() { - let sources = d.src.clone(); - let preserve_attributes = d.preserve_attributes; - let follow_links = d.follow_links; - let handle = self.ops_manager.begin( - crate::ops::OpKind::Copy, - sources.clone(), - Some(dst.clone()), - ); - match crate::ops::copy::copy_many( - &sources, - &dst, - &handle, - false, - preserve_attributes, - follow_links, - ) { - Ok(()) => { - self.status.set_message(format!( - "Copied {} item(s) to {}", - sources.len(), - dst.display() - )); - self.ops_manager.finish(); - let _ = self.active_panel_mut().refresh(); - } - Err(crate::ops::OpsError::DestExists(p)) => { - self.pending_op = Some(PendingFileOp { - is_move: false, - sources, - dst, - preserve_attributes, - follow_links, - }); - self.dialog = Some(DialogState::Overwrite(Box::new( - overwrite_dialog::OverwriteDialog::new_copy( - p.display().to_string(), - ), - ))); - } - Err(e) => { - self.status.set_message(format!("copy: {e}")); - } - } - } - } - Some(DialogState::Move(d)) => { - if let Some(dst) = d.result() { - let sources = d.src.clone(); - let preserve_attributes = d.preserve_attributes; - let follow_links = d.follow_links; - let handle = self.ops_manager.begin( - crate::ops::OpKind::Move, - sources.clone(), - Some(dst.clone()), - ); - match crate::ops::move_op::move_many( - &sources, - &dst, - &handle, - false, - preserve_attributes, - follow_links, - ) { - Ok(()) => { - self.status.set_message(format!( - "Moved {} item(s) to {}", - sources.len(), - dst.display() - )); - self.ops_manager.finish(); - let _ = self.active_panel_mut().refresh(); - } - Err(crate::ops::OpsError::DestExists(p)) => { - self.pending_op = Some(PendingFileOp { - is_move: true, - sources, - dst, - preserve_attributes, - follow_links, - }); - self.dialog = Some(DialogState::Overwrite(Box::new( - overwrite_dialog::OverwriteDialog::new_move( - p.display().to_string(), - ), - ))); - } - Err(e) => { - self.status.set_message(format!("move: {e}")); - } - } - } - } - Some(DialogState::Delete(d)) => { - #[allow(clippy::collapsible_match, reason = "guard would change fallthrough semantics")] - if d.is_confirmed() { - let paths = d.paths.clone(); - let handle = - self.ops_manager - .begin(crate::ops::OpKind::Delete, paths.clone(), None); - match crate::ops::delete::delete_many(&paths, &handle) { - Ok(()) => { - self.status - .set_message(format!("Deleted {} item(s)", paths.len())); - self.ops_manager.finish(); - let _ = self.active_panel_mut().refresh(); - } - Err(e) => { - self.status.set_message(format!("delete: {e}")); - } - } - } - } - // Info dialog: just close. - Some(DialogState::Info(_)) => {} - Some(DialogState::Quit(d)) => { - #[allow(clippy::collapsible_match, reason = "guard would change fallthrough semantics")] - if d.confirmed { - self.should_quit = true; - } - } - Some(DialogState::SelectGroup(d)) => { - if let Some(pat) = d.result() { - self.active_panel_mut().mark_pattern(pat); - self.status.set_message(format!("Select group: {pat}")); - } - } - Some(DialogState::UnselectGroup(d)) => { - if let Some(pat) = d.result() { - self.active_panel_mut().unmark_pattern(pat); - self.status.set_message(format!("Unselect group: {pat}")); - } - } - Some(DialogState::QuickCd(d)) => { - if let Some(ref path) = d.confirmed_path { - if let Ok(()) = self.active_panel_mut().set_path(path) { - let _ = self.active_panel_mut().refresh(); - } - self.status.set_message(format!("cd {}", path.display())); - } - } - Some(DialogState::Overwrite(d)) => { - use overwrite_dialog::OverwriteOutcome; - if let Some(op) = self.pending_op.take() { - match d.outcome { - OverwriteOutcome::Yes | OverwriteOutcome::YesAll => { - let handle = self.ops_manager.begin( - if op.is_move { - crate::ops::OpKind::Move - } else { - crate::ops::OpKind::Copy - }, - op.sources.clone(), - Some(op.dst.clone()), - ); - let result = if op.is_move { - crate::ops::move_op::move_many( - &op.sources, - &op.dst, - &handle, - true, - op.preserve_attributes, - op.follow_links, - ) - } else { - crate::ops::copy::copy_many( - &op.sources, - &op.dst, - &handle, - true, - op.preserve_attributes, - op.follow_links, - ) - }; - match result { - Ok(()) => { - let verb = if op.is_move { "Moved" } else { "Copied" }; - self.status.set_message(format!( - "{} {} item(s) to {}", - verb, - op.sources.len(), - op.dst.display() - )); - self.ops_manager.finish(); - let _ = self.active_panel_mut().refresh(); - } - Err(e) => { - self.status.set_message(format!( - "{}: {e}", - if op.is_move { "move" } else { "copy" } - )); - } - } - } - OverwriteOutcome::No - | OverwriteOutcome::NoAll - | OverwriteOutcome::Abort => { - self.status.set_message("Operation skipped."); - } - OverwriteOutcome::Running => { - self.pending_op = Some(op); - } - } - } - } - None => {} - } - } - /// Handle a key that wasn't bound to a Cmd (cursor movement, etc). /// Returns `Ok(())` always. pub fn handle_unbound_key(&mut self, key: crate::key::Key) -> Result<()> { @@ -2170,556 +568,44 @@ impl FileManager { } } - /// Render the file manager to a ratatui frame. - pub fn render(&mut self, frame: &mut Frame, area: Rect) { - if let Some(ed) = &mut self.editor { - ed.render(frame, area, &self.theme); - return; - } - if let Some(v) = &mut self.viewer { - v.render(frame, area, &self.theme); - return; - } - if let Some(x) = &mut self.exec { - x.render(frame, area, &self.theme); - return; - } - - if !self.panels_visible { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(area); - let blank = Paragraph::new("").style( - Style::default() - .fg(self.theme.foreground) - .bg(self.theme.background), - ); - frame.render_widget(blank, chunks[0]); - if self.cmdline.is_active() { - self.cmdline.render_inline(frame, chunks[1], &self.theme); - } else if self.status.has_message() { - self.status.render(frame, chunks[1], &self.theme); - } else { - self.cmdline.render_inline(frame, chunks[1], &self.theme); - } - self.render_buttonbar(frame, chunks[2]); - return; - } - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(3), // panels - Constraint::Length(1), // command/status line - Constraint::Length(1), // function-key bar - ]) - .split(area); - - self.render_panels(frame, chunks[0]); - let (left_panel_area, right_panel_area) = self.panel_areas(chunks[0]); - if self.cmdline.is_active() { - self.cmdline.render_inline(frame, chunks[1], &self.theme); - } else if self.status.has_message() { - self.status.render(frame, chunks[1], &self.theme); - } else { - self.cmdline.render_inline(frame, chunks[1], &self.theme); - } - self.render_buttonbar(frame, chunks[2]); - if let Some(ref mb) = self.menubar { - mb.render(frame, area, &self.theme); - } - if let Some(d) = &mut self.dialog { - match d { - DialogState::Info(d) => d.render(frame, area, &self.theme), - DialogState::Permission(d) => d.render(frame, area, &self.theme), - DialogState::Owner(d) => d.render(frame, area, &self.theme), - DialogState::Link(d) => d.render(frame, area, &self.theme), - DialogState::MkDir(d) => d.render(frame, area, &self.theme), - DialogState::Copy(d) => d.render(frame, area, &self.theme), - DialogState::Move(d) => d.render(frame, area, &self.theme), - DialogState::Delete(d) => d.render(frame, area, &self.theme), - DialogState::Find(d) => d.render(frame, area, &self.theme), - DialogState::Hotlist(d) => d.render(frame, area, &self.theme), - DialogState::Tree(d) => d.render(frame, area, &self.theme), - DialogState::UserMenu(d) => d.render(frame, area, &self.theme), - DialogState::Help(d) => d.render(frame, area), - DialogState::Skin(d) => d.render(frame, area, &self.theme), - DialogState::Quit(d) => d.render(frame, area, &self.theme), - DialogState::SelectGroup(d) => d.render(frame, area, &self.theme), - DialogState::UnselectGroup(d) => d.render(frame, area, &self.theme), - DialogState::QuickCd(d) => d.render( - frame, - match self.active { - Active::Left => left_panel_area, - Active::Right => right_panel_area, - }, - &self.theme, - ), - DialogState::Overwrite(d) => d.render(frame, area, &self.theme), - DialogState::Layout(d) => d.render(frame, area, &self.theme), - DialogState::PanelOptions(d) => d.render(frame, area, &self.theme), - DialogState::Config(d) => d.render(frame, area, &self.theme), - DialogState::Jobs(d) => d.render(frame, area, &self.theme), - DialogState::ExternalPanelize(d) => d.render(frame, area, &self.theme), - DialogState::VfsList(d) => d.render(frame, area, &self.theme), - DialogState::ScreenList(d) => d.render(frame, area, &self.theme), - DialogState::EditHistory(d) => d.render(frame, area, &self.theme), - DialogState::FilteredView(d) => d.render(frame, area, &self.theme), - DialogState::Compare(d) => d.render(frame, area, &self.theme), - } - } - } - - fn panel_areas(&self, area: Rect) -> (Rect, Rect) { - let left_pct = if self.runtime.equal_split.unwrap_or(true) { - 50 - } else { - self.split_ratio.clamp(1, 99) - }; - let right_pct = 100u16.saturating_sub(left_pct); - match self.layout { - LayoutMode::Horizontal => { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(left_pct), - Constraint::Percentage(right_pct), - ]) - .split(area); - (chunks[0], chunks[1]) - } - LayoutMode::Vertical => { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(left_pct), - Constraint::Percentage(right_pct), - ]) - .split(area); - (chunks[0], chunks[1]) - } - } - } - - fn render_panels(&mut self, frame: &mut Frame, area: Rect) { - let (left_a, right_a) = self.panel_areas(area); - // Adjust each panel's scroll so the cursor is visible. - let left_rows = panel_body_rows(left_a); - let right_rows = panel_body_rows(right_a); - self.left.ensure_cursor_visible(left_rows); - self.right.ensure_cursor_visible(right_rows); - self.render_panel(frame, left_a, &self.left, self.active == Active::Left); - self.render_panel(frame, right_a, &self.right, self.active == Active::Right); - } - - /// Render the function-key bar (bottom row): 1 Help 2 Menu 3 View ... - fn render_buttonbar(&self, frame: &mut Frame, area: Rect) { - let hotkey_pair = mc_skin::color_pair(self.theme.name, "buttonbar", "hotkey"); - let button_pair = mc_skin::color_pair(self.theme.name, "buttonbar", "button"); - let bar_bg = button_pair.map(|p| p.bg).unwrap_or(self.theme.buttonbar_bg); - let bar_fg = button_pair.map(|p| p.fg).unwrap_or(self.theme.buttonbar_fg); - let hot_fg = hotkey_pair.map(|p| p.fg).unwrap_or(self.theme.buttonbar_bg); - let hot_bg = hotkey_pair.map(|p| p.bg).unwrap_or(self.theme.buttonbar_fg); - let labels = [ - ("1", "Help"), - ("2", "Menu"), - ("3", "View"), - ("4", "Edit"), - ("5", "Copy"), - ("6", "Move"), - ("7", "MkDir"), - ("8", "Del"), - ("9", "Menu"), - ("10", "Quit"), - ]; - frame.render_widget( - Paragraph::new("") - .style(Style::default().bg(bar_bg).fg(bar_fg)), - area, - ); - let mut spans: Vec = Vec::new(); - for (num, label) in &labels { - spans.push(Span::styled( - *num, - Style::default() - .fg(hot_fg) - .bg(hot_bg) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled( - format!(" {} ", label), - Style::default() - .fg(bar_fg) - .bg(bar_bg), - )); - } - frame.render_widget( - Paragraph::new(Line::from(spans)), - area, - ); - } - - fn render_panel(&self, frame: &mut Frame, area: Rect, panel: &Panel, active: bool) { - let core_default = mc_skin::color_pair(self.theme.name, "core", "_default_"); - let core_header = mc_skin::color_pair(self.theme.name, "core", "header"); - let core_disabled = mc_skin::color_pair(self.theme.name, "core", "disabled"); - let panel_bg = core_default.map(|p| p.bg).unwrap_or(self.theme.background); - let panel_fg = core_default.map(|p| p.fg).unwrap_or(self.theme.foreground); - let header_fg = core_header.map(|p| p.fg).unwrap_or(self.theme.title_fg); - let header_bg = core_header - .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 = format!(" {} ", path_short(panel.path())); - let block = Block::default() - .borders(Borders::ALL) - .border_style(if active { - Style::default().fg(header_fg).bg(panel_bg) - } else { - Style::default().fg(self.theme.border).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), - )); - let inner = block.inner(area); - frame.render_widget(block, area); - - if inner.width == 0 || inner.height == 0 { - return; - } - - let show_meta = inner.height >= 3; - let show_footer = inner.height >= 2; - let body_y = inner.y + u16::from(show_meta); - let footer_h = u16::from(show_footer); - let body_height = inner - .height - .saturating_sub(u16::from(show_meta)) - .saturating_sub(footer_h) - .max(1); - let height = body_height as usize; - 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 { - frame.render_widget( - Paragraph::new(Span::styled( - panel_meta_text(panel), - Style::default().fg(disabled_fg).bg(panel_bg), - )), - Rect::new(inner.x, inner.y, inner.width, 1), - ); - } - - let list_area = Rect::new(inner.x, body_y, inner.width, body_height); - - match mode { - panel::ListingMode::Info => { - render_info_body(frame, list_area, panel, panel_fg, panel_bg); - } - panel::ListingMode::QuickView => { - render_quickview_body(frame, list_area, panel, panel_fg, panel_bg); - } - _ => { - let items: Vec = (0..height) - .map(|row| { - let idx = top + row; - if let Some(entry) = panel.entries().get(idx) { - let is_marked = panel - .marked_names() - .iter() - .any(|n| n == &entry.name); - let style = - entry_style(entry, active, idx == cursor, is_marked, &self.theme); - let prefix = if is_marked { "*" } else { " " }; - let display = - format!("{prefix}{}", format_line_mode(entry, mode, line_width)); - ListItem::new(Span::styled(display, style)) - } else { - ListItem::new(Span::raw("")) - } - }) - .collect(); - let list = List::new(items) - .style(Style::default().fg(panel_fg).bg(panel_bg)); - frame.render_widget(list, list_area); - } - } - - if show_footer { - let footer_y = inner.y + inner.height.saturating_sub(1); - frame.render_widget( - Paragraph::new(Span::styled( - panel_footer_text(panel), - Style::default().fg(disabled_fg).bg(panel_bg), - )), - Rect::new(inner.x, footer_y, inner.width, 1), - ); - } - - // Cursor marker: a `>` on the leftmost column for the active panel. - if active { - let cursor_y = body_y + (cursor.saturating_sub(top)) as u16; - if cursor_y < body_y + body_height { - let marker = Paragraph::new(Span::styled( - ">", - Style::default() - .fg(self.theme.cursor_fg) - .bg(self.theme.cursor_bg) - .add_modifier(Modifier::BOLD), - )); - frame.render_widget(marker, Rect::new(inner.x, cursor_y, 1, 1)); - } - } - } - /// Post a status message. pub fn post_status(&mut self, msg: impl Into, ttl: std::time::Duration) { self.status.post(msg, ttl); } } +#[cfg(test)] fn path_short(path: &Path) -> String { - let s = path.display().to_string(); - if s.len() > 50 { - // Show ".../". - let tail: String = s - .chars() - .rev() - .take(47) - .collect::() - .chars() - .rev() - .collect(); - format!(".../{tail}") - } else { - s - } + format_utils::path_short(path) } +#[cfg(test)] fn entry_style( e: &crate::vfs::local::Entry, active: bool, cursor: bool, marked: bool, theme: &Theme, -) -> Style { - let core_default = mc_skin::color_pair(theme.name, "core", "_default_"); - let core_selected = mc_skin::color_pair(theme.name, "core", "selected"); - let core_reverse = mc_skin::color_pair(theme.name, "core", "reverse"); - let core_marked = mc_skin::color_pair(theme.name, "core", "marked"); - let core_markselect = mc_skin::color_pair(theme.name, "core", "markselect"); - let base = if e.is_dir() { - Style::default().fg(theme.directory) - } else if e.is_symlink() { - Style::default().fg(theme.symlink) - } else { - let is_executable = e.stat.permissions.owner_exec - || e.stat.permissions.group_exec - || e.stat.permissions.other_exec; - let ft = filehighlight::categorize(&e.name, is_executable); - let color = filehighlight::file_type_color(ft, theme) - .unwrap_or_else(|| core_default.map(|p| p.fg).unwrap_or(theme.foreground)); - Style::default().fg(color) - }; - if cursor && marked { - let pair = core_markselect.unwrap_or_else(|| { - if active { - core_selected.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg }) - } else { - core_marked.unwrap_or(mc_skin::ColorPair { fg: theme.marked_fg, bg: theme.marked_bg }) - } - }); - base.bg(pair.bg).fg(pair.fg).add_modifier(Modifier::BOLD) - } else if cursor && active { - let pair = core_selected.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg }); - base.bg(pair.bg).fg(pair.fg).add_modifier(Modifier::BOLD) - } else if cursor { - let pair = core_reverse.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg }); - base.bg(pair.bg).fg(pair.fg) - } else if marked { - let pair = core_marked.unwrap_or(mc_skin::ColorPair { fg: theme.marked_fg, bg: theme.marked_bg }); - base.bg(pair.bg).fg(pair.fg) - } else { - base - } +) -> ratatui::style::Style { + render::entry_style(e, active, cursor, marked, theme) } +#[cfg(test)] fn format_line_mode(e: &crate::vfs::local::Entry, mode: panel::ListingMode, width: usize) -> String { - if e.name == ".." { - return "../".to_string(); - } - match mode { - panel::ListingMode::Brief => { - let suffix = if e.is_dir() { "/" } else { "" }; - 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 size_str = if e.is_dir() { - "".to_string() - } else { - 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); - 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}"); - 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)); - if out.chars().count() > width { - out = out.chars().take(width).collect(); - } - out - } - } - } -} - -fn format_size(bytes: u64) -> String { - if bytes < 1024 { - format!("{bytes}B") - } else if bytes < 1024 * 1024 { - format!("{:.1}K", bytes as f64 / 1024.0) - } else if bytes < 1024 * 1024 * 1024 { - format!("{:.1}M", bytes as f64 / (1024.0 * 1024.0)) - } else { - format!("{:.1}G", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) - } + render::format_line_mode(e, mode, width) } +#[cfg(test)] fn info_mode_lines(panel: &Panel) -> Vec { - let mut lines: Vec = Vec::new(); - let dir_path = panel.path(); - let fs_line = match std::fs::metadata(dir_path) { - Ok(_) => match dir_path.to_str() { - Some(s) => format!("Filesystem: {s}"), - None => format!("Filesystem: {}", dir_path.display()), - }, - Err(e) => format!("Filesystem: "), - }; - lines.push(fs_line); - - let entries = panel.entry_count(); - let dirs = panel.dir_count(); - let files = entries.saturating_sub(dirs); - lines.push(format!("Items: {entries} ({files} files, {dirs} dirs)")); - - if let Some(entry) = panel.entries().get(panel.cursor()) { - if entry.name != ".." { - lines.push(String::new()); - lines.push(format!("File: {}", entry.name)); - let size_str = if entry.is_dir() { - "".to_string() - } else { - format_size(entry.stat.size) - }; - lines.push(format!("Size: {size_str}")); - lines.push(format!("Mode: {:o}", entry.stat.permissions.to_mode() & 0o7777)); - lines.push(format!( - "Owner: {} Group: {}", - entry.stat.uid, entry.stat.gid - )); - lines.push(format!("Links: {}", entry.stat.nlinks)); - lines.push(format!("Modified: {}", info_format_time(entry.stat.mtime))); - lines.push(format!("Accessed: {}", info_format_time(entry.stat.atime))); - lines.push(format!("Changed: {}", info_format_time(entry.stat.ctime))); - } - } - lines -} - -fn info_format_time(secs: i64) -> String { - if secs <= 0 { - return "—".to_string(); - } - match chrono::DateTime::from_timestamp(secs, 0) { - Some(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(), - None => format!("@{secs}"), - } + render::info_mode_lines(panel) } +#[cfg(test)] fn quickview_lines(panel: &Panel) -> Vec { - const MAX_LINES: usize = 30; - let Some(entry) = panel.entries().get(panel.cursor()) else { - return vec!["(no entry)".to_string()]; - }; - if entry.name == ".." { - return vec!["(parent directory)".to_string()]; - } - if entry.is_dir() { - return vec!["Directory, use Enter to browse".to_string()]; - } - let target = panel.path().join(&entry.name); - let meta = match std::fs::metadata(&target) { - Ok(m) => m, - Err(e) => return vec![format!("(cannot stat: {e})")], - }; - if !meta.is_file() { - return vec!["(not a regular file)".to_string()]; - } - let file = match std::fs::File::open(&target) { - Ok(f) => f, - Err(e) => return vec![format!("(cannot open: {e})")], - }; - let reader = std::io::BufReader::new(file); - use std::io::BufRead; - let mut lines: Vec = Vec::new(); - let mut saw_binary = false; - for line_res in reader.lines().take(MAX_LINES) { - match line_res { - Ok(l) => { - if l.bytes().any(|b| b == 0) { - saw_binary = true; - break; - } - lines.push(l); - } - Err(_) => { - saw_binary = true; - break; - } - } - } - if saw_binary { - return vec!["Binary file, cannot display".to_string()]; - } - if lines.is_empty() { - return vec!["(empty file)".to_string()]; - } - lines + render::quickview_lines(panel) } +#[cfg(test)] fn render_info_body( frame: &mut Frame, area: Rect, @@ -2727,26 +613,10 @@ fn render_info_body( fg: ratatui::style::Color, bg: ratatui::style::Color, ) { - let lines = info_mode_lines(panel); - let width = area.width as usize; - let height = area.height as usize; - let max_rows = height.max(1); - let para_lines: Vec = lines - .into_iter() - .take(max_rows) - .map(|s| { - let truncated: String = if s.chars().count() > width { - s.chars().take(width).collect() - } else { - s - }; - Line::from(Span::styled(truncated, Style::default().fg(fg).bg(bg))) - }) - .collect(); - let p = Paragraph::new(para_lines).style(Style::default().fg(fg).bg(bg)); - frame.render_widget(p, area); + render::render_info_body(frame, area, panel, fg, bg); } +#[cfg(test)] fn render_quickview_body( frame: &mut Frame, area: Rect, @@ -2754,75 +624,22 @@ fn render_quickview_body( fg: ratatui::style::Color, bg: ratatui::style::Color, ) { - let lines = quickview_lines(panel); - let width = area.width as usize; - let height = area.height as usize; - let max_rows = height.max(1); - let para_lines: Vec = lines - .into_iter() - .take(max_rows) - .map(|s| { - let truncated: String = if s.chars().count() > width { - s.chars().take(width).collect() - } else { - s - }; - Line::from(Span::styled(truncated, Style::default().fg(fg).bg(bg))) - }) - .collect(); - let p = Paragraph::new(para_lines).style(Style::default().fg(fg).bg(bg)); - frame.render_widget(p, area); + render::render_quickview_body(frame, area, panel, fg, bg); } +#[cfg(test)] fn panel_body_rows(area: Rect) -> usize { - let inner_height = area.height.saturating_sub(2); - let meta = usize::from(inner_height >= 3); - let footer = usize::from(inner_height >= 2); - inner_height.saturating_sub((meta + footer) as u16).max(1) as usize + render::panel_body_rows(area) } +#[cfg(test)] fn panel_meta_text(panel: &Panel) -> String { - let filter = panel - .filter() - .map(|f| format!(" Filter:{f}")) - .unwrap_or_default(); - let hidden = if panel.show_hidden() { " Hidden" } else { "" }; - let reverse = if panel.sort_reverse() { " desc" } else { "" }; - format!( - " {} Sort:{}{} {} items{}{} ", - panel.listing_mode().label(), - panel.sort_field_name(), - reverse, - panel.entry_count(), - hidden, - filter - ) + format_utils::panel_meta_text(panel) } +#[cfg(test)] fn panel_footer_text(panel: &Panel) -> String { - let total = panel.entry_count(); - let dirs = panel.dir_count(); - let files = total.saturating_sub(dirs); - let marked = panel.marked_count(); - let stats = if marked > 0 { - format!("{files}f {dirs}d {marked} marked") - } else { - format!("{files}f {dirs}d") - }; - let current = panel - .entries() - .get(panel.cursor()) - .map(|e| { - if e.name == ".." { - "../".to_string() - } else if e.is_dir() { - format!("{}/", e.name) - } else { - format!("{} {}", e.name, format_size(e.stat.size)) - } - }) - .unwrap_or_default(); - format!(" {stats} {current} ") + format_utils::panel_footer_text(panel) } /// Render the file manager into a frame at the given area. @@ -2838,95 +655,6 @@ fn _link_sorts() { let _: SortField = PanelSortField::Name; } -/// Build a name→size index from panel snapshots. -fn build_size_index_from_snap( - snaps: &[(String, u64, bool)], -) -> std::collections::HashMap { - snaps.iter().map(|(n, s, _)| (n.clone(), *s)).collect() -} - -/// Mark entries whose size differs from the index. Returns count marked. -fn mark_differing_from_snap( - panel: &mut Panel, - index: &std::collections::HashMap, -) -> usize { - panel.unmark_all(); - let mut count = 0; - let entries: Vec<(String, u64)> = panel - .file_snapshots() - .into_iter() - .map(|(n, s, _)| (n, s)) - .collect(); - for (i, (name, size)) in entries.iter().enumerate() { - if let Some(&other_size) = index.get(name) { - if other_size != *size { - panel.mark_at(i); - count += 1; - } - } - } - count -} - -/// Compute a relative path from `link_dir` to `target`. -fn relpath_from(target: &std::path::Path, link_dir: &std::path::Path) -> std::path::PathBuf { - let target_components: Vec<_> = target.components().collect(); - let link_components: Vec<_> = link_dir.components().collect(); - let common = target_components - .iter() - .zip(link_components.iter()) - .take_while(|(a, b)| a == b) - .count(); - let up = link_components.len().saturating_sub(common); - let mut result = std::path::PathBuf::new(); - for _ in 0..up { - result.push(".."); - } - for comp in &target_components[common..] { - result.push(comp.as_os_str()); - } - if result.as_os_str().is_empty() { - result.push("."); - } - result -} - -/// Short human label for the currently-open dialog (used by the -/// screen-list overlay). -fn dialog_label(d: &DialogState) -> String { - match d { - DialogState::Info(_) => "Info".to_string(), - DialogState::Permission(_) => "Chmod".to_string(), - DialogState::Owner(_) => "Chown".to_string(), - DialogState::Link(_) => "Link".to_string(), - DialogState::MkDir(_) => "Mkdir".to_string(), - DialogState::Copy(_) => "Copy".to_string(), - DialogState::Move(_) => "Move".to_string(), - DialogState::Delete(_) => "Delete".to_string(), - DialogState::Find(_) => "Find".to_string(), - DialogState::Hotlist(_) => "Hotlist".to_string(), - DialogState::Tree(_) => "Tree".to_string(), - DialogState::UserMenu(_) => "User menu".to_string(), - DialogState::Help(_) => "Help".to_string(), - DialogState::Skin(_) => "Skin".to_string(), - DialogState::Quit(_) => "Quit".to_string(), - DialogState::SelectGroup(_) => "Select group".to_string(), - DialogState::UnselectGroup(_) => "Unselect group".to_string(), - DialogState::QuickCd(_) => "Quick cd".to_string(), - DialogState::Overwrite(_) => "Overwrite".to_string(), - DialogState::Layout(_) => "Layout".to_string(), - DialogState::PanelOptions(_) => "Panel options".to_string(), - DialogState::Config(_) => "Configuration".to_string(), - DialogState::Jobs(_) => "Jobs".to_string(), - DialogState::ExternalPanelize(_) => "External panelize".to_string(), - DialogState::VfsList(_) => "VFS list".to_string(), - DialogState::ScreenList(_) => "Screen list".to_string(), - DialogState::EditHistory(_) => "Directory history".to_string(), - DialogState::FilteredView(_) => "Filtered view".to_string(), - DialogState::Compare(_) => "Compare files".to_string(), - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/filemanager/render.rs b/local/recipes/tui/tlc/source/src/filemanager/render.rs new file mode 100644 index 0000000000..d7950272fe --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/render.rs @@ -0,0 +1,669 @@ +//! Rendering helpers for the file manager. +//! +//! Houses the [`FileManager::render`] entry point together with the +//! panel/body drawing routines, the listing-mode panel renderers +//! (Info / QuickView) and the small helpers (snapshot comparison, +//! symlink-relative paths, panel row formatting) they use. + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; +use ratatui::Frame; + +use crate::filemanager::panel::Panel; +use crate::filemanager::{ + format_utils, Active, DialogState, FileManager, LayoutMode, +}; +use crate::terminal::color::Theme; +use crate::terminal::mc_skin; +use crate::vfs::local::Entry; + +use super::filehighlight; +use super::panel; + +/// Render the file manager to a ratatui frame. +pub fn render(fm: &mut FileManager, frame: &mut Frame, area: Rect) { + fm.render(frame, area); +} + +impl FileManager { + /// Render the file manager to a ratatui frame. + pub fn render(&mut self, frame: &mut Frame, area: Rect) { + if let Some(ed) = &mut self.editor { + ed.render(frame, area, &self.theme); + return; + } + if let Some(v) = &mut self.viewer { + v.render(frame, area, &self.theme); + return; + } + if let Some(x) = &mut self.exec { + x.render(frame, area, &self.theme); + return; + } + + if !self.panels_visible { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + let blank = Paragraph::new("").style( + Style::default() + .fg(self.theme.foreground) + .bg(self.theme.background), + ); + frame.render_widget(blank, chunks[0]); + if self.cmdline.is_active() { + self.cmdline.render_inline(frame, chunks[1], &self.theme); + } else if self.status.has_message() { + self.status.render(frame, chunks[1], &self.theme); + } else { + self.cmdline.render_inline(frame, chunks[1], &self.theme); + } + self.render_buttonbar(frame, chunks[2]); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), // panels + Constraint::Length(1), // command/status line + Constraint::Length(1), // function-key bar + ]) + .split(area); + + self.render_panels(frame, chunks[0]); + let (left_panel_area, right_panel_area) = self.panel_areas(chunks[0]); + if self.cmdline.is_active() { + self.cmdline.render_inline(frame, chunks[1], &self.theme); + } else if self.status.has_message() { + self.status.render(frame, chunks[1], &self.theme); + } else { + self.cmdline.render_inline(frame, chunks[1], &self.theme); + } + self.render_buttonbar(frame, chunks[2]); + if let Some(ref mb) = self.menubar { + mb.render(frame, area, &self.theme); + } + if let Some(d) = &mut self.dialog { + match d { + DialogState::Info(d) => d.render(frame, area, &self.theme), + DialogState::Permission(d) => d.render(frame, area, &self.theme), + DialogState::Owner(d) => d.render(frame, area, &self.theme), + DialogState::Link(d) => d.render(frame, area, &self.theme), + DialogState::MkDir(d) => d.render(frame, area, &self.theme), + DialogState::Copy(d) => d.render(frame, area, &self.theme), + DialogState::Move(d) => d.render(frame, area, &self.theme), + DialogState::Delete(d) => d.render(frame, area, &self.theme), + DialogState::Find(d) => d.render(frame, area, &self.theme), + DialogState::Hotlist(d) => d.render(frame, area, &self.theme), + DialogState::Tree(d) => d.render(frame, area, &self.theme), + DialogState::UserMenu(d) => d.render(frame, area, &self.theme), + DialogState::Help(d) => d.render(frame, area), + DialogState::Skin(d) => d.render(frame, area, &self.theme), + DialogState::Quit(d) => d.render(frame, area, &self.theme), + DialogState::SelectGroup(d) => d.render(frame, area, &self.theme), + DialogState::UnselectGroup(d) => d.render(frame, area, &self.theme), + DialogState::QuickCd(d) => d.render( + frame, + match self.active { + Active::Left => left_panel_area, + Active::Right => right_panel_area, + }, + &self.theme, + ), + DialogState::Overwrite(d) => d.render(frame, area, &self.theme), + DialogState::Layout(d) => d.render(frame, area, &self.theme), + DialogState::PanelOptions(d) => d.render(frame, area, &self.theme), + DialogState::Config(d) => d.render(frame, area, &self.theme), + DialogState::Jobs(d) => d.render(frame, area, &self.theme), + DialogState::ExternalPanelize(d) => d.render(frame, area, &self.theme), + DialogState::VfsList(d) => d.render(frame, area, &self.theme), + DialogState::ScreenList(d) => d.render(frame, area, &self.theme), + DialogState::EditHistory(d) => d.render(frame, area, &self.theme), + DialogState::FilteredView(d) => d.render(frame, area, &self.theme), + DialogState::Compare(d) => d.render(frame, area, &self.theme), + } + } + } + + /// Compute the two panel rectangles based on the active layout + /// mode and the configured split ratio. + pub fn panel_areas(&self, area: Rect) -> (Rect, Rect) { + let left_pct = if self.runtime.equal_split.unwrap_or(true) { + 50 + } else { + self.split_ratio.clamp(1, 99) + }; + let right_pct = 100u16.saturating_sub(left_pct); + match self.layout { + LayoutMode::Horizontal => { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(left_pct), + Constraint::Percentage(right_pct), + ]) + .split(area); + (chunks[0], chunks[1]) + } + LayoutMode::Vertical => { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(left_pct), + Constraint::Percentage(right_pct), + ]) + .split(area); + (chunks[0], chunks[1]) + } + } + } + + /// Render both panels into the given area. + pub fn render_panels(&mut self, frame: &mut Frame, area: Rect) { + let (left_a, right_a) = self.panel_areas(area); + // Adjust each panel's scroll so the cursor is visible. + let left_rows = panel_body_rows(left_a); + let right_rows = panel_body_rows(right_a); + self.left.ensure_cursor_visible(left_rows); + self.right.ensure_cursor_visible(right_rows); + self.render_panel(frame, left_a, &self.left, self.active == Active::Left); + self.render_panel(frame, right_a, &self.right, self.active == Active::Right); + } + + /// Render the function-key bar (bottom row): 1 Help 2 Menu 3 View ... + pub fn render_buttonbar(&self, frame: &mut Frame, area: Rect) { + let hotkey_pair = mc_skin::color_pair(self.theme.name, "buttonbar", "hotkey"); + let button_pair = mc_skin::color_pair(self.theme.name, "buttonbar", "button"); + let bar_bg = button_pair.map(|p| p.bg).unwrap_or(self.theme.buttonbar_bg); + let bar_fg = button_pair.map(|p| p.fg).unwrap_or(self.theme.buttonbar_fg); + let hot_fg = hotkey_pair.map(|p| p.fg).unwrap_or(self.theme.buttonbar_bg); + let hot_bg = hotkey_pair.map(|p| p.bg).unwrap_or(self.theme.buttonbar_fg); + let labels = [ + ("1", "Help"), + ("2", "Menu"), + ("3", "View"), + ("4", "Edit"), + ("5", "Copy"), + ("6", "Move"), + ("7", "MkDir"), + ("8", "Del"), + ("9", "Menu"), + ("10", "Quit"), + ]; + frame.render_widget( + Paragraph::new("") + .style(Style::default().bg(bar_bg).fg(bar_fg)), + area, + ); + let mut spans: Vec = Vec::new(); + for (num, label) in &labels { + spans.push(Span::styled( + *num, + Style::default() + .fg(hot_fg) + .bg(hot_bg) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + format!(" {} ", label), + Style::default() + .fg(bar_fg) + .bg(bar_bg), + )); + } + frame.render_widget( + Paragraph::new(Line::from(spans)), + area, + ); + } + + /// Render a single panel into `area`. `active` controls the + /// border highlight and the cursor marker. + pub fn render_panel(&self, frame: &mut Frame, area: Rect, panel: &Panel, active: bool) { + let core_default = mc_skin::color_pair(self.theme.name, "core", "_default_"); + let core_header = mc_skin::color_pair(self.theme.name, "core", "header"); + let core_disabled = mc_skin::color_pair(self.theme.name, "core", "disabled"); + let panel_bg = core_default.map(|p| p.bg).unwrap_or(self.theme.background); + let panel_fg = core_default.map(|p| p.fg).unwrap_or(self.theme.foreground); + let header_fg = core_header.map(|p| p.fg).unwrap_or(self.theme.title_fg); + let header_bg = core_header + .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 = format!(" {} ", format_utils::path_short(panel.path())); + let block = Block::default() + .borders(Borders::ALL) + .border_style(if active { + Style::default().fg(header_fg).bg(panel_bg) + } else { + Style::default().fg(self.theme.border).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), + )); + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.width == 0 || inner.height == 0 { + return; + } + + let show_meta = inner.height >= 3; + let show_footer = inner.height >= 2; + let body_y = inner.y + u16::from(show_meta); + let footer_h = u16::from(show_footer); + let body_height = inner + .height + .saturating_sub(u16::from(show_meta)) + .saturating_sub(footer_h) + .max(1); + let height = body_height as usize; + 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 { + 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), + ); + } + + let list_area = Rect::new(inner.x, body_y, inner.width, body_height); + + match mode { + panel::ListingMode::Info => { + render_info_body(frame, list_area, panel, panel_fg, panel_bg); + } + panel::ListingMode::QuickView => { + render_quickview_body(frame, list_area, panel, panel_fg, panel_bg); + } + _ => { + let items: Vec = (0..height) + .map(|row| { + let idx = top + row; + if let Some(entry) = panel.entries().get(idx) { + let is_marked = panel + .marked_names() + .iter() + .any(|n| n == &entry.name); + let style = + entry_style(entry, active, idx == cursor, is_marked, &self.theme); + let prefix = if is_marked { "*" } else { " " }; + let display = + format!("{prefix}{}", format_line_mode(entry, mode, line_width)); + ListItem::new(Span::styled(display, style)) + } else { + ListItem::new(Span::raw("")) + } + }) + .collect(); + let list = List::new(items) + .style(Style::default().fg(panel_fg).bg(panel_bg)); + frame.render_widget(list, list_area); + } + } + + if show_footer { + let footer_y = inner.y + inner.height.saturating_sub(1); + frame.render_widget( + Paragraph::new(Span::styled( + format_utils::panel_footer_text(panel), + Style::default().fg(disabled_fg).bg(panel_bg), + )), + Rect::new(inner.x, footer_y, inner.width, 1), + ); + } + + // Cursor marker: a `>` on the leftmost column for the active panel. + if active { + let cursor_y = body_y + (cursor.saturating_sub(top)) as u16; + if cursor_y < body_y + body_height { + let marker = Paragraph::new(Span::styled( + ">", + Style::default() + .fg(self.theme.cursor_fg) + .bg(self.theme.cursor_bg) + .add_modifier(Modifier::BOLD), + )); + frame.render_widget(marker, Rect::new(inner.x, cursor_y, 1, 1)); + } + } + } +} + +/// Compute the [`Style`] for a panel entry given selection state. +pub fn entry_style( + e: &Entry, + active: bool, + cursor: bool, + marked: bool, + theme: &Theme, +) -> Style { + let core_default = mc_skin::color_pair(theme.name, "core", "_default_"); + let core_selected = mc_skin::color_pair(theme.name, "core", "selected"); + let core_reverse = mc_skin::color_pair(theme.name, "core", "reverse"); + let core_marked = mc_skin::color_pair(theme.name, "core", "marked"); + let core_markselect = mc_skin::color_pair(theme.name, "core", "markselect"); + let base = if e.is_dir() { + Style::default().fg(theme.directory) + } else if e.is_symlink() { + Style::default().fg(theme.symlink) + } else { + let is_executable = e.stat.permissions.owner_exec + || e.stat.permissions.group_exec + || e.stat.permissions.other_exec; + let ft = filehighlight::categorize(&e.name, is_executable); + let color = filehighlight::file_type_color(ft, theme) + .unwrap_or_else(|| core_default.map(|p| p.fg).unwrap_or(theme.foreground)); + Style::default().fg(color) + }; + if cursor && marked { + let pair = core_markselect.unwrap_or_else(|| { + if active { + core_selected.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg }) + } else { + core_marked.unwrap_or(mc_skin::ColorPair { fg: theme.marked_fg, bg: theme.marked_bg }) + } + }); + base.bg(pair.bg).fg(pair.fg).add_modifier(Modifier::BOLD) + } else if cursor && active { + let pair = core_selected.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg }); + base.bg(pair.bg).fg(pair.fg).add_modifier(Modifier::BOLD) + } else if cursor { + let pair = core_reverse.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg }); + base.bg(pair.bg).fg(pair.fg) + } else if marked { + let pair = core_marked.unwrap_or(mc_skin::ColorPair { fg: theme.marked_fg, bg: theme.marked_bg }); + base.bg(pair.bg).fg(pair.fg) + } else { + base + } +} + +/// Format a single panel row according to the active listing mode. +pub fn format_line_mode(e: &Entry, mode: panel::ListingMode, width: usize) -> String { + if e.name == ".." { + return "../".to_string(); + } + match mode { + panel::ListingMode::Brief => { + let suffix = if e.is_dir() { "/" } else { "" }; + 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 size_str = if e.is_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); + 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}"); + 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)); + if out.chars().count() > width { + out = out.chars().take(width).collect(); + } + out + } + } + } +} + +/// Build a name→size index from panel snapshots. +pub fn build_size_index_from_snap( + snaps: &[(String, u64, bool)], +) -> std::collections::HashMap { + snaps.iter().map(|(n, s, _)| (n.clone(), *s)).collect() +} + +/// Mark entries whose size differs from the index. Returns count marked. +pub fn mark_differing_from_snap( + panel: &mut Panel, + index: &std::collections::HashMap, +) -> usize { + panel.unmark_all(); + let mut count = 0; + let entries: Vec<(String, u64)> = panel + .file_snapshots() + .into_iter() + .map(|(n, s, _)| (n, s)) + .collect(); + for (i, (name, size)) in entries.iter().enumerate() { + if let Some(&other_size) = index.get(name) { + if other_size != *size { + panel.mark_at(i); + count += 1; + } + } + } + count +} + +/// Compute a relative path from `link_dir` to `target`. +pub fn relpath_from(target: &std::path::Path, link_dir: &std::path::Path) -> std::path::PathBuf { + let target_components: Vec<_> = target.components().collect(); + let link_components: Vec<_> = link_dir.components().collect(); + let common = target_components + .iter() + .zip(link_components.iter()) + .take_while(|(a, b)| a == b) + .count(); + let up = link_components.len().saturating_sub(common); + let mut result = std::path::PathBuf::new(); + for _ in 0..up { + result.push(".."); + } + for comp in &target_components[common..] { + result.push(comp.as_os_str()); + } + if result.as_os_str().is_empty() { + result.push("."); + } + result +} + +/// Visible row count of a panel's body area (excluding border / meta / footer). +pub fn panel_body_rows(area: Rect) -> usize { + let inner_height = area.height.saturating_sub(2); + let meta = usize::from(inner_height >= 3); + let footer = usize::from(inner_height >= 2); + inner_height.saturating_sub((meta + footer) as u16).max(1) as usize +} + +/// Render the Info-mode body of the panel. +pub fn render_info_body( + frame: &mut Frame, + area: Rect, + panel: &Panel, + fg: ratatui::style::Color, + bg: ratatui::style::Color, +) { + let lines = info_mode_lines(panel); + let width = area.width as usize; + let height = area.height as usize; + let max_rows = height.max(1); + let para_lines: Vec = lines + .into_iter() + .take(max_rows) + .map(|s| { + let truncated: String = if s.chars().count() > width { + s.chars().take(width).collect() + } else { + s + }; + Line::from(Span::styled(truncated, Style::default().fg(fg).bg(bg))) + }) + .collect(); + let p = Paragraph::new(para_lines).style(Style::default().fg(fg).bg(bg)); + frame.render_widget(p, area); +} + +/// Render the QuickView-mode body of the panel. +pub fn render_quickview_body( + frame: &mut Frame, + area: Rect, + panel: &Panel, + fg: ratatui::style::Color, + bg: ratatui::style::Color, +) { + let lines = quickview_lines(panel); + let width = area.width as usize; + let height = area.height as usize; + let max_rows = height.max(1); + let para_lines: Vec = lines + .into_iter() + .take(max_rows) + .map(|s| { + let truncated: String = if s.chars().count() > width { + s.chars().take(width).collect() + } else { + s + }; + Line::from(Span::styled(truncated, Style::default().fg(fg).bg(bg))) + }) + .collect(); + let p = Paragraph::new(para_lines).style(Style::default().fg(fg).bg(bg)); + frame.render_widget(p, area); +} + +/// Build the lines of text displayed by the Info listing mode. +pub fn info_mode_lines(panel: &Panel) -> Vec { + let mut lines: Vec = Vec::new(); + let dir_path = panel.path(); + let fs_line = match std::fs::metadata(dir_path) { + Ok(_) => match dir_path.to_str() { + Some(s) => format!("Filesystem: {s}"), + None => format!("Filesystem: {}", dir_path.display()), + }, + Err(e) => format!("Filesystem: "), + }; + lines.push(fs_line); + + let entries = panel.entry_count(); + let dirs = panel.dir_count(); + let files = entries.saturating_sub(dirs); + lines.push(format!("Items: {entries} ({files} files, {dirs} dirs)")); + + if let Some(entry) = panel.entries().get(panel.cursor()) { + if entry.name != ".." { + lines.push(String::new()); + lines.push(format!("File: {}", entry.name)); + let size_str = if entry.is_dir() { + "".to_string() + } else { + format_utils::format_size(entry.stat.size) + }; + lines.push(format!("Size: {size_str}")); + lines.push(format!("Mode: {:o}", entry.stat.permissions.to_mode() & 0o7777)); + lines.push(format!( + "Owner: {} Group: {}", + entry.stat.uid, entry.stat.gid + )); + lines.push(format!("Links: {}", entry.stat.nlinks)); + lines.push(format!( + "Modified: {}", + format_utils::info_format_time(entry.stat.mtime) + )); + lines.push(format!( + "Accessed: {}", + format_utils::info_format_time(entry.stat.atime) + )); + lines.push(format!( + "Changed: {}", + format_utils::info_format_time(entry.stat.ctime) + )); + } + } + lines +} + +/// Read up to 30 lines from the cursor file for the QuickView mode. +pub fn quickview_lines(panel: &Panel) -> Vec { + const MAX_LINES: usize = 30; + let Some(entry) = panel.entries().get(panel.cursor()) else { + return vec!["(no entry)".to_string()]; + }; + if entry.name == ".." { + return vec!["(parent directory)".to_string()]; + } + if entry.is_dir() { + return vec!["Directory, use Enter to browse".to_string()]; + } + let target = panel.path().join(&entry.name); + let meta = match std::fs::metadata(&target) { + Ok(m) => m, + Err(e) => return vec![format!("(cannot stat: {e})")], + }; + if !meta.is_file() { + return vec!["(not a regular file)".to_string()]; + } + let file = match std::fs::File::open(&target) { + Ok(f) => f, + Err(e) => return vec![format!("(cannot open: {e})")], + }; + let reader = std::io::BufReader::new(file); + use std::io::BufRead; + let mut lines: Vec = Vec::new(); + let mut saw_binary = false; + for line_res in reader.lines().take(MAX_LINES) { + match line_res { + Ok(l) => { + if l.bytes().any(|b| b == 0) { + saw_binary = true; + break; + } + lines.push(l); + } + Err(_) => { + saw_binary = true; + break; + } + } + } + if saw_binary { + return vec!["Binary file, cannot display".to_string()]; + } + if lines.is_empty() { + return vec!["(empty file)".to_string()]; + } + lines +} \ No newline at end of file