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                        // Try to wrap to end of previous line - find a row with content
642                        if let Some(composite) = composite {
643                            let focused_pane = view_state.focused_pane;
644                            let mut target_row = view_state.cursor_row - 1;
645                            while target_row > 0 {
646                                if let Some(row) = composite.alignment.get_row(target_row) {
647                                    if row.get_pane_line(focused_pane).is_some() {
648                                        break;
649                                    }
650                                }
651                                target_row -= 1;
652                            }
653                            // Only wrap if target row has content
654                            if let Some(row) = composite.alignment.get_row(target_row) {
655                                if row.get_pane_line(focused_pane).is_some() {
656                                    view_state.cursor_row = target_row;
657                                    if view_state.cursor_row < view_state.scroll_row {
658                                        view_state.scroll_row = view_state.cursor_row;
659                                    }
660                                    wrapped_to_new_line = true;
661                                }
662                            }
663                        }
664                    }
665                }
666                CursorMovement::Right => {
667                    if view_state.cursor_column < line_info.length {
668                        view_state.move_cursor_right(line_info.length, line_info.pane_width);
669                    } else if view_state.cursor_row < max_row {
670                        // Try to wrap to start of next line - find a row with content
671                        if let Some(composite) = composite {
672                            let focused_pane = view_state.focused_pane;
673                            let mut target_row = view_state.cursor_row + 1;
674                            while target_row < max_row {
675                                if let Some(row) = composite.alignment.get_row(target_row) {
676                                    if row.get_pane_line(focused_pane).is_some() {
677                                        break;
678                                    }
679                                }
680                                target_row += 1;
681                            }
682                            // Only wrap if target row has content
683                            if let Some(row) = composite.alignment.get_row(target_row) {
684                                if row.get_pane_line(focused_pane).is_some() {
685                                    view_state.cursor_row = target_row;
686                                    view_state.cursor_column = 0;
687                                    view_state.sticky_column = 0;
688                                    if view_state.cursor_row
689                                        >= view_state.scroll_row + viewport_height
690                                    {
691                                        view_state.scroll_row = view_state
692                                            .cursor_row
693                                            .saturating_sub(viewport_height - 1);
694                                    }
695                                    // Reset horizontal scroll for ALL panes
696                                    for viewport in &mut view_state.pane_viewports {
697                                        viewport.left_column = 0;
698                                    }
699                                }
700                            }
701                        }
702                    }
703                }
704                CursorMovement::LineStart => {
705                    view_state.move_cursor_to_line_start();
706                }
707                CursorMovement::LineEnd => {
708                    view_state.move_cursor_to_line_end(line_info.length, line_info.pane_width);
709                }
710                CursorMovement::WordLeft => {
711                    let new_col =
712                        find_word_boundary_left(&line_info.content, view_state.cursor_column);
713                    if new_col < view_state.cursor_column {
714                        view_state.cursor_column = new_col;
715                        view_state.sticky_column = new_col;
716                        // Update horizontal scroll for ALL panes to keep cursor visible
717                        let current_left = view_state
718                            .pane_viewports
719                            .get(view_state.focused_pane)
720                            .map(|v| v.left_column)
721                            .unwrap_or(0);
722                        if view_state.cursor_column < current_left {
723                            for viewport in &mut view_state.pane_viewports {
724                                viewport.left_column = view_state.cursor_column;
725                            }
726                        }
727                    } else if view_state.cursor_row > 0 {
728                        // At start of line, wrap to end of previous line - find a row with content
729                        if let Some(composite) = composite {
730                            let focused_pane = view_state.focused_pane;
731                            let mut target_row = view_state.cursor_row - 1;
732                            while target_row > 0 {
733                                if let Some(row) = composite.alignment.get_row(target_row) {
734                                    if row.get_pane_line(focused_pane).is_some() {
735                                        break;
736                                    }
737                                }
738                                target_row -= 1;
739                            }
740                            // Only wrap if target row has content
741                            if let Some(row) = composite.alignment.get_row(target_row) {
742                                if row.get_pane_line(focused_pane).is_some() {
743                                    view_state.cursor_row = target_row;
744                                    if view_state.cursor_row < view_state.scroll_row {
745                                        view_state.scroll_row = view_state.cursor_row;
746                                    }
747                                    wrapped_to_new_line = true;
748                                }
749                            }
750                        }
751                    }
752                }
753                CursorMovement::WordRight => {
754                    let new_col = find_word_boundary_right(
755                        &line_info.content,
756                        view_state.cursor_column,
757                        line_info.length,
758                    );
759                    if new_col > view_state.cursor_column {
760                        view_state.cursor_column = new_col;
761                        view_state.sticky_column = new_col;
762                        // Update horizontal scroll for ALL panes to keep cursor visible
763                        let visible_width = line_info.pane_width.saturating_sub(4);
764                        let current_left = view_state
765                            .pane_viewports
766                            .get(view_state.focused_pane)
767                            .map(|v| v.left_column)
768                            .unwrap_or(0);
769                        if visible_width > 0
770                            && view_state.cursor_column >= current_left + visible_width
771                        {
772                            let new_left = view_state
773                                .cursor_column
774                                .saturating_sub(visible_width.saturating_sub(1));
775                            for viewport in &mut view_state.pane_viewports {
776                                viewport.left_column = new_left;
777                            }
778                        }
779                    } else if view_state.cursor_row < max_row {
780                        // At end of line, wrap to start of next line - find a row with content
781                        if let Some(composite) = composite {
782                            let focused_pane = view_state.focused_pane;
783                            let mut target_row = view_state.cursor_row + 1;
784                            while target_row < max_row {
785                                if let Some(row) = composite.alignment.get_row(target_row) {
786                                    if row.get_pane_line(focused_pane).is_some() {
787                                        break;
788                                    }
789                                }
790                                target_row += 1;
791                            }
792                            // Only wrap if target row has content
793                            if let Some(row) = composite.alignment.get_row(target_row) {
794                                if row.get_pane_line(focused_pane).is_some() {
795                                    view_state.cursor_row = target_row;
796                                    view_state.cursor_column = 0;
797                                    view_state.sticky_column = 0;
798                                    if view_state.cursor_row
799                                        >= view_state.scroll_row + viewport_height
800                                    {
801                                        view_state.scroll_row = view_state
802                                            .cursor_row
803                                            .saturating_sub(viewport_height - 1);
804                                    }
805                                    // Reset horizontal scroll for ALL panes
806                                    for viewport in &mut view_state.pane_viewports {
807                                        viewport.left_column = 0;
808                                    }
809                                }
810                            }
811                        }
812                    }
813                }
814                CursorMovement::WordEnd => {
815                    let new_col = find_word_end_right(
816                        &line_info.content,
817                        view_state.cursor_column,
818                        line_info.length,
819                    );
820                    if new_col > view_state.cursor_column {
821                        view_state.cursor_column = new_col;
822                        view_state.sticky_column = new_col;
823                        // Update horizontal scroll for ALL panes to keep cursor visible
824                        let visible_width = line_info.pane_width.saturating_sub(4);
825                        let current_left = view_state
826                            .pane_viewports
827                            .get(view_state.focused_pane)
828                            .map(|v| v.left_column)
829                            .unwrap_or(0);
830                        if visible_width > 0
831                            && view_state.cursor_column >= current_left + visible_width
832                        {
833                            let new_left = view_state
834                                .cursor_column
835                                .saturating_sub(visible_width.saturating_sub(1));
836                            for viewport in &mut view_state.pane_viewports {
837                                viewport.left_column = new_left;
838                            }
839                        }
840                    } else if view_state.cursor_row < max_row {
841                        // At end of line, wrap to start of next line - find a row with content
842                        if let Some(composite) = composite {
843                            let focused_pane = view_state.focused_pane;
844                            let mut target_row = view_state.cursor_row + 1;
845                            while target_row < max_row {
846                                if let Some(row) = composite.alignment.get_row(target_row) {
847                                    if row.get_pane_line(focused_pane).is_some() {
848                                        break;
849                                    }
850                                }
851                                target_row += 1;
852                            }
853                            // Only wrap if target row has content
854                            if let Some(row) = composite.alignment.get_row(target_row) {
855                                if row.get_pane_line(focused_pane).is_some() {
856                                    view_state.cursor_row = target_row;
857                                    view_state.cursor_column = 0;
858                                    view_state.sticky_column = 0;
859                                    if view_state.cursor_row
860                                        >= view_state.scroll_row + viewport_height
861                                    {
862                                        view_state.scroll_row = view_state
863                                            .cursor_row
864                                            .saturating_sub(viewport_height - 1);
865                                    }
866                                    // Reset horizontal scroll for ALL panes
867                                    for viewport in &mut view_state.pane_viewports {
868                                        viewport.left_column = 0;
869                                    }
870                                }
871                            }
872                        }
873                    }
874                }
875            }
876        }
877
878        // For vertical movement or line wrap, get line info for the NEW row and clamp/set cursor column
879        if is_vertical || wrapped_to_new_line {
880            let new_line_info = self.get_cursor_line_info(split_id, buffer_id);
881            if let Some(view_state) = self
882                .active_window_mut()
883                .composite_view_states
884                .get_mut(&(split_id, buffer_id))
885            {
886                if wrapped_to_new_line
887                    && matches!(movement, CursorMovement::Left | CursorMovement::WordLeft)
888                {
889                    // Wrapping left goes to end of previous line
890                    tracing::debug!(
891                        "Wrap left to row {}, setting column to line length {}",
892                        view_state.cursor_row,
893                        new_line_info.length
894                    );
895                    view_state.cursor_column = new_line_info.length;
896                    view_state.sticky_column = new_line_info.length;
897                    // Scroll ALL panes horizontally to show cursor at end of line
898                    let visible_width = new_line_info.pane_width.saturating_sub(4);
899                    if visible_width > 0 && view_state.cursor_column >= visible_width {
900                        let new_left = view_state
901                            .cursor_column
902                            .saturating_sub(visible_width.saturating_sub(1));
903                        for viewport in &mut view_state.pane_viewports {
904                            viewport.left_column = new_left;
905                        }
906                    }
907                } else {
908                    view_state.clamp_cursor_to_line(new_line_info.length);
909                }
910            }
911        }
912    }
913
914    /// Handle cursor movement actions (both Move and Select variants)
915    fn handle_cursor_movement_action(
916        &mut self,
917        split_id: LeafId,
918        buffer_id: BufferId,
919        movement: CursorMovement,
920        extend_selection: bool,
921    ) -> Option<bool> {
922        let viewport_height = self.active_window().get_composite_viewport_height(split_id);
923
924        let line_info = self.get_cursor_line_info(split_id, buffer_id);
925
926        if extend_selection {
927            // Start visual selection if extending and not already in visual mode
928            if let Some(view_state) = self
929                .active_window_mut()
930                .composite_view_states
931                .get_mut(&(split_id, buffer_id))
932            {
933                if !view_state.visual_mode {
934                    view_state.start_visual_selection();
935                }
936            }
937        } else {
938            // Clear selection when moving without shift
939            if let Some(view_state) = self
940                .active_window_mut()
941                .composite_view_states
942                .get_mut(&(split_id, buffer_id))
943            {
944                if view_state.visual_mode {
945                    view_state.clear_selection();
946                }
947            }
948        }
949
950        self.apply_cursor_movement(split_id, buffer_id, movement, &line_info, viewport_height);
951        self.active_window_mut()
952            .sync_editor_cursor_from_composite(split_id, buffer_id);
953
954        Some(true)
955    }
956
957    /// Handle an action for a composite buffer.
958    ///
959    /// For navigation and selection actions, this forwards to the focused source buffer
960    /// and syncs scroll between panes. Returns Some(true) if handled, None to fall through
961    /// to normal buffer handling.
962    pub fn handle_composite_action(
963        &mut self,
964        buffer_id: BufferId,
965        action: &crate::input::keybindings::Action,
966    ) -> Option<bool> {
967        use crate::input::keybindings::Action;
968
969        // Resolve the leaf the composite actually lives in. The Review Diff
970        // panels are a group tab, so the focused composite is in an *inner*
971        // group leaf, not the outer `active_split()`. `effective_active_pair`
972        // descends into the focused group leaf (and matches how the renderer
973        // keys `composite_view_states`), so the lookups below hit. Using the
974        // outer `active_split()` here made every keyboard movement a no-op.
975        let split_id = self.active_window().effective_active_pair().0;
976
977        // Verify this is a valid composite buffer
978        let _composite = self.active_window().composite_buffers.get(&buffer_id)?;
979        let _view_state = self
980            .active_window()
981            .composite_view_states
982            .get(&(split_id, buffer_id))?;
983
984        match action {
985            // Tab switches between panes
986            Action::InsertTab => {
987                self.active_window_mut()
988                    .composite_focus_next(split_id, buffer_id);
989                Some(true)
990            }
991
992            // Copy from the focused pane
993            Action::Copy => {
994                self.handle_composite_copy(split_id, buffer_id);
995                Some(true)
996            }
997
998            // Cursor movement (without selection)
999            Action::MoveDown => {
1000                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, false)
1001            }
1002            Action::MoveUp => {
1003                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, false)
1004            }
1005            Action::MoveLeft => {
1006                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
1007            }
1008            Action::MoveRight => self.handle_cursor_movement_action(
1009                split_id,
1010                buffer_id,
1011                CursorMovement::Right,
1012                false,
1013            ),
1014            Action::MoveLineStart | Action::SmartHome => self.handle_cursor_movement_action(
1015                split_id,
1016                buffer_id,
1017                CursorMovement::LineStart,
1018                false,
1019            ),
1020            Action::MoveLineEnd => self.handle_cursor_movement_action(
1021                split_id,
1022                buffer_id,
1023                CursorMovement::LineEnd,
1024                false,
1025            ),
1026            Action::MoveWordLeft => self.handle_cursor_movement_action(
1027                split_id,
1028                buffer_id,
1029                CursorMovement::WordLeft,
1030                false,
1031            ),
1032            Action::MoveWordRight => self.handle_cursor_movement_action(
1033                split_id,
1034                buffer_id,
1035                CursorMovement::WordRight,
1036                false,
1037            ),
1038            Action::MoveWordEnd | Action::ViMoveWordEnd => self.handle_cursor_movement_action(
1039                split_id,
1040                buffer_id,
1041                CursorMovement::WordEnd,
1042                false,
1043            ),
1044            Action::MoveLeftInLine => {
1045                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
1046            }
1047            Action::MoveRightInLine => self.handle_cursor_movement_action(
1048                split_id,
1049                buffer_id,
1050                CursorMovement::Right,
1051                false,
1052            ),
1053
1054            // Cursor movement with selection
1055            Action::SelectDown => {
1056                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, true)
1057            }
1058            Action::SelectUp => {
1059                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, true)
1060            }
1061            Action::SelectLeft => {
1062                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, true)
1063            }
1064            Action::SelectRight => {
1065                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Right, true)
1066            }
1067            Action::SelectLineStart => self.handle_cursor_movement_action(
1068                split_id,
1069                buffer_id,
1070                CursorMovement::LineStart,
1071                true,
1072            ),
1073            Action::SelectLineEnd => self.handle_cursor_movement_action(
1074                split_id,
1075                buffer_id,
1076                CursorMovement::LineEnd,
1077                true,
1078            ),
1079            Action::SelectWordLeft => self.handle_cursor_movement_action(
1080                split_id,
1081                buffer_id,
1082                CursorMovement::WordLeft,
1083                true,
1084            ),
1085            Action::SelectWordRight => self.handle_cursor_movement_action(
1086                split_id,
1087                buffer_id,
1088                CursorMovement::WordRight,
1089                true,
1090            ),
1091            Action::SelectWordEnd | Action::ViSelectWordEnd => self.handle_cursor_movement_action(
1092                split_id,
1093                buffer_id,
1094                CursorMovement::WordEnd,
1095                true,
1096            ),
1097
1098            // Page navigation
1099            Action::MovePageDown | Action::MovePageUp => {
1100                let viewport_height = self.active_window().get_composite_viewport_height(split_id);
1101                let win = self.active_window_mut();
1102                if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
1103                {
1104                    if matches!(action, Action::MovePageDown) {
1105                        if let Some(composite) = win.composite_buffers.get(&buffer_id) {
1106                            let max_row = composite.row_count().saturating_sub(1);
1107                            view_state.page_down(viewport_height, max_row);
1108                            view_state.cursor_row = view_state.scroll_row;
1109                        }
1110                    } else {
1111                        view_state.page_up(viewport_height);
1112                        view_state.cursor_row = view_state.scroll_row;
1113                    }
1114                }
1115                self.active_window_mut()
1116                    .sync_editor_cursor_from_composite(split_id, buffer_id);
1117                Some(true)
1118            }
1119
1120            // Document start/end
1121            Action::MoveDocumentStart | Action::MoveDocumentEnd => {
1122                let viewport_height = self.active_window().get_composite_viewport_height(split_id);
1123                let win = self.active_window_mut();
1124                if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
1125                {
1126                    if matches!(action, Action::MoveDocumentStart) {
1127                        view_state.move_cursor_to_top();
1128                    } else if let Some(composite) = win.composite_buffers.get(&buffer_id) {
1129                        let max_row = composite.row_count().saturating_sub(1);
1130                        view_state.move_cursor_to_bottom(max_row, viewport_height);
1131                    }
1132                }
1133                self.active_window_mut()
1134                    .sync_editor_cursor_from_composite(split_id, buffer_id);
1135                Some(true)
1136            }
1137
1138            // Scroll without moving cursor
1139            Action::ScrollDown | Action::ScrollUp => {
1140                let delta = if matches!(action, Action::ScrollDown) {
1141                    1
1142                } else {
1143                    -1
1144                };
1145                self.active_window_mut()
1146                    .composite_scroll(split_id, buffer_id, delta);
1147                Some(true)
1148            }
1149
1150            // For other actions, return None to fall through to normal handling
1151            _ => None,
1152        }
1153    }
1154
1155    /// Handle copy action for composite buffer
1156    fn handle_composite_copy(&mut self, split_id: LeafId, buffer_id: BufferId) {
1157        let text = {
1158            let composite = match self.active_window().composite_buffers.get(&buffer_id) {
1159                Some(c) => c,
1160                None => return,
1161            };
1162            let view_state = match self
1163                .active_window()
1164                .composite_view_states
1165                .get(&(split_id, buffer_id))
1166            {
1167                Some(vs) => vs,
1168                None => return,
1169            };
1170
1171            let (start_row, end_row) = match view_state.selection_row_range() {
1172                Some(range) => range,
1173                None => return,
1174            };
1175
1176            let source = match composite.sources.get(view_state.focused_pane) {
1177                Some(s) => s,
1178                None => return,
1179            };
1180
1181            let source_state = match self
1182                .windows
1183                .get(&self.active_window)
1184                .map(|w| &w.buffers)
1185                .expect("active window present")
1186                .get(&source.buffer_id)
1187            {
1188                Some(s) => s,
1189                None => return,
1190            };
1191
1192            // Collect text from selected rows
1193            let mut text = String::new();
1194            for row in start_row..=end_row {
1195                if let Some(aligned_row) = composite.alignment.rows.get(row) {
1196                    if let Some(line_ref) = aligned_row.get_pane_line(view_state.focused_pane) {
1197                        if let Some(line_bytes) = source_state.buffer.get_line(line_ref.line) {
1198                            if !text.is_empty() {
1199                                text.push('\n');
1200                            }
1201                            // Strip trailing newline from line content to avoid double newlines
1202                            let line_str = String::from_utf8_lossy(&line_bytes);
1203                            let line_trimmed = line_str.trim_end_matches(&['\n', '\r'][..]);
1204                            text.push_str(line_trimmed);
1205                        }
1206                    }
1207                }
1208            }
1209            text
1210        };
1211
1212        if !text.is_empty() {
1213            self.clipboard.copy(text);
1214        }
1215
1216        // Don't clear selection after copy - user may want to continue working with it
1217    }
1218
1219    // =========================================================================
1220    // Plugin Command Handlers
1221    // =========================================================================
1222
1223    /// Handle the CreateCompositeBuffer plugin command
1224    #[cfg(feature = "plugins")]
1225    pub(crate) fn handle_create_composite_buffer(
1226        &mut self,
1227        name: String,
1228        mode: String,
1229        layout_config: fresh_core::api::CompositeLayoutConfig,
1230        source_configs: Vec<fresh_core::api::CompositeSourceConfig>,
1231        hunks: Option<Vec<fresh_core::api::CompositeHunk>>,
1232        initial_focus_hunk: Option<usize>,
1233        _request_id: Option<u64>,
1234    ) {
1235        use crate::model::composite_buffer::{
1236            CompositeLayout, DiffHunk, GutterStyle, LineAlignment, PaneStyle, SourcePane,
1237        };
1238
1239        // Convert layout config
1240        let layout = match layout_config.layout_type.as_str() {
1241            "stacked" => CompositeLayout::Stacked {
1242                spacing: layout_config.spacing.unwrap_or(1),
1243            },
1244            "unified" => CompositeLayout::Unified,
1245            _ => CompositeLayout::SideBySide {
1246                ratios: layout_config.ratios.unwrap_or_else(|| vec![0.5, 0.5]),
1247                show_separator: layout_config.show_separator,
1248            },
1249        };
1250
1251        // Convert source configs
1252        let sources: Vec<SourcePane> = source_configs
1253            .into_iter()
1254            .map(|src| {
1255                let mut pane = SourcePane::new(BufferId(src.buffer_id), src.label, src.editable);
1256                if let Some(style_config) = src.style {
1257                    let gutter_style = match style_config.gutter_style.as_deref() {
1258                        Some("diff-markers") => GutterStyle::DiffMarkers,
1259                        Some("both") => GutterStyle::Both,
1260                        Some("none") => GutterStyle::None,
1261                        _ => GutterStyle::LineNumbers,
1262                    };
1263                    // Convert [u8; 3] arrays to (u8, u8, u8) tuples
1264                    let to_tuple = |arr: [u8; 3]| (arr[0], arr[1], arr[2]);
1265                    pane.style = PaneStyle {
1266                        add_bg: style_config.add_bg.map(to_tuple),
1267                        remove_bg: style_config.remove_bg.map(to_tuple),
1268                        modify_bg: style_config.modify_bg.map(to_tuple),
1269                        gutter_style,
1270                    };
1271                }
1272                pane
1273            })
1274            .collect();
1275
1276        // Create the composite buffer
1277        let buffer_id = self.create_composite_buffer(name.clone(), mode.clone(), layout, sources);
1278
1279        // Set alignment from hunks if provided
1280        if let Some(hunk_configs) = hunks {
1281            let diff_hunks: Vec<DiffHunk> = hunk_configs
1282                .into_iter()
1283                .map(|h| {
1284                    DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count)
1285                        .with_ops(h.ops)
1286                })
1287                .collect();
1288
1289            // Get line counts from source buffers
1290            let old_line_count = self
1291                .buffers()
1292                .get(
1293                    &self
1294                        .active_window()
1295                        .composite_buffers
1296                        .get(&buffer_id)
1297                        .unwrap()
1298                        .sources[0]
1299                        .buffer_id,
1300                )
1301                .and_then(|s| s.buffer.line_count())
1302                .unwrap_or(0);
1303            let new_line_count = self
1304                .buffers()
1305                .get(
1306                    &self
1307                        .active_window()
1308                        .composite_buffers
1309                        .get(&buffer_id)
1310                        .unwrap()
1311                        .sources[1]
1312                        .buffer_id,
1313                )
1314                .and_then(|s| s.buffer.line_count())
1315                .unwrap_or(0);
1316
1317            let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1318            self.active_window_mut()
1319                .set_composite_alignment(buffer_id, alignment);
1320        }
1321
1322        // Store initial focus hunk for the first render to apply
1323        if initial_focus_hunk.is_some() {
1324            if let Some(composite) = self
1325                .active_window_mut()
1326                .composite_buffers
1327                .get_mut(&buffer_id)
1328            {
1329                composite.initial_focus_hunk = initial_focus_hunk;
1330            }
1331        }
1332
1333        tracing::info!(
1334            "Created composite buffer '{}' with mode '{}' (id={:?})",
1335            name,
1336            mode,
1337            buffer_id
1338        );
1339
1340        // Resolve callback with buffer_id if request_id is provided
1341        if let Some(req_id) = _request_id {
1342            // Return just the buffer ID as a number (consistent with createVirtualBuffer)
1343            let result = buffer_id.0.to_string();
1344            self.plugin_manager
1345                .read()
1346                .unwrap()
1347                .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), result);
1348            tracing::info!(
1349                "CreateCompositeBuffer: resolve_callback sent for request_id={}",
1350                req_id
1351            );
1352        }
1353    }
1354
1355    /// Handle the UpdateCompositeAlignment plugin command
1356    #[cfg(feature = "plugins")]
1357    pub(crate) fn handle_update_composite_alignment(
1358        &mut self,
1359        buffer_id: BufferId,
1360        hunk_configs: Vec<fresh_core::api::CompositeHunk>,
1361    ) {
1362        use crate::model::composite_buffer::{DiffHunk, LineAlignment};
1363
1364        if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1365            let diff_hunks: Vec<DiffHunk> = hunk_configs
1366                .into_iter()
1367                .map(|h| {
1368                    DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count)
1369                        .with_ops(h.ops)
1370                })
1371                .collect();
1372
1373            // Get line counts from source buffers
1374            let old_line_count = self
1375                .buffers()
1376                .get(&composite.sources[0].buffer_id)
1377                .and_then(|s| s.buffer.line_count())
1378                .unwrap_or(0);
1379            let new_line_count = self
1380                .buffers()
1381                .get(&composite.sources[1].buffer_id)
1382                .and_then(|s| s.buffer.line_count())
1383                .unwrap_or(0);
1384
1385            let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1386            self.active_window_mut()
1387                .set_composite_alignment(buffer_id, alignment);
1388        }
1389    }
1390
1391    /// Handle a mouse click in a composite buffer view
1392    pub(crate) fn handle_composite_click(
1393        &mut self,
1394        col: u16,
1395        row: u16,
1396        split_id: LeafId,
1397        buffer_id: BufferId,
1398        content_rect: ratatui::layout::Rect,
1399    ) -> AnyhowResult<()> {
1400        // Calculate which pane was clicked based on x coordinate
1401        let pane_idx = if let Some(view_state) = self
1402            .active_window()
1403            .composite_view_states
1404            .get(&(split_id, buffer_id))
1405        {
1406            let mut x = content_rect.x;
1407            let mut found_pane = 0;
1408            for (i, &width) in view_state.pane_widths.iter().enumerate() {
1409                if col >= x && col < x + width {
1410                    found_pane = i;
1411                    break;
1412                }
1413                x += width + 1; // +1 for separator
1414            }
1415            found_pane
1416        } else {
1417            0
1418        };
1419
1420        // Calculate the clicked row (relative to scroll position)
1421        // Subtract 1 for the header row ("OLD (HEAD)" / "NEW (Working)")
1422        let content_row = row.saturating_sub(content_rect.y).saturating_sub(1) as usize;
1423
1424        // Calculate column within the pane (accounting for gutter and horizontal scroll)
1425        let (pane_start_x, left_column) = if let Some(view_state) = self
1426            .active_window()
1427            .composite_view_states
1428            .get(&(split_id, buffer_id))
1429        {
1430            let mut x = content_rect.x;
1431            for (i, &width) in view_state.pane_widths.iter().enumerate() {
1432                if i == pane_idx {
1433                    break;
1434                }
1435                x += width + 1;
1436            }
1437            let left_col = view_state
1438                .pane_viewports
1439                .get(pane_idx)
1440                .map(|vp| vp.left_column)
1441                .unwrap_or(0);
1442            (x, left_col)
1443        } else {
1444            (content_rect.x, 0)
1445        };
1446        let gutter_width = 4; // Line number width
1447        let visual_col = col
1448            .saturating_sub(pane_start_x)
1449            .saturating_sub(gutter_width) as usize;
1450        // Convert visual column to actual column by adding horizontal scroll offset
1451        let click_col = left_column + visual_col;
1452
1453        // Get line length to clamp cursor position
1454        let display_row = if let Some(view_state) = self
1455            .active_window()
1456            .composite_view_states
1457            .get(&(split_id, buffer_id))
1458        {
1459            view_state.scroll_row + content_row
1460        } else {
1461            content_row
1462        };
1463
1464        let line_length =
1465            if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1466                composite
1467                    .alignment
1468                    .get_row(display_row)
1469                    .and_then(|row| row.get_pane_line(pane_idx))
1470                    .and_then(|line_ref| {
1471                        let source = composite.sources.get(pane_idx)?;
1472                        self.windows
1473                            .get(&self.active_window)
1474                            .map(|w| &w.buffers)
1475                            .expect("active window present")
1476                            .get(&source.buffer_id)?
1477                            .buffer
1478                            .get_line(line_ref.line)
1479                    })
1480                    .map(|bytes| {
1481                        let s = String::from_utf8_lossy(&bytes);
1482                        // Strip trailing newline - cursor shouldn't go past end of visible content
1483                        let trimmed = s.trim_end_matches('\n').trim_end_matches('\r');
1484                        trimmed.graphemes(true).count()
1485                    })
1486                    .unwrap_or(0)
1487            } else {
1488                0
1489            };
1490
1491        // Clamp click column to line length
1492        let clamped_col = click_col.min(line_length);
1493
1494        // Update composite buffer's active pane
1495        if let Some(composite) = self
1496            .active_window_mut()
1497            .composite_buffers
1498            .get_mut(&buffer_id)
1499        {
1500            composite.active_pane = pane_idx;
1501        }
1502
1503        // Update composite view state with click position
1504        if let Some(view_state) = self
1505            .active_window_mut()
1506            .composite_view_states
1507            .get_mut(&(split_id, buffer_id))
1508        {
1509            view_state.focused_pane = pane_idx;
1510            view_state.cursor_row = display_row;
1511            view_state.cursor_column = clamped_col;
1512            view_state.sticky_column = clamped_col;
1513
1514            // Clear selection on click (will start fresh selection on drag)
1515            view_state.clear_selection();
1516        }
1517
1518        // Store state for potential text selection drag
1519        self.active_window_mut().mouse_state.dragging_text_selection = false; // Disable regular text selection for composite
1520        self.active_window_mut().mouse_state.drag_selection_split = Some(split_id);
1521
1522        // Sync cursor position to EditorState for status bar display
1523        self.active_window_mut()
1524            .sync_editor_cursor_from_composite(split_id, buffer_id);
1525
1526        Ok(())
1527    }
1528}