tlc: phase 22 — wire F11 user-menu command execution

Replaces the Phase 21 TODO stub with the full MC
edit_user_menu flow:

  src/editor/mod.rs — Editor::run_user_menu_command(raw_command):
    1. Expand percent variables via PercentCtx::for_file
       (MC: expand_format).
    2. Stash active selection to clipfile (MC: edit_save_block).
       Block file path exposed via %b for downstream expansion.
    3. Run via 'sh -c <expanded>' so user can use pipes,
       redirections, globs (matches MC user_menu_cmd shell-out).
    4. On non-empty stdout: delete any selection, then
       insert_str at cursor position (MC: edit_insert_file).
       Buffer cursor is synced to editor cursor after
       delete_selection so the insert lands at the right spot.
    5. Status line: 'Menu: ok (N bytes inserted)' on success,
       'Menu: exit N, stderr: ...' on failure.

  src/editor/handlers.rs — replaces TODO with call to
    self.run_user_menu_command(command).

Tests: 1141 passed (was 1137, +4):
  - inserts stdout at cursor
  - replaces active selection (cursor sync verified)
  - reports non-zero exit status
  - expands %f to current file path via cat %f

Release binaries build clean.
This commit is contained in:
vasilito
2026-06-20 21:27:37 +03:00
parent 9d120cc802
commit 75d704c06c
3 changed files with 153 additions and 8 deletions
+3 -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 substantially complete.
**Last updated:** 2026-06-20 — Phase 21 F11 user-menu dispatch (editor `CK_UserMenu`; reuses filemanager usermenu infra with "edit" condition; 1137 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)
Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e, 16, 17, 18, 19, 20, 21, 22 substantially complete.
**Last updated:** 2026-06-20 — Phase 22 F11 user-menu execute path complete (selection stash → %expand → sh -c → stdout insert → status; 1141 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)
**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.
@@ -45,10 +45,7 @@ impl Editor {
}
UserMenuOutcome::Execute(command) => {
self.usermenu_session = None;
// TODO: stash active selection to clipfile,
// expand percent vars, run via std::process::Command,
// insert stdout. For now surface a status message.
self.message = Some(format!("Menu cmd: {command}"));
self.run_user_menu_command(command);
EditorResult::Running
}
};
+149 -1
View File
@@ -341,7 +341,94 @@ bracket_flash: None,
had_selection,
block_file,
));
self.message = Some("User menu (F2): Esc to cancel".to_string());
self.message = Some("User menu (F11): Esc to cancel".to_string());
}
/// Run the picked user-menu command and insert its stdout at
/// the cursor.
///
/// Mirrors the second half of MC's `edit_user_menu`:
/// 1. Expand percent variables (`%f`, `%p`, `%x`, `%b`, `%d`,
/// `%%`) for the current file.
/// 2. Run via `sh -c <expanded>` (so the user can use pipes,
/// redirections, glob expansions, etc. — same as MC).
/// 3. If the command produced stdout, replace any active
/// selection with that output (else insert at cursor).
/// 4. Stderr and exit status surface in the status line.
pub fn run_user_menu_command(&mut self, raw_command: String) {
let path = self.path.clone().unwrap_or_else(|| PathBuf::from("(new)"));
let dir = path
.parent()
.map(|p| p.to_path_buf())
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| PathBuf::from("."));
let expanded = crate::filemanager::percent::expand_percent(
&raw_command,
&crate::filemanager::percent::PercentCtx::for_file(&path, &dir),
);
// Stash any active selection to the clipfile before running
// (MC: edit_save_block). The block file is exposed via %b
// for downstream expansion.
if self.cursor.has_selection() {
if let Some(text) = self.cursor.selected_text(&self.buffer) {
let block_path = crate::editor::usermenu::default_block_file();
if let Some(parent) = block_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&block_path, text);
}
}
// Run via `sh -c` so the user can use pipes and redirections
// (`fmt | sed ...`). MC's user_menu_cmd does the same.
let output = match std::process::Command::new("sh")
.arg("-c")
.arg(&expanded)
.current_dir(&dir)
.output()
{
Ok(out) => out,
Err(e) => {
self.message = Some(format!("Menu: failed to launch `sh`: {e}"));
return;
}
};
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let trimmed = stdout.trim_end_matches('\n').to_string();
if !trimmed.is_empty() {
// MC: replaces the marked block with the new clipfile
// output. We replicate by deleting the selection first
// (if any), then inserting.
if self.cursor.has_selection() {
self.cursor.delete_selection(&mut self.buffer);
}
// Sync the buffer cursor to the editor cursor so the
// subsequent insert lands at the right position
// (delete_selection mutates the editor cursor but not
// the underlying buffer cursor).
self.buffer.set_cursor(self.cursor.position());
self.insert_str(&trimmed);
}
let status = output.status.code().unwrap_or(-1);
let stderr_trim = stderr.trim_end();
if status != 0 || !stderr_trim.is_empty() {
let first = stderr_trim.lines().next().unwrap_or("");
self.message = Some(format!(
"Menu: exit {status}, stderr: {first}"
));
} else {
self.message = Some(format!(
"Menu: ok ({} bytes inserted)",
trimmed.len()
));
}
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = self.buffer.is_modified();
}
/// Restore cursor line/column from the filepos database for the
@@ -2578,5 +2665,66 @@ mod tests {
e.handle_key(Key::ESCAPE);
assert!(e.usermenu_session.is_none());
}
#[test]
fn run_user_menu_command_inserts_stdout() {
// Direct call to run_user_menu_command — bypasses the dialog
// by passing the command directly.
let mut e = make_empty();
e.insert_str("before\n");
e.run_user_menu_command("echo hello".to_string());
let s = e.buffer().as_string();
assert!(s.contains("hello"), "buffer = {s:?}");
assert!(s.starts_with("before"), "buffer = {s:?}");
assert!(e.message.as_deref().unwrap_or("").starts_with("Menu: ok"));
assert!(e.modified);
}
#[test]
fn run_user_menu_command_replaces_selection() {
// With an active selection, the command output should
// replace it (MC semantics for marked-block commands).
let mut e = make_empty();
e.insert_str("hello world");
// Place cursor at start, then mark selection to "hello".
e.buffer.set_cursor(0);
e.cursor.set_position(0, &e.buffer);
e.cursor.start_selection();
e.buffer.set_cursor(5);
e.cursor.set_position(5, &e.buffer);
// Debug: confirm selection.
assert!(e.cursor.has_selection());
e.run_user_menu_command("printf replaced".to_string());
let s = e.buffer().as_string();
assert!(s.starts_with("replaced"), "got {s:?}");
}
#[test]
fn run_user_menu_command_handles_nonzero_exit() {
let mut e = make_empty();
e.insert_str("x");
e.run_user_menu_command("false".to_string());
// false exits with code 1 — status should reflect this.
assert!(e
.message
.as_deref()
.unwrap_or("")
.contains("exit 1"));
}
#[test]
fn run_user_menu_command_expands_percent_f() {
// %f should expand to the current file path. We use the
// standalone expand path via a synthetic command.
let tmp = std::env::temp_dir().join("tlc_pct_test.txt");
std::fs::write(&tmp, "x").unwrap();
let mut e = make_empty();
e.insert_str("placeholder");
e.path = Some(tmp.clone());
// `cat %f` should print the file content into the buffer.
e.run_user_menu_command("cat %f".to_string());
assert!(e.buffer().as_string().contains("x"));
let _ = std::fs::remove_file(&tmp);
}
}