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:
@@ -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 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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user