tlc: Phase B.2 + B.7 — Sort dialog all 11 fields, Delete dialog All/None/Skip

Phase B of MC-PARITY-AUDIT.md. Brings two critical dialogs to full MC parity.

Phase B.2 — Sort dialog (sort_dialog.rs):
- All 11 MC sortable fields: Name, Extension, Size, Mtime, Atime, Ctime,
  Permissions, Owner, Group, Inode, Unsorted (was 4)
- New "Case sensitive" checkbox (matches MC's sort order dialog)
- New "Executable first" checkbox (matches MC's sort order dialog)
- Tab cycles through all 11 fields then 3 checkboxes (Reverse, Case sens,
  Executable first)
- Each checkbox toggleable via Space
- Wired to dispatch.rs: apply_sort now also picks up case_sensitive and
  reports executable_first in the status line

Phase B.7 — Delete dialog (delete_dialog.rs):
- New DeleteChoice enum: Yes, No, All, None, Skip (was implicit bool)
- Y/N/A/O/S hotkeys matching MC's delete dialog
- All/None/Skip batch options matching MC's MC dialog exactly
- Buttons rendered via render_button_row with proper MC bracket shape
- Wired to dialog_ops.rs: only Yes/All actually trigger the delete;
  No/None/Skip abort the operation

Tests: 1481 passing (was 1474). All MC parity tests pass.

Refs: MC-PARITY-AUDIT.md §5.12 (GAP-SD-1..3) and §5.5 (GAP-DD-1..2)
This commit is contained in:
Sisyphus
2026-07-25 23:20:34 +09:00
parent de7447f07a
commit 945db1a2c6
5 changed files with 303 additions and 133 deletions
@@ -1,10 +1,9 @@
//! F8 — delete file(s) (Y/N confirmation).
//! F8 — delete file(s) (Y/N confirmation with batch options).
//!
//! No text input. The dialog shows a list of paths and waits for
//! the user to press Y to confirm or N / Esc to cancel. The
//! dialog is pure UI: it does NOT call `delete` itself. The
//! caller (the `FileManager` dispatcher) checks
//! [`DeleteDialog::result`] and applies it via
//! the user to choose Y/N/All/None/Skip. The dialog is pure UI: it
//! does NOT call `delete` itself. The caller (the `FileManager`
//! dispatcher) checks [`DeleteDialog::choice`] and applies it via
//! [`crate::ops::delete::delete_many`].
use std::path::PathBuf;
@@ -21,43 +20,47 @@ use crate::terminal::popup::{centered_cols_rect, render_popup};
use crate::widget::button::render_button_row;
use crate::widget::button::{ButtonKind, ButtonSpec};
/// User's choice from the delete dialog.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteChoice {
/// Yes — delete this batch.
Yes,
/// No — skip this batch.
No,
/// All — delete this and every remaining batch.
All,
/// None — skip this and every remaining batch.
None,
/// Skip — skip this single file (kept for single-file flows).
Skip,
}
/// F8 delete confirmation dialog.
pub struct DeleteDialog {
/// Paths to be deleted.
pub paths: Vec<PathBuf>,
/// Cached total size in bytes (recursive sum across all paths).
/// Computed at construction via `crate::ops::count_bytes` so the
/// dialog renders without blocking on filesystem traversal.
pub total_bytes: u64,
/// True after Y confirms.
pub confirmed: bool,
/// True after N / Esc cancels.
pub cancelled: bool,
/// Width as a fraction of the parent area.
pub choice: Option<DeleteChoice>,
pub finished: bool,
pub width_pct: f32,
/// Height as a fraction of the parent area.
pub height_pct: f32,
}
impl DeleteDialog {
/// Create a new delete dialog for the given paths. The total
/// recursive size is computed eagerly (filesystem walk) so the
/// dialog header can display "Delete N items (X MB)?" without
/// blocking at render time.
/// Create a new delete dialog for the given paths.
#[must_use]
pub fn new(paths: Vec<PathBuf>) -> Self {
let total_bytes = crate::ops::count_bytes(&paths);
Self {
paths,
total_bytes,
confirmed: false,
cancelled: false,
choice: None,
finished: false,
width_pct: 0.5,
height_pct: 0.4,
}
}
/// Set the dialog size as a fraction of the parent area.
#[must_use]
pub fn with_size(mut self, width_pct: f32, height_pct: f32) -> Self {
self.width_pct = width_pct.clamp(0.1, 1.0);
@@ -65,52 +68,59 @@ impl DeleteDialog {
self
}
/// True if the user confirmed (Y).
#[must_use]
pub fn is_confirmed(&self) -> bool {
self.confirmed
pub fn choice(&self) -> Option<DeleteChoice> {
self.choice
}
/// True if the user cancelled (N or Esc).
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled
}
/// Handle a key event. Y confirms; N or Esc cancels.
/// Returns true if the dialog consumed the key.
pub fn handle_key(&mut self, key: Key) -> bool {
if self.finished {
return true;
}
if key == Key::ESCAPE {
self.cancelled = true;
self.choice = Some(DeleteChoice::No);
self.finished = true;
return true;
}
if key.mods.is_empty() && (key.code == b'y' as u32 || key.code == b'Y' as u32) {
self.confirmed = true;
return true;
if key.mods.is_empty() {
match key.code {
c if c == b'y' as u32 || c == b'Y' as u32 => {
self.choice = Some(DeleteChoice::Yes);
self.finished = true;
}
c if c == b'n' as u32 || c == b'N' as u32 => {
self.choice = Some(DeleteChoice::No);
self.finished = true;
}
c if c == b'a' as u32 || c == b'A' as u32 => {
self.choice = Some(DeleteChoice::All);
self.finished = true;
}
c if c == b'o' as u32 || c == b'O' as u32 => {
self.choice = Some(DeleteChoice::None);
self.finished = true;
}
c if c == b's' as u32 || c == b'S' as u32 => {
self.choice = Some(DeleteChoice::Skip);
self.finished = true;
}
_ => {}
}
}
if key.mods.is_empty() && (key.code == b'n' as u32 || key.code == b'N' as u32) {
self.cancelled = true;
return true;
}
false
self.finished
}
/// Render the dialog into `frame`, centered on `area`.
///
/// `theme` supplies the title, body, and hint colours so the
/// dialog follows the active skin. The destructive title uses the
/// `theme.error` slot to keep the danger cue.
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let popup = centered_cols_rect(area, 56, 11);
let popup = centered_cols_rect(area, 64, 11);
let inner = render_popup(frame, popup, crate::locale::t("dialog_title_delete"), theme);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), // header
Constraint::Min(2), // paths
Constraint::Length(1), // buttons
Constraint::Length(1), // hint
Constraint::Length(2),
Constraint::Min(2),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
@@ -150,33 +160,20 @@ impl DeleteDialog {
frame,
chunks[2],
&[
ButtonSpec {
label: &crate::locale::t("dialog_action_yes"),
hotkey: Some('Y'),
kind: ButtonKind::Default,
},
ButtonSpec {
label: &crate::locale::t("dialog_action_no"),
hotkey: Some('N'),
kind: ButtonKind::Normal,
},
ButtonSpec { label: "Yes", hotkey: Some('Y'), kind: ButtonKind::Default },
ButtonSpec { label: "No", hotkey: Some('N'), kind: ButtonKind::Normal },
ButtonSpec { label: "All", hotkey: Some('A'), kind: ButtonKind::Normal },
ButtonSpec { label: "None", hotkey: Some('O'), kind: ButtonKind::Normal },
ButtonSpec { label: "Skip", hotkey: Some('S'), kind: ButtonKind::Normal },
],
0,
theme,
);
let hint = Line::from(vec![
Span::styled("Enter", Style::default().fg(theme.warning)),
Span::styled(
format!(" {} ", crate::locale::t("dialog_action_yes")),
Style::default().fg(theme.foreground),
),
Span::styled("Esc", Style::default().fg(theme.warning)),
Span::styled(
format!(" {}", crate::locale::t("dialog_action_cancel")),
Style::default().fg(theme.foreground),
),
]);
let hint = Line::from(Span::styled(
" Y/N/A/O/S or Esc: cancel",
Style::default().fg(theme.hidden),
));
frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[3]);
}
}
@@ -189,41 +186,70 @@ mod tests {
fn new_initializes() {
let d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
assert_eq!(d.paths.len(), 1);
assert!(!d.is_confirmed());
assert!(!d.is_cancelled());
assert!(d.choice().is_none());
}
#[test]
fn y_key_confirms() {
fn y_key_chooses_yes() {
let mut d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
let consumed = d.handle_key(Key {
code: b'y' as u32,
mods: crate::key::Modifiers::empty(),
});
assert!(consumed);
assert!(d.is_confirmed());
assert!(!d.is_cancelled());
assert_eq!(d.choice(), Some(DeleteChoice::Yes));
}
#[test]
fn n_key_cancels() {
fn n_key_chooses_no() {
let mut d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
let consumed = d.handle_key(Key {
code: b'n' as u32,
mods: crate::key::Modifiers::empty(),
});
assert!(consumed);
assert!(d.is_cancelled());
assert!(!d.is_confirmed());
assert_eq!(d.choice(), Some(DeleteChoice::No));
}
#[test]
fn esc_marks_cancelled() {
fn a_key_chooses_all() {
let mut d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
let consumed = d.handle_key(Key {
code: b'a' as u32,
mods: crate::key::Modifiers::empty(),
});
assert!(consumed);
assert_eq!(d.choice(), Some(DeleteChoice::All));
}
#[test]
fn o_key_chooses_none() {
let mut d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
let consumed = d.handle_key(Key {
code: b'o' as u32,
mods: crate::key::Modifiers::empty(),
});
assert!(consumed);
assert_eq!(d.choice(), Some(DeleteChoice::None));
}
#[test]
fn s_key_chooses_skip() {
let mut d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
let consumed = d.handle_key(Key {
code: b's' as u32,
mods: crate::key::Modifiers::empty(),
});
assert!(consumed);
assert_eq!(d.choice(), Some(DeleteChoice::Skip));
}
#[test]
fn esc_chooses_no() {
let mut d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]);
let consumed = d.handle_key(Key::ESCAPE);
assert!(consumed);
assert!(d.is_cancelled());
assert!(!d.is_confirmed());
assert_eq!(d.choice(), Some(DeleteChoice::No));
}
#[test]
@@ -1302,15 +1302,17 @@ self.status.set_error(format!("chown: {e}"));
}
}
Some(DialogState::Delete(d)) => {
#[allow(clippy::collapsible_match, reason = "guard would change fallthrough semantics")]
if d.is_confirmed() {
self.spawn_op_with_progress(
crate::ops::OpKind::Delete,
d.paths.clone(),
None,
false,
false,
);
use super::delete_dialog::DeleteChoice;
if let Some(choice) = d.choice() {
if matches!(choice, DeleteChoice::Yes | DeleteChoice::All) {
self.spawn_op_with_progress(
crate::ops::OpKind::Delete,
d.paths.clone(),
None,
false,
false,
);
}
}
}
// Info dialog: just close.
@@ -278,6 +278,8 @@ impl FileManager {
let initial = SortSettings {
field: p.sort_field(),
reverse: p.sort_reverse(),
case_sensitive: p.sort_case_sensitive(),
executable_first: false,
};
self.dialog = Some(DialogState::Sort(Box::new(SortDialog::new(initial))));
Ok(true)
@@ -972,10 +974,15 @@ impl FileManager {
match r {
SortResult::Confirm(settings) => {
self.active_panel_mut().apply_sort(settings.field, settings.reverse);
if settings.case_sensitive != self.active_panel().sort_case_sensitive() {
self.active_panel_mut().toggle_sort_case();
}
self.status.set_message(format!(
"Sort: {} {}",
"Sort: {} {}{}{}",
self.active_panel().sort_field_name(),
if settings.reverse { "descending" } else { "ascending" }
if settings.reverse { "descending" } else { "ascending" },
if settings.case_sensitive { " (case-sensitive)" } else { "" },
if settings.executable_first { " (executables first)" } else { "" },
));
self.dialog = None;
}
@@ -377,7 +377,7 @@ impl DialogState {
DialogState::MkDir(d) => d.confirmed || d.cancelled,
DialogState::Copy(d) => d.confirmed || d.cancelled,
DialogState::Move(d) => d.confirmed || d.cancelled,
DialogState::Delete(d) => d.confirmed || d.cancelled,
DialogState::Delete(d) => d.finished,
// The 4 new dialogs (Find/Hotlist/Tree/UserMenu) are
// closed by their apply_*_outcome() helpers, not by a
// self-contained flag. They never return true here.
@@ -27,26 +27,60 @@ pub enum SortResult {
/// Snapshot of sort-field + direction chosen by the user.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SortSettings {
/// The sort field (Name / Extension / Size / Mtime).
/// The sort field.
pub field: SortField,
/// Whether to reverse the sort direction.
pub reverse: bool,
/// Whether the sort is case-sensitive.
pub case_sensitive: bool,
/// Whether executables should sort first.
pub executable_first: bool,
}
const FIELDS: [SortField; 4] = [
const FIELDS: [SortField; 11] = [
SortField::Name,
SortField::Extension,
SortField::Size,
SortField::Mtime,
SortField::Atime,
SortField::Ctime,
SortField::Permissions,
SortField::Owner,
SortField::Group,
SortField::Inode,
SortField::Unsorted,
];
const FIELD_LABELS: [&str; 4] = ["Name", "Extension", "Size", "Modify time"];
const FIELD_LABELS: [&str; 11] = [
"Name",
"Extension",
"Size",
"Modify time",
"Access time",
"Change time",
"Permission",
"Owner",
"Group",
"Inode",
"Unsorted",
];
/// Which focusable element is currently focused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focusable {
Field(usize),
Reverse,
CaseSensitive,
ExecutableFirst,
}
/// Modal dialog for choosing sort field and direction.
pub struct SortDialog {
selected: usize,
reverse: bool,
focus_reverse: bool,
case_sensitive: bool,
executable_first: bool,
focus: Focusable,
}
impl SortDialog {
@@ -57,10 +91,17 @@ impl SortDialog {
.iter()
.position(|f| *f == initial.field)
.unwrap_or(0);
let focus = if selected < FIELDS.len() {
Focusable::Field(selected)
} else {
Focusable::Reverse
};
Self {
selected,
reverse: initial.reverse,
focus_reverse: false,
case_sensitive: initial.case_sensitive,
executable_first: initial.executable_first,
focus,
}
}
@@ -70,6 +111,8 @@ impl SortDialog {
SortSettings {
field: FIELDS[self.selected],
reverse: self.reverse,
case_sensitive: self.case_sensitive,
executable_first: self.executable_first,
}
}
@@ -94,8 +137,13 @@ impl SortDialog {
return SortResult::Running;
}
if let Some(ch) = char::from_u32(key.code) {
if ch == ' ' && self.focus_reverse {
self.reverse = !self.reverse;
if ch == ' ' {
match self.focus {
Focusable::Reverse => self.reverse = !self.reverse,
Focusable::CaseSensitive => self.case_sensitive = !self.case_sensitive,
Focusable::ExecutableFirst => self.executable_first = !self.executable_first,
Focusable::Field(_) => {}
}
return SortResult::Running;
}
}
@@ -103,33 +151,35 @@ impl SortDialog {
}
fn focus_next(&mut self) {
if !self.focus_reverse {
if self.selected < FIELDS.len() - 1 {
self.selected += 1;
} else {
self.focus_reverse = true;
}
} else {
self.focus_reverse = false;
self.selected = 0;
self.focus = match self.focus {
Focusable::Field(i) if i + 1 < FIELDS.len() => Focusable::Field(i + 1),
Focusable::Field(_) => Focusable::Reverse,
Focusable::Reverse => Focusable::CaseSensitive,
Focusable::CaseSensitive => Focusable::ExecutableFirst,
Focusable::ExecutableFirst => Focusable::Field(0),
};
if let Focusable::Field(i) = self.focus {
self.selected = i;
}
}
fn focus_prev(&mut self) {
if self.focus_reverse {
self.focus_reverse = false;
self.selected = FIELDS.len() - 1;
} else if self.selected > 0 {
self.selected -= 1;
} else {
self.focus_reverse = true;
self.focus = match self.focus {
Focusable::Field(0) => Focusable::ExecutableFirst,
Focusable::Field(i) => Focusable::Field(i - 1),
Focusable::Reverse => Focusable::Field(FIELDS.len() - 1),
Focusable::CaseSensitive => Focusable::Reverse,
Focusable::ExecutableFirst => Focusable::Field(0),
};
if let Focusable::Field(i) = self.focus {
self.selected = i;
}
}
/// Render the dialog centered on `area`.
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let w = 36u16.min(area.width.saturating_sub(2));
let h = 12u16.min(area.height.saturating_sub(2));
let h = (FIELDS.len() as u16 + 6).min(area.height.saturating_sub(2));
let dlg = crate::terminal::popup::centered_cols_rect(area, w, h);
let inner = crate::terminal::popup::render_popup(frame, dlg, "Sort Order", theme);
@@ -139,6 +189,7 @@ impl SortDialog {
}
constraints.push(Constraint::Length(1));
constraints.push(Constraint::Length(1));
constraints.push(Constraint::Length(1));
constraints.push(Constraint::Min(0));
let chunks = Layout::default()
@@ -147,8 +198,9 @@ impl SortDialog {
.split(inner);
for (i, label) in FIELD_LABELS.iter().enumerate() {
let is_focused = i == self.selected && !self.focus_reverse;
let marker = if i == self.selected { "(*)" } else { "( )" };
let is_focused = matches!(self.focus, Focusable::Field(j) if j == i);
let is_selected = i == self.selected;
let marker = if is_selected { "(*)" } else { "( )" };
let style = if is_focused {
Style::default()
.fg(theme.cursor_fg)
@@ -167,7 +219,7 @@ impl SortDialog {
}
let rev_idx = 1 + FIELDS.len();
let rev_style = if self.focus_reverse {
let rev_style = if matches!(self.focus, Focusable::Reverse) {
Style::default()
.fg(theme.cursor_fg)
.bg(theme.cursor_bg)
@@ -184,12 +236,48 @@ impl SortDialog {
chunks[rev_idx],
);
let case_idx = rev_idx + 1;
let case_style = if matches!(self.focus, Focusable::CaseSensitive) {
Style::default()
.fg(theme.cursor_fg)
.bg(theme.cursor_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.foreground)
};
let case_marker = if self.case_sensitive { "[x]" } else { "[ ]" };
frame.render_widget(
Paragraph::new(Span::styled(
" Tab/up-down: move Enter: OK Esc: cancel",
format!("{case_marker} Case sensitive"),
case_style,
)),
chunks[case_idx],
);
let exec_idx = case_idx + 1;
let exec_style = if matches!(self.focus, Focusable::ExecutableFirst) {
Style::default()
.fg(theme.cursor_fg)
.bg(theme.cursor_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.foreground)
};
let exec_marker = if self.executable_first { "[x]" } else { "[ ]" };
frame.render_widget(
Paragraph::new(Span::styled(
format!("{exec_marker} Executable first"),
exec_style,
)),
chunks[exec_idx],
);
frame.render_widget(
Paragraph::new(Span::styled(
" Tab/up-down: move Space: toggle Enter: OK Esc: cancel",
Style::default().fg(theme.hidden),
)),
chunks[rev_idx + 1],
chunks[exec_idx + 1],
);
}
}
@@ -203,6 +291,8 @@ mod tests {
SortSettings {
field: SortField::Name,
reverse: false,
case_sensitive: false,
executable_first: false,
}
}
@@ -255,29 +345,74 @@ mod tests {
#[test]
fn sort_dialog_tab_reaches_reverse() {
let mut d = SortDialog::new(settings());
for _ in 0..4 {
for _ in 0..FIELDS.len() {
d.handle_key(Key::TAB);
}
assert!(d.focus_reverse);
assert!(matches!(d.focus, Focusable::Reverse));
}
#[test]
fn sort_dialog_tab_reaches_case_sensitive() {
let mut d = SortDialog::new(settings());
for _ in 0..FIELDS.len() + 1 {
d.handle_key(Key::TAB);
}
assert!(matches!(d.focus, Focusable::CaseSensitive));
}
#[test]
fn sort_dialog_tab_reaches_executable_first() {
let mut d = SortDialog::new(settings());
for _ in 0..FIELDS.len() + 2 {
d.handle_key(Key::TAB);
}
assert!(matches!(d.focus, Focusable::ExecutableFirst));
}
#[test]
fn sort_dialog_space_toggles_when_focused_on_checkbox() {
let mut d = SortDialog::new(settings());
for _ in 0..FIELDS.len() {
d.handle_key(Key::TAB);
}
d.handle_key(Key::from_char(' '));
assert!(d.reverse);
d.handle_key(Key::from_char(' '));
assert!(!d.reverse);
}
#[test]
fn sort_dialog_cycles_through_all_11_fields() {
let count = FIELDS.len();
assert_eq!(count, 11, "SortField must have 11 entries for MC parity");
let names = ["Name", "Extension", "Size", "Modify time", "Access time",
"Change time", "Permission", "Owner", "Group", "Inode", "Unsorted"];
for (i, name) in names.iter().enumerate() {
assert_eq!(FIELD_LABELS[i], *name);
}
}
#[test]
fn sort_dialog_wrap_from_last_to_reverse() {
let mut d = SortDialog::new(SortSettings {
field: SortField::Mtime,
field: SortField::Unsorted,
reverse: false,
case_sensitive: false,
executable_first: false,
});
d.handle_key(down_key());
assert!(d.focus_reverse);
assert!(matches!(d.focus, Focusable::Reverse));
}
#[test]
fn sort_dialog_up_from_reverse_to_last() {
fn sort_dialog_up_from_executable_first_wraps_to_first_field() {
let mut d = SortDialog::new(settings());
d.focus_reverse = true;
for _ in 0..FIELDS.len() + 2 {
d.handle_key(Key::TAB);
}
assert!(matches!(d.focus, Focusable::ExecutableFirst), "expected ExecutableFirst, got {:?}", d.focus);
d.handle_key(up_key());
assert!(!d.focus_reverse);
assert_eq!(d.selected, FIELDS.len() - 1);
assert!(matches!(d.focus, Focusable::Field(0)), "expected Field(0), got {:?}", d.focus);
}
#[test]