fresh/app/clipboard.rs
1//! Clipboard and multi-cursor operations for the Editor.
2//!
3//! This module contains clipboard operations and multi-cursor actions:
4//! - Copy/cut/paste operations
5//! - Copy with formatting (HTML with syntax highlighting)
6//! - Multi-cursor add above/below/at next match
7
8use ratatui::style::{Modifier, Style};
9use rust_i18n::t;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::{Duration, Instant};
12
13use crate::input::multi_cursor::{
14 add_cursor_above, add_cursor_at_next_match, add_cursor_below, line_end_positions_in_selection,
15 AddCursorResult,
16};
17use crate::model::buffer_position::byte_to_2d;
18use crate::model::cursor::Cursor;
19use crate::model::event::{BufferId, CursorId, Event};
20use crate::primitives::word_navigation::{
21 find_vi_word_end, find_word_start_left, find_word_start_right,
22};
23use crate::services::async_bridge::AsyncMessage;
24use crate::view::virtual_text::{VirtualTextId, VirtualTextPosition};
25
26use super::Editor;
27
28/// Per-paste timeout. The async-paste path renders a placeholder
29/// marker and lets the user keep editing; if the background arboard
30/// read doesn't return within this window, the marker is removed and
31/// the paste is silently cancelled. 500 ms is comfortably longer than
32/// any reasonable clipboard round trip and short enough that users
33/// recognise a stalled paste before they've moved on.
34pub(crate) const PASTE_ASYNC_DEADLINE: Duration = Duration::from_millis(500);
35
36/// Inline-wait budget at the top of `paste()`. Before going async, we
37/// race the arboard read against this duration; if the clipboard
38/// responds within the window (the common case on a responsive
39/// system, ~3ms), we paste inline and skip the placeholder entirely
40/// — the user sees zero perceptible latency, indistinguishable from
41/// the old synchronous path. Only when arboard takes longer than
42/// this do we fall through to the placeholder/event-bridge path.
43///
44/// 50ms catches typical X11/Wayland clipboard round trips even on
45/// slower systems (the prior 20ms budget was missing them — anything
46/// in the 20-50ms band fell into the slow placeholder+bridge path,
47/// which a slow renderer compounds into hundreds of ms of perceived
48/// latency since each render frame is gated on the render itself).
49/// It's at the edge of the ~50ms human latency-perception threshold,
50/// so a worst-case inline wait still feels nearly instant; on a hung
51/// clipboard it's a short, bounded stall before the async path takes
52/// over.
53pub(crate) const PASTE_INLINE_WAIT: Duration = Duration::from_millis(50);
54
55/// Hard cap on concurrent pending pastes. Each entry costs one virtual
56/// text + one marker + one OS thread; in practice the deadline keeps
57/// the count near zero. The cap exists only to bound damage from a
58/// runaway macro / wedged process holding the clipboard forever.
59const MAX_PENDING_PASTES: usize = 64;
60
61/// Single anchor a paste will land at when its read returns. Stored
62/// per-cursor at dispatch time (selections having been deleted first
63/// so the anchor sits at the eventual insertion point).
64#[derive(Debug, Clone, Copy)]
65pub struct PasteAnchor {
66 /// Virtual text rendering the visual "▍" placeholder; also owns
67 /// the underlying marker that tracks the position through edits.
68 pub virtual_text_id: VirtualTextId,
69}
70
71/// In-flight async paste. Lives in `Editor::paste_pending` keyed by
72/// `request_id` between dispatching the background read and receiving
73/// the matching `AsyncMessage::ClipboardPasteResult`. Multiple may be
74/// pending at once (each Ctrl+V allocates a new id) and each captures
75/// the OS clipboard contents at the moment its own thread starts.
76#[derive(Debug, Clone)]
77pub struct PendingPaste {
78 /// Wall-clock cutoff. The tick walks `paste_pending` and removes
79 /// any entry past this point; arboard threads that come back
80 /// afterwards find no matching entry and are dropped. (The
81 /// request id is the map key, not stored here.)
82 pub deadline: Instant,
83 /// Buffer the anchors live in. Used at resolve time so a paste
84 /// initiated in buffer A still lands in A even if the user
85 /// switched to buffer B during the wait. If the buffer was closed
86 /// in the meantime the entire entry is discarded.
87 pub buffer_id: BufferId,
88 /// One anchor per cursor at dispatch time (after any selection
89 /// deletes were applied). Insertions happen in descending position
90 /// order at resolve time so earlier offsets stay valid.
91 pub anchors: Vec<PasteAnchor>,
92 /// Cursor count captured at dispatch — column-mode paste (one line
93 /// per cursor) is decided against this snapshot, not against the
94 /// live cursor list which may have changed during the wait.
95 pub cursor_count_at_dispatch: usize,
96 /// Buffer line-ending captured at dispatch, used to convert the
97 /// clipboard's LF-normalised text back to the buffer's format
98 /// before insertion.
99 pub line_ending: crate::model::buffer::LineEnding,
100 /// Wall-clock when paste() was called, used by the `paste_timing`
101 /// trace target to measure end-to-end latency from Ctrl+V to the
102 /// pasted text appearing on screen.
103 pub dispatched_at: Instant,
104}
105
106static NEXT_PASTE_REQUEST_ID: AtomicU64 = AtomicU64::new(1);
107
108pub(crate) fn allocate_paste_request_id() -> u64 {
109 NEXT_PASTE_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
110}
111
112// These are the clipboard and multi-cursor operations on Editor.
113//
114// MOTIVATION FOR SEPARATION:
115// - Buffer operations need: multi-cursor, selections, event sourcing, undo/redo
116// - Prompt operations need: simple string manipulation, no selection tracking
117// - Sharing code would force prompts to use Buffer (expensive) or buffers to
118// lose features (selections, multi-cursor, undo)
119//
120// Both use the same clipboard storage (self.clipboard) ensuring copy/paste
121// works across buffer editing and prompt input.
122
123impl Editor {
124 /// Copy the current selection to clipboard
125 ///
126 /// If no selection exists, copies the entire current line (like VSCode/Rider/Zed).
127 /// For block selections, copies only the rectangular region.
128 pub fn copy_selection(&mut self) {
129 // Check if any cursor has a block selection (takes priority)
130 let has_block_selection = self
131 .active_cursors()
132 .iter()
133 .any(|(_, cursor)| cursor.has_block_selection());
134
135 if has_block_selection {
136 // Block selection: copy rectangular region
137 let text = self.copy_block_selection_text();
138 if !text.is_empty() {
139 self.clipboard.copy(text);
140 self.active_window_mut().status_message = Some(t!("clipboard.copied").to_string());
141 }
142 return;
143 }
144
145 // Check if any cursor has a normal selection
146 let has_selection = self
147 .active_cursors()
148 .iter()
149 .any(|(_, cursor)| cursor.selection_range().is_some());
150
151 if has_selection {
152 // Original behavior: copy selected text
153 let ranges: Vec<_> = self
154 .active_cursors()
155 .iter()
156 .filter_map(|(_, cursor)| cursor.selection_range())
157 .collect();
158
159 let mut text = String::new();
160 let state = self.active_state_mut();
161 for range in ranges {
162 if !text.is_empty() {
163 text.push('\n');
164 }
165 let range_text = state.get_text_range(range.start, range.end);
166 text.push_str(&range_text);
167 }
168
169 if !text.is_empty() {
170 self.clipboard.copy(text);
171 self.active_window_mut().status_message = Some(t!("clipboard.copied").to_string());
172 }
173 } else {
174 // No selection: copy entire line(s) for each cursor
175 let estimated_line_length = 80;
176 let mut text = String::new();
177
178 // Collect cursor positions first
179 let positions: Vec<_> = self
180 .active_cursors()
181 .iter()
182 .map(|(_, c)| c.position)
183 .collect();
184 let state = self.active_state_mut();
185
186 for pos in positions {
187 let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
188 if let Some((_start, content)) = iter.next_line() {
189 if !text.is_empty() {
190 text.push('\n');
191 }
192 text.push_str(&content);
193 }
194 }
195
196 if !text.is_empty() {
197 self.clipboard.copy(text);
198 self.active_window_mut().status_message =
199 Some(t!("clipboard.copied_line").to_string());
200 }
201 }
202 }
203
204 /// Extract text from block (rectangular) selection
205 ///
206 /// For block selection, we need to extract a rectangular region defined by:
207 /// - The block anchor (stored as Position2D with line and column)
208 /// - The current cursor position (byte offset, converted to 2D)
209 ///
210 /// This works for both small and large files by using line_iterator
211 /// for iteration and only using 2D positions for column extraction.
212 pub(crate) fn copy_block_selection_text(&mut self) -> String {
213 let estimated_line_length = 120;
214
215 // Collect block selection info from all cursors
216 let block_infos: Vec<_> = self
217 .active_cursors()
218 .iter()
219 .filter_map(|(_, cursor)| {
220 if !cursor.has_block_selection() {
221 return None;
222 }
223 let block_anchor = cursor.block_anchor?;
224 let anchor_byte = cursor.anchor?; // byte offset of anchor
225 let cursor_byte = cursor.position;
226 Some((block_anchor, anchor_byte, cursor_byte))
227 })
228 .collect();
229
230 let mut result = String::new();
231
232 for (block_anchor, anchor_byte, cursor_byte) in block_infos {
233 // Get current cursor position as 2D
234 let cursor_2d = {
235 let state = self.active_state();
236 byte_to_2d(&state.buffer, cursor_byte)
237 };
238
239 // Calculate column bounds (min and max columns for the rectangle)
240 let min_col = block_anchor.column.min(cursor_2d.column);
241 let max_col = block_anchor.column.max(cursor_2d.column);
242
243 // Calculate line bounds using byte positions
244 let start_byte = anchor_byte.min(cursor_byte);
245 let end_byte = anchor_byte.max(cursor_byte);
246
247 // Use line_iterator to iterate through lines
248 let state = self.active_state_mut();
249 let mut iter = state
250 .buffer
251 .line_iterator(start_byte, estimated_line_length);
252
253 // Collect lines within the block selection range
254 let mut lines_text = Vec::new();
255 loop {
256 let line_start = iter.current_position();
257
258 // Stop if we've passed the end of the selection
259 if line_start > end_byte {
260 break;
261 }
262
263 if let Some((_offset, line_content)) = iter.next_line() {
264 // Extract the column range from this line
265 // Remove trailing newline for column calculation
266 let content_without_newline = line_content.trim_end_matches(&['\n', '\r'][..]);
267 let chars: Vec<char> = content_without_newline.chars().collect();
268
269 // Extract characters from min_col to max_col (exclusive)
270 let extracted: String = chars
271 .iter()
272 .skip(min_col)
273 .take(max_col.saturating_sub(min_col))
274 .collect();
275
276 lines_text.push(extracted);
277
278 // If this line extends past end_byte, we're done
279 if line_start + line_content.len() > end_byte {
280 break;
281 }
282 } else {
283 break;
284 }
285 }
286
287 // Join the extracted text from each line
288 if !result.is_empty() && !lines_text.is_empty() {
289 result.push('\n');
290 }
291 result.push_str(&lines_text.join("\n"));
292 }
293
294 result
295 }
296
297 /// Copy selection with a specific theme's formatting
298 ///
299 /// If theme_name is empty, opens a prompt to select a theme.
300 /// Otherwise, copies the selected text as HTML with inline CSS styles.
301 pub fn copy_selection_with_theme(&mut self, theme_name: &str) {
302 // Check if there's a selection first
303 let has_selection = self
304 .active_cursors()
305 .iter()
306 .any(|(_, cursor)| cursor.selection_range().is_some());
307
308 if !has_selection {
309 self.active_window_mut().status_message =
310 Some(t!("clipboard.no_selection").to_string());
311 return;
312 }
313
314 // Empty theme = open theme picker prompt
315 if theme_name.is_empty() {
316 self.start_copy_with_formatting_prompt();
317 return;
318 }
319 use crate::services::styled_html::render_styled_html;
320
321 // Get the requested theme from registry
322 let theme = match self.theme_registry.get_cloned(theme_name) {
323 Some(t) => t,
324 None => {
325 self.active_window_mut().status_message =
326 Some(format!("Theme '{}' not found", theme_name));
327 return;
328 }
329 };
330
331 // Collect ranges and their byte offsets
332 let ranges: Vec<_> = self
333 .active_cursors()
334 .iter()
335 .filter_map(|(_, cursor)| cursor.selection_range())
336 .collect();
337
338 if ranges.is_empty() {
339 self.active_window_mut().status_message =
340 Some(t!("clipboard.no_selection").to_string());
341 return;
342 }
343
344 // Get the overall range for highlighting
345 let min_offset = ranges.iter().map(|r| r.start).min().unwrap_or(0);
346 let max_offset = ranges.iter().map(|r| r.end).max().unwrap_or(0);
347
348 // Collect text and highlight spans from state
349 let (text, highlight_spans) = {
350 let state = self.active_state_mut();
351
352 // Collect text from all ranges
353 let mut text = String::new();
354 for range in &ranges {
355 if !text.is_empty() {
356 text.push('\n');
357 }
358 let range_text = state.get_text_range(range.start, range.end);
359 text.push_str(&range_text);
360 }
361
362 if text.is_empty() {
363 (text, Vec::new())
364 } else {
365 // Get highlight spans for the selected region
366 let highlight_spans = state.highlighter.highlight_viewport(
367 &state.buffer,
368 min_offset,
369 max_offset,
370 &theme,
371 0, // No context needed since we're copying exact selection
372 );
373 (text, highlight_spans)
374 }
375 };
376
377 if text.is_empty() {
378 self.active_window_mut().status_message = Some(t!("clipboard.no_text").to_string());
379 return;
380 }
381
382 // Adjust highlight spans to be relative to the copied text
383 let adjusted_spans: Vec<_> = if ranges.len() == 1 {
384 let base_offset = ranges[0].start;
385 highlight_spans
386 .into_iter()
387 .filter_map(|span| {
388 if span.range.end <= base_offset || span.range.start >= ranges[0].end {
389 return None;
390 }
391 let start = span.range.start.saturating_sub(base_offset);
392 let end = (span.range.end - base_offset).min(text.len());
393 if start < end {
394 Some(crate::primitives::highlighter::HighlightSpan {
395 range: start..end,
396 color: span.color,
397 bg: None,
398 category: span.category,
399 })
400 } else {
401 None
402 }
403 })
404 .collect()
405 } else {
406 Vec::new()
407 };
408
409 // Render the styled text to HTML
410 let html = render_styled_html(&text, &adjusted_spans, &theme);
411
412 // Copy the HTML to clipboard (with plain text fallback)
413 if self.clipboard.copy_html(&html, &text) {
414 self.active_window_mut().status_message =
415 Some(t!("clipboard.copied_with_theme", theme = theme_name).to_string());
416 } else {
417 self.clipboard.copy(text);
418 self.active_window_mut().status_message =
419 Some(t!("clipboard.copied_plain").to_string());
420 }
421 }
422
423 /// Start the theme selection prompt for copy with formatting
424 fn start_copy_with_formatting_prompt(&mut self) {
425 use crate::view::prompt::PromptType;
426
427 let available_themes = self.theme_registry.list();
428 // Resolve the config value (portable form) to a canonical registry
429 // key so the picker can pre-highlight the current theme.
430 let resolved_current = self
431 .theme_registry
432 .resolve_key(&self.config.theme.0)
433 .unwrap_or_else(|| self.config.theme.0.clone());
434 let current_theme_key = resolved_current.as_str();
435
436 // Find the index of the current theme (match by key first, then name)
437 let current_index = available_themes
438 .iter()
439 .position(|info| info.key == *current_theme_key)
440 .or_else(|| {
441 let normalized = crate::view::theme::normalize_theme_name(current_theme_key);
442 available_themes.iter().position(|info| {
443 crate::view::theme::normalize_theme_name(&info.name) == normalized
444 })
445 })
446 .unwrap_or(0);
447
448 let suggestions: Vec<crate::input::commands::Suggestion> = available_themes
449 .iter()
450 .map(|info| {
451 let is_current = Some(info) == available_themes.get(current_index);
452 let description = if is_current {
453 Some(format!("{} (current)", info.key))
454 } else {
455 Some(info.key.clone())
456 };
457 crate::input::commands::Suggestion {
458 description_spans: None,
459 text: info.name.clone(),
460 description,
461 value: Some(info.key.clone()),
462 disabled: false,
463 keybinding: None,
464 source: None,
465 }
466 })
467 .collect();
468
469 self.active_window_mut().prompt = Some(crate::view::prompt::Prompt::with_suggestions(
470 "Copy with theme: ".to_string(),
471 PromptType::CopyWithFormattingTheme,
472 suggestions,
473 ));
474
475 if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
476 if !prompt.suggestions.is_empty() {
477 prompt.selected_suggestion = Some(current_index);
478 prompt.input = current_theme_key.to_string();
479 prompt.cursor_pos = prompt.input.len();
480 }
481 }
482 }
483
484 /// Cut the current selection to clipboard
485 ///
486 /// If no selection exists, cuts the entire current line (like VSCode/Rider/Zed).
487 pub fn cut_selection(&mut self) {
488 // Check if any cursor has a selection
489 let has_selection = self
490 .active_cursors()
491 .iter()
492 .any(|(_, cursor)| cursor.selection_range().is_some());
493
494 // Copy first (this handles both selection and whole-line cases)
495 self.copy_selection();
496
497 if has_selection {
498 // Delete selected text from all cursors
499 // IMPORTANT: Sort deletions by position to ensure we process from end to start
500 let mut deletions: Vec<_> = self
501 .active_cursors()
502 .iter()
503 .filter_map(|(_, c)| c.selection_range())
504 .collect();
505 // Sort by start position so reverse iteration processes from end to start
506 deletions.sort_by_key(|r| r.start);
507
508 let primary_id = self.active_cursors().primary_id();
509 let state = self.active_state_mut();
510 let events: Vec<_> = deletions
511 .iter()
512 .rev()
513 .map(|range| {
514 let deleted_text = state.get_text_range(range.start, range.end);
515 Event::Delete {
516 range: range.clone(),
517 deleted_text,
518 cursor_id: primary_id,
519 }
520 })
521 .collect();
522
523 // Apply events with atomic undo using bulk edit for O(n) performance
524 if events.len() > 1 {
525 // Use optimized bulk edit for multi-cursor cut
526 if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, "Cut".to_string()) {
527 self.active_event_log_mut().append(bulk_edit);
528 }
529 } else if let Some(event) = events.into_iter().next() {
530 self.log_and_apply_event(&event);
531 }
532
533 if !deletions.is_empty() {
534 self.active_window_mut().status_message = Some(t!("clipboard.cut").to_string());
535 }
536 } else {
537 // No selection: delete entire line(s) for each cursor
538 let estimated_line_length = 80;
539
540 // Collect line ranges for each cursor
541 // IMPORTANT: Sort deletions by position to ensure we process from end to start
542 let positions: Vec<_> = self
543 .active_cursors()
544 .iter()
545 .map(|(_, c)| c.position)
546 .collect();
547 let mut deletions: Vec<_> = {
548 let state = self.active_state_mut();
549 positions
550 .into_iter()
551 .filter_map(|pos| {
552 let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
553 let line_start = iter.current_position();
554 iter.next_line().map(|(_start, content)| {
555 let line_end = line_start + content.len();
556 line_start..line_end
557 })
558 })
559 .collect()
560 };
561 // Sort by start position so reverse iteration processes from end to start
562 deletions.sort_by_key(|r| r.start);
563
564 let primary_id = self.active_cursors().primary_id();
565 let state = self.active_state_mut();
566 let events: Vec<_> = deletions
567 .iter()
568 .rev()
569 .map(|range| {
570 let deleted_text = state.get_text_range(range.start, range.end);
571 Event::Delete {
572 range: range.clone(),
573 deleted_text,
574 cursor_id: primary_id,
575 }
576 })
577 .collect();
578
579 // Apply events with atomic undo using bulk edit for O(n) performance
580 if events.len() > 1 {
581 // Use optimized bulk edit for multi-cursor cut
582 if let Some(bulk_edit) =
583 self.apply_events_as_bulk_edit(events, "Cut line".to_string())
584 {
585 self.active_event_log_mut().append(bulk_edit);
586 }
587 } else if let Some(event) = events.into_iter().next() {
588 self.log_and_apply_event(&event);
589 }
590
591 if !deletions.is_empty() {
592 self.active_window_mut().status_message =
593 Some(t!("clipboard.cut_line").to_string());
594 }
595 }
596 }
597
598 /// Paste the clipboard content at all cursor positions
599 ///
600 /// Handles:
601 /// - Single cursor paste
602 /// - Multi-cursor paste (pastes at each cursor)
603 /// - Selection replacement (deletes selection before inserting)
604 /// - Atomic undo (single undo step for entire operation)
605 pub fn paste(&mut self) {
606 // Defensive fast-paths. Prompt/terminal/file-explorer paste
607 // routes go through their own actions (PromptPaste,
608 // TerminalPaste, FileExplorerPaste); the buffer paste path
609 // below assumes there's a real buffer view in front of us. If
610 // we somehow landed here under one of those modes anyway,
611 // hand off to the synchronous service-level paste.
612 if self.active_window().prompt.is_some() || self.active_window().terminal_mode {
613 if let Some(text) = self.clipboard.paste() {
614 self.paste_text(text);
615 }
616 return;
617 }
618
619 // No bridge (early bootstrap / test harness): there is no
620 // event loop to deliver the async result through, so a
621 // background read would never come back. The no-bridge
622 // configuration also implies no display, so the synchronous
623 // arboard call won't actually block.
624 let sender = match self.async_bridge.as_ref() {
625 Some(bridge) => bridge.sender(),
626 None => {
627 if let Some(text) = self.clipboard.paste() {
628 self.paste_text(text);
629 }
630 return;
631 }
632 };
633
634 // System clipboard disabled (internal-only test mode, or user
635 // opted out via config). Spinning up a thread for arboard is
636 // pointless when we already know we won't touch the OS.
637 if !self.clipboard.uses_system_clipboard() || self.clipboard.is_internal_only() {
638 if let Some(text) = self.clipboard.paste_internal() {
639 self.paste_text(text);
640 }
641 return;
642 }
643
644 // Bound concurrent pendings. A clipboard owner stuck for an
645 // unusual length of time, combined with Ctrl+V autorepeat,
646 // could otherwise grow the map without limit. The deadline
647 // keeps the count near zero in normal use.
648 if self.paste_pending.len() >= MAX_PENDING_PASTES {
649 tracing::warn!(
650 "MAX_PENDING_PASTES ({}) reached, ignoring Ctrl+V",
651 MAX_PENDING_PASTES
652 );
653 return;
654 }
655
656 let buffer_id = self.active_buffer();
657 let line_ending = self.active_state().buffer.line_ending();
658
659 // Kick the arboard read off on its own thread RIGHT AWAY,
660 // before touching the buffer. Two channels: a private
661 // `inline_tx` (bounded to 1) we race against a short timer
662 // for the fast path, and the editor's `AsyncBridge` for the
663 // slow path. The background thread tries `inline_tx` first
664 // and falls back to the bridge only if the inline receiver
665 // is gone (we dropped it after timing out).
666 //
667 // Each thread does its own `arboard::Clipboard::new().get_text()`,
668 // so back-to-back Ctrl+V with different OS-clipboard contents
669 // in between still picks each one up — the contents captured
670 // are whatever the OS clipboard held when this thread reached
671 // `get_text`.
672 let request_id = allocate_paste_request_id();
673 let dispatch_at = Instant::now();
674 let (inline_tx, inline_rx) = std::sync::mpsc::sync_channel::<Option<String>>(1);
675 let bridge_sender = sender.clone();
676 let thread_request_id = request_id;
677 // The system-clipboard reader (overridable in tests) and the
678 // internal-clipboard snapshot captured *now*. The thread returns
679 // `system.or(internal)`: on a host where the OS clipboard is
680 // unreadable (Termux, where arboard has no Android backend; a
681 // headless TTY; an opt-out) the system read yields `None` and the
682 // paste falls back to Fresh's own internal clipboard — restoring
683 // the in-editor copy/paste round-trip that the pre-async
684 // synchronous path provided (regression from #2155).
685 let reader = self
686 .system_clipboard_reader
687 .unwrap_or(crate::services::clipboard::read_system_clipboard);
688 let internal_fallback = self.clipboard.paste_internal();
689 std::thread::Builder::new()
690 .name("clipboard-paste".into())
691 .spawn(move || {
692 let arboard_start = Instant::now();
693 let text = reader().or(internal_fallback);
694 let arboard_ms = arboard_start.elapsed().as_millis();
695 let len = text.as_ref().map(|s| s.len()).unwrap_or(0);
696 // Try the inline channel first. If the main thread
697 // is still inside its `recv_timeout`, the send
698 // succeeds and the fast path applies the paste. If
699 // the main thread already gave up and dropped
700 // `inline_rx`, fall through to the bridge for the
701 // async (placeholder) path.
702 match inline_tx.send(text.clone()) {
703 Ok(()) => {
704 tracing::info!(
705 target: "paste_timing",
706 "[req {}] arboard returned in {}ms ({} bytes), delivered via INLINE",
707 thread_request_id, arboard_ms, len
708 );
709 }
710 Err(_) => {
711 tracing::info!(
712 target: "paste_timing",
713 "[req {}] arboard returned in {}ms ({} bytes), inline gone — sending via bridge",
714 thread_request_id, arboard_ms, len
715 );
716 if let Err(e) = bridge_sender.send(AsyncMessage::ClipboardPasteResult {
717 request_id: thread_request_id,
718 text,
719 }) {
720 tracing::trace!("clipboard paste result delivery failed: {}", e);
721 }
722 }
723 }
724 })
725 .ok();
726
727 // Now race a short inline wait against the spawned read.
728 // Doing the selection-delete *after* this wait would be
729 // wrong: a fast inline paste needs the selection cleared
730 // first so it can replace it via `paste_text`'s normal
731 // logic. So delete the selection now (it's a synchronous
732 // local operation, ~µs) and only THEN race the wait.
733 let cursor_selections: Vec<(CursorId, std::ops::Range<usize>)> = self
734 .active_cursors()
735 .iter()
736 .filter_map(|(id, c)| c.selection_range().map(|r| (id, r)))
737 .collect();
738
739 if !cursor_selections.is_empty() {
740 let mut delete_events = Vec::with_capacity(cursor_selections.len());
741 for (cursor_id, range) in &cursor_selections {
742 let deleted_text = self
743 .active_state_mut()
744 .get_text_range(range.start, range.end);
745 delete_events.push(Event::Delete {
746 range: range.clone(),
747 deleted_text,
748 cursor_id: *cursor_id,
749 });
750 }
751 delete_events.sort_by(|a, b| {
752 let pa = if let Event::Delete { range, .. } = a {
753 range.start
754 } else {
755 0
756 };
757 let pb = if let Event::Delete { range, .. } = b {
758 range.start
759 } else {
760 0
761 };
762 pb.cmp(&pa)
763 });
764 if let Err(e) = self.apply_events_to_buffer_as_bulk_edit(
765 buffer_id,
766 delete_events,
767 "Paste (clear selection)".to_string(),
768 ) {
769 tracing::warn!("paste selection delete failed: {}", e);
770 return;
771 }
772 }
773
774 // Inline wait: if arboard came back within budget, paste
775 // synchronously and skip the placeholder entirely — the
776 // user sees the paste appear in the same frame as the
777 // keystroke, indistinguishable from the old synchronous
778 // path. If the read is still in flight after the budget,
779 // drop `inline_rx` (which signals the thread to deliver via
780 // the bridge instead) and continue to the placeholder path.
781 match inline_rx.recv_timeout(PASTE_INLINE_WAIT) {
782 Ok(text) => {
783 tracing::info!(
784 target: "paste_timing",
785 "[req {}] fast path: inline result in {}ms, no placeholder needed",
786 request_id,
787 dispatch_at.elapsed().as_millis()
788 );
789 if let Some(t) = text {
790 self.paste_text(t);
791 }
792 return;
793 }
794 Err(_) => {
795 tracing::info!(
796 target: "paste_timing",
797 "[req {}] inline wait timed out after {}ms — falling back to placeholder",
798 request_id,
799 dispatch_at.elapsed().as_millis()
800 );
801 // Dropping `inline_rx` here would race the thread
802 // (it might be mid-send). Keep it alive until after
803 // we've drained any last-second arrival.
804 if let Ok(text) = inline_rx.try_recv() {
805 tracing::info!(
806 target: "paste_timing",
807 "[req {}] caught race — fast path after timeout",
808 request_id
809 );
810 if let Some(t) = text {
811 self.paste_text(t);
812 }
813 return;
814 }
815 drop(inline_rx);
816 }
817 }
818
819 // Slow path: plant placeholders and register the pending
820 // paste so the eventual bridge delivery lands at the anchor.
821 let mut positions: Vec<usize> = self
822 .active_cursors()
823 .iter()
824 .map(|(_, c)| c.position)
825 .collect();
826 positions.sort_unstable();
827 positions.dedup();
828 let cursor_count = positions.len();
829
830 if positions.is_empty() {
831 return;
832 }
833
834 let placeholder_style = Style::default().add_modifier(Modifier::DIM);
835 let anchors: Vec<PasteAnchor> = {
836 let Some(state) = self.buffers_mut().get_mut(&buffer_id) else {
837 return;
838 };
839 positions
840 .iter()
841 .map(|&pos| {
842 let id = state.virtual_texts.add(
843 &mut state.marker_list,
844 pos,
845 "▍".to_string(),
846 placeholder_style,
847 VirtualTextPosition::BeforeChar,
848 -100,
849 );
850 PasteAnchor {
851 virtual_text_id: id,
852 }
853 })
854 .collect()
855 };
856
857 let deadline = Instant::now() + PASTE_ASYNC_DEADLINE;
858 tracing::info!(
859 target: "paste_timing",
860 "[req {}] slow path: placeholder planted, registering for async delivery",
861 request_id
862 );
863
864 self.paste_pending.insert(
865 request_id,
866 PendingPaste {
867 deadline,
868 buffer_id,
869 anchors,
870 cursor_count_at_dispatch: cursor_count,
871 line_ending,
872 dispatched_at: dispatch_at,
873 },
874 );
875
876 // Signal the input dispatcher to skip the immediate render
877 // for this keystroke, AND set a hard render-suppression
878 // deadline that the main loop checks. The placeholder is in
879 // the buffer; the next render that fires after the deadline
880 // (or after the paste resolves, whichever is first) will
881 // pick it up. For a common fast-ish clipboard the resolve
882 // beats the deadline by a wide margin and that single
883 // post-resolve render is the only frame the user sees —
884 // instead of paying for two full `terminal.draw` cycles.
885 // The suppression window is bounded by the paste deadline
886 // so a wedged clipboard can't permanently veto rendering.
887 self.paste_slow_path_just_armed = true;
888 self.paste_render_suppress_until = Some(deadline);
889 }
890
891 /// Consume the "paste just went async" flag set by the slow
892 /// placeholder path of `paste()`. Returns whether it was set
893 /// (so the caller can suppress the otherwise-automatic render).
894 pub(crate) fn take_paste_slow_path_armed(&mut self) -> bool {
895 std::mem::take(&mut self.paste_slow_path_just_armed)
896 }
897
898 /// True when the main loop should hold off on rendering a frame
899 /// because an async paste is in flight and its placeholder
900 /// shouldn't get its own (expensive) render before the paste
901 /// itself resolves. The suppression auto-expires at the paste
902 /// deadline so a hung clipboard can't permanently veto renders.
903 pub fn should_suppress_render(&self) -> bool {
904 match self.paste_render_suppress_until {
905 Some(until) => Instant::now() < until,
906 None => false,
907 }
908 }
909
910 /// Resolve an in-flight async paste keyed by `request_id`.
911 ///
912 /// - Drops the result if no entry matches: a deadline-fired
913 /// timeout already cleaned up the anchors, or a different
914 /// paste cycle is in flight.
915 /// - If `text` is `Some` and the target buffer still exists,
916 /// inserts at every anchor's current position (column-mode
917 /// distributed using the dispatch-time cursor count).
918 /// - Cleans up the placeholder virtual texts in all cases so the
919 /// visible "▍" markers go away.
920 pub(crate) fn resolve_pending_paste(&mut self, request_id: u64, text: Option<String>) {
921 let Some(pending) = self.paste_pending.remove(&request_id) else {
922 tracing::info!(
923 target: "paste_timing",
924 "[req {}] resolve called but no matching entry (already cancelled/stale)",
925 request_id
926 );
927 return;
928 };
929 let total_ms = pending.dispatched_at.elapsed().as_millis();
930 let text_len = text.as_ref().map(|s| s.len()).unwrap_or(0);
931 tracing::info!(
932 target: "paste_timing",
933 "[req {}] resolving after {}ms ({} bytes from clipboard)",
934 request_id, total_ms, text_len
935 );
936
937 // Clear the render-suppression window if this was the last
938 // pending paste (so the about-to-be-applied insertion can
939 // render in this frame). If other pastes are still in flight
940 // the suppression stays so we keep batching their renders.
941 if self.paste_pending.is_empty() {
942 self.paste_render_suppress_until = None;
943 }
944
945 // Bail out if the buffer is gone (closed during the wait).
946 // The buffer's drop took its `virtual_texts` and `marker_list`
947 // with it, so the anchors are already cleaned up.
948 if self.buffers().get(&pending.buffer_id).is_none() {
949 tracing::debug!(
950 "paste request {} resolved against closed buffer {:?}, discarding",
951 request_id,
952 pending.buffer_id
953 );
954 return;
955 }
956
957 // Resolve each anchor's current position via the marker tree.
958 // Skip any anchor whose marker was deleted by an intervening
959 // edit (e.g. the user deleted through the placeholder).
960 let mut anchor_positions: Vec<(usize, usize)> = {
961 let state = self
962 .buffers()
963 .get(&pending.buffer_id)
964 .expect("checked above");
965 pending
966 .anchors
967 .iter()
968 .enumerate()
969 .filter_map(|(i, a)| {
970 let mid = state.virtual_texts.marker_id_of(a.virtual_text_id)?;
971 let pos = state.marker_list.get_position(mid)?;
972 Some((i, pos))
973 })
974 .collect()
975 };
976
977 if let Some(raw_text) = text.filter(|s| !s.is_empty()) {
978 // Normalise to LF (mirrors `paste_text`) so column-mode
979 // line splitting is unambiguous, then convert back to the
980 // buffer's line ending captured at dispatch.
981 let normalized = raw_text.replace("\r\n", "\n").replace('\r', "\n");
982 let mut lines_for_distribution: Vec<&str> = normalized.split('\n').collect();
983 if lines_for_distribution.len() > 1 && lines_for_distribution.last() == Some(&"") {
984 lines_for_distribution.pop();
985 }
986 let use_column_paste = pending.cursor_count_at_dispatch > 1
987 && lines_for_distribution.len() > 1
988 && lines_for_distribution.len() == pending.cursor_count_at_dispatch
989 && anchor_positions.len() == pending.cursor_count_at_dispatch;
990
991 let paste_text_full = match pending.line_ending {
992 crate::model::buffer::LineEnding::LF => normalized.clone(),
993 crate::model::buffer::LineEnding::CRLF => normalized.replace('\n', "\r\n"),
994 crate::model::buffer::LineEnding::CR => normalized.replace('\n', "\r"),
995 };
996
997 // Sort anchors by position descending so each insertion
998 // doesn't shift subsequent ones forward. The original
999 // index is retained for column-mode line lookup.
1000 anchor_positions.sort_by(|a, b| b.1.cmp(&a.1));
1001
1002 let total = pending.cursor_count_at_dispatch;
1003 let mut events = Vec::with_capacity(anchor_positions.len());
1004 for (original_index, pos) in &anchor_positions {
1005 let text_for_anchor = if use_column_paste {
1006 // Topmost cursor (smallest position) gets the
1007 // first line — matches `paste_text`'s mapping so
1008 // a block-selected round-trip preserves shape.
1009 lines_for_distribution[total - 1 - (total - 1 - *original_index)].to_string()
1010 } else {
1011 paste_text_full.clone()
1012 };
1013 events.push(Event::Insert {
1014 position: *pos,
1015 text: text_for_anchor,
1016 // No cursor moves on this insert: the user has
1017 // been editing freely, and yanking their cursor
1018 // to the paste site (which might be far away)
1019 // would be the freeze bug in a different form.
1020 cursor_id: CursorId::UNDO_SENTINEL,
1021 });
1022 }
1023
1024 if let Err(e) = self.apply_events_to_buffer_as_bulk_edit(
1025 pending.buffer_id,
1026 events,
1027 "Paste".to_string(),
1028 ) {
1029 tracing::warn!("paste insertion failed: {}", e);
1030 } else {
1031 self.set_status_message(t!("clipboard.pasted").to_string());
1032 }
1033 } else {
1034 // Deadline fired or read returned empty. Leave the buffer
1035 // untouched; cleanup of the placeholder markers below.
1036 tracing::debug!(
1037 "paste request {} resolved with no text — removing anchors",
1038 request_id
1039 );
1040 }
1041
1042 // Remove the placeholder virtual texts (and their markers).
1043 let Some(state) = self.buffers_mut().get_mut(&pending.buffer_id) else {
1044 return;
1045 };
1046 for anchor in pending.anchors {
1047 state
1048 .virtual_texts
1049 .remove(&mut state.marker_list, anchor.virtual_text_id);
1050 }
1051 }
1052
1053 /// Walk pending pastes, cancelling any whose deadline has passed.
1054 /// Returns true when at least one entry was cancelled (the caller
1055 /// should redraw to refresh the now-empty placeholder cells).
1056 pub(crate) fn check_paste_deadline(&mut self) -> bool {
1057 let now = Instant::now();
1058 let expired_ids: Vec<u64> = self
1059 .paste_pending
1060 .iter()
1061 .filter_map(|(id, pending)| (now >= pending.deadline).then_some(*id))
1062 .collect();
1063 if expired_ids.is_empty() {
1064 return false;
1065 }
1066 for id in expired_ids {
1067 tracing::debug!(
1068 "paste request {} hit {}ms deadline, cancelling",
1069 id,
1070 PASTE_ASYNC_DEADLINE.as_millis()
1071 );
1072 self.resolve_pending_paste(id, None);
1073 }
1074 true
1075 }
1076
1077 /// Earliest deadline across all in-flight pastes, used by the
1078 /// tick loop to know when to wake.
1079 ///
1080 /// Returns the SOONER of:
1081 /// - the actual cancel deadline of the earliest pending paste
1082 /// (`PASTE_ASYNC_DEADLINE` from dispatch), and
1083 /// - a 1 ms drain hint, so the loop wakes ~1ms after the
1084 /// background `clipboard-paste` thread sends its result on
1085 /// the `AsyncBridge`. The bridge is an mpsc channel with no
1086 /// wake mechanism, so the editor only sees the result when
1087 /// `editor_tick` next runs — without the 1 ms hint the loop
1088 /// could sleep for up to 50ms (idle poll) or 16ms (frame
1089 /// budget) per iteration, and a slow render env (which gates
1090 /// the next render on `FRAME_DURATION`) compounds that into
1091 /// a several-hundred-millisecond perceived paste latency.
1092 ///
1093 /// CPU cost is bounded: the deadline cap of
1094 /// `PASTE_ASYNC_DEADLINE` (500 ms) means at most ~500 extra tick
1095 /// iterations per paste cycle. Each iteration is a `try_recv_all`
1096 /// on the bridge plus a few cheap checks; no rendering work
1097 /// happens unless something actually changed.
1098 pub(crate) fn next_paste_deadline(&self) -> Option<Instant> {
1099 let cancel_deadline = self.paste_pending.values().map(|p| p.deadline).min()?;
1100 let drain_hint = Instant::now() + Duration::from_millis(1);
1101 Some(cancel_deadline.min(drain_hint))
1102 }
1103
1104 /// Whether at least one async paste is in flight. Exposed mainly
1105 /// for tests and instrumentation; the input loop no longer keys
1106 /// off this — input is dispatched immediately and the anchor
1107 /// catches the eventual paste.
1108 pub fn is_paste_pending(&self) -> bool {
1109 !self.paste_pending.is_empty()
1110 }
1111
1112 /// Cancel any pending pastes whose anchors live in the given
1113 /// buffer. Called by the buffer-close path so we don't try to
1114 /// insert into a freed buffer when the result arrives. The
1115 /// buffer's `virtual_texts` and `marker_list` are about to be
1116 /// dropped along with the buffer, so we just forget the entries
1117 /// — no virtual-text removal needed.
1118 pub fn cancel_pending_pastes_for_buffer(&mut self, buffer_id: BufferId) {
1119 self.paste_pending
1120 .retain(|_, pending| pending.buffer_id != buffer_id);
1121 if self.paste_pending.is_empty() {
1122 self.paste_render_suppress_until = None;
1123 }
1124 }
1125
1126 /// Route a terminal-initiated bracketed paste to a focused
1127 /// floating panel (Orchestrator picker / New-Session form / plugin
1128 /// overlay) or focused dock when one owns the keyboard.
1129 ///
1130 /// Bracketed paste arrives as a single `Event::Paste` rather than
1131 /// per-key events, so — unlike typed characters and `Ctrl+V` — it
1132 /// never passes through `dispatch_floating_widget_key`. Without this
1133 /// routing it falls straight through to `paste_text`, which targets
1134 /// the buffer underneath the modal (the user-reported bug: pasting
1135 /// into the New-Session dialog dumped the text into the obscured
1136 /// file instead of the focused field).
1137 ///
1138 /// Returns `true` when a panel owns the keyboard (the paste was
1139 /// either inserted into its focused `Text` widget, or deliberately
1140 /// swallowed because focus isn't on a text field — a modal with no
1141 /// text input focused must ignore the paste, not leak it into the
1142 /// hidden buffer). Returns `false` when no panel owns the keyboard,
1143 /// so the caller falls back to the normal `paste_text` path.
1144 pub(crate) fn paste_bracketed_into_focused_panel(&mut self, text: &str) -> bool {
1145 // The Settings dialog is a capture-all modal overlay that owns the
1146 // keyboard above any panel. A bracketed paste must reach its focused
1147 // text input (or be swallowed when no field is focused) rather than
1148 // leaking into the buffer obscured behind it — the same class of bug
1149 // the floating-panel routing below fixes (issue #2268). Gate on
1150 // `visible`, not mere presence: `close_settings` only hides the
1151 // state (it isn't dropped), and a lingering hidden dialog must not
1152 // swallow pastes meant for the buffer.
1153 if self.settings_state.as_ref().is_some_and(|s| s.visible) {
1154 if let Some(settings) = self.settings_state.as_mut() {
1155 if settings.paste_into_focused_text(text) {
1156 self.set_status_message(t!("clipboard.pasted").to_string());
1157 }
1158 }
1159 return true;
1160 }
1161
1162 // Mirror the keyboard-dispatch precedence in `handle_key`: a
1163 // focused centered modal wins over a focused dock.
1164 let slot = if self
1165 .floating_widget_panel
1166 .as_ref()
1167 .is_some_and(|f| f.focused)
1168 {
1169 super::PanelSlot::Floating
1170 } else if self.dock.as_ref().is_some_and(|d| d.focused) {
1171 super::PanelSlot::Dock
1172 } else {
1173 return false;
1174 };
1175 let Some(panel_id) = self.panel(slot).map(|f| f.panel_key.clone()) else {
1176 return false;
1177 };
1178 if self.panel_focused_widget_is_text(&panel_id) {
1179 // Single-line `TextEdit` strips embedded newlines; multi-line
1180 // stores plain `\n`. Normalise CRLF / CR → LF first, matching
1181 // the `Action::Paste` widget-routing path.
1182 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
1183 self.handle_widget_insert_str(&panel_id, &normalized);
1184 self.set_status_message(t!("clipboard.pasted").to_string());
1185 }
1186 true
1187 }
1188
1189 /// Paste text directly into the editor
1190 ///
1191 /// Handles:
1192 /// - Line ending normalization (CRLF/CR → buffer's format)
1193 /// - Single cursor paste
1194 /// - Multi-cursor paste (pastes at each cursor)
1195 /// - Column-mode paste: when the cursor count equals the number of
1196 /// clipboard lines, each cursor receives a distinct line (matches
1197 /// VSCode/Notepad++ behavior, see issue #1057). This makes a
1198 /// block-selected copy/paste round-trip preserve its rectangular shape.
1199 /// - Selection replacement (deletes selection before inserting)
1200 /// - Atomic undo (single undo step for entire operation)
1201 /// - Routing to prompt if one is open
1202 pub fn paste_text(&mut self, paste_text: String) {
1203 if paste_text.is_empty() {
1204 return;
1205 }
1206
1207 // Normalize line endings: first convert all to LF, then to buffer's format
1208 // This handles Windows clipboard (CRLF), old Mac (CR), and Unix (LF)
1209 let normalized = paste_text.replace("\r\n", "\n").replace('\r', "\n");
1210
1211 // If a prompt is open, paste into the prompt (prompts use LF internally)
1212 if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
1213 prompt.insert_str(&normalized);
1214 self.update_prompt_suggestions();
1215 self.active_window_mut().status_message = Some(t!("clipboard.pasted").to_string());
1216 return;
1217 }
1218
1219 // If in terminal mode, send paste to the terminal PTY
1220 if self.active_window().terminal_mode {
1221 self.active_window_mut()
1222 .send_terminal_input(normalized.as_bytes());
1223 return;
1224 }
1225
1226 // Collect cursor info sorted in reverse order by position
1227 let mut cursor_data: Vec<_> = self
1228 .active_cursors()
1229 .iter()
1230 .map(|(cursor_id, cursor)| {
1231 let selection = cursor.selection_range();
1232 let insert_position = selection
1233 .as_ref()
1234 .map(|r| r.start)
1235 .unwrap_or(cursor.position);
1236 (cursor_id, selection, insert_position)
1237 })
1238 .collect();
1239 cursor_data.sort_by_key(|(_, _, pos)| std::cmp::Reverse(*pos));
1240
1241 // Decide whether to distribute one clipboard line per cursor
1242 // (column-mode paste). We split on LF (after normalization above) and
1243 // ignore a single trailing empty entry from a trailing newline so that
1244 // "a\nb\nc" and "a\nb\nc\n" both yield 3 lines.
1245 let mut lines_for_distribution: Vec<&str> = normalized.split('\n').collect();
1246 if lines_for_distribution.len() > 1 && lines_for_distribution.last() == Some(&"") {
1247 lines_for_distribution.pop();
1248 }
1249 let use_column_paste = cursor_data.len() > 1
1250 && lines_for_distribution.len() > 1
1251 && lines_for_distribution.len() == cursor_data.len();
1252
1253 // Convert to buffer's line ending format (only used in non-column mode;
1254 // a single column-paste line never contains an embedded newline).
1255 let paste_text_full = match self.active_state().buffer.line_ending() {
1256 crate::model::buffer::LineEnding::LF => normalized.clone(),
1257 crate::model::buffer::LineEnding::CRLF => normalized.replace('\n', "\r\n"),
1258 crate::model::buffer::LineEnding::CR => normalized.replace('\n', "\r"),
1259 };
1260
1261 // Get deleted text for each selection
1262 let cursor_data_with_text: Vec<_> = {
1263 let state = self.active_state_mut();
1264 cursor_data
1265 .into_iter()
1266 .map(|(cursor_id, selection, insert_position)| {
1267 let deleted_text = selection
1268 .as_ref()
1269 .map(|r| state.get_text_range(r.start, r.end));
1270 (cursor_id, selection, insert_position, deleted_text)
1271 })
1272 .collect()
1273 };
1274
1275 // Build events for each cursor.
1276 //
1277 // cursor_data_with_text is sorted by position DESCENDING (so events
1278 // applied in vector order don't invalidate earlier offsets). For column
1279 // paste we want the topmost cursor (smallest position) to receive the
1280 // first clipboard line, so we index into `lines_for_distribution` from
1281 // the back when iterating.
1282 let total = cursor_data_with_text.len();
1283 let mut events = Vec::new();
1284 for (i, (cursor_id, selection, insert_position, deleted_text)) in
1285 cursor_data_with_text.into_iter().enumerate()
1286 {
1287 if let (Some(range), Some(text)) = (selection, deleted_text) {
1288 events.push(Event::Delete {
1289 range,
1290 deleted_text: text,
1291 cursor_id,
1292 });
1293 }
1294 let text = if use_column_paste {
1295 lines_for_distribution[total - 1 - i].to_string()
1296 } else {
1297 paste_text_full.clone()
1298 };
1299 events.push(Event::Insert {
1300 position: insert_position,
1301 text,
1302 cursor_id,
1303 });
1304 }
1305
1306 // Apply events with atomic undo using bulk edit for O(n) performance
1307 if events.len() > 1 {
1308 // Use optimized bulk edit for multi-cursor paste
1309 if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, "Paste".to_string()) {
1310 self.active_event_log_mut().append(bulk_edit);
1311 }
1312 } else if let Some(event) = events.into_iter().next() {
1313 self.log_and_apply_event(&event);
1314 }
1315
1316 self.active_window_mut().status_message = Some(t!("clipboard.pasted").to_string());
1317 }
1318
1319 /// Set clipboard content for testing purposes
1320 /// This sets the internal clipboard and enables internal-only mode to avoid
1321 /// system clipboard interference between parallel tests
1322 #[doc(hidden)]
1323 pub fn set_clipboard_for_test(&mut self, text: String) {
1324 self.clipboard.set_internal(text);
1325 self.clipboard.set_internal_only(true);
1326 }
1327
1328 /// Override the async paste path's system-clipboard reader for tests.
1329 ///
1330 /// Lets a test deterministically simulate a host whose OS clipboard is
1331 /// unreadable (e.g. Termux, where arboard has no backend) by passing
1332 /// `|| None`, while leaving the system clipboard nominally *enabled* —
1333 /// the exact configuration that exposed the lost internal-clipboard
1334 /// fallback (#2343). Without this seam a test would read the real host
1335 /// clipboard, which is neither deterministic nor isolated.
1336 #[doc(hidden)]
1337 pub fn set_system_clipboard_reader_for_test(&mut self, reader: fn() -> Option<String>) {
1338 self.system_clipboard_reader = Some(reader);
1339 }
1340
1341 /// Paste from internal clipboard only (for testing)
1342 /// This bypasses the system clipboard to avoid interference from CI environments
1343 #[doc(hidden)]
1344 pub fn paste_for_test(&mut self) {
1345 // Get content from internal clipboard only (ignores system clipboard)
1346 let paste_text = match self.clipboard.paste_internal() {
1347 Some(text) => text,
1348 None => return,
1349 };
1350
1351 // Use the same paste logic as the regular paste method
1352 self.paste_text(paste_text);
1353 }
1354
1355 /// Get clipboard content for testing purposes
1356 /// Returns the internal clipboard content
1357 #[doc(hidden)]
1358 pub fn clipboard_content_for_test(&self) -> String {
1359 self.clipboard.get_internal().to_string()
1360 }
1361
1362 /// Copy a buffer's file path to the clipboard.
1363 ///
1364 /// When `relative` is true the path is made relative to the workspace root;
1365 /// if the file lives outside the workspace the absolute path is used as a
1366 /// safe fallback (the user still gets a usable path rather than nothing).
1367 /// When `relative` is false the absolute path is always copied.
1368 ///
1369 /// If the buffer has no associated file (unsaved scratch buffer) or the
1370 /// buffer id is unknown, a status message is shown and the clipboard is
1371 /// left untouched.
1372 pub fn copy_buffer_path(&mut self, buffer_id: crate::model::event::BufferId, relative: bool) {
1373 let path = self
1374 .buffers()
1375 .get(&buffer_id)
1376 .and_then(|state| state.buffer.file_path().map(|p| p.to_path_buf()));
1377 let Some(path) = path else {
1378 self.active_window_mut().status_message =
1379 Some(t!("clipboard.no_file_path").to_string());
1380 return;
1381 };
1382
1383 let path_str = if relative {
1384 path.strip_prefix(self.working_dir())
1385 .unwrap_or(&path)
1386 .to_string_lossy()
1387 .into_owned()
1388 } else {
1389 path.to_string_lossy().into_owned()
1390 };
1391
1392 self.clipboard.copy(path_str.clone());
1393 self.active_window_mut().status_message =
1394 Some(t!("clipboard.copied_path", path = &path_str).to_string());
1395 }
1396
1397 /// Copy the active buffer's file path. See [`Self::copy_buffer_path`].
1398 pub fn copy_active_buffer_path(&mut self, relative: bool) {
1399 let buffer_id = self.active_buffer();
1400 self.copy_buffer_path(buffer_id, relative);
1401 }
1402
1403 /// Add a cursor at the next occurrence of the selected text
1404 /// If no selection, first selects the entire word at cursor position.
1405 ///
1406 /// When an active substring search has placed the cursor at a match
1407 /// (cursor inside `search_state.matches[i]..matches[i] + match_lengths[i]`),
1408 /// the search match is selected instead of the surrounding word. This
1409 /// way subsequent presses look for the search substring rather than the
1410 /// whole word, which would skip other substring occurrences (issue #1697).
1411 pub fn add_cursor_at_next_match(&mut self) {
1412 if let Some(range) = self.active_window().search_match_at_primary_cursor() {
1413 let primary_id = self.active_cursors().primary_id();
1414 let primary = self.active_cursors().primary();
1415 let event = Event::MoveCursor {
1416 cursor_id: primary_id,
1417 old_position: primary.position,
1418 new_position: range.end,
1419 old_anchor: primary.anchor,
1420 new_anchor: Some(range.start),
1421 old_sticky_column: primary.sticky_column,
1422 new_sticky_column: 0,
1423 };
1424 self.active_event_log_mut().append(event.clone());
1425 self.apply_event_to_active_buffer(&event);
1426 return;
1427 }
1428
1429 let cursors = self.active_cursors().clone();
1430 let state = self.active_state_mut();
1431 match add_cursor_at_next_match(state, &cursors) {
1432 AddCursorResult::Success {
1433 cursor,
1434 total_cursors,
1435 } => {
1436 // Create AddCursor event with the next cursor ID
1437 let next_id = CursorId(self.active_cursors().count());
1438 let event = Event::AddCursor {
1439 cursor_id: next_id,
1440 position: cursor.position,
1441 anchor: cursor.anchor,
1442 };
1443
1444 // Log and apply the event
1445 self.active_event_log_mut().append(event.clone());
1446 self.apply_event_to_active_buffer(&event);
1447
1448 self.active_window_mut().status_message =
1449 Some(t!("clipboard.added_cursor_match", count = total_cursors).to_string());
1450 }
1451 AddCursorResult::WordSelected {
1452 word_start,
1453 word_end,
1454 } => {
1455 // Select the word by updating the primary cursor
1456 let primary_id = self.active_cursors().primary_id();
1457 let primary = self.active_cursors().primary();
1458 let event = Event::MoveCursor {
1459 cursor_id: primary_id,
1460 old_position: primary.position,
1461 new_position: word_end,
1462 old_anchor: primary.anchor,
1463 new_anchor: Some(word_start),
1464 old_sticky_column: primary.sticky_column,
1465 new_sticky_column: 0,
1466 };
1467
1468 // Log and apply the event
1469 self.active_event_log_mut().append(event.clone());
1470 self.apply_event_to_active_buffer(&event);
1471 }
1472 AddCursorResult::Failed { message } => {
1473 self.active_window_mut().status_message = Some(message);
1474 }
1475 }
1476 }
1477
1478 /// Add a cursor above the primary cursor at the same column
1479 pub fn add_cursor_above(&mut self) {
1480 let cursors = self.active_cursors().clone();
1481 let state = self.active_state_mut();
1482 match add_cursor_above(state, &cursors) {
1483 AddCursorResult::Success {
1484 cursor,
1485 total_cursors,
1486 } => {
1487 // Create AddCursor event with the next cursor ID
1488 let next_id = CursorId(self.active_cursors().count());
1489 let event = Event::AddCursor {
1490 cursor_id: next_id,
1491 position: cursor.position,
1492 anchor: cursor.anchor,
1493 };
1494
1495 // Log and apply the event
1496 self.active_event_log_mut().append(event.clone());
1497 self.apply_event_to_active_buffer(&event);
1498
1499 self.active_window_mut().status_message =
1500 Some(t!("clipboard.added_cursor_above", count = total_cursors).to_string());
1501 }
1502 AddCursorResult::Failed { message } => {
1503 self.active_window_mut().status_message = Some(message);
1504 }
1505 AddCursorResult::WordSelected { .. } => unreachable!(),
1506 }
1507 }
1508
1509 /// Add a cursor below the primary cursor at the same column
1510 pub fn add_cursor_below(&mut self) {
1511 let cursors = self.active_cursors().clone();
1512 let state = self.active_state_mut();
1513 match add_cursor_below(state, &cursors) {
1514 AddCursorResult::Success {
1515 cursor,
1516 total_cursors,
1517 } => {
1518 // Create AddCursor event with the next cursor ID
1519 let next_id = CursorId(self.active_cursors().count());
1520 let event = Event::AddCursor {
1521 cursor_id: next_id,
1522 position: cursor.position,
1523 anchor: cursor.anchor,
1524 };
1525
1526 // Log and apply the event
1527 self.active_event_log_mut().append(event.clone());
1528 self.apply_event_to_active_buffer(&event);
1529
1530 self.active_window_mut().status_message =
1531 Some(t!("clipboard.added_cursor_below", count = total_cursors).to_string());
1532 }
1533 AddCursorResult::Failed { message } => {
1534 self.active_window_mut().status_message = Some(message);
1535 }
1536 AddCursorResult::WordSelected { .. } => unreachable!(),
1537 }
1538 }
1539
1540 /// Place a cursor at the end of every line covered by ANY existing
1541 /// cursor's selection (or each cursor's own line if it has no selection).
1542 /// Matches VSCode's "Add Cursor to Line Ends" / Sublime's "Split Selection
1543 /// into Lines": every existing cursor contributes, no cursor is silently
1544 /// dropped. Two cursors on the same line collapse to a single cursor.
1545 /// All selections are cleared.
1546 pub fn add_cursors_to_line_ends(&mut self) {
1547 let cursors = self.active_cursors().clone();
1548 let state = self.active_state_mut();
1549 let positions = line_end_positions_in_selection(state, &cursors);
1550
1551 if positions.is_empty() {
1552 self.active_window_mut().status_message =
1553 Some(t!("clipboard.added_cursors_to_line_ends_failed").to_string());
1554 return;
1555 }
1556
1557 // Sort the existing cursors in document order and map them index-wise
1558 // onto the new positions. This preserves cursor IDs where possible —
1559 // important for undo/redo — and minimises the move distance for each
1560 // surviving cursor.
1561 let mut existing: Vec<(CursorId, Cursor)> =
1562 cursors.iter().map(|(id, c)| (id, *c)).collect();
1563 existing.sort_by_key(|(_, c)| c.position);
1564
1565 let mut events: Vec<Event> = Vec::new();
1566 let reuse = existing.len().min(positions.len());
1567
1568 for i in 0..reuse {
1569 let (cursor_id, cur) = existing[i];
1570 let target = positions[i];
1571 events.push(Event::MoveCursor {
1572 cursor_id,
1573 old_position: cur.position,
1574 new_position: target,
1575 old_anchor: cur.anchor,
1576 new_anchor: None,
1577 old_sticky_column: cur.sticky_column,
1578 new_sticky_column: 0,
1579 });
1580 }
1581
1582 // If two cursors collapsed onto the same line, dedup left us with
1583 // fewer positions than cursors — drop the extras.
1584 for &(cursor_id, cur) in existing.iter().skip(reuse) {
1585 events.push(Event::RemoveCursor {
1586 cursor_id,
1587 position: cur.position,
1588 anchor: cur.anchor,
1589 });
1590 }
1591
1592 // Add fresh cursors for any extra line ends, with IDs strictly above
1593 // the highest existing one so we never collide with a cursor an undo
1594 // could re-insert later.
1595 let next_free_id = cursors
1596 .iter()
1597 .map(|(id, _)| id.0)
1598 .max()
1599 .map(|m| m + 1)
1600 .unwrap_or(0);
1601 for (i, &pos) in positions.iter().enumerate().skip(reuse) {
1602 let new_id = CursorId(next_free_id + i - reuse);
1603 events.push(Event::AddCursor {
1604 cursor_id: new_id,
1605 position: pos,
1606 anchor: None,
1607 });
1608 }
1609
1610 let total = positions.len();
1611 let batch = Event::Batch {
1612 events,
1613 description: "Add cursors to line ends".to_string(),
1614 };
1615 self.active_event_log_mut().append(batch.clone());
1616 self.apply_event_to_active_buffer(&batch);
1617
1618 self.active_window_mut().status_message =
1619 Some(t!("clipboard.added_cursors_to_line_ends", count = total).to_string());
1620 }
1621
1622 // =========================================================================
1623 // Vi-style yank operations (copy range without requiring selection)
1624 // =========================================================================
1625
1626 /// Yank (copy) from cursor to next word start
1627 pub fn yank_word_forward(&mut self) {
1628 let cursor_positions: Vec<_> = self
1629 .active_cursors()
1630 .iter()
1631 .map(|(_, c)| c.position)
1632 .collect();
1633 let ranges: Vec<_> = {
1634 let state = self.active_state();
1635 cursor_positions
1636 .into_iter()
1637 .filter_map(|start| {
1638 let end = find_word_start_right(&state.buffer, start);
1639 if end > start {
1640 Some(start..end)
1641 } else {
1642 None
1643 }
1644 })
1645 .collect()
1646 };
1647
1648 if ranges.is_empty() {
1649 return;
1650 }
1651
1652 // Copy text from all ranges
1653 let mut text = String::new();
1654 let state = self.active_state_mut();
1655 for range in ranges {
1656 if !text.is_empty() {
1657 text.push('\n');
1658 }
1659 let range_text = state.get_text_range(range.start, range.end);
1660 text.push_str(&range_text);
1661 }
1662
1663 if !text.is_empty() {
1664 let len = text.len();
1665 self.clipboard.copy(text);
1666 self.active_window_mut().status_message =
1667 Some(t!("clipboard.yanked", count = len).to_string());
1668 }
1669 }
1670
1671 /// Yank (copy) from cursor to vim word end (inclusive)
1672 pub fn yank_vi_word_end(&mut self) {
1673 let cursor_positions: Vec<_> = self
1674 .active_cursors()
1675 .iter()
1676 .map(|(_, c)| c.position)
1677 .collect();
1678 let ranges: Vec<_> = {
1679 let state = self.active_state();
1680 cursor_positions
1681 .into_iter()
1682 .filter_map(|start| {
1683 let word_end = find_vi_word_end(&state.buffer, start);
1684 let end = (word_end + 1).min(state.buffer.len());
1685 if end > start {
1686 Some(start..end)
1687 } else {
1688 None
1689 }
1690 })
1691 .collect()
1692 };
1693
1694 if ranges.is_empty() {
1695 return;
1696 }
1697
1698 let mut text = String::new();
1699 let state = self.active_state_mut();
1700 for range in ranges {
1701 if !text.is_empty() {
1702 text.push('\n');
1703 }
1704 let range_text = state.get_text_range(range.start, range.end);
1705 text.push_str(&range_text);
1706 }
1707
1708 if !text.is_empty() {
1709 let len = text.len();
1710 self.clipboard.copy(text);
1711 self.active_window_mut().status_message =
1712 Some(t!("clipboard.yanked", count = len).to_string());
1713 }
1714 }
1715
1716 /// Yank (copy) from previous word start to cursor
1717 pub fn yank_word_backward(&mut self) {
1718 let cursor_positions: Vec<_> = self
1719 .active_cursors()
1720 .iter()
1721 .map(|(_, c)| c.position)
1722 .collect();
1723 let ranges: Vec<_> = {
1724 let state = self.active_state();
1725 cursor_positions
1726 .into_iter()
1727 .filter_map(|end| {
1728 let start = find_word_start_left(&state.buffer, end);
1729 if start < end {
1730 Some(start..end)
1731 } else {
1732 None
1733 }
1734 })
1735 .collect()
1736 };
1737
1738 if ranges.is_empty() {
1739 return;
1740 }
1741
1742 let mut text = String::new();
1743 let state = self.active_state_mut();
1744 for range in ranges {
1745 if !text.is_empty() {
1746 text.push('\n');
1747 }
1748 let range_text = state.get_text_range(range.start, range.end);
1749 text.push_str(&range_text);
1750 }
1751
1752 if !text.is_empty() {
1753 let len = text.len();
1754 self.clipboard.copy(text);
1755 self.active_window_mut().status_message =
1756 Some(t!("clipboard.yanked", count = len).to_string());
1757 }
1758 }
1759
1760 /// Yank (copy) from cursor to end of line
1761 pub fn yank_to_line_end(&mut self) {
1762 let estimated_line_length = 80;
1763
1764 // First collect cursor positions with immutable borrow
1765 let cursor_positions: Vec<_> = self
1766 .active_cursors()
1767 .iter()
1768 .map(|(_, cursor)| cursor.position)
1769 .collect();
1770
1771 // Now compute ranges with mutable borrow (line_iterator needs &mut self)
1772 let state = self.active_state_mut();
1773 let mut ranges = Vec::new();
1774 for pos in cursor_positions {
1775 let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
1776 let line_start = iter.current_position();
1777 if let Some((_start, content)) = iter.next_line() {
1778 // Don't include the line ending in yank
1779 let content_len = content.trim_end_matches(&['\n', '\r'][..]).len();
1780 let line_end = line_start + content_len;
1781 if pos < line_end {
1782 ranges.push(pos..line_end);
1783 }
1784 }
1785 }
1786
1787 if ranges.is_empty() {
1788 return;
1789 }
1790
1791 let mut text = String::new();
1792 for range in ranges {
1793 if !text.is_empty() {
1794 text.push('\n');
1795 }
1796 let range_text = state.get_text_range(range.start, range.end);
1797 text.push_str(&range_text);
1798 }
1799
1800 if !text.is_empty() {
1801 let len = text.len();
1802 self.clipboard.copy(text);
1803 self.active_window_mut().status_message =
1804 Some(t!("clipboard.yanked", count = len).to_string());
1805 }
1806 }
1807
1808 /// Yank (copy) from start of line to cursor
1809 pub fn yank_to_line_start(&mut self) {
1810 let estimated_line_length = 80;
1811
1812 // First collect cursor positions with immutable borrow
1813 let cursor_positions: Vec<_> = self
1814 .active_cursors()
1815 .iter()
1816 .map(|(_, cursor)| cursor.position)
1817 .collect();
1818
1819 // Now compute ranges with mutable borrow (line_iterator needs &mut self)
1820 let state = self.active_state_mut();
1821 let mut ranges = Vec::new();
1822 for pos in cursor_positions {
1823 let iter = state.buffer.line_iterator(pos, estimated_line_length);
1824 let line_start = iter.current_position();
1825 if pos > line_start {
1826 ranges.push(line_start..pos);
1827 }
1828 }
1829
1830 if ranges.is_empty() {
1831 return;
1832 }
1833
1834 let mut text = String::new();
1835 for range in ranges {
1836 if !text.is_empty() {
1837 text.push('\n');
1838 }
1839 let range_text = state.get_text_range(range.start, range.end);
1840 text.push_str(&range_text);
1841 }
1842
1843 if !text.is_empty() {
1844 let len = text.len();
1845 self.clipboard.copy(text);
1846 self.active_window_mut().status_message =
1847 Some(t!("clipboard.yanked", count = len).to_string());
1848 }
1849 }
1850}