Skip to main content

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}