From 18c4d910ee7727cfb34d4df091d2950d9405c91c Mon Sep 17 00:00:00 2001 From: kromych Date: Tue, 14 Apr 2026 23:13:57 -0700 Subject: [PATCH 1/2] recall and copy output --- crates/ruf4/src/action.rs | 13 +++++++++++++ crates/ruf4/src/draw.rs | 2 +- crates/ruf4/src/fileops.rs | 1 + crates/ruf4/src/platform.rs | 38 +++++++++++++++++++++++++++++++++++++ crates/ruf4/src/state.rs | 25 ++++++++++++++++++++++-- 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/crates/ruf4/src/action.rs b/crates/ruf4/src/action.rs index 0abbc07..20c06b3 100644 --- a/crates/ruf4/src/action.rs +++ b/crates/ruf4/src/action.rs @@ -48,6 +48,7 @@ pub enum Action { ChooseSort, // App + LastOutput, Help, SaveSettings, Refresh, @@ -135,6 +136,10 @@ pub fn default_bindings() -> Vec { key: vk::F10, action: Action::Quit, }, + Binding { + key: vk::F12, + action: Action::LastOutput, + }, // Ctrl combinations (cross-platform) Binding { key: kbmod::CTRL | vk::Q, @@ -313,6 +318,8 @@ pub fn key_display_name(key: InputKey) -> String { k if k == vk::F8 => "F8", k if k == vk::F9 => "F9", k if k == vk::F10 => "F10", + k if k == vk::F11 => "F11", + k if k == vk::F12 => "F12", k if k == vk::UP => "Up", k if k == vk::DOWN => "Down", k if k == vk::LEFT => "Left", @@ -397,6 +404,7 @@ pub fn action_label(action: Action) -> &'static str { Action::ChangeRoot => "Change root", Action::DirHistory => "Directory history", Action::CmdHistory => "Command history", + Action::LastOutput => "Last output", } } @@ -440,6 +448,7 @@ pub fn action_str(action: Action) -> &'static str { Action::CmdHistory => "cmd_history", Action::FocusMenu => "focus_menu", Action::Quit => "quit", + Action::LastOutput => "last_output", } } @@ -481,6 +490,7 @@ pub fn parse_action(s: &str) -> Option { "cmd_history" => Action::CmdHistory, "focus_menu" => Action::FocusMenu, "quit" => Action::Quit, + "last_output" => Action::LastOutput, _ => return None, }) } @@ -519,6 +529,8 @@ pub fn parse_key_name(s: &str) -> Option { "F8" => vk::F8, "F9" => vk::F9, "F10" => vk::F10, + "F11" => vk::F11, + "F12" => vk::F12, "Up" => vk::UP, "Down" => vk::DOWN, "Left" => vk::LEFT, @@ -582,6 +594,7 @@ pub fn build_help_text(bindings: &[Binding]) -> Vec<(String, &'static str, Actio Some(Action::Delete), Some(Action::FocusMenu), Some(Action::Quit), + Some(Action::LastOutput), None, // separator Some(Action::CursorUp), Some(Action::CursorDown), diff --git a/crates/ruf4/src/draw.rs b/crates/ruf4/src/draw.rs index cf573e8..97e6621 100644 --- a/crates/ruf4/src/draw.rs +++ b/crates/ruf4/src/draw.rs @@ -1208,7 +1208,7 @@ fn draw_shell_output_dialog( let w = (size.width - 4).max(20); let h = (size.height - 4).max(8); - let title = arena_format!(ctx.arena(), "$ {command} - Esc/Enter=Close"); + let title = arena_format!(ctx.arena(), "$ {command} - Ctrl+C=Copy Esc/Enter=Close"); ctx.modal_begin("shell-dialog", &title); ctx.attr_intrinsic_size(Size { width: w, diff --git a/crates/ruf4/src/fileops.rs b/crates/ruf4/src/fileops.rs index 6382675..03d511a 100644 --- a/crates/ruf4/src/fileops.rs +++ b/crates/ruf4/src/fileops.rs @@ -288,6 +288,7 @@ pub fn execute_command(state: &mut State) { match platform::run_command(&cmd, &cwd) { Ok((text, _code)) => { + state.last_output = Some((cmd.clone(), text.clone())); state.dialog = Dialog::ShellOutput { command: cmd, output: text, diff --git a/crates/ruf4/src/platform.rs b/crates/ruf4/src/platform.rs index d9e4e02..8c9bef9 100644 --- a/crates/ruf4/src/platform.rs +++ b/crates/ruf4/src/platform.rs @@ -521,6 +521,44 @@ pub fn remove_symlink(path: &Path) -> std::io::Result<()> { } /// Recreate a symlink at `dst` pointing to the same target as `src`. +/// Copy text to the system clipboard via OSC 52 escape sequence. +/// This works in most modern terminals (iTerm2, kitty, alacritty, Windows Terminal, etc.). +pub fn copy_to_clipboard(text: &str) { + use ruf4_tui::sys; + use std::io::Write; + + let mut buf = Vec::new(); + let encoded = base64_encode(text.as_bytes()); + write!(buf, "\x1b]52;c;{encoded}\x1b\\").ok(); + if let Ok(s) = std::str::from_utf8(&buf) { + sys::write_stdout(s); + } +} + +fn base64_encode(data: &[u8]) -> String { + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut result = String::with_capacity((data.len() + 2) / 3 * 4); + for chunk in data.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = chunk.get(1).copied().unwrap_or(0) as u32; + let b2 = chunk.get(2).copied().unwrap_or(0) as u32; + let triple = (b0 << 16) | (b1 << 8) | b2; + result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char); + result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char); + if chunk.len() > 1 { + result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char); + } else { + result.push('='); + } + if chunk.len() > 2 { + result.push(CHARS[(triple & 0x3F) as usize] as char); + } else { + result.push('='); + } + } + result +} + pub fn copy_symlink(src: &Path, dst: &Path) -> std::io::Result<()> { let link_target = fs::read_link(src)?; #[cfg(unix)] diff --git a/crates/ruf4/src/state.rs b/crates/ruf4/src/state.rs index 02601f8..500049f 100644 --- a/crates/ruf4/src/state.rs +++ b/crates/ruf4/src/state.rs @@ -138,6 +138,7 @@ pub struct State { pub bindings: Vec, pub help_text: Vec<(String, &'static str, Action)>, pub theme: Theme, + pub last_output: Option<(String, String)>, // (command, output) last_click: Option<(Instant, Point)>, } @@ -181,6 +182,7 @@ impl State { bindings, help_text, theme, + last_output: None, last_click: None, }; @@ -229,6 +231,7 @@ impl State { bindings, help_text, theme, + last_output: None, last_click: None, } } @@ -369,6 +372,15 @@ impl State { Action::ChangeRoot => self.open_choose_root(), Action::DirHistory => self.open_dir_history(), Action::CmdHistory => self.open_cmd_history(), + Action::LastOutput => { + if let Some((ref cmd, ref out)) = self.last_output { + self.dialog = Dialog::ShellOutput { + command: cmd.clone(), + output: out.clone(), + scroll: 0, + }; + } + } Action::FocusMenu => self.want_menu_focus = true, Action::Quit => { self.dialog = Dialog::ConfirmQuit { @@ -1132,12 +1144,16 @@ impl State { } fn handle_scrollable_dialog(&mut self, ev: &Input) { - let Dialog::ShellOutput { scroll, .. } = &mut self.dialog else { + let Dialog::ShellOutput { scroll, output, .. } = &mut self.dialog else { return; }; match ev { Input::Keyboard(key) => { let key = *key; + if key == (kbmod::CTRL | vk::C) { + platform::copy_to_clipboard(output); + return; + } if key == vk::ESCAPE || key == vk::RETURN || key == vk::SPACE { *scroll = usize::MAX; // signal dismiss (finalize_dialog cleans up) } else if key == vk::UP { @@ -1315,9 +1331,14 @@ impl State { impl State { /// Call after handle_dialog_input to finalize deferred state changes. pub fn finalize_dialog(&mut self) { - if let Dialog::ShellOutput { scroll, .. } = &self.dialog + if let Dialog::ShellOutput { + scroll, + command, + output, + } = &self.dialog && *scroll == usize::MAX { + self.last_output = Some((command.clone(), output.clone())); self.dialog = Dialog::None; } } From efc729dd48d5375b022a0b2aff51d8724eca00c4 Mon Sep 17 00:00:00 2001 From: kromych Date: Sat, 25 Apr 2026 16:38:20 -0700 Subject: [PATCH 2/2] clippy --- crates/ruf4/src/platform.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruf4/src/platform.rs b/crates/ruf4/src/platform.rs index 8c9bef9..caa8476 100644 --- a/crates/ruf4/src/platform.rs +++ b/crates/ruf4/src/platform.rs @@ -537,7 +537,7 @@ pub fn copy_to_clipboard(text: &str) { fn base64_encode(data: &[u8]) -> String { const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut result = String::with_capacity((data.len() + 2) / 3 * 4); + let mut result = String::with_capacity(data.len().div_ceil(3) * 4); for chunk in data.chunks(3) { let b0 = chunk[0] as u32; let b1 = chunk.get(1).copied().unwrap_or(0) as u32;