Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions src/core/engine/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7712,10 +7712,10 @@ impl Engine {

/// Route pasted text to the correct input context.
///
/// Checks active contexts in priority order (terminal, picker, search,
/// SC commit, extension sidebar, AI chat) before falling through to
/// mode-based dispatch. Both backends call this instead of reimplementing
/// the priority chain.
/// Checks active contexts in priority order (terminal, picker, explorer
/// rename, search, SC commit, extension sidebar, AI chat) before falling
/// through to mode-based dispatch. Both backends call this instead of
/// reimplementing the priority chain.
pub fn route_paste(&mut self, text: &str) {
let first_line = text.lines().next().unwrap_or("");

Expand Down Expand Up @@ -7748,6 +7748,28 @@ impl Engine {
return;
}

// Tree inline-edit (explorer rename). Mirrors the selection-replace
// + insert behaviour `handle_explorer_rename_key`'s own `ctrl+v`
// branch already has — that branch reads `self.clipboard_read`
// directly and only ever fired from a raw `KeyPressed("v", ctrl)`,
// which quadraui's GTK runner no longer delivers (#593: Ctrl+V is
// intercepted and turned into this fn's `text` argument instead).
if let Some(state) = self.explorer_rename.as_mut() {
if !first_line.is_empty() {
if let Some(anchor) = state.selection_anchor.take() {
let lo = anchor.min(state.cursor);
let hi = anchor.max(state.cursor);
if lo != hi {
state.input.drain(lo..hi);
state.cursor = lo;
}
}
state.input.insert_str(state.cursor, first_line);
state.cursor += first_line.len();
}
return;
}

if self.search_has_focus {
let focus_id = self.search_panel_form_focus.borrow().clone();
if focus_id.is_some() {
Expand Down
20 changes: 20 additions & 0 deletions src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19957,6 +19957,26 @@ fn test_explorer_rename_ctrl_v_paste() {
assert_eq!(rename.input, "pasted_name.rs");
}

/// #593: `UiEvent::ClipboardPaste` (delivered by quadraui's GTK runner when
/// it intercepts Ctrl+V, and by TUI's crossterm bracketed-paste) reaches the
/// engine as a call to `route_paste`, not a raw `KeyPressed("v", ctrl)` —
/// so the explorer-rename ctrl+v branch in `handle_explorer_rename_key`
/// (exercised by `test_explorer_rename_ctrl_v_paste` above) never fires for
/// it. `route_paste` needs its own explorer-rename arm, checked before this
/// fix landed: it fell through to the mode-based dispatch at the bottom of
/// `route_paste` and did nothing, since `start_explorer_rename` doesn't
/// change `engine.mode`.
#[test]
fn test_explorer_rename_route_paste() {
let mut e = Engine::new();
e.start_explorer_rename(PathBuf::from("/tmp/hello.rs"));
// Selection is "hello" (anchor=0, cursor=5) — route_paste must replace it,
// same as the ctrl+v key path does.
e.route_paste("pasted_name");
let rename = e.explorer_rename.as_ref().unwrap();
assert_eq!(rename.input, "pasted_name.rs");
}

#[test]
fn test_explorer_rename_ctrl_c_copies_selection() {
use std::sync::{Arc, Mutex};
Expand Down
64 changes: 18 additions & 46 deletions src/gtk/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1294,10 +1294,6 @@ enum Msg {
SelectForDiff(PathBuf),
/// Open a vsplit diff: current file is right side, stored path is left.
DiffWithSelected(PathBuf),
/// GDK clipboard text arrived for pasting into command/search/insert input.
ClipboardPasteToInput {
text: String,
},
/// Toggle the integrated terminal panel open/closed.
ToggleTerminal,
/// Toggle the "terminal maximized" state (panel fills editor area).
Expand Down Expand Up @@ -2463,7 +2459,6 @@ impl App {
| Msg::CopyRelativePath(_)
| Msg::SelectForDiff(_)
| Msg::DiffWithSelected(_)
| Msg::ClipboardPasteToInput { .. }
| Msg::WindowClosing { .. } => {
self.handle_file_ops_msg(msg);
}
Expand Down Expand Up @@ -2986,25 +2981,6 @@ impl App {

#[allow(clippy::too_many_lines)]
fn handle_key_press(&mut self, key_name: String, unicode: Option<char>, ctrl: bool, alt: bool) {
// Handle Ctrl-Shift-V paste (sent as synthetic "PasteClipboard" key):
// do async GDK clipboard read → ClipboardPasteToInput
if key_name == "PasteClipboard" {
if let Some(display) = gdk::Display::default() {
let sender = self.sender.clone();
display
.clipboard()
.read_text_async(gtk4::gio::Cancellable::NONE, move |result| {
let text = result
.ok()
.flatten()
.map(|s| s.to_string())
.unwrap_or_default();
sender.send(Msg::ClipboardPasteToInput { text }).ok();
});
}
return;
}

// Dismiss context menu on any key press (Escape, or j/k for nav, Enter to confirm).
if self.engine.borrow().context_menu.is_some() {
let mut engine = self.engine.borrow_mut();
Expand Down Expand Up @@ -3185,25 +3161,12 @@ impl App {
let mapped = map_gtk_key_name(key_name.as_str());
if engine.dialog.is_some() {
engine.handle_key(mapped, unicode, ctrl);
} else if ctrl && mapped == "v" {
drop(engine);
if let Some(display) = gdk::Display::default() {
let sender = self.sender.clone();
display.clipboard().read_text_async(
gtk4::gio::Cancellable::NONE,
move |result| {
let text = result
.ok()
.flatten()
.map(|s| s.to_string())
.unwrap_or_default();
sender.send(Msg::ClipboardPasteToInput { text }).ok();
},
);
}
self.draw_needed.set(true);
return;
} else {
// Ctrl+V no longer reaches here: quadraui's runner
// intercepts it and delivers `UiEvent::ClipboardPaste`
// straight to `ShellApp::handle`, which routes through
// `Engine::route_paste` (covers the search/replace
// fields too) before any key event is dispatched (#593).
engine.dispatch_search_sidebar_key_unified(mapped, ctrl, alt, unicode);
}
let still_focused = engine.search_has_focus;
Expand Down Expand Up @@ -8034,10 +7997,6 @@ impl App {
drop(engine);
self.draw_needed.set(true);
}
Msg::ClipboardPasteToInput { text } => {
self.engine.borrow_mut().route_paste(&text);
self.draw_needed.set(true);
}
Msg::WindowClosing { width, height } => {
let mut engine = self.engine.borrow_mut();
engine.session.window.width = width;
Expand Down Expand Up @@ -10300,6 +10259,19 @@ impl quadraui::ShellApp for App {
UiEvent::WindowClose => {
self.dispatch(Msg::ShowQuitConfirm);
}
// #593: quadraui's runner reads the system clipboard on Ctrl+V /
// Ctrl+Shift+V / middle-click and delivers the text here,
// unconditionally consuming the key — there is no raw KeyPressed
// fallback to catch a paste with. `Engine::route_paste` is the
// same focus-priority router TUI's `UiEvent::ClipboardPaste` arm
// already calls (`tui_main/shell_app.rs`), so this one arm covers
// the command line, search/replace fields, explorer rename, and
// the editor buffer — see that fn's doc for the full priority
// chain.
UiEvent::ClipboardPaste(text) => {
self.engine.borrow_mut().route_paste(&text);
self.draw_needed.set(true);
}
_ => {}
}

Expand Down
47 changes: 47 additions & 0 deletions src/gtk/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4722,3 +4722,50 @@ mod app_icon {
);
}
}

#[cfg(test)]
mod clipboard_paste {
use super::*;
use quadraui::UiEvent;

/// #593: `Ctrl+V` did nothing on GTK. quadraui's runner reads the system
/// clipboard and delivers `UiEvent::ClipboardPaste` straight to
/// `ShellApp::handle`, unconditionally consuming the keypress — there is
/// no raw `KeyPressed` fallback for an app that ignores it. Before this
/// fix, `handle`'s catch-all `_` arm swallowed the event and the paste
/// vanished. Mirrors TUI's
/// `bracketed_paste_reaches_the_buffer_via_shell_app`
/// (`tui_main/shell_app.rs`), which covers the same `Engine::route_paste`
/// entry point from the other backend.
///
/// Command line chosen as the black-box target (rather than the editor
/// buffer) because the GTK backend does not `record_painted_text` editor
/// text — see this module's doc comment — so a buffer paste has no
/// painted pixels this harness can assert against. The command line
/// paints through `Surface::CommandLine`, a quadraui primitive whose text
/// the paint-time recording sink *does* capture (confirmed by the
/// harness smoke test's `"EXPLORER"` assertion against the same sink).
/// `route_paste`'s other destinations (search/replace fields, explorer
/// rename, editor buffer) share this one dispatch arm and are covered at
/// the engine level instead: `search_input_paste` already has coverage,
/// and #593 adds `test_explorer_rename_route_paste`
/// (`core/engine/tests.rs`) for the tree inline-edit target this same
/// fix newly wires up.
#[test]
fn ctrl_v_paste_reaches_the_command_line_via_shell_app() {
let mut engine = Engine::new_for_test();
engine.mode = crate::core::Mode::Command;
let mut h = harness(engine, 1200, 800);

h.driver
.dispatch(UiEvent::ClipboardPaste("ZQXW_PASTE_MARKER".to_string()));
h.driver.render();

assert!(
h.driver.screen_contains(":ZQXW_PASTE_MARKER"),
"UiEvent::ClipboardPaste must route through Engine::route_paste \
into the command line; painted texts were {:?}",
h.driver.painted_texts()
);
}
}
Loading