diff --git a/local/recipes/tui/tlc/PLAN.md b/local/recipes/tui/tlc/PLAN.md index 9d9a991112..b2dd134e75 100644 --- a/local/recipes/tui/tlc/PLAN.md +++ b/local/recipes/tui/tlc/PLAN.md @@ -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 > ` → 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`; 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 > ` 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 | diff --git a/local/recipes/tui/tlc/source/src/editor/handlers.rs b/local/recipes/tui/tlc/source/src/editor/handlers.rs index 861911b734..30dbcb52fb 100644 --- a/local/recipes/tui/tlc/source/src/editor/handlers.rs +++ b/local/recipes/tui/tlc/source/src/editor/handlers.rs @@ -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. diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index 4d665769e2..fe2e91bcd9 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -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 > ` 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"); + } } diff --git a/local/recipes/tui/tlc/source/src/editor/mode.rs b/local/recipes/tui/tlc/source/src/editor/mode.rs index b95531ea0b..acf255e1e1 100644 --- a/local/recipes/tui/tlc/source/src/editor/mode.rs +++ b/local/recipes/tui/tlc/source/src/editor/mode.rs @@ -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 { diff --git a/local/recipes/tui/tlc/source/src/editor/render.rs b/local/recipes/tui/tlc/source/src/editor/render.rs index 240753170d..ddd02fe82e 100644 --- a/local/recipes/tui/tlc/source/src/editor/render.rs +++ b/local/recipes/tui/tlc/source/src/editor/render.rs @@ -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!(