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::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}