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:
@@ -1,9 +1,9 @@
|
||||
# Twilight Commander (TLC) — Pure Rust Reimplementation Plan
|
||||
|
||||
**Status:** Architecture chosen. Implementation in progress. Phases 0–8 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!(
|
||||
|
||||
Reference in New Issue
Block a user