tlc: phase 24 — editor word sort (Alt-F8)

Mirrors MC's edit_sort_cmd flow (CK_Sort / M-F8 →
src/editor/editcmd.c::edit_sort_cmd):

  1. Require a marked block; surface error message if no
     selection is active.
  2. Stash the active selection to a unique temp file under
     /tlc-sort-<nanos>/in.txt (per-call unique dir to
     avoid races between concurrent sort runs).
  3. Run via 'sh -c sort <opts> <in> > <out>' so user-supplied
     options pass through verbatim (matches MC g_strconcat).
     Empty options means default lexical sort.
  4. On non-zero exit: surface exit code + first stderr line.
  5. On success: delete the original selection, insert the
     sorted output at the cursor, mark buffer modified, and
     surface a status line ('Sort: ok (N bytes, M lines)').

Components:
  src/editor/mode.rs — new PromptKind::Sort variant.
  src/editor/handlers.rs — Alt-F8 keybind (errors if no
    selection, otherwise opens the sort prompt); commit_prompt
    routes PromptKind::Sort to Editor::sort_block(options).
  src/editor/render.rs — title ('Run sort') + label ('sort
    options (empty for default)') + mode tag (' [Sort]') for
    the prompt.
  src/editor/mod.rs — Editor::sort_block(options).

Tests: 1153 passed (was 1150, +3):
  - sort_block_sorts_active_selection (apple/banana/cherry)
  - sort_block_with_no_selection_reports_error
  - sort_block_with_reverse_flag (-r → c/b/a)
Release binaries build clean.

PLAN.md §15d row 32a marked Done.
This commit is contained in:
vasilito
2026-06-20 22:09:49 +03:00
parent 08561033ae
commit 28906f16cf
5 changed files with 176 additions and 3 deletions
+4 -3
View File
@@ -1,9 +1,9 @@
# Twilight Commander (TLC) — Pure Rust Reimplementation Plan
**Status:** Architecture chosen. Implementation in progress. Phases 08 substantially complete.
Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e, 16, 17, 18, 19, 20, 21, 22, 23 substantially complete.
**Last updated:** 2026-06-20 — Phase 23 viewer FileNext/FilePrev (Ctrl-F/Ctrl-B navigates to lexically next/prev sibling in the current file's directory; 1150 tests pass).
**Date:** 2026-06-12 (initial) · 2026-06-13 (rename + comprehensive review + audit fixes) · 2026-06-19 (bug fixes, standalone binaries, syntax highlighter, parity audit reconciliation) · 2026-06-20 (Phase 16, Phase 17, Phase 18, Phase 19, Phase 20, Phase 21, Phase 22, Phase 23)
Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e, 16, 17, 18, 19, 20, 21, 22, 23, 24 substantially complete.
**Last updated:** 2026-06-20 — Phase 24 editor word sort (Alt-F8 sort prompt → `sh -c sort <opts> <in> > <out>` → block replace; 1153 tests pass).
**Date:** 2026-06-12 (initial) · 2026-06-13 (rename + comprehensive review + audit fixes) · 2026-06-19 (bug fixes, standalone binaries, syntax highlighter, parity audit reconciliation) · 2026-06-20 (Phase 16, Phase 17, Phase 18, Phase 19, Phase 20, Phase 21, Phase 22, Phase 23, Phase 24)
**Branch:** `0.2.4`
**Decision authority:** User selected Option A (Pure Rust TLC) on 2026-06-12.
**Scope:** Reimplement ALL of Midnight Commander (MC 4.8.33) in pure Rust.
@@ -1289,6 +1289,7 @@ dispatcher):
| 30 | PTY-based persistent subshell | ❌ Not started | Use `portable-pty` crate; fork bash/zsh/fish with `--init-command`; manage CWD pipe; handle `SIGSTOP`/`SIGCONT` |
| 31 | Macros (record/replay) | ✅ Done | `editor::Macro`; records keystroke sequence to `Vec<KeyEvent>`; replays with timing (or instant); persisted via `~/.config/tlc/macro.json` |
| 32 | Format paragraph (Alt-P) | ❌ Not started | `editor::format_paragraph`; re-flow to fill-width (configurable, default 72); preserves paragraphs separated by blank lines |
| 32a | Word sort (Alt-F8) | ✅ Done (Phase 24) | `mode::PromptKind::Sort`; `Editor::sort_block(options)` runs `sh -c sort <opts> <in> > <out>` via temp files; replaces active selection with sorted output; status surfaces exit code + line count |
| 33 | Growing buffer (tail -f mode) | ❌ Not started | `viewer::Growing`; poll `file.metadata().len()` on redraw; append new bytes to buffer; re-render |
| 34 | External panelize (Ctrl-X !) | 🚧 Partial | `Cmd::Panelize` stub prints "not implemented" (module being integrated); `vfs::Panelized` not yet wired |
| 35 | Compare directories (Ctrl-X d) | ❌ Not started | `ops::CompareDirs`; walk both panels, mark entries unique to left, unique to right, differing (size or mtime), identical |
@@ -476,6 +476,17 @@ impl Editor {
}
return EditorResult::Running;
}
// Alt-F8 — Sort the active block via external `sort(1)` (MC
// `CK_Sort` / `M-F8` → `edit_sort_cmd`). Errors if there is
// no selection.
if key.mods == Modifiers::ALT && key == Key::f(8) {
if !self.cursor.has_selection() {
self.message =
Some("Sort: highlight a block of text first".to_string());
} else {
return self.open_prompt(PromptKind::Sort);
}
}
// Alt-Shift-L — Select current line (including newline).
if key.mods == (Modifiers::ALT | Modifiers::SHIFT) && key.code == 0x6C {
self.select_current_line();
@@ -993,6 +1004,12 @@ impl Editor {
}
}
}
PromptKind::Sort => {
// Empty options means default lexical sort (matches
// MC `sort` with no flags). text may be empty.
let options = text.trim();
self.sort_block(options);
}
PromptKind::SaveBeforeClose => {
// The save-before-close prompt routes through
// `handle_save_before_close`, not this function.
@@ -431,6 +431,104 @@ bracket_flash: None,
self.modified = self.buffer.is_modified();
}
/// Sort the active block via external `sort(1)`.
///
/// Mirrors MC's `edit_sort_cmd` flow
/// (`MC src/editor/editcmd.c:1764`):
/// 1. Stash the active selection to a temp file.
/// 2. Run `sort <options> <temp_in> > <temp_out>` via `sh -c`.
/// 3. Delete the original block; insert the sorted output.
/// 4. Surface exit code + first line of stderr in the status bar.
///
/// `options` is passed verbatim to `sort(1)` — empty means
/// default lexical sort. Errors if no selection is active or
/// `sort` is not on PATH.
pub fn sort_block(&mut self, options: &str) {
if !self.cursor.has_selection() {
self.message = Some("Sort: no selection".to_string());
return;
}
let Some(text) = self.cursor.selected_text(&self.buffer) else {
self.message = Some("Sort: cannot read selection".to_string());
return;
};
// Stash selection to a temp input file. Use a per-call unique
// directory so concurrent sort runs don't race over the
// same input/output files.
let tmp_dir = std::env::temp_dir().join(format!(
"tlc-sort-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::create_dir_all(&tmp_dir);
let tmp_in = tmp_dir.join("in.txt");
let tmp_out = tmp_dir.join("out.txt");
if let Err(e) = std::fs::write(&tmp_in, text.as_bytes()) {
self.message = Some(format!("Sort: write input: {e}"));
return;
}
// Build the shell command. Empty options = default lexical
// sort. User-supplied options are passed verbatim, so they
// can contain redirections (matches MC `g_strconcat`).
let cmd = if options.is_empty() {
format!("sort {} > {}", tmp_in.display(), tmp_out.display())
} else {
format!(
"sort {} {} > {}",
options,
tmp_in.display(),
tmp_out.display()
)
};
let output = match std::process::Command::new("sh")
.arg("-c")
.arg(&cmd)
.output()
{
Ok(out) => out,
Err(e) => {
self.message = Some(format!("Sort: launch `sh`: {e}"));
return;
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let first = stderr.lines().next().unwrap_or("");
self.message = Some(format!(
"Sort: exit {}, stderr: {}",
output.status.code().unwrap_or(-1),
first
));
return;
}
let sorted = match std::fs::read_to_string(&tmp_out) {
Ok(s) => s,
Err(e) => {
self.message = Some(format!("Sort: read output: {e}"));
return;
}
};
// Replace selection with sorted text.
let _ = self.cursor.delete_selection(&mut self.buffer);
self.buffer.set_cursor(self.cursor.position());
self.insert_str(&sorted);
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = self.buffer.is_modified();
self.message = Some(format!(
"Sort: ok ({} bytes, {} lines)",
sorted.len(),
sorted.lines().count()
));
}
/// Restore cursor line/column from the filepos database for the
/// current file. Returns true if a saved position was found and
/// applied.
@@ -2726,5 +2824,56 @@ mod tests {
assert!(e.buffer().as_string().contains("x"));
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn sort_block_sorts_active_selection() {
let mut e = make_empty();
e.insert_str("cherry\napple\nbanana\n");
e.buffer.set_cursor(0);
e.cursor.set_position(0, &e.buffer);
e.cursor.start_selection();
// Selection covers the whole buffer (0..end of last line).
e.buffer.set_cursor(e.buffer.len());
e.cursor.set_position(e.buffer.len(), &e.buffer);
e.sort_block("");
assert_eq!(
e.buffer().as_string(),
"apple\nbanana\ncherry\n"
);
assert!(e.modified);
assert!(e
.message
.as_deref()
.unwrap_or("")
.starts_with("Sort: ok"));
}
#[test]
fn sort_block_with_no_selection_reports_error() {
let mut e = make_empty();
e.insert_str("nothing to sort\n");
e.sort_block("");
assert!(e
.message
.as_deref()
.unwrap_or("")
.contains("no selection"));
// Buffer unchanged.
assert_eq!(e.buffer().as_string(), "nothing to sort\n");
}
#[test]
fn sort_block_with_reverse_flag() {
let mut e = make_empty();
e.insert_str("a\nb\nc\n");
e.buffer.set_cursor(0);
e.cursor.set_position(0, &e.buffer);
e.cursor.start_selection();
e.buffer.set_cursor(e.buffer.len());
e.cursor.set_position(e.buffer.len(), &e.buffer);
// -r reverses the sort.
e.sort_block("-r");
assert_eq!(e.buffer().as_string(), "c\nb\na\n");
}
}
@@ -78,6 +78,9 @@ pub enum PromptKind {
/// Insert file (F15) — prompt for a path; Enter reads the file
/// and inserts its contents at the cursor.
InsertFile,
/// Sort (M-F8) — prompt for sort(1) options; Enter sorts the
/// active block via `sort` and replaces it with the output.
Sort,
}
impl Mode {
@@ -535,6 +535,7 @@ impl Editor {
PromptKind::SaveAs => crate::locale::t("dialog_title_save_as"),
PromptKind::InsertFile => "Insert File".to_string(),
PromptKind::SaveBeforeClose => unreachable!(),
PromptKind::Sort => "Run sort".to_string(),
};
let label = match kind {
PromptKind::Find => crate::locale::t("dialog_label_find"),
@@ -547,6 +548,7 @@ impl Editor {
PromptKind::SaveAs => crate::locale::t("dialog_label_path"),
PromptKind::InsertFile => crate::locale::t("dialog_label_path"),
PromptKind::SaveBeforeClose => unreachable!(),
PromptKind::Sort => "sort options (empty for default)".to_string(),
};
let inner = render_popup(frame, popup, title, theme);
let rows = Layout::default()
@@ -774,6 +776,7 @@ impl Editor {
PromptKind::BookmarkClear => " [BookmarkClear]",
PromptKind::SaveAs => " [SaveAs]",
PromptKind::InsertFile => " [InsertFile]",
PromptKind::Sort => " [Sort]",
},
};
format!(