diff --git a/local/recipes/tui/tlc/PLAN.md b/local/recipes/tui/tlc/PLAN.md index 83a35bb4c8..c8dfda01f5 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 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. diff --git a/local/recipes/tui/tlc/source/src/editor/handlers.rs b/local/recipes/tui/tlc/source/src/editor/handlers.rs index 5982e92a25..861911b734 100644 --- a/local/recipes/tui/tlc/source/src/editor/handlers.rs +++ b/local/recipes/tui/tlc/source/src/editor/handlers.rs @@ -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 } }; diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index 82c90bedda..4d665769e2 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -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 ` (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); + } }