tlc: comprehensive W1-W5 fixes — dead dialogs, suspend, connection wiring, warning cleanup

W1: Fix 3 dead dialogs (DisplayBits/VfsSettings/LearnKeys) that could never close
    - Capture handle_key() return value, close on Cancel/Confirm
W2: Implement Cmd::Suspend with actual SIGTSTP via kill -TSTP 4035172
    - Add want_suspend field, ExternalAction::Suspend variant
    - Drop TUI, send SIGTSTP, recreate TUI on resume
W3: Wire Connection dialog to Panel::navigate_to_vfs()
    - Parse VFS URL, look up backend, redirect active panel
    - Store Encoding dialog selection on FileManager.display_encoding
W4: Wire CK_EditUserMenu (EditorCmd::EditUserMenu)
    - Opens user menu storage_path in editor via Editor::open()
    - Fix unreachable!() in SaveBeforeClose prompt rendering
W5: Add ErrorOutcome::SkipAll variant + Shift-S keybinding
    - Fix misleading doc comment about non-existent 'All' variants
    - Add SkipAll button in error dialog render

Also: Fix all 41 compiler warnings (unused imports/vars, missing docs on
public API, remove dead SPECIAL_LABELS constant, remove unused viewer_bold)

1381 tests pass, zero warnings.
This commit is contained in:
2026-07-06 04:18:44 +03:00
parent f54b53fbfb
commit 8a77a6ebde
19 changed files with 174 additions and 43 deletions
+28 -7
View File
@@ -352,9 +352,14 @@ fn render(tui: &mut Tui, fm: &mut FileManager) -> Result<()> {
enum ExternalAction {
Subshell(PathBuf),
Command { cmd: String, cwd: PathBuf },
Suspend,
}
fn take_external_action(fm: &mut FileManager) -> Option<ExternalAction> {
if fm.want_suspend {
fm.want_suspend = false;
return Some(ExternalAction::Suspend);
}
if fm.want_subshell {
fm.want_subshell = false;
return Some(ExternalAction::Subshell(
@@ -379,14 +384,30 @@ fn run_external(
) -> Result<Tui> {
drop(tui);
if let Err(e) = match &action {
ExternalAction::Subshell(cwd) => shell_manager.toggle_subshell(cwd),
ExternalAction::Command { cmd, cwd } => {
println!();
shell_manager.run_command(cmd, cwd)
match &action {
ExternalAction::Suspend => {
#[cfg(unix)]
{
let pid = std::process::id();
let _ = std::process::Command::new("kill")
.arg("-TSTP")
.arg(pid.to_string())
.status();
}
}
ExternalAction::Subshell(cwd) => {
if let Err(e) = shell_manager.toggle_subshell(cwd) {
eprintln!("tlc: {e}");
}
}
ExternalAction::Command { cmd, cwd } => {
if let Err(e) = {
println!();
shell_manager.run_command(cmd, cwd)
} {
eprintln!("tlc: {e}");
}
}
} {
eprintln!("tlc: {e}");
}
Tui::new()
@@ -77,6 +77,8 @@ pub enum EditorCmd {
ToggleSyntax,
/// Toggle relative line numbers (Alt-N).
ToggleRelativeLines,
/// Edit the user menu file (MC `CK_EditUserMenu`).
EditUserMenu,
}
/// Outcome of a menu-bar key press.
+18 -2
View File
@@ -54,6 +54,7 @@ pub mod prompt;
pub mod render;
pub mod save;
pub mod search;
/// Spell checker with built-in dictionary.
pub mod spell;
#[cfg(feature = "syntect")]
pub mod syntax;
@@ -720,15 +721,18 @@ impl Editor {
self.show_whitespace
}
/// Toggle spell checking and return the new state.
pub fn toggle_spell_check(&mut self) -> bool {
self.spell_check_enabled = !self.spell_check_enabled;
self.spell_check_enabled
}
/// Whether spell checking is currently enabled.
pub fn spell_check_enabled(&self) -> bool {
self.spell_check_enabled
}
/// Find the next misspelled word starting from `from_byte`.
pub fn find_next_misspelled(&self, from_byte: usize) -> Option<(usize, usize)> {
if !self.spell_check_enabled {
return None;
@@ -1014,14 +1018,17 @@ impl Editor {
self.modified = true;
}
/// Whether any secondary cursors are active.
pub fn has_multi_cursor(&self) -> bool {
!self.secondary_cursors.is_empty()
}
/// Number of active secondary cursors.
pub fn secondary_cursor_count(&self) -> usize {
self.secondary_cursors.len()
}
/// Add a secondary cursor at `byte_pos` (deduped, sorted).
pub fn add_secondary_cursor(&mut self, byte_pos: usize) {
let pos = byte_pos.min(self.buffer.len());
if pos == self.cursor.position() {
@@ -1034,10 +1041,12 @@ impl Editor {
}
}
/// Remove all secondary cursors.
pub fn clear_secondary_cursors(&mut self) {
self.secondary_cursors.clear();
}
/// Return all cursor positions (primary + secondary), sorted and deduped.
pub fn all_cursor_positions(&self) -> Vec<usize> {
let mut all = self.secondary_cursors.clone();
all.push(self.cursor.position());
@@ -1059,7 +1068,7 @@ impl Editor {
let char_len = c.len_utf8();
let line_before = self.buffer_line_of(self.cursor.position());
let lines_before = self.buffer.line_count();
let mut positions = self.all_cursor_positions();
let positions = self.all_cursor_positions();
for &pos in positions.iter().rev() {
self.buffer.set_cursor(pos);
self.buffer.insert_char(c);
@@ -1085,7 +1094,7 @@ impl Editor {
self.completer.cancel();
let line_before = self.buffer_line_of(self.cursor.position());
let lines_before = self.buffer.line_count();
let mut positions = self.all_cursor_positions();
let positions = self.all_cursor_positions();
for &pos in positions.iter().rev() {
if pos == 0 {
continue;
@@ -1371,6 +1380,13 @@ impl Editor {
self.message = Some("No bookmarks set".to_string());
}
}
EditorCmd::EditUserMenu => {
let menu = crate::filemanager::usermenu::UserMenu::new();
let path = menu.storage_path.clone();
let new_editor = Self::open(&path);
*self = new_editor;
self.message = Some(format!("Editing user menu: {}", path.display()));
}
_ => {
self.message = Some(format!("F9: {:?} (not yet wired)", cmd));
}
@@ -512,7 +512,7 @@ impl Editor {
PromptKind::BookmarkClear => crate::locale::t("dialog_title_bookmark_clear"),
PromptKind::SaveAs => crate::locale::t("dialog_title_save_as"),
PromptKind::InsertFile => "Insert File".to_string(),
PromptKind::SaveBeforeClose => unreachable!(),
PromptKind::SaveBeforeClose => "Save before close".to_string(),
PromptKind::Sort => "Run sort".to_string(),
};
let label = match kind {
@@ -525,7 +525,7 @@ impl Editor {
| PromptKind::BookmarkClear => crate::locale::t("dialog_label_bookmark"),
PromptKind::SaveAs => crate::locale::t("dialog_label_path"),
PromptKind::InsertFile => crate::locale::t("dialog_label_path"),
PromptKind::SaveBeforeClose => unreachable!(),
PromptKind::SaveBeforeClose => "Save modified buffer?".to_string(),
PromptKind::Sort => "sort options (empty for default)".to_string(),
};
let inner = render_popup(frame, popup, title, theme);
@@ -13,9 +13,11 @@ pub struct SpellChecker {
words: HashSet<String>,
}
/// Byte range (start, end) of a misspelled word.
pub type Misspelled = (usize, usize);
impl SpellChecker {
/// Create a spell checker populated with the built-in dictionary.
pub fn new() -> Self {
let words: HashSet<String> = BUILTIN_WORDS
.iter()
@@ -12,10 +12,10 @@ use anyhow::Result;
use std::path::Path;
use crate::filemanager::{
config_dialog, display_bits_dialog, edit_history, error_dialog, external_panelize,
filtered_view, find, help, hotlist, layout_dialog, learn_keys_dialog, link,
config_dialog, edit_history, error_dialog, external_panelize,
filtered_view, find, help, hotlist, layout_dialog, link,
overwrite_dialog, panel_options, percent, render, screen_list, skin_dialog, tree,
usermenu, vfs_list, vfs_settings_dialog, DialogState, FileManager, LinkDialog,
usermenu, vfs_list, DialogState, FileManager, LinkDialog,
PendingErrorOp, PendingFileOp,
};
use crate::filemanager::link::LinkKind;
@@ -941,7 +941,7 @@ impl FileManager {
),
)));
} else {
let handle = self.ops_manager.begin(
let _handle = self.ops_manager.begin(
crate::ops::OpKind::Copy,
sources.clone(),
Some(dst.clone()),
@@ -1299,7 +1299,8 @@ impl FileManager {
}
}
ErrorOutcome::Skip
| ErrorOutcome::Ignore => {
| ErrorOutcome::Ignore
| ErrorOutcome::SkipAll => {
self.status.set_message(format!("{}: skipped ({})", op.kind, d.failed_path));
}
ErrorOutcome::Abort => {
@@ -508,8 +508,7 @@ impl FileManager {
}
}
Cmd::Suspend => {
self.status
.set_message("Suspend: use Ctrl-O to drop to a shell".to_string());
self.want_suspend = true;
Ok(true)
}
Cmd::PanelInfo => {
@@ -713,18 +712,24 @@ impl FileManager {
consumed = true;
}
Some(DialogState::DisplayBits(d)) => {
d.handle_key(key);
let r = d.handle_key(key);
if r != display_bits_dialog::DialogOutcome::Running {
self.dialog = None;
}
consumed = true;
}
Some(DialogState::VfsSettings(d)) => {
d.handle_key(key);
let r = d.handle_key(key);
if r != vfs_settings_dialog::DialogOutcome::Running {
self.dialog = None;
}
consumed = true;
}
Some(DialogState::LearnKeys(d)) => {
// LearnKeys needs the keymap to resolve the captured
// key to its Cmd. The dialog also consumes the key
// so the filemanager does not also dispatch it.
let _ = d.handle_key(key);
let r = d.handle_key(key);
if r == learn_keys_dialog::Outcome::Cancel {
self.dialog = None;
}
consumed = true;
}
Some(DialogState::Layout(d)) => {
@@ -799,6 +804,7 @@ impl FileManager {
let r = d.handle_key(key);
match r {
encoding_dialog::EncodingResult::Select(enc) => {
self.display_encoding = enc.clone();
self.status.set_message(format!("Display encoding: {enc}"));
self.dialog = None;
}
@@ -813,7 +819,23 @@ impl FileManager {
let r = d.handle_key(key);
match r {
connection_dialog::ConnectionResult::Connect(url) => {
self.status.set_message(format!("Connect: {url}"));
match d.parse_vfs_path(&url) {
Ok(vp) => {
match self.active_panel_mut().navigate_to_vfs(vp) {
Ok(()) => {
self.status.set_message(format!("Connected: {url}"));
}
Err(e) => {
self.status
.set_message(format!("Connection failed: {e}"));
}
}
}
Err(e) => {
self.status
.set_message(format!("Invalid URL: {e}"));
}
}
self.dialog = None;
}
connection_dialog::ConnectionResult::Cancel => {
@@ -22,7 +22,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::key::Key;
use crate::key::{Key, Modifiers};
use crate::terminal::color::Theme;
use crate::terminal::popup::{centered_cols_rect, render_popup};
use crate::widget::button::{render_button_row, ButtonKind, ButtonSpec};
@@ -30,11 +30,7 @@ use crate::widget::button::{render_button_row, ButtonKind, ButtonSpec};
/// Outcome of the error recovery dialog.
///
/// Mirrors MC's `FileProgressStatus` enum from `filegui.h:45-54`
/// (FILE_CONT, FILE_RETRY, FILE_SKIP, FILE_ABORT, FILE_IGNORE,
/// FILE_IGNORE_ALL, FILE_SUSPEND). The "All" variants require a
/// batch context which is not yet wired into the copy engine; they
/// are accepted by the dialog but currently behave as their
/// non-All counterparts.
/// (FILE_CONT, FILE_RETRY, FILE_SKIP, FILE_ABORT, FILE_IGNORE).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ErrorOutcome {
/// Continue with the next step (used internally as "no
@@ -48,6 +44,8 @@ pub enum ErrorOutcome {
Abort,
/// Ignore this failure (alias for Skip; MC's FILE_IGNORE).
Ignore,
/// Skip all remaining failures in this batch without prompting.
SkipAll,
}
/// The error recovery dialog.
@@ -83,6 +81,7 @@ impl ErrorDialog {
/// Handle a key event. Returns true if the dialog consumed the
/// key. Letter shortcuts follow MC's button labels:
/// R = Retry, S = Skip, I = Ignore, A = Abort.
/// Shift-S = Skip All (skip remaining errors in batch).
/// Esc maps to Abort (matches MC).
pub fn handle_key(&mut self, key: Key) -> bool {
if key == Key::ESCAPE {
@@ -90,6 +89,11 @@ impl ErrorDialog {
self.finished = true;
return true;
}
if key.mods.contains(Modifiers::SHIFT) && (key.code == b'S' as u32 || key.code == b's' as u32) {
self.outcome = ErrorOutcome::SkipAll;
self.finished = true;
return true;
}
if key.mods.is_empty() {
match key.code {
c if c == b'r' as u32 || c == b'R' as u32 => {
@@ -160,6 +164,11 @@ impl ErrorDialog {
hotkey: Some('S'),
kind: ButtonKind::Normal,
},
ButtonSpec {
label: "SkipAll",
hotkey: Some('S'),
kind: ButtonKind::Normal,
},
ButtonSpec {
label: "Ignore",
hotkey: Some('I'),
@@ -19,7 +19,7 @@
//! The dialog closes on Esc.
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::Frame;
@@ -19,8 +19,11 @@ use crate::terminal::popup::render_popup;
/// Result of a menubar key press.
#[derive(Debug, Clone)]
pub enum MenuBarOutcome {
/// Menubar is still active, no action taken.
Running,
/// User selected a command; the optional usize is the panel index.
Dispatch(Cmd, Option<usize>),
/// User dismissed the menubar.
Close,
}
@@ -158,6 +158,9 @@ pub struct FileManager {
/// The name of the active skin (used to write the config back
/// when the user picks a different skin at runtime).
pub skin_name: String,
/// Selected display encoding (from Encoding dialog). Stored so
/// the viewer and editor can consult it; currently informational.
pub display_encoding: String,
/// Active modal dialog (None = no dialog open).
pub dialog: Option<DialogState>,
/// Bottom-of-screen command line.
@@ -181,6 +184,8 @@ pub struct FileManager {
/// Set by dispatch when Ctrl-O (SubShell) is received. App checks
/// this after dispatch to perform the suspend/spawn/resume cycle.
pub want_subshell: bool,
/// Set by dispatch when Ctrl-Z (Suspend) is received.
pub want_suspend: bool,
/// Set by `start_exec` when the user types a command in the
/// command line. App checks this after dispatch and runs the
/// command in the foreground terminal (not in a popup dialog).
@@ -423,6 +428,7 @@ impl FileManager {
ops_manager: crate::ops::OpsManager::new(),
jobs: Arc::new(Mutex::new(jobs::JobRegistry::new())),
skin_name,
display_encoding: "UTF-8".to_string(),
dialog: None,
cmdline: cmdline::Cmdline::new(),
editor: None,
@@ -433,6 +439,7 @@ impl FileManager {
menubar: None,
panels_visible: true,
want_subshell: false,
want_suspend: false,
want_exec: None,
pending_op: None,
pending_error_op: None,
@@ -732,6 +732,32 @@ impl Panel {
self.read_directory(&resolved)
}
/// Navigate the panel to a remote VFS path (FTP/SFTP/Shell link).
/// Looks up the backend via [`crate::vfs::for_path`], stores it,
/// and replaces the directory listing with the VFS root.
pub fn navigate_to_vfs(&mut self, vp: VfsPath) -> Result<()> {
match crate::vfs::for_path(&vp) {
Ok(Some(backend)) => {
self.vfs = Some(backend);
self.vfs_path = Some(vp.clone());
self.replace_directory_vfs(&vp)?;
Ok(())
}
Ok(None) => {
self.last_error = Some(
"no VFS backend available for this scheme — \
check that the feature is enabled"
.to_string(),
);
Err(anyhow::anyhow!("no VFS backend for {vp}"))
}
Err(e) => {
self.last_error = Some(format!("connection failed: {e}"));
Err(e)
}
}
}
/// Re-read the current directory.
pub fn refresh(&mut self) -> Result<()> {
let p = self.path.clone();
@@ -81,8 +81,6 @@ pub const PERM_CELLS: [PermCell; 9] = [
}, // other x
];
const SPECIAL_LABELS: [&str; 3] = ["setuid", "setgid", "sticky"];
const SPECIAL_CELLS: [PermCell; 3] = [
PermCell {
class_shift: 0o4000,
@@ -193,11 +193,17 @@ pub enum Cmd {
EditMenuFile,
/// Command menu — edit the file highlighting rules.
EditHighlightFile,
/// File menu — view a file by path prompt.
ViewFile,
/// Options menu — display bits configuration.
DisplayBits,
/// Options menu — learn keys dialog.
LearnKeysDialog,
/// Options menu — virtual filesystem settings.
VfsSettings,
/// Command menu — browse command history.
CommandHistory,
/// Options menu — toggle confirmation dialogs.
OptionsConfirm,
}
@@ -19,11 +19,16 @@ use crate::widget::ProgressGauge;
/// The progress dialog. Renders a running operation with a
/// centered modal overlay and a Cancel button.
pub struct ProgressDialog {
/// Dialog title shown in the border header.
pub title: String,
/// Handle into the ops manager for progress queries.
pub handle: Option<OpHandle>,
started: Option<Instant>,
/// Width as percentage of screen (0.01.0).
pub width_pct: f32,
/// Height as percentage of screen (0.01.0).
pub height_pct: f32,
/// Whether the Cancel button has focus (vs the rest of the dialog).
pub cancel_focused: bool,
rate_samples: Vec<u64>,
last_sample_at: Option<Instant>,
@@ -398,13 +398,29 @@ mod tests {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MouseAction {
/// Left-click on a file item — select it.
Click { row: u16, col: u16 },
Click {
/// Screen row of the click.
row: u16,
/// Screen column of the click.
col: u16,
},
/// Double-click on a file item — enter the item.
DoubleClick { row: u16, col: u16 },
DoubleClick {
/// Screen row of the click.
row: u16,
/// Screen column of the click.
col: u16,
},
/// Scroll up by one row, or one page if `pages` is true.
ScrollUp { pages: bool },
ScrollUp {
/// If true, scroll a full page instead of one row.
pages: bool,
},
/// Scroll down by one row, or one page if `pages` is true.
ScrollDown { pages: bool },
ScrollDown {
/// If true, scroll a full page instead of one row.
pages: bool,
},
/// Click on the "<" history button (top of panel).
HistoryPrev,
/// Click on the ">" history button.
@@ -47,7 +47,6 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
}
};
let bytes = &viewport_bytes;
let viewport_base = v.top * BYTES_PER_ROW;
let total_height = area.height as usize;
if total_height == 0 {
@@ -6,7 +6,7 @@
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Wrap};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::terminal::color::Theme;
@@ -205,14 +205,12 @@ impl Default for TextView {
/// follows the active skin.
pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
let viewer_default = mc_skin::color_pair(theme.name, "viewer", "_default_");
let viewer_bold = mc_skin::color_pair(theme.name, "viewer", "viewbold");
let body_fg = viewer_default.map(|p| p.fg).unwrap_or(theme.foreground);
let body_bg = viewer_default.map(|p| p.bg).unwrap_or(theme.background);
let cursor_line_bg = match body_bg {
Color::Rgb(r, g, b) => Color::Rgb(r.saturating_add(12), g.saturating_add(12), b.saturating_add(12)),
_ => theme.current_line_bg(),
};
let bold_fg = viewer_bold.map(|p| p.fg).unwrap_or(theme.warning);
#[cfg(feature = "syntect")]
{
if v.last_render_top != v.top {
@@ -117,7 +117,7 @@ impl ProgressGauge {
/// Each cell represents 1/8 of a character width via the glyphs
/// `▏▎▍▌▋▊▉█`, giving 8× horizontal resolution over a stock `Gauge`.
/// The label is centered on top of the bar.
pub fn render_fractional(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
pub fn render_fractional(&self, frame: &mut Frame, area: Rect, _theme: &Theme) {
let width = area.width as usize;
if width == 0 {
return;