Skip to main content

fresh/app/
composite_buffer_actions.rs

1//! Composite buffer management actions
2//!
3//! This module handles creating, managing, and closing composite buffers
4//! which display multiple source buffers in a single tab.
5//!
6//! ## Cursor and Selection Handling
7//!
8//! Composite buffers re-implement cursor movement and selection rather than routing
9//! to the underlying source buffers. This is a deliberate trade-off because:
10//!
11//! - Composite buffers use a display-row coordinate system with alignment rows that
12//!   may not have 1:1 mapping to source lines (e.g., padding rows for deleted lines)
13//! - The cursor position is shared across all panes but each pane may have different
14//!   content at the same display row
15//! - Horizontal scroll must sync across panes for side-by-side comparison
16//!
17//! Routing to underlying buffers was considered but would require complex coordinate
18//! translation and wouldn't handle padding rows or synced scrolling naturally.
19
20use crate::app::types::BufferMetadata;
21use crate::app::Editor;
22use crate::model::composite_buffer::{CompositeBuffer, CompositeLayout, LineAlignment, SourcePane};
23use crate::model::event::{BufferId, LeafId};
24use crate::view::composite_view::CompositeViewState;
25use anyhow::Result as AnyhowResult;
26use unicode_segmentation::UnicodeSegmentation;
27
28/// Information about the current cursor line needed for movement operations
29struct CursorLineInfo {
30    content: String,
31    length: usize,
32    pane_width: usize,
33}
34
35/// Direction for cursor movement
36#[derive(Clone, Copy)]
37enum CursorMovement {
38    Up,
39    Down,
40    Left,
41    Right,
42    LineStart,
43    LineEnd,
44    WordLeft,
45    WordRight,
46    WordEnd, // Move to end of current word
47}
48
49/// Find the previous word boundary position in a line
50fn find_word_boundary_left(line: &str, from_column: usize) -> usize {
51    let graphemes: Vec<&str> = line.graphemes(true).collect();
52    let mut pos = from_column;
53    // Skip spaces going left
54    while pos > 0
55        && graphemes
56            .get(pos.saturating_sub(1))
57            .is_some_and(|g| g.chars().all(|c| c.is_whitespace()))
58    {
59        pos -= 1;
60    }
61    // Skip word chars going left
62    while pos > 0
63        && graphemes
64            .get(pos.saturating_sub(1))
65            .is_some_and(|g| !g.chars().all(|c| c.is_whitespace()))
66    {
67        pos -= 1;
68    }
69    pos
70}
71
72/// Find the next word boundary position in a line
73fn find_word_boundary_right(line: &str, from_column: usize, line_length: usize) -> usize {
74    let graphemes: Vec<&str> = line.graphemes(true).collect();
75    let mut pos = from_column;
76    // Skip word chars going right
77    while pos < graphemes.len() && !graphemes[pos].chars().all(|c| c.is_whitespace()) {
78        pos += 1;
79    }
80    // Skip spaces going right
81    while pos < graphemes.len() && graphemes[pos].chars().all(|c| c.is_whitespace()) {
82        pos += 1;
83    }
84    pos.min(line_length)
85}
86
87/// Find the end of the current word (or end of next word if on whitespace)
88fn find_word_end_right(line: &str, from_column: usize, line_length: usize) -> usize {
89    let graphemes: Vec<&str> = line.graphemes(true).collect();
90    let mut pos = from_column;
91
92    // If on whitespace, skip it first
93    while pos < graphemes.len() && graphemes[pos].chars().all(|c| c.is_whitespace()) {
94        pos += 1;
95    }
96
97    // If on punctuation, consume it
98    if pos < graphemes.len()
99        && !graphemes[pos]
100            .chars()
101            .any(|c| c.is_alphanumeric() || c == '_')
102        && !graphemes[pos].chars().all(|c| c.is_whitespace())
103    {
104        while pos < graphemes.len()
105            && !graphemes[pos]
106                .chars()
107                .any(|c| c.is_alphanumeric() || c == '_')
108            && !graphemes[pos].chars().all(|c| c.is_whitespace())
109        {
110            pos += 1;
111        }
112    } else {
113        // Skip word chars (alphanumeric + underscore)
114        while pos < graphemes.len()
115            && graphemes[pos]
116                .chars()
117                .any(|c| c.is_alphanumeric() || c == '_')
118        {
119            pos += 1;
120        }
121    }
122
123    pos.min(line_length)
124}
125
126impl crate::app::window::Window {
127    /// Check if a buffer is a composite buffer
128    pub fn is_composite_buffer(&self, buffer_id: BufferId) -> bool {
129        self.composite_buffers.contains_key(&buffer_id)
130    }
131
132    /// Get a composite buffer by ID
133    pub fn get_composite(&self, buffer_id: BufferId) -> Option<&CompositeBuffer> {
134        self.composite_buffers.get(&buffer_id)
135    }
136
137    /// Get a mutable composite buffer by ID
138    pub fn get_composite_mut(&mut self, buffer_id: BufferId) -> Option<&mut CompositeBuffer> {
139        self.composite_buffers.get_mut(&buffer_id)
140    }
141
142    /// Cursor info for the active composite (diff-view) buffer.
143    ///
144    /// Returns `None` when the active buffer is not a composite buffer.
145    /// Otherwise yields `(focused_pane, pane_count, per_pane_source_line)`
146    /// where each entry of `per_pane_source_line` is the 0-indexed source
147    /// line shown in that pane on the cursor's aligned row (`None` where the
148    /// pane has no content on that row — e.g. the blank side of an insertion
149    /// or deletion). Plugins use this to map a side-by-side cursor back to a
150    /// concrete (file version, line) so they can open it on disk.
151    ///
152    /// The cursor can rest on a hunk-header / spacer alignment row that has no
153    /// source content on any pane (e.g. at the very top of the diff, before
154    /// the first context line). From such a row "open the file" should still
155    /// target a real line, so the cursor row is first resolved to the nearest
156    /// row that carries content — scanning down (into the hunk the header
157    /// introduces) before up. Rows that already have content on at least one
158    /// pane are used as-is, preserving per-pane `None` for the blank side of
159    /// an add/delete.
160    pub fn active_composite_cursor_info(&self) -> Option<(usize, usize, Vec<Option<usize>>)> {
161        let (split_id, buffer_id) = self.effective_active_pair();
162        if !self.is_composite_buffer(buffer_id) {
163            return None;
164        }
165        let composite = self.composite_buffers.get(&buffer_id)?;
166        let view_state = self.composite_view_states.get(&(split_id, buffer_id))?;
167        let pane_count = composite.sources.len();
168        let row_count = composite.alignment.row_count();
169
170        let row_has_content = |r: usize| -> bool {
171            composite
172                .alignment
173                .get_row(r)
174                .map(|row| (0..pane_count).any(|i| row.get_pane_line(i).is_some()))
175                .unwrap_or(false)
176        };
177
178        let start = view_state.cursor_row;
179        let resolved_row = if row_has_content(start) {
180            Some(start)
181        } else {
182            let mut found = None;
183            let mut delta = 1;
184            while delta < row_count {
185                if start + delta < row_count && row_has_content(start + delta) {
186                    found = Some(start + delta);
187                    break;
188                }
189                if start >= delta && row_has_content(start - delta) {
190                    found = Some(start - delta);
191                    break;
192                }
193                delta += 1;
194            }
195            found
196        };
197
198        let row = resolved_row.and_then(|r| composite.alignment.get_row(r));
199        let lines: Vec<Option<usize>> = (0..pane_count)
200            .map(|i| {
201                row.and_then(|r| r.get_pane_line(i))
202                    .map(|line_ref| line_ref.line)
203            })
204            .collect();
205        Some((view_state.focused_pane, pane_count, lines))
206    }
207
208    /// Set the line alignment for a composite buffer
209    pub fn set_composite_alignment(&mut self, buffer_id: BufferId, alignment: LineAlignment) {
210        if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
211            composite.set_alignment(alignment);
212        }
213    }
214
215    /// Close a composite buffer and clean up associated state
216    pub fn close_composite_buffer(&mut self, buffer_id: BufferId) {
217        self.composite_buffers.remove(&buffer_id);
218        self.buffer_metadata.remove(&buffer_id);
219        self.composite_view_states
220            .retain(|(_, bid), _| *bid != buffer_id);
221    }
222
223    /// Switch focus to the next pane in a composite buffer
224    pub fn composite_focus_next(&mut self, split_id: LeafId, buffer_id: BufferId) {
225        if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
226            composite.focus_next();
227        }
228        if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
229            view_state.focus_next_pane();
230        }
231    }
232
233    /// Switch focus to the previous pane in a composite buffer
234    pub fn composite_focus_prev(&mut self, split_id: LeafId, buffer_id: BufferId) {
235        if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
236            composite.focus_prev();
237        }
238        if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
239            view_state.focus_prev_pane();
240        }
241    }
242
243    /// Effective viewport height for composite-buffer scrolling on
244    /// `split_id`. Subtracts the one-row composite header from the
245    /// raw split viewport. Falls back to a 24-row default when the
246    /// split doesn't yet have a viewport (pre-first-render).
247    fn get_composite_viewport_height(&self, split_id: LeafId) -> usize {
248        const COMPOSITE_HEADER_HEIGHT: u16 = 1;
249        const DEFAULT_VIEWPORT_HEIGHT: usize = 24;
250
251        self.buffers
252            .splits()
253            .map(|(_, vs)| vs)
254            .expect("window must have a populated split layout")
255            .get(&split_id)
256            .map(|vs| vs.viewport.height.saturating_sub(COMPOSITE_HEADER_HEIGHT) as usize)
257            .unwrap_or(DEFAULT_VIEWPORT_HEIGHT)
258    }
259
260    /// Mirror the composite view's cursor row/column back onto the
261    /// underlying buffer's `EditorState` and the active split's view
262    /// state. Called from hunk-navigation handlers so the status-bar
263    /// `Ln/Col` reflects the new alignment row instead of stale
264    /// pre-jump data.
265    fn sync_editor_cursor_from_composite(&mut self, split_id: LeafId, buffer_id: BufferId) {
266        let (cursor_row, cursor_column, focused_pane) = self
267            .composite_view_states
268            .get(&(split_id, buffer_id))
269            .map(|vs| (vs.cursor_row, vs.cursor_column, vs.focused_pane))
270            .unwrap_or((0, 0, 0));
271
272        let display_line = self
273            .composite_buffers
274            .get(&buffer_id)
275            .and_then(|composite| composite.alignment.get_row(cursor_row))
276            .and_then(|row| row.get_pane_line(focused_pane))
277            .map(|line_ref| line_ref.line)
278            .unwrap_or(cursor_row);
279
280        if let Some(state) = self.buffers.get_mut(&buffer_id) {
281            state.primary_cursor_line_number =
282                crate::model::buffer::LineNumber::Absolute(display_line);
283        }
284
285        // Write the cursor column into the same leaf the composite is keyed
286        // under (`split_id`), not a recomputed `active_split()` — for the
287        // Review Diff group tab those differ and the status-bar column would
288        // land on the wrong leaf's view state.
289        if let Some((_, vs_map)) = self.buffers.splits_mut() {
290            if let Some(view_state) = vs_map.get_mut(&split_id) {
291                view_state.cursors.primary_mut().position = cursor_column;
292            }
293        }
294    }
295
296    /// Navigate to the next hunk (composite-buffer diff view) on
297    /// `split_id`. Centers the new hunk header roughly a third of the
298    /// way down the viewport and syncs the editor cursor so the status
299    /// bar's Ln/Col follows. Returns `true` iff a next hunk existed.
300    pub fn composite_next_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
301        let viewport_height = self.get_composite_viewport_height(split_id);
302        let moved = if let (Some(composite), Some(view_state)) = (
303            self.composite_buffers.get(&buffer_id),
304            self.composite_view_states.get_mut(&(split_id, buffer_id)),
305        ) {
306            if let Some(next_row) = composite.alignment.next_hunk_row(view_state.cursor_row) {
307                view_state.cursor_row = next_row;
308                let context_above = viewport_height / 3;
309                view_state.scroll_row = next_row.saturating_sub(context_above);
310                true
311            } else {
312                false
313            }
314        } else {
315            false
316        };
317        if moved {
318            self.sync_editor_cursor_from_composite(split_id, buffer_id);
319        }
320        moved
321    }
322
323    /// Navigate to the previous hunk in a composite-buffer diff view.
324    /// See `composite_next_hunk` for behaviour.
325    pub fn composite_prev_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
326        let viewport_height = self.get_composite_viewport_height(split_id);
327        let moved = if let (Some(composite), Some(view_state)) = (
328            self.composite_buffers.get(&buffer_id),
329            self.composite_view_states.get_mut(&(split_id, buffer_id)),
330        ) {
331            if let Some(prev_row) = composite.alignment.prev_hunk_row(view_state.cursor_row) {
332                view_state.cursor_row = prev_row;
333                let context_above = viewport_height / 3;
334                view_state.scroll_row = prev_row.saturating_sub(context_above);
335                true
336            } else {
337                false
338            }
339        } else {
340            false
341        };
342        if moved {
343            self.sync_editor_cursor_from_composite(split_id, buffer_id);
344        }
345        moved
346    }
347
348    /// Hunk navigation entry point that resolves `split_id` from this
349    /// window's active split. Used by keybinding handlers that don't
350    /// carry a split id.
351    pub fn composite_next_hunk_active(&mut self, buffer_id: BufferId) -> bool {
352        // Inner group leaf (see `handle_composite_action`): the composite
353        // lives in the focused group leaf, not the outer active split.
354        let split_id = self.effective_active_pair().0;
355        self.composite_next_hunk(split_id, buffer_id)
356    }
357
358    /// `composite_prev_hunk` flavour for the active split.
359    pub fn composite_prev_hunk_active(&mut self, buffer_id: BufferId) -> bool {
360        let split_id = self.effective_active_pair().0;
361        self.composite_prev_hunk(split_id, buffer_id)
362    }
363
364    /// Scroll a composite-buffer view by `delta` rows, clamped to the
365    /// composite's row count. No-op if the buffer or view state is
366    /// missing.
367    pub fn composite_scroll(&mut self, split_id: LeafId, buffer_id: BufferId, delta: isize) {
368        if let (Some(composite), Some(view_state)) = (
369            self.composite_buffers.get(&buffer_id),
370            self.composite_view_states.get_mut(&(split_id, buffer_id)),
371        ) {
372            let max_row = composite.row_count().saturating_sub(1);
373            view_state.scroll(delta, max_row);
374        }
375    }
376
377    /// Scroll a composite-buffer view to absolute row `row`, clamped.
378    pub fn composite_scroll_to(&mut self, split_id: LeafId, buffer_id: BufferId, row: usize) {
379        if let (Some(composite), Some(view_state)) = (
380            self.composite_buffers.get(&buffer_id),
381            self.composite_view_states.get_mut(&(split_id, buffer_id)),
382        ) {
383            let max_row = composite.row_count().saturating_sub(1);
384            view_state.set_scroll_row(row, max_row);
385        }
386    }
387}
388
389impl Editor {
390    // =========================================================================
391    // Layout Flush (synchronous state materialization)
392    // =========================================================================
393
394    /// Force-materialize render-dependent state for all visible splits.
395    ///
396    /// This is the editor's equivalent of iOS `layoutIfNeeded()` or browser
397    /// forced reflow. It ensures that `CompositeViewState` entries exist for
398    /// any visible composite buffer, using the split's current viewport
399    /// dimensions. After calling this, commands like `compositeNextHunk` can
400    /// safely read and modify view state that would otherwise only exist after
401    /// the next render cycle.
402    pub fn flush_layout(&mut self) {
403        use crate::view::composite_view::CompositeViewState;
404
405        let visible = self
406            .windows
407            .get(&self.active_window)
408            .and_then(|w| w.buffers.splits())
409            .map(|(mgr, _)| mgr)
410            .expect("active window must have a populated split layout")
411            .get_visible_buffers(ratatui::layout::Rect {
412                x: 0,
413                y: 0,
414                width: self.terminal_width,
415                height: self.terminal_height,
416            });
417
418        for (split_id, buffer_id, _area) in &visible {
419            // Only process composite buffers
420            if let Some(composite) = self.active_window().composite_buffers.get(buffer_id) {
421                let pane_count = composite.pane_count();
422                self.active_window_mut()
423                    .composite_view_states
424                    .entry((*split_id, *buffer_id))
425                    .or_insert_with(|| CompositeViewState::new(*buffer_id, pane_count));
426            }
427        }
428    }
429
430    // =========================================================================
431    // Composite Buffer Methods
432    // =========================================================================
433    //
434    // The simple read/write helpers (`is_composite_buffer`, `get_composite`,
435    // `get_composite_mut`, `set_composite_alignment`, `close_composite_buffer`,
436    // `composite_focus_next`, `composite_focus_prev`) live on `impl Window`
437    // above — call them via `self.active_window().X` /
438    // `self.active_window_mut().X` from `impl Editor`. The remaining
439    // methods below stay on `impl Editor` because they read editor-global
440    // state (`terminal_width`/`height`, plugin manager, status messages)
441    // alongside their window-scoped work.
442
443    /// Get or create composite view state for a split
444    pub fn get_composite_view_state(
445        &mut self,
446        split_id: LeafId,
447        buffer_id: BufferId,
448    ) -> Option<&mut CompositeViewState> {
449        if !self
450            .active_window()
451            .composite_buffers
452            .contains_key(&buffer_id)
453        {
454            return None;
455        }
456
457        let pane_count = self
458            .active_window()
459            .composite_buffers
460            .get(&buffer_id)?
461            .pane_count();
462
463        Some(
464            self.active_window_mut()
465                .composite_view_states
466                .entry((split_id, buffer_id))
467                .or_insert_with(|| CompositeViewState::new(buffer_id, pane_count)),
468        )
469    }
470
471    /// Create a new composite buffer
472    ///
473    /// # Arguments
474    /// * `name` - Display name for the composite buffer (shown in tab)
475    /// * `mode` - Mode for keybindings (e.g., "diff-view")
476    /// * `layout` - How panes are arranged (side-by-side, stacked, unified)
477    /// * `sources` - Source panes to display
478    ///
479    /// # Returns
480    /// The ID of the newly created composite buffer
481    pub fn create_composite_buffer(
482        &mut self,
483        name: String,
484        mode: String,
485        layout: CompositeLayout,
486        sources: Vec<SourcePane>,
487    ) -> BufferId {
488        let buffer_id = self.alloc_buffer_id();
489
490        let composite =
491            CompositeBuffer::new(buffer_id, name.clone(), mode.clone(), layout, sources);
492        self.active_window_mut()
493            .composite_buffers
494            .insert(buffer_id, composite);
495
496        // Add metadata for display
497        // Note: We use virtual_buffer() but override hidden_from_tabs since composite buffers
498        // should be visible in tabs (unlike their hidden source panes)
499        let mut metadata = BufferMetadata::virtual_buffer(name.clone(), mode.clone(), true);
500        metadata.hidden_from_tabs = false;
501        self.active_window_mut()
502            .buffer_metadata
503            .insert(buffer_id, metadata);
504
505        // Create an EditorState entry so the buffer can be shown in tabs and via showBuffer()
506        // The actual content rendering is handled by the composite renderer
507        let mut state = crate::state::EditorState::new(
508            80,
509            24,
510            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
511            std::sync::Arc::clone(&self.authority().filesystem),
512        );
513        state.is_composite_buffer = true;
514        state.editing_disabled = true;
515        state.mode = mode;
516        self.windows
517            .get_mut(&self.active_window)
518            .map(|w| &mut w.buffers)
519            .expect("active window present")
520            .insert(buffer_id, state);
521        // Create an event log entry (required for many editor operations)
522        self.active_window_mut()
523            .event_logs
524            .insert(buffer_id, crate::model::event::EventLog::new());
525
526        // NOTE: the composite is intentionally NOT auto-attached to the
527        // active split here. Callers place it explicitly — `showBuffer` for a
528        // standalone drill-down (which adds it to the active split via
529        // `set_active_buffer`), or `setBufferGroupPanelBuffer` for an in-panel
530        // review center. Attaching here too left the composite as a stray tab
531        // in the main split, spawning "[No Name]" tabs when it was later
532        // closed on a file switch.
533        buffer_id
534    }
535
536    // `set_composite_alignment`, `close_composite_buffer`,
537    // `composite_focus_next`, `composite_focus_prev` moved to
538    // `impl Window` above. Editor callers reach them via
539    // `self.active_window_mut().X(...)`.
540
541    // =========================================================================
542    // Action Handling for Composite Buffers
543    // =========================================================================
544
545    /// Get information about the line at the cursor position
546    fn get_cursor_line_info(&self, split_id: LeafId, buffer_id: BufferId) -> CursorLineInfo {
547        let composite = self.active_window().composite_buffers.get(&buffer_id);
548        let view_state = self
549            .active_window()
550            .composite_view_states
551            .get(&(split_id, buffer_id));
552
553        if let (Some(composite), Some(view_state)) = (composite, view_state) {
554            let pane_line = composite
555                .alignment
556                .get_row(view_state.cursor_row)
557                .and_then(|row| row.get_pane_line(view_state.focused_pane));
558
559            tracing::debug!(
560                "get_cursor_line_info: cursor_row={}, focused_pane={}, pane_line={:?}",
561                view_state.cursor_row,
562                view_state.focused_pane,
563                pane_line
564            );
565
566            let line_bytes = pane_line.and_then(|line_ref| {
567                let source = composite.sources.get(view_state.focused_pane)?;
568                self.windows
569                    .get(&self.active_window)
570                    .map(|w| &w.buffers)
571                    .expect("active window present")
572                    .get(&source.buffer_id)?
573                    .buffer
574                    .get_line(line_ref.line)
575            });
576
577            let content = line_bytes
578                .as_ref()
579                .map(|b| {
580                    let s = String::from_utf8_lossy(b).to_string();
581                    // Strip trailing newline - cursor shouldn't go past end of visible content
582                    s.trim_end_matches('\n').trim_end_matches('\r').to_string()
583                })
584                .unwrap_or_default();
585            let length = content.graphemes(true).count();
586            let pane_width = view_state
587                .pane_widths
588                .get(view_state.focused_pane)
589                .copied()
590                .unwrap_or(40) as usize;
591
592            CursorLineInfo {
593                content,
594                length,
595                pane_width,
596            }
597        } else {
598            CursorLineInfo {
599                content: String::new(),
600                length: 0,
601                pane_width: 40,
602            }
603        }
604    }
605
606    /// Apply a cursor movement to a composite view state
607    fn apply_cursor_movement(
608        &mut self,
609        split_id: LeafId,
610        buffer_id: BufferId,
611        movement: CursorMovement,
612        line_info: &CursorLineInfo,
613        viewport_height: usize,
614    ) {
615        let max_row = self
616            .active_window_mut()
617            .composite_buffers
618            .get(&buffer_id)
619            .map(|c| c.row_count().saturating_sub(1))
620            .unwrap_or(0);
621
622        let is_vertical = matches!(movement, CursorMovement::Up | CursorMovement::Down);
623        let mut wrapped_to_new_line = false;
624
625        // Get alignment reference for wrap checks
626        let win = self.active_window_mut();
627        let composite = win.composite_buffers.get(&buffer_id);
628
629        if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id)) {
630            match movement {
631                CursorMovement::Down => {
632                    view_state.move_cursor_down(max_row, viewport_height);
633                }
634                CursorMovement::Up => {
635                    view_state.move_cursor_up(viewport_height);
636                }
637                CursorMovement::Left => {
638                    if view_state.cursor_column > 0 {
639                        view_state.move_cursor_left();
640                    } else if view_state.cursor_row > 0 {
641                        if let Some(composite) = composite {
642                            wrapped_to_new_line =
643                                wrap_cursor_to_prev_content_row(view_state, composite);
644                        }
645                    }
646                }
647                CursorMovement::Right => {
648                    if view_state.cursor_column < line_info.length {
649                        view_state.move_cursor_right(line_info.length, line_info.pane_width);
650                    } else if view_state.cursor_row < max_row {
651                        if let Some(composite) = composite {
652                            wrap_cursor_to_next_content_row(
653                                view_state,
654                                composite,
655                                max_row,
656                                viewport_height,
657                            );
658                        }
659                    }
660                }
661                CursorMovement::LineStart => {
662                    view_state.move_cursor_to_line_start();
663                }
664                CursorMovement::LineEnd => {
665                    view_state.move_cursor_to_line_end(line_info.length, line_info.pane_width);
666                }
667                CursorMovement::WordLeft => {
668                    let new_col =
669                        find_word_boundary_left(&line_info.content, view_state.cursor_column);
670                    if new_col < view_state.cursor_column {
671                        view_state.cursor_column = new_col;
672                        view_state.sticky_column = new_col;
673                        // Scroll all panes left if the cursor moved off-screen.
674                        let current_left = view_state
675                            .pane_viewports
676                            .get(view_state.focused_pane)
677                            .map(|v| v.left_column)
678                            .unwrap_or(0);
679                        if view_state.cursor_column < current_left {
680                            for viewport in &mut view_state.pane_viewports {
681                                viewport.left_column = view_state.cursor_column;
682                            }
683                        }
684                    } else if view_state.cursor_row > 0 {
685                        if let Some(composite) = composite {
686                            wrapped_to_new_line =
687                                wrap_cursor_to_prev_content_row(view_state, composite);
688                        }
689                    }
690                }
691                CursorMovement::WordRight => {
692                    let new_col = find_word_boundary_right(
693                        &line_info.content,
694                        view_state.cursor_column,
695                        line_info.length,
696                    );
697                    if new_col > view_state.cursor_column {
698                        view_state.cursor_column = new_col;
699                        view_state.sticky_column = new_col;
700                        scroll_panes_right_to_cursor(view_state, line_info.pane_width);
701                    } else if view_state.cursor_row < max_row {
702                        if let Some(composite) = composite {
703                            wrap_cursor_to_next_content_row(
704                                view_state,
705                                composite,
706                                max_row,
707                                viewport_height,
708                            );
709                        }
710                    }
711                }
712                CursorMovement::WordEnd => {
713                    let new_col = find_word_end_right(
714                        &line_info.content,
715                        view_state.cursor_column,
716                        line_info.length,
717                    );
718                    if new_col > view_state.cursor_column {
719                        view_state.cursor_column = new_col;
720                        view_state.sticky_column = new_col;
721                        scroll_panes_right_to_cursor(view_state, line_info.pane_width);
722                    } else if view_state.cursor_row < max_row {
723                        if let Some(composite) = composite {
724                            wrap_cursor_to_next_content_row(
725                                view_state,
726                                composite,
727                                max_row,
728                                viewport_height,
729                            );
730                        }
731                    }
732                }
733            }
734        }
735
736        // For vertical movement or line wrap, get line info for the NEW row and clamp/set cursor column
737        if is_vertical || wrapped_to_new_line {
738            let new_line_info = self.get_cursor_line_info(split_id, buffer_id);
739            if let Some(view_state) = self
740                .active_window_mut()
741                .composite_view_states
742                .get_mut(&(split_id, buffer_id))
743            {
744                if wrapped_to_new_line
745                    && matches!(movement, CursorMovement::Left | CursorMovement::WordLeft)
746                {
747                    // Wrapping left goes to end of previous line
748                    tracing::debug!(
749                        "Wrap left to row {}, setting column to line length {}",
750                        view_state.cursor_row,
751                        new_line_info.length
752                    );
753                    view_state.cursor_column = new_line_info.length;
754                    view_state.sticky_column = new_line_info.length;
755                    // Scroll ALL panes horizontally to show cursor at end of line
756                    let visible_width = new_line_info.pane_width.saturating_sub(4);
757                    if visible_width > 0 && view_state.cursor_column >= visible_width {
758                        let new_left = view_state
759                            .cursor_column
760                            .saturating_sub(visible_width.saturating_sub(1));
761                        for viewport in &mut view_state.pane_viewports {
762                            viewport.left_column = new_left;
763                        }
764                    }
765                } else {
766                    view_state.clamp_cursor_to_line(new_line_info.length);
767                }
768            }
769        }
770    }
771
772    /// Handle cursor movement actions (both Move and Select variants)
773    fn handle_cursor_movement_action(
774        &mut self,
775        split_id: LeafId,
776        buffer_id: BufferId,
777        movement: CursorMovement,
778        extend_selection: bool,
779    ) -> Option<bool> {
780        let viewport_height = self.active_window().get_composite_viewport_height(split_id);
781
782        let line_info = self.get_cursor_line_info(split_id, buffer_id);
783
784        if extend_selection {
785            // Start visual selection if extending and not already in visual mode
786            if let Some(view_state) = self
787                .active_window_mut()
788                .composite_view_states
789                .get_mut(&(split_id, buffer_id))
790            {
791                if !view_state.visual_mode {
792                    view_state.start_visual_selection();
793                }
794            }
795        } else {
796            // Clear selection when moving without shift
797            if let Some(view_state) = self
798                .active_window_mut()
799                .composite_view_states
800                .get_mut(&(split_id, buffer_id))
801            {
802                if view_state.visual_mode {
803                    view_state.clear_selection();
804                }
805            }
806        }
807
808        self.apply_cursor_movement(split_id, buffer_id, movement, &line_info, viewport_height);
809        self.active_window_mut()
810            .sync_editor_cursor_from_composite(split_id, buffer_id);
811
812        Some(true)
813    }
814
815    /// Handle an action for a composite buffer.
816    ///
817    /// For navigation and selection actions, this forwards to the focused source buffer
818    /// and syncs scroll between panes. Returns Some(true) if handled, None to fall through
819    /// to normal buffer handling.
820    pub fn handle_composite_action(
821        &mut self,
822        buffer_id: BufferId,
823        action: &crate::input::keybindings::Action,
824    ) -> Option<bool> {
825        use crate::input::keybindings::Action;
826
827        // Resolve the leaf the composite actually lives in. The Review Diff
828        // panels are a group tab, so the focused composite is in an *inner*
829        // group leaf, not the outer `active_split()`. `effective_active_pair`
830        // descends into the focused group leaf (and matches how the renderer
831        // keys `composite_view_states`), so the lookups below hit. Using the
832        // outer `active_split()` here made every keyboard movement a no-op.
833        let split_id = self.active_window().effective_active_pair().0;
834
835        // Verify this is a valid composite buffer
836        let _composite = self.active_window().composite_buffers.get(&buffer_id)?;
837        let _view_state = self
838            .active_window()
839            .composite_view_states
840            .get(&(split_id, buffer_id))?;
841
842        match action {
843            // Tab switches between panes
844            Action::InsertTab => {
845                self.active_window_mut()
846                    .composite_focus_next(split_id, buffer_id);
847                Some(true)
848            }
849
850            // Copy from the focused pane
851            Action::Copy => {
852                self.handle_composite_copy(split_id, buffer_id);
853                Some(true)
854            }
855
856            // Cursor movement (without selection)
857            Action::MoveDown => {
858                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, false)
859            }
860            Action::MoveUp => {
861                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, false)
862            }
863            Action::MoveLeft => {
864                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
865            }
866            Action::MoveRight => self.handle_cursor_movement_action(
867                split_id,
868                buffer_id,
869                CursorMovement::Right,
870                false,
871            ),
872            Action::MoveLineStart | Action::SmartHome => self.handle_cursor_movement_action(
873                split_id,
874                buffer_id,
875                CursorMovement::LineStart,
876                false,
877            ),
878            Action::MoveLineEnd => self.handle_cursor_movement_action(
879                split_id,
880                buffer_id,
881                CursorMovement::LineEnd,
882                false,
883            ),
884            Action::MoveWordLeft => self.handle_cursor_movement_action(
885                split_id,
886                buffer_id,
887                CursorMovement::WordLeft,
888                false,
889            ),
890            Action::MoveWordRight => self.handle_cursor_movement_action(
891                split_id,
892                buffer_id,
893                CursorMovement::WordRight,
894                false,
895            ),
896            Action::MoveWordEnd | Action::ViMoveWordEnd => self.handle_cursor_movement_action(
897                split_id,
898                buffer_id,
899                CursorMovement::WordEnd,
900                false,
901            ),
902            Action::MoveLeftInLine => {
903                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
904            }
905            Action::MoveRightInLine => self.handle_cursor_movement_action(
906                split_id,
907                buffer_id,
908                CursorMovement::Right,
909                false,
910            ),
911
912            // Cursor movement with selection
913            Action::SelectDown => {
914                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, true)
915            }
916            Action::SelectUp => {
917                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, true)
918            }
919            Action::SelectLeft => {
920                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, true)
921            }
922            Action::SelectRight => {
923                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Right, true)
924            }
925            Action::SelectLineStart => self.handle_cursor_movement_action(
926                split_id,
927                buffer_id,
928                CursorMovement::LineStart,
929                true,
930            ),
931            Action::SelectLineEnd => self.handle_cursor_movement_action(
932                split_id,
933                buffer_id,
934                CursorMovement::LineEnd,
935                true,
936            ),
937            Action::SelectWordLeft => self.handle_cursor_movement_action(
938                split_id,
939                buffer_id,
940                CursorMovement::WordLeft,
941                true,
942            ),
943            Action::SelectWordRight => self.handle_cursor_movement_action(
944                split_id,
945                buffer_id,
946                CursorMovement::WordRight,
947                true,
948            ),
949            Action::SelectWordEnd | Action::ViSelectWordEnd => self.handle_cursor_movement_action(
950                split_id,
951                buffer_id,
952                CursorMovement::WordEnd,
953                true,
954            ),
955
956            // Page navigation
957            Action::MovePageDown | Action::MovePageUp => {
958                let viewport_height = self.active_window().get_composite_viewport_height(split_id);
959                let win = self.active_window_mut();
960                if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
961                {
962                    if matches!(action, Action::MovePageDown) {
963                        if let Some(composite) = win.composite_buffers.get(&buffer_id) {
964                            let max_row = composite.row_count().saturating_sub(1);
965                            view_state.page_down(viewport_height, max_row);
966                            view_state.cursor_row = view_state.scroll_row;
967                        }
968                    } else {
969                        view_state.page_up(viewport_height);
970                        view_state.cursor_row = view_state.scroll_row;
971                    }
972                }
973                self.active_window_mut()
974                    .sync_editor_cursor_from_composite(split_id, buffer_id);
975                Some(true)
976            }
977
978            // Document start/end
979            Action::MoveDocumentStart | Action::MoveDocumentEnd => {
980                let viewport_height = self.active_window().get_composite_viewport_height(split_id);
981                let win = self.active_window_mut();
982                if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
983                {
984                    if matches!(action, Action::MoveDocumentStart) {
985                        view_state.move_cursor_to_top();
986                    } else if let Some(composite) = win.composite_buffers.get(&buffer_id) {
987                        let max_row = composite.row_count().saturating_sub(1);
988                        view_state.move_cursor_to_bottom(max_row, viewport_height);
989                    }
990                }
991                self.active_window_mut()
992                    .sync_editor_cursor_from_composite(split_id, buffer_id);
993                Some(true)
994            }
995
996            // Scroll without moving cursor
997            Action::ScrollDown | Action::ScrollUp => {
998                let delta = if matches!(action, Action::ScrollDown) {
999                    1
1000                } else {
1001                    -1
1002                };
1003                self.active_window_mut()
1004                    .composite_scroll(split_id, buffer_id, delta);
1005                Some(true)
1006            }
1007
1008            // For other actions, return None to fall through to normal handling
1009            _ => None,
1010        }
1011    }
1012
1013    /// Handle copy action for composite buffer
1014    fn handle_composite_copy(&mut self, split_id: LeafId, buffer_id: BufferId) {
1015        let text = {
1016            let composite = match self.active_window().composite_buffers.get(&buffer_id) {
1017                Some(c) => c,
1018                None => return,
1019            };
1020            let view_state = match self
1021                .active_window()
1022                .composite_view_states
1023                .get(&(split_id, buffer_id))
1024            {
1025                Some(vs) => vs,
1026                None => return,
1027            };
1028
1029            let (start_row, end_row) = match view_state.selection_row_range() {
1030                Some(range) => range,
1031                None => return,
1032            };
1033
1034            let source = match composite.sources.get(view_state.focused_pane) {
1035                Some(s) => s,
1036                None => return,
1037            };
1038
1039            let source_state = match self
1040                .windows
1041                .get(&self.active_window)
1042                .map(|w| &w.buffers)
1043                .expect("active window present")
1044                .get(&source.buffer_id)
1045            {
1046                Some(s) => s,
1047                None => return,
1048            };
1049
1050            // Collect text from selected rows
1051            let mut text = String::new();
1052            for row in start_row..=end_row {
1053                if let Some(aligned_row) = composite.alignment.rows.get(row) {
1054                    if let Some(line_ref) = aligned_row.get_pane_line(view_state.focused_pane) {
1055                        if let Some(line_bytes) = source_state.buffer.get_line(line_ref.line) {
1056                            if !text.is_empty() {
1057                                text.push('\n');
1058                            }
1059                            // Strip trailing newline from line content to avoid double newlines
1060                            let line_str = String::from_utf8_lossy(&line_bytes);
1061                            let line_trimmed = line_str.trim_end_matches(&['\n', '\r'][..]);
1062                            text.push_str(line_trimmed);
1063                        }
1064                    }
1065                }
1066            }
1067            text
1068        };
1069
1070        // Strip ANSI escape codes so the plain copy carries the visible text
1071        // rather than the raw control codes that ANSI-aware panes render as styling.
1072        let text = crate::primitives::ansi::strip_ansi_codes(&text);
1073        if !text.is_empty() {
1074            self.clipboard.copy(text);
1075        }
1076
1077        // Don't clear selection after copy - user may want to continue working with it
1078    }
1079
1080    // =========================================================================
1081    // Plugin Command Handlers
1082    // =========================================================================
1083
1084    /// Handle the CreateCompositeBuffer plugin command
1085    #[cfg(feature = "plugins")]
1086    pub(crate) fn handle_create_composite_buffer(
1087        &mut self,
1088        name: String,
1089        mode: String,
1090        layout_config: fresh_core::api::CompositeLayoutConfig,
1091        source_configs: Vec<fresh_core::api::CompositeSourceConfig>,
1092        hunks: Option<Vec<fresh_core::api::CompositeHunk>>,
1093        initial_focus_hunk: Option<usize>,
1094        _request_id: Option<u64>,
1095    ) {
1096        use crate::model::composite_buffer::{
1097            CompositeLayout, DiffHunk, GutterStyle, LineAlignment, PaneStyle, SourcePane,
1098        };
1099
1100        // Convert layout config
1101        let layout = match layout_config.layout_type.as_str() {
1102            "stacked" => CompositeLayout::Stacked {
1103                spacing: layout_config.spacing.unwrap_or(1),
1104            },
1105            "unified" => CompositeLayout::Unified,
1106            _ => CompositeLayout::SideBySide {
1107                ratios: layout_config.ratios.unwrap_or_else(|| vec![0.5, 0.5]),
1108                show_separator: layout_config.show_separator,
1109            },
1110        };
1111
1112        // Convert source configs
1113        let sources: Vec<SourcePane> = source_configs
1114            .into_iter()
1115            .map(|src| {
1116                let mut pane = SourcePane::new(BufferId(src.buffer_id), src.label, src.editable);
1117                if let Some(style_config) = src.style {
1118                    let gutter_style = match style_config.gutter_style.as_deref() {
1119                        Some("diff-markers") => GutterStyle::DiffMarkers,
1120                        Some("both") => GutterStyle::Both,
1121                        Some("none") => GutterStyle::None,
1122                        _ => GutterStyle::LineNumbers,
1123                    };
1124                    // Convert [u8; 3] arrays to (u8, u8, u8) tuples
1125                    let to_tuple = |arr: [u8; 3]| (arr[0], arr[1], arr[2]);
1126                    pane.style = PaneStyle {
1127                        add_bg: style_config.add_bg.map(to_tuple),
1128                        remove_bg: style_config.remove_bg.map(to_tuple),
1129                        modify_bg: style_config.modify_bg.map(to_tuple),
1130                        gutter_style,
1131                    };
1132                }
1133                pane
1134            })
1135            .collect();
1136
1137        // Create the composite buffer
1138        let buffer_id = self.create_composite_buffer(name.clone(), mode.clone(), layout, sources);
1139
1140        // Set alignment from hunks if provided
1141        if let Some(hunk_configs) = hunks {
1142            let diff_hunks: Vec<DiffHunk> = hunk_configs
1143                .into_iter()
1144                .map(|h| {
1145                    DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count)
1146                        .with_ops(h.ops)
1147                })
1148                .collect();
1149
1150            // Get line counts from source buffers
1151            let old_line_count = self
1152                .buffers()
1153                .get(
1154                    &self
1155                        .active_window()
1156                        .composite_buffers
1157                        .get(&buffer_id)
1158                        .unwrap()
1159                        .sources[0]
1160                        .buffer_id,
1161                )
1162                .and_then(|s| s.buffer.line_count())
1163                .unwrap_or(0);
1164            let new_line_count = self
1165                .buffers()
1166                .get(
1167                    &self
1168                        .active_window()
1169                        .composite_buffers
1170                        .get(&buffer_id)
1171                        .unwrap()
1172                        .sources[1]
1173                        .buffer_id,
1174                )
1175                .and_then(|s| s.buffer.line_count())
1176                .unwrap_or(0);
1177
1178            let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1179            self.active_window_mut()
1180                .set_composite_alignment(buffer_id, alignment);
1181        }
1182
1183        // Store initial focus hunk for the first render to apply
1184        if initial_focus_hunk.is_some() {
1185            if let Some(composite) = self
1186                .active_window_mut()
1187                .composite_buffers
1188                .get_mut(&buffer_id)
1189            {
1190                composite.initial_focus_hunk = initial_focus_hunk;
1191            }
1192        }
1193
1194        tracing::info!(
1195            "Created composite buffer '{}' with mode '{}' (id={:?})",
1196            name,
1197            mode,
1198            buffer_id
1199        );
1200
1201        // Resolve callback with buffer_id if request_id is provided
1202        if let Some(req_id) = _request_id {
1203            // Return just the buffer ID as a number (consistent with createVirtualBuffer)
1204            let result = buffer_id.0.to_string();
1205            self.plugin_manager
1206                .read()
1207                .unwrap()
1208                .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), result);
1209            tracing::info!(
1210                "CreateCompositeBuffer: resolve_callback sent for request_id={}",
1211                req_id
1212            );
1213        }
1214    }
1215
1216    /// Handle the UpdateCompositeAlignment plugin command
1217    #[cfg(feature = "plugins")]
1218    pub(crate) fn handle_update_composite_alignment(
1219        &mut self,
1220        buffer_id: BufferId,
1221        hunk_configs: Vec<fresh_core::api::CompositeHunk>,
1222    ) {
1223        use crate::model::composite_buffer::{DiffHunk, LineAlignment};
1224
1225        if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1226            let diff_hunks: Vec<DiffHunk> = hunk_configs
1227                .into_iter()
1228                .map(|h| {
1229                    DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count)
1230                        .with_ops(h.ops)
1231                })
1232                .collect();
1233
1234            // Get line counts from source buffers
1235            let old_line_count = self
1236                .buffers()
1237                .get(&composite.sources[0].buffer_id)
1238                .and_then(|s| s.buffer.line_count())
1239                .unwrap_or(0);
1240            let new_line_count = self
1241                .buffers()
1242                .get(&composite.sources[1].buffer_id)
1243                .and_then(|s| s.buffer.line_count())
1244                .unwrap_or(0);
1245
1246            let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1247            self.active_window_mut()
1248                .set_composite_alignment(buffer_id, alignment);
1249        }
1250    }
1251
1252    /// Handle a mouse click in a composite buffer view
1253    pub(crate) fn handle_composite_click(
1254        &mut self,
1255        col: u16,
1256        row: u16,
1257        split_id: LeafId,
1258        buffer_id: BufferId,
1259        content_rect: ratatui::layout::Rect,
1260    ) -> AnyhowResult<()> {
1261        // Calculate which pane was clicked based on x coordinate
1262        let pane_idx = if let Some(view_state) = self
1263            .active_window()
1264            .composite_view_states
1265            .get(&(split_id, buffer_id))
1266        {
1267            let mut x = content_rect.x;
1268            let mut found_pane = 0;
1269            for (i, &width) in view_state.pane_widths.iter().enumerate() {
1270                if col >= x && col < x + width {
1271                    found_pane = i;
1272                    break;
1273                }
1274                x += width + 1; // +1 for separator
1275            }
1276            found_pane
1277        } else {
1278            0
1279        };
1280
1281        // Calculate the clicked row (relative to scroll position)
1282        // Subtract 1 for the header row ("OLD (HEAD)" / "NEW (Working)")
1283        let content_row = row.saturating_sub(content_rect.y).saturating_sub(1) as usize;
1284
1285        // Calculate column within the pane (accounting for gutter and horizontal scroll)
1286        let (pane_start_x, left_column) = if let Some(view_state) = self
1287            .active_window()
1288            .composite_view_states
1289            .get(&(split_id, buffer_id))
1290        {
1291            let mut x = content_rect.x;
1292            for (i, &width) in view_state.pane_widths.iter().enumerate() {
1293                if i == pane_idx {
1294                    break;
1295                }
1296                x += width + 1;
1297            }
1298            let left_col = view_state
1299                .pane_viewports
1300                .get(pane_idx)
1301                .map(|vp| vp.left_column)
1302                .unwrap_or(0);
1303            (x, left_col)
1304        } else {
1305            (content_rect.x, 0)
1306        };
1307        let gutter_width = 4; // Line number width
1308        let visual_col = col
1309            .saturating_sub(pane_start_x)
1310            .saturating_sub(gutter_width) as usize;
1311        // Convert visual column to actual column by adding horizontal scroll offset
1312        let click_col = left_column + visual_col;
1313
1314        // Get line length to clamp cursor position
1315        let display_row = if let Some(view_state) = self
1316            .active_window()
1317            .composite_view_states
1318            .get(&(split_id, buffer_id))
1319        {
1320            view_state.scroll_row + content_row
1321        } else {
1322            content_row
1323        };
1324
1325        let line_length =
1326            if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1327                composite
1328                    .alignment
1329                    .get_row(display_row)
1330                    .and_then(|row| row.get_pane_line(pane_idx))
1331                    .and_then(|line_ref| {
1332                        let source = composite.sources.get(pane_idx)?;
1333                        self.windows
1334                            .get(&self.active_window)
1335                            .map(|w| &w.buffers)
1336                            .expect("active window present")
1337                            .get(&source.buffer_id)?
1338                            .buffer
1339                            .get_line(line_ref.line)
1340                    })
1341                    .map(|bytes| {
1342                        let s = String::from_utf8_lossy(&bytes);
1343                        // Strip trailing newline - cursor shouldn't go past end of visible content
1344                        let trimmed = s.trim_end_matches('\n').trim_end_matches('\r');
1345                        trimmed.graphemes(true).count()
1346                    })
1347                    .unwrap_or(0)
1348            } else {
1349                0
1350            };
1351
1352        // Clamp click column to line length
1353        let clamped_col = click_col.min(line_length);
1354
1355        // Update composite buffer's active pane
1356        if let Some(composite) = self
1357            .active_window_mut()
1358            .composite_buffers
1359            .get_mut(&buffer_id)
1360        {
1361            composite.active_pane = pane_idx;
1362        }
1363
1364        // Update composite view state with click position
1365        if let Some(view_state) = self
1366            .active_window_mut()
1367            .composite_view_states
1368            .get_mut(&(split_id, buffer_id))
1369        {
1370            view_state.focused_pane = pane_idx;
1371            view_state.cursor_row = display_row;
1372            view_state.cursor_column = clamped_col;
1373            view_state.sticky_column = clamped_col;
1374
1375            // Clear selection on click (will start fresh selection on drag)
1376            view_state.clear_selection();
1377        }
1378
1379        // Store state for potential text selection drag
1380        self.active_window_mut().mouse_state.dragging_text_selection = false; // Disable regular text selection for composite
1381        self.active_window_mut().mouse_state.drag_selection_split = Some(split_id);
1382
1383        // Sync cursor position to EditorState for status bar display
1384        self.active_window_mut()
1385            .sync_editor_cursor_from_composite(split_id, buffer_id);
1386
1387        Ok(())
1388    }
1389}
1390
1391/// Scan upward from the row just above the cursor to the nearest row whose
1392/// focused pane has content, move the cursor there, and scroll up if needed.
1393/// Returns whether the cursor actually wrapped. Callers must ensure
1394/// `cursor_row > 0` before calling.
1395fn wrap_cursor_to_prev_content_row(
1396    view_state: &mut CompositeViewState,
1397    composite: &CompositeBuffer,
1398) -> bool {
1399    let focused_pane = view_state.focused_pane;
1400    let has_content = |row: usize| {
1401        composite
1402            .alignment
1403            .get_row(row)
1404            .is_some_and(|r| r.get_pane_line(focused_pane).is_some())
1405    };
1406
1407    let mut target_row = view_state.cursor_row - 1;
1408    while target_row > 0 && !has_content(target_row) {
1409        target_row -= 1;
1410    }
1411    if !has_content(target_row) {
1412        return false;
1413    }
1414
1415    view_state.cursor_row = target_row;
1416    if view_state.cursor_row < view_state.scroll_row {
1417        view_state.scroll_row = view_state.cursor_row;
1418    }
1419    true
1420}
1421
1422/// Scan downward from the row just below the cursor to the nearest row (before
1423/// `max_row`) whose focused pane has content, move the cursor to its start,
1424/// reset horizontal scroll across panes, and scroll down if needed. Returns
1425/// whether the cursor actually wrapped. Callers must ensure `cursor_row <
1426/// max_row` before calling.
1427fn wrap_cursor_to_next_content_row(
1428    view_state: &mut CompositeViewState,
1429    composite: &CompositeBuffer,
1430    max_row: usize,
1431    viewport_height: usize,
1432) -> bool {
1433    let focused_pane = view_state.focused_pane;
1434    let has_content = |row: usize| {
1435        composite
1436            .alignment
1437            .get_row(row)
1438            .is_some_and(|r| r.get_pane_line(focused_pane).is_some())
1439    };
1440
1441    let mut target_row = view_state.cursor_row + 1;
1442    while target_row < max_row && !has_content(target_row) {
1443        target_row += 1;
1444    }
1445    if !has_content(target_row) {
1446        return false;
1447    }
1448
1449    view_state.cursor_row = target_row;
1450    view_state.cursor_column = 0;
1451    view_state.sticky_column = 0;
1452    if view_state.cursor_row >= view_state.scroll_row + viewport_height {
1453        view_state.scroll_row = view_state.cursor_row.saturating_sub(viewport_height - 1);
1454    }
1455    // Reset horizontal scroll for ALL panes so the wrapped line starts flush.
1456    for viewport in &mut view_state.pane_viewports {
1457        viewport.left_column = 0;
1458    }
1459    true
1460}
1461
1462/// Scroll all panes horizontally so a rightward cursor stays visible.
1463fn scroll_panes_right_to_cursor(view_state: &mut CompositeViewState, pane_width: usize) {
1464    let visible_width = pane_width.saturating_sub(4);
1465    let current_left = view_state
1466        .pane_viewports
1467        .get(view_state.focused_pane)
1468        .map(|v| v.left_column)
1469        .unwrap_or(0);
1470    if visible_width > 0 && view_state.cursor_column >= current_left + visible_width {
1471        let new_left = view_state
1472            .cursor_column
1473            .saturating_sub(visible_width.saturating_sub(1));
1474        for viewport in &mut view_state.pane_viewports {
1475            viewport.left_column = new_left;
1476        }
1477    }
1478}