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