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        let active_split = self
286            .buffers
287            .splits()
288            .map(|(mgr, _)| mgr)
289            .expect("window must have a populated split layout")
290            .active_split();
291        if let Some((_, vs_map)) = self.buffers.splits_mut() {
292            if let Some(view_state) = vs_map.get_mut(&active_split) {
293                view_state.cursors.primary_mut().position = cursor_column;
294            }
295        }
296    }
297
298    /// Navigate to the next hunk (composite-buffer diff view) on
299    /// `split_id`. Centers the new hunk header roughly a third of the
300    /// way down the viewport and syncs the editor cursor so the status
301    /// bar's Ln/Col follows. Returns `true` iff a next hunk existed.
302    pub fn composite_next_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
303        let viewport_height = self.get_composite_viewport_height(split_id);
304        let moved = if let (Some(composite), Some(view_state)) = (
305            self.composite_buffers.get(&buffer_id),
306            self.composite_view_states.get_mut(&(split_id, buffer_id)),
307        ) {
308            if let Some(next_row) = composite.alignment.next_hunk_row(view_state.cursor_row) {
309                view_state.cursor_row = next_row;
310                let context_above = viewport_height / 3;
311                view_state.scroll_row = next_row.saturating_sub(context_above);
312                true
313            } else {
314                false
315            }
316        } else {
317            false
318        };
319        if moved {
320            self.sync_editor_cursor_from_composite(split_id, buffer_id);
321        }
322        moved
323    }
324
325    /// Navigate to the previous hunk in a composite-buffer diff view.
326    /// See `composite_next_hunk` for behaviour.
327    pub fn composite_prev_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
328        let viewport_height = self.get_composite_viewport_height(split_id);
329        let moved = if let (Some(composite), Some(view_state)) = (
330            self.composite_buffers.get(&buffer_id),
331            self.composite_view_states.get_mut(&(split_id, buffer_id)),
332        ) {
333            if let Some(prev_row) = composite.alignment.prev_hunk_row(view_state.cursor_row) {
334                view_state.cursor_row = prev_row;
335                let context_above = viewport_height / 3;
336                view_state.scroll_row = prev_row.saturating_sub(context_above);
337                true
338            } else {
339                false
340            }
341        } else {
342            false
343        };
344        if moved {
345            self.sync_editor_cursor_from_composite(split_id, buffer_id);
346        }
347        moved
348    }
349
350    /// Hunk navigation entry point that resolves `split_id` from this
351    /// window's active split. Used by keybinding handlers that don't
352    /// carry a split id.
353    pub fn composite_next_hunk_active(&mut self, buffer_id: BufferId) -> bool {
354        let split_id = self
355            .buffers
356            .splits()
357            .map(|(mgr, _)| mgr)
358            .expect("window must have a populated split layout")
359            .active_split();
360        self.composite_next_hunk(split_id, buffer_id)
361    }
362
363    /// `composite_prev_hunk` flavour for the active split.
364    pub fn composite_prev_hunk_active(&mut self, buffer_id: BufferId) -> bool {
365        let split_id = self
366            .buffers
367            .splits()
368            .map(|(mgr, _)| mgr)
369            .expect("window must have a populated split layout")
370            .active_split();
371        self.composite_prev_hunk(split_id, buffer_id)
372    }
373
374    /// Scroll a composite-buffer view by `delta` rows, clamped to the
375    /// composite's row count. No-op if the buffer or view state is
376    /// missing.
377    pub fn composite_scroll(&mut self, split_id: LeafId, buffer_id: BufferId, delta: isize) {
378        if let (Some(composite), Some(view_state)) = (
379            self.composite_buffers.get(&buffer_id),
380            self.composite_view_states.get_mut(&(split_id, buffer_id)),
381        ) {
382            let max_row = composite.row_count().saturating_sub(1);
383            view_state.scroll(delta, max_row);
384        }
385    }
386
387    /// Scroll a composite-buffer view to absolute row `row`, clamped.
388    pub fn composite_scroll_to(&mut self, split_id: LeafId, buffer_id: BufferId, row: usize) {
389        if let (Some(composite), Some(view_state)) = (
390            self.composite_buffers.get(&buffer_id),
391            self.composite_view_states.get_mut(&(split_id, buffer_id)),
392        ) {
393            let max_row = composite.row_count().saturating_sub(1);
394            view_state.set_scroll_row(row, max_row);
395        }
396    }
397}
398
399impl Editor {
400    // =========================================================================
401    // Layout Flush (synchronous state materialization)
402    // =========================================================================
403
404    /// Force-materialize render-dependent state for all visible splits.
405    ///
406    /// This is the editor's equivalent of iOS `layoutIfNeeded()` or browser
407    /// forced reflow. It ensures that `CompositeViewState` entries exist for
408    /// any visible composite buffer, using the split's current viewport
409    /// dimensions. After calling this, commands like `compositeNextHunk` can
410    /// safely read and modify view state that would otherwise only exist after
411    /// the next render cycle.
412    pub fn flush_layout(&mut self) {
413        use crate::view::composite_view::CompositeViewState;
414
415        let visible = self
416            .windows
417            .get(&self.active_window)
418            .and_then(|w| w.buffers.splits())
419            .map(|(mgr, _)| mgr)
420            .expect("active window must have a populated split layout")
421            .get_visible_buffers(ratatui::layout::Rect {
422                x: 0,
423                y: 0,
424                width: self.terminal_width,
425                height: self.terminal_height,
426            });
427
428        for (split_id, buffer_id, _area) in &visible {
429            // Only process composite buffers
430            if let Some(composite) = self.active_window().composite_buffers.get(buffer_id) {
431                let pane_count = composite.pane_count();
432                self.active_window_mut()
433                    .composite_view_states
434                    .entry((*split_id, *buffer_id))
435                    .or_insert_with(|| CompositeViewState::new(*buffer_id, pane_count));
436            }
437        }
438    }
439
440    // =========================================================================
441    // Composite Buffer Methods
442    // =========================================================================
443    //
444    // The simple read/write helpers (`is_composite_buffer`, `get_composite`,
445    // `get_composite_mut`, `set_composite_alignment`, `close_composite_buffer`,
446    // `composite_focus_next`, `composite_focus_prev`) live on `impl Window`
447    // above — call them via `self.active_window().X` /
448    // `self.active_window_mut().X` from `impl Editor`. The remaining
449    // methods below stay on `impl Editor` because they read editor-global
450    // state (`terminal_width`/`height`, plugin manager, status messages)
451    // alongside their window-scoped work.
452
453    /// Get or create composite view state for a split
454    pub fn get_composite_view_state(
455        &mut self,
456        split_id: LeafId,
457        buffer_id: BufferId,
458    ) -> Option<&mut CompositeViewState> {
459        if !self
460            .active_window()
461            .composite_buffers
462            .contains_key(&buffer_id)
463        {
464            return None;
465        }
466
467        let pane_count = self
468            .active_window()
469            .composite_buffers
470            .get(&buffer_id)?
471            .pane_count();
472
473        Some(
474            self.active_window_mut()
475                .composite_view_states
476                .entry((split_id, buffer_id))
477                .or_insert_with(|| CompositeViewState::new(buffer_id, pane_count)),
478        )
479    }
480
481    /// Create a new composite buffer
482    ///
483    /// # Arguments
484    /// * `name` - Display name for the composite buffer (shown in tab)
485    /// * `mode` - Mode for keybindings (e.g., "diff-view")
486    /// * `layout` - How panes are arranged (side-by-side, stacked, unified)
487    /// * `sources` - Source panes to display
488    ///
489    /// # Returns
490    /// The ID of the newly created composite buffer
491    pub fn create_composite_buffer(
492        &mut self,
493        name: String,
494        mode: String,
495        layout: CompositeLayout,
496        sources: Vec<SourcePane>,
497    ) -> BufferId {
498        let buffer_id = self.alloc_buffer_id();
499
500        let composite =
501            CompositeBuffer::new(buffer_id, name.clone(), mode.clone(), layout, sources);
502        self.active_window_mut()
503            .composite_buffers
504            .insert(buffer_id, composite);
505
506        // Add metadata for display
507        // Note: We use virtual_buffer() but override hidden_from_tabs since composite buffers
508        // should be visible in tabs (unlike their hidden source panes)
509        let mut metadata = BufferMetadata::virtual_buffer(name.clone(), mode.clone(), true);
510        metadata.hidden_from_tabs = false;
511        self.active_window_mut()
512            .buffer_metadata
513            .insert(buffer_id, metadata);
514
515        // Create an EditorState entry so the buffer can be shown in tabs and via showBuffer()
516        // The actual content rendering is handled by the composite renderer
517        let mut state = crate::state::EditorState::new(
518            80,
519            24,
520            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
521            std::sync::Arc::clone(&self.authority.filesystem),
522        );
523        state.is_composite_buffer = true;
524        state.editing_disabled = true;
525        state.mode = mode;
526        self.windows
527            .get_mut(&self.active_window)
528            .map(|w| &mut w.buffers)
529            .expect("active window present")
530            .insert(buffer_id, state);
531        // Create an event log entry (required for many editor operations)
532        self.active_window_mut()
533            .event_logs
534            .insert(buffer_id, crate::model::event::EventLog::new());
535
536        // Register with the active split so it appears in tabs
537        let split_id = self
538            .windows
539            .get(&self.active_window)
540            .and_then(|w| w.buffers.splits())
541            .map(|(mgr, _)| mgr)
542            .expect("active window must have a populated split layout")
543            .active_split();
544        if let Some(view_state) = self
545            .windows
546            .get_mut(&self.active_window)
547            .and_then(|w| w.split_view_states_mut())
548            .expect("active window must have a populated split layout")
549            .get_mut(&split_id)
550        {
551            view_state.add_buffer(buffer_id);
552        }
553
554        buffer_id
555    }
556
557    // `set_composite_alignment`, `close_composite_buffer`,
558    // `composite_focus_next`, `composite_focus_prev` moved to
559    // `impl Window` above. Editor callers reach them via
560    // `self.active_window_mut().X(...)`.
561
562    // =========================================================================
563    // Action Handling for Composite Buffers
564    // =========================================================================
565
566    /// Get information about the line at the cursor position
567    fn get_cursor_line_info(&self, split_id: LeafId, buffer_id: BufferId) -> CursorLineInfo {
568        let composite = self.active_window().composite_buffers.get(&buffer_id);
569        let view_state = self
570            .active_window()
571            .composite_view_states
572            .get(&(split_id, buffer_id));
573
574        if let (Some(composite), Some(view_state)) = (composite, view_state) {
575            let pane_line = composite
576                .alignment
577                .get_row(view_state.cursor_row)
578                .and_then(|row| row.get_pane_line(view_state.focused_pane));
579
580            tracing::debug!(
581                "get_cursor_line_info: cursor_row={}, focused_pane={}, pane_line={:?}",
582                view_state.cursor_row,
583                view_state.focused_pane,
584                pane_line
585            );
586
587            let line_bytes = pane_line.and_then(|line_ref| {
588                let source = composite.sources.get(view_state.focused_pane)?;
589                self.windows
590                    .get(&self.active_window)
591                    .map(|w| &w.buffers)
592                    .expect("active window present")
593                    .get(&source.buffer_id)?
594                    .buffer
595                    .get_line(line_ref.line)
596            });
597
598            let content = line_bytes
599                .as_ref()
600                .map(|b| {
601                    let s = String::from_utf8_lossy(b).to_string();
602                    // Strip trailing newline - cursor shouldn't go past end of visible content
603                    s.trim_end_matches('\n').trim_end_matches('\r').to_string()
604                })
605                .unwrap_or_default();
606            let length = content.graphemes(true).count();
607            let pane_width = view_state
608                .pane_widths
609                .get(view_state.focused_pane)
610                .copied()
611                .unwrap_or(40) as usize;
612
613            CursorLineInfo {
614                content,
615                length,
616                pane_width,
617            }
618        } else {
619            CursorLineInfo {
620                content: String::new(),
621                length: 0,
622                pane_width: 40,
623            }
624        }
625    }
626
627    /// Apply a cursor movement to a composite view state
628    fn apply_cursor_movement(
629        &mut self,
630        split_id: LeafId,
631        buffer_id: BufferId,
632        movement: CursorMovement,
633        line_info: &CursorLineInfo,
634        viewport_height: usize,
635    ) {
636        let max_row = self
637            .active_window_mut()
638            .composite_buffers
639            .get(&buffer_id)
640            .map(|c| c.row_count().saturating_sub(1))
641            .unwrap_or(0);
642
643        let is_vertical = matches!(movement, CursorMovement::Up | CursorMovement::Down);
644        let mut wrapped_to_new_line = false;
645
646        // Get alignment reference for wrap checks
647        let win = self.active_window_mut();
648        let composite = win.composite_buffers.get(&buffer_id);
649
650        if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id)) {
651            match movement {
652                CursorMovement::Down => {
653                    view_state.move_cursor_down(max_row, viewport_height);
654                }
655                CursorMovement::Up => {
656                    view_state.move_cursor_up(viewport_height);
657                }
658                CursorMovement::Left => {
659                    if view_state.cursor_column > 0 {
660                        view_state.move_cursor_left();
661                    } else if view_state.cursor_row > 0 {
662                        // Try to wrap to end of previous line - find a row with content
663                        if let Some(composite) = composite {
664                            let focused_pane = view_state.focused_pane;
665                            let mut target_row = view_state.cursor_row - 1;
666                            while target_row > 0 {
667                                if let Some(row) = composite.alignment.get_row(target_row) {
668                                    if row.get_pane_line(focused_pane).is_some() {
669                                        break;
670                                    }
671                                }
672                                target_row -= 1;
673                            }
674                            // Only wrap if target row has content
675                            if let Some(row) = composite.alignment.get_row(target_row) {
676                                if row.get_pane_line(focused_pane).is_some() {
677                                    view_state.cursor_row = target_row;
678                                    if view_state.cursor_row < view_state.scroll_row {
679                                        view_state.scroll_row = view_state.cursor_row;
680                                    }
681                                    wrapped_to_new_line = true;
682                                }
683                            }
684                        }
685                    }
686                }
687                CursorMovement::Right => {
688                    if view_state.cursor_column < line_info.length {
689                        view_state.move_cursor_right(line_info.length, line_info.pane_width);
690                    } else if view_state.cursor_row < max_row {
691                        // Try to wrap to start of next line - find a row with content
692                        if let Some(composite) = composite {
693                            let focused_pane = view_state.focused_pane;
694                            let mut target_row = view_state.cursor_row + 1;
695                            while target_row < max_row {
696                                if let Some(row) = composite.alignment.get_row(target_row) {
697                                    if row.get_pane_line(focused_pane).is_some() {
698                                        break;
699                                    }
700                                }
701                                target_row += 1;
702                            }
703                            // Only wrap if target row has content
704                            if let Some(row) = composite.alignment.get_row(target_row) {
705                                if row.get_pane_line(focused_pane).is_some() {
706                                    view_state.cursor_row = target_row;
707                                    view_state.cursor_column = 0;
708                                    view_state.sticky_column = 0;
709                                    if view_state.cursor_row
710                                        >= view_state.scroll_row + viewport_height
711                                    {
712                                        view_state.scroll_row = view_state
713                                            .cursor_row
714                                            .saturating_sub(viewport_height - 1);
715                                    }
716                                    // Reset horizontal scroll for ALL panes
717                                    for viewport in &mut view_state.pane_viewports {
718                                        viewport.left_column = 0;
719                                    }
720                                }
721                            }
722                        }
723                    }
724                }
725                CursorMovement::LineStart => {
726                    view_state.move_cursor_to_line_start();
727                }
728                CursorMovement::LineEnd => {
729                    view_state.move_cursor_to_line_end(line_info.length, line_info.pane_width);
730                }
731                CursorMovement::WordLeft => {
732                    let new_col =
733                        find_word_boundary_left(&line_info.content, view_state.cursor_column);
734                    if new_col < view_state.cursor_column {
735                        view_state.cursor_column = new_col;
736                        view_state.sticky_column = new_col;
737                        // Update horizontal scroll for ALL panes to keep cursor visible
738                        let current_left = view_state
739                            .pane_viewports
740                            .get(view_state.focused_pane)
741                            .map(|v| v.left_column)
742                            .unwrap_or(0);
743                        if view_state.cursor_column < current_left {
744                            for viewport in &mut view_state.pane_viewports {
745                                viewport.left_column = view_state.cursor_column;
746                            }
747                        }
748                    } else if view_state.cursor_row > 0 {
749                        // At start of line, wrap to end of previous line - find a row with content
750                        if let Some(composite) = composite {
751                            let focused_pane = view_state.focused_pane;
752                            let mut target_row = view_state.cursor_row - 1;
753                            while target_row > 0 {
754                                if let Some(row) = composite.alignment.get_row(target_row) {
755                                    if row.get_pane_line(focused_pane).is_some() {
756                                        break;
757                                    }
758                                }
759                                target_row -= 1;
760                            }
761                            // Only wrap if target row has content
762                            if let Some(row) = composite.alignment.get_row(target_row) {
763                                if row.get_pane_line(focused_pane).is_some() {
764                                    view_state.cursor_row = target_row;
765                                    if view_state.cursor_row < view_state.scroll_row {
766                                        view_state.scroll_row = view_state.cursor_row;
767                                    }
768                                    wrapped_to_new_line = true;
769                                }
770                            }
771                        }
772                    }
773                }
774                CursorMovement::WordRight => {
775                    let new_col = find_word_boundary_right(
776                        &line_info.content,
777                        view_state.cursor_column,
778                        line_info.length,
779                    );
780                    if new_col > view_state.cursor_column {
781                        view_state.cursor_column = new_col;
782                        view_state.sticky_column = new_col;
783                        // Update horizontal scroll for ALL panes to keep cursor visible
784                        let visible_width = line_info.pane_width.saturating_sub(4);
785                        let current_left = view_state
786                            .pane_viewports
787                            .get(view_state.focused_pane)
788                            .map(|v| v.left_column)
789                            .unwrap_or(0);
790                        if visible_width > 0
791                            && view_state.cursor_column >= current_left + visible_width
792                        {
793                            let new_left = view_state
794                                .cursor_column
795                                .saturating_sub(visible_width.saturating_sub(1));
796                            for viewport in &mut view_state.pane_viewports {
797                                viewport.left_column = new_left;
798                            }
799                        }
800                    } else if view_state.cursor_row < max_row {
801                        // At end of line, wrap to start of next line - find a row with content
802                        if let Some(composite) = composite {
803                            let focused_pane = view_state.focused_pane;
804                            let mut target_row = view_state.cursor_row + 1;
805                            while target_row < max_row {
806                                if let Some(row) = composite.alignment.get_row(target_row) {
807                                    if row.get_pane_line(focused_pane).is_some() {
808                                        break;
809                                    }
810                                }
811                                target_row += 1;
812                            }
813                            // Only wrap if target row has content
814                            if let Some(row) = composite.alignment.get_row(target_row) {
815                                if row.get_pane_line(focused_pane).is_some() {
816                                    view_state.cursor_row = target_row;
817                                    view_state.cursor_column = 0;
818                                    view_state.sticky_column = 0;
819                                    if view_state.cursor_row
820                                        >= view_state.scroll_row + viewport_height
821                                    {
822                                        view_state.scroll_row = view_state
823                                            .cursor_row
824                                            .saturating_sub(viewport_height - 1);
825                                    }
826                                    // Reset horizontal scroll for ALL panes
827                                    for viewport in &mut view_state.pane_viewports {
828                                        viewport.left_column = 0;
829                                    }
830                                }
831                            }
832                        }
833                    }
834                }
835                CursorMovement::WordEnd => {
836                    let new_col = find_word_end_right(
837                        &line_info.content,
838                        view_state.cursor_column,
839                        line_info.length,
840                    );
841                    if new_col > view_state.cursor_column {
842                        view_state.cursor_column = new_col;
843                        view_state.sticky_column = new_col;
844                        // Update horizontal scroll for ALL panes to keep cursor visible
845                        let visible_width = line_info.pane_width.saturating_sub(4);
846                        let current_left = view_state
847                            .pane_viewports
848                            .get(view_state.focused_pane)
849                            .map(|v| v.left_column)
850                            .unwrap_or(0);
851                        if visible_width > 0
852                            && view_state.cursor_column >= current_left + visible_width
853                        {
854                            let new_left = view_state
855                                .cursor_column
856                                .saturating_sub(visible_width.saturating_sub(1));
857                            for viewport in &mut view_state.pane_viewports {
858                                viewport.left_column = new_left;
859                            }
860                        }
861                    } else if view_state.cursor_row < max_row {
862                        // At end of line, wrap to start of next line - find a row with content
863                        if let Some(composite) = composite {
864                            let focused_pane = view_state.focused_pane;
865                            let mut target_row = view_state.cursor_row + 1;
866                            while target_row < max_row {
867                                if let Some(row) = composite.alignment.get_row(target_row) {
868                                    if row.get_pane_line(focused_pane).is_some() {
869                                        break;
870                                    }
871                                }
872                                target_row += 1;
873                            }
874                            // Only wrap if target row has content
875                            if let Some(row) = composite.alignment.get_row(target_row) {
876                                if row.get_pane_line(focused_pane).is_some() {
877                                    view_state.cursor_row = target_row;
878                                    view_state.cursor_column = 0;
879                                    view_state.sticky_column = 0;
880                                    if view_state.cursor_row
881                                        >= view_state.scroll_row + viewport_height
882                                    {
883                                        view_state.scroll_row = view_state
884                                            .cursor_row
885                                            .saturating_sub(viewport_height - 1);
886                                    }
887                                    // Reset horizontal scroll for ALL panes
888                                    for viewport in &mut view_state.pane_viewports {
889                                        viewport.left_column = 0;
890                                    }
891                                }
892                            }
893                        }
894                    }
895                }
896            }
897        }
898
899        // For vertical movement or line wrap, get line info for the NEW row and clamp/set cursor column
900        if is_vertical || wrapped_to_new_line {
901            let new_line_info = self.get_cursor_line_info(split_id, buffer_id);
902            if let Some(view_state) = self
903                .active_window_mut()
904                .composite_view_states
905                .get_mut(&(split_id, buffer_id))
906            {
907                if wrapped_to_new_line
908                    && matches!(movement, CursorMovement::Left | CursorMovement::WordLeft)
909                {
910                    // Wrapping left goes to end of previous line
911                    tracing::debug!(
912                        "Wrap left to row {}, setting column to line length {}",
913                        view_state.cursor_row,
914                        new_line_info.length
915                    );
916                    view_state.cursor_column = new_line_info.length;
917                    view_state.sticky_column = new_line_info.length;
918                    // Scroll ALL panes horizontally to show cursor at end of line
919                    let visible_width = new_line_info.pane_width.saturating_sub(4);
920                    if visible_width > 0 && view_state.cursor_column >= visible_width {
921                        let new_left = view_state
922                            .cursor_column
923                            .saturating_sub(visible_width.saturating_sub(1));
924                        for viewport in &mut view_state.pane_viewports {
925                            viewport.left_column = new_left;
926                        }
927                    }
928                } else {
929                    view_state.clamp_cursor_to_line(new_line_info.length);
930                }
931            }
932        }
933    }
934
935    /// Handle cursor movement actions (both Move and Select variants)
936    fn handle_cursor_movement_action(
937        &mut self,
938        split_id: LeafId,
939        buffer_id: BufferId,
940        movement: CursorMovement,
941        extend_selection: bool,
942    ) -> Option<bool> {
943        let viewport_height = self.active_window().get_composite_viewport_height(split_id);
944
945        let line_info = self.get_cursor_line_info(split_id, buffer_id);
946
947        if extend_selection {
948            // Start visual selection if extending and not already in visual mode
949            if let Some(view_state) = self
950                .active_window_mut()
951                .composite_view_states
952                .get_mut(&(split_id, buffer_id))
953            {
954                if !view_state.visual_mode {
955                    view_state.start_visual_selection();
956                }
957            }
958        } else {
959            // Clear selection when moving without shift
960            if let Some(view_state) = self
961                .active_window_mut()
962                .composite_view_states
963                .get_mut(&(split_id, buffer_id))
964            {
965                if view_state.visual_mode {
966                    view_state.clear_selection();
967                }
968            }
969        }
970
971        self.apply_cursor_movement(split_id, buffer_id, movement, &line_info, viewport_height);
972        self.active_window_mut()
973            .sync_editor_cursor_from_composite(split_id, buffer_id);
974
975        Some(true)
976    }
977
978    /// Handle an action for a composite buffer.
979    ///
980    /// For navigation and selection actions, this forwards to the focused source buffer
981    /// and syncs scroll between panes. Returns Some(true) if handled, None to fall through
982    /// to normal buffer handling.
983    pub fn handle_composite_action(
984        &mut self,
985        buffer_id: BufferId,
986        action: &crate::input::keybindings::Action,
987    ) -> Option<bool> {
988        use crate::input::keybindings::Action;
989
990        let split_id = self
991            .windows
992            .get(&self.active_window)
993            .and_then(|w| w.buffers.splits())
994            .map(|(mgr, _)| mgr)
995            .expect("active window must have a populated split layout")
996            .active_split();
997
998        // Verify this is a valid composite buffer
999        let _composite = self.active_window().composite_buffers.get(&buffer_id)?;
1000        let _view_state = self
1001            .active_window()
1002            .composite_view_states
1003            .get(&(split_id, buffer_id))?;
1004
1005        match action {
1006            // Tab switches between panes
1007            Action::InsertTab => {
1008                self.active_window_mut()
1009                    .composite_focus_next(split_id, buffer_id);
1010                Some(true)
1011            }
1012
1013            // Copy from the focused pane
1014            Action::Copy => {
1015                self.handle_composite_copy(split_id, buffer_id);
1016                Some(true)
1017            }
1018
1019            // Cursor movement (without selection)
1020            Action::MoveDown => {
1021                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, false)
1022            }
1023            Action::MoveUp => {
1024                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, false)
1025            }
1026            Action::MoveLeft => {
1027                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
1028            }
1029            Action::MoveRight => self.handle_cursor_movement_action(
1030                split_id,
1031                buffer_id,
1032                CursorMovement::Right,
1033                false,
1034            ),
1035            Action::MoveLineStart | Action::SmartHome => self.handle_cursor_movement_action(
1036                split_id,
1037                buffer_id,
1038                CursorMovement::LineStart,
1039                false,
1040            ),
1041            Action::MoveLineEnd => self.handle_cursor_movement_action(
1042                split_id,
1043                buffer_id,
1044                CursorMovement::LineEnd,
1045                false,
1046            ),
1047            Action::MoveWordLeft => self.handle_cursor_movement_action(
1048                split_id,
1049                buffer_id,
1050                CursorMovement::WordLeft,
1051                false,
1052            ),
1053            Action::MoveWordRight => self.handle_cursor_movement_action(
1054                split_id,
1055                buffer_id,
1056                CursorMovement::WordRight,
1057                false,
1058            ),
1059            Action::MoveWordEnd | Action::ViMoveWordEnd => self.handle_cursor_movement_action(
1060                split_id,
1061                buffer_id,
1062                CursorMovement::WordEnd,
1063                false,
1064            ),
1065            Action::MoveLeftInLine => {
1066                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
1067            }
1068            Action::MoveRightInLine => self.handle_cursor_movement_action(
1069                split_id,
1070                buffer_id,
1071                CursorMovement::Right,
1072                false,
1073            ),
1074
1075            // Cursor movement with selection
1076            Action::SelectDown => {
1077                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, true)
1078            }
1079            Action::SelectUp => {
1080                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, true)
1081            }
1082            Action::SelectLeft => {
1083                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, true)
1084            }
1085            Action::SelectRight => {
1086                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Right, true)
1087            }
1088            Action::SelectLineStart => self.handle_cursor_movement_action(
1089                split_id,
1090                buffer_id,
1091                CursorMovement::LineStart,
1092                true,
1093            ),
1094            Action::SelectLineEnd => self.handle_cursor_movement_action(
1095                split_id,
1096                buffer_id,
1097                CursorMovement::LineEnd,
1098                true,
1099            ),
1100            Action::SelectWordLeft => self.handle_cursor_movement_action(
1101                split_id,
1102                buffer_id,
1103                CursorMovement::WordLeft,
1104                true,
1105            ),
1106            Action::SelectWordRight => self.handle_cursor_movement_action(
1107                split_id,
1108                buffer_id,
1109                CursorMovement::WordRight,
1110                true,
1111            ),
1112            Action::SelectWordEnd | Action::ViSelectWordEnd => self.handle_cursor_movement_action(
1113                split_id,
1114                buffer_id,
1115                CursorMovement::WordEnd,
1116                true,
1117            ),
1118
1119            // Page navigation
1120            Action::MovePageDown | Action::MovePageUp => {
1121                let viewport_height = self.active_window().get_composite_viewport_height(split_id);
1122                let win = self.active_window_mut();
1123                if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
1124                {
1125                    if matches!(action, Action::MovePageDown) {
1126                        if let Some(composite) = win.composite_buffers.get(&buffer_id) {
1127                            let max_row = composite.row_count().saturating_sub(1);
1128                            view_state.page_down(viewport_height, max_row);
1129                            view_state.cursor_row = view_state.scroll_row;
1130                        }
1131                    } else {
1132                        view_state.page_up(viewport_height);
1133                        view_state.cursor_row = view_state.scroll_row;
1134                    }
1135                }
1136                self.active_window_mut()
1137                    .sync_editor_cursor_from_composite(split_id, buffer_id);
1138                Some(true)
1139            }
1140
1141            // Document start/end
1142            Action::MoveDocumentStart | Action::MoveDocumentEnd => {
1143                let viewport_height = self.active_window().get_composite_viewport_height(split_id);
1144                let win = self.active_window_mut();
1145                if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
1146                {
1147                    if matches!(action, Action::MoveDocumentStart) {
1148                        view_state.move_cursor_to_top();
1149                    } else if let Some(composite) = win.composite_buffers.get(&buffer_id) {
1150                        let max_row = composite.row_count().saturating_sub(1);
1151                        view_state.move_cursor_to_bottom(max_row, viewport_height);
1152                    }
1153                }
1154                self.active_window_mut()
1155                    .sync_editor_cursor_from_composite(split_id, buffer_id);
1156                Some(true)
1157            }
1158
1159            // Scroll without moving cursor
1160            Action::ScrollDown | Action::ScrollUp => {
1161                let delta = if matches!(action, Action::ScrollDown) {
1162                    1
1163                } else {
1164                    -1
1165                };
1166                self.active_window_mut()
1167                    .composite_scroll(split_id, buffer_id, delta);
1168                Some(true)
1169            }
1170
1171            // For other actions, return None to fall through to normal handling
1172            _ => None,
1173        }
1174    }
1175
1176    /// Handle copy action for composite buffer
1177    fn handle_composite_copy(&mut self, split_id: LeafId, buffer_id: BufferId) {
1178        let text = {
1179            let composite = match self.active_window().composite_buffers.get(&buffer_id) {
1180                Some(c) => c,
1181                None => return,
1182            };
1183            let view_state = match self
1184                .active_window()
1185                .composite_view_states
1186                .get(&(split_id, buffer_id))
1187            {
1188                Some(vs) => vs,
1189                None => return,
1190            };
1191
1192            let (start_row, end_row) = match view_state.selection_row_range() {
1193                Some(range) => range,
1194                None => return,
1195            };
1196
1197            let source = match composite.sources.get(view_state.focused_pane) {
1198                Some(s) => s,
1199                None => return,
1200            };
1201
1202            let source_state = match self
1203                .windows
1204                .get(&self.active_window)
1205                .map(|w| &w.buffers)
1206                .expect("active window present")
1207                .get(&source.buffer_id)
1208            {
1209                Some(s) => s,
1210                None => return,
1211            };
1212
1213            // Collect text from selected rows
1214            let mut text = String::new();
1215            for row in start_row..=end_row {
1216                if let Some(aligned_row) = composite.alignment.rows.get(row) {
1217                    if let Some(line_ref) = aligned_row.get_pane_line(view_state.focused_pane) {
1218                        if let Some(line_bytes) = source_state.buffer.get_line(line_ref.line) {
1219                            if !text.is_empty() {
1220                                text.push('\n');
1221                            }
1222                            // Strip trailing newline from line content to avoid double newlines
1223                            let line_str = String::from_utf8_lossy(&line_bytes);
1224                            let line_trimmed = line_str.trim_end_matches(&['\n', '\r'][..]);
1225                            text.push_str(line_trimmed);
1226                        }
1227                    }
1228                }
1229            }
1230            text
1231        };
1232
1233        if !text.is_empty() {
1234            self.clipboard.copy(text);
1235        }
1236
1237        // Don't clear selection after copy - user may want to continue working with it
1238    }
1239
1240    // =========================================================================
1241    // Plugin Command Handlers
1242    // =========================================================================
1243
1244    /// Handle the CreateCompositeBuffer plugin command
1245    #[cfg(feature = "plugins")]
1246    pub(crate) fn handle_create_composite_buffer(
1247        &mut self,
1248        name: String,
1249        mode: String,
1250        layout_config: fresh_core::api::CompositeLayoutConfig,
1251        source_configs: Vec<fresh_core::api::CompositeSourceConfig>,
1252        hunks: Option<Vec<fresh_core::api::CompositeHunk>>,
1253        initial_focus_hunk: Option<usize>,
1254        _request_id: Option<u64>,
1255    ) {
1256        use crate::model::composite_buffer::{
1257            CompositeLayout, DiffHunk, GutterStyle, LineAlignment, PaneStyle, SourcePane,
1258        };
1259
1260        // Convert layout config
1261        let layout = match layout_config.layout_type.as_str() {
1262            "stacked" => CompositeLayout::Stacked {
1263                spacing: layout_config.spacing.unwrap_or(1),
1264            },
1265            "unified" => CompositeLayout::Unified,
1266            _ => CompositeLayout::SideBySide {
1267                ratios: layout_config.ratios.unwrap_or_else(|| vec![0.5, 0.5]),
1268                show_separator: layout_config.show_separator,
1269            },
1270        };
1271
1272        // Convert source configs
1273        let sources: Vec<SourcePane> = source_configs
1274            .into_iter()
1275            .map(|src| {
1276                let mut pane = SourcePane::new(BufferId(src.buffer_id), src.label, src.editable);
1277                if let Some(style_config) = src.style {
1278                    let gutter_style = match style_config.gutter_style.as_deref() {
1279                        Some("diff-markers") => GutterStyle::DiffMarkers,
1280                        Some("both") => GutterStyle::Both,
1281                        Some("none") => GutterStyle::None,
1282                        _ => GutterStyle::LineNumbers,
1283                    };
1284                    // Convert [u8; 3] arrays to (u8, u8, u8) tuples
1285                    let to_tuple = |arr: [u8; 3]| (arr[0], arr[1], arr[2]);
1286                    pane.style = PaneStyle {
1287                        add_bg: style_config.add_bg.map(to_tuple),
1288                        remove_bg: style_config.remove_bg.map(to_tuple),
1289                        modify_bg: style_config.modify_bg.map(to_tuple),
1290                        gutter_style,
1291                    };
1292                }
1293                pane
1294            })
1295            .collect();
1296
1297        // Create the composite buffer
1298        let buffer_id = self.create_composite_buffer(name.clone(), mode.clone(), layout, sources);
1299
1300        // Set alignment from hunks if provided
1301        if let Some(hunk_configs) = hunks {
1302            let diff_hunks: Vec<DiffHunk> = hunk_configs
1303                .into_iter()
1304                .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1305                .collect();
1306
1307            // Get line counts from source buffers
1308            let old_line_count = self
1309                .buffers()
1310                .get(
1311                    &self
1312                        .active_window()
1313                        .composite_buffers
1314                        .get(&buffer_id)
1315                        .unwrap()
1316                        .sources[0]
1317                        .buffer_id,
1318                )
1319                .and_then(|s| s.buffer.line_count())
1320                .unwrap_or(0);
1321            let new_line_count = self
1322                .buffers()
1323                .get(
1324                    &self
1325                        .active_window()
1326                        .composite_buffers
1327                        .get(&buffer_id)
1328                        .unwrap()
1329                        .sources[1]
1330                        .buffer_id,
1331                )
1332                .and_then(|s| s.buffer.line_count())
1333                .unwrap_or(0);
1334
1335            let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1336            self.active_window_mut()
1337                .set_composite_alignment(buffer_id, alignment);
1338        }
1339
1340        // Store initial focus hunk for the first render to apply
1341        if initial_focus_hunk.is_some() {
1342            if let Some(composite) = self
1343                .active_window_mut()
1344                .composite_buffers
1345                .get_mut(&buffer_id)
1346            {
1347                composite.initial_focus_hunk = initial_focus_hunk;
1348            }
1349        }
1350
1351        tracing::info!(
1352            "Created composite buffer '{}' with mode '{}' (id={:?})",
1353            name,
1354            mode,
1355            buffer_id
1356        );
1357
1358        // Resolve callback with buffer_id if request_id is provided
1359        if let Some(req_id) = _request_id {
1360            // Return just the buffer ID as a number (consistent with createVirtualBuffer)
1361            let result = buffer_id.0.to_string();
1362            self.plugin_manager
1363                .read()
1364                .unwrap()
1365                .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), result);
1366            tracing::info!(
1367                "CreateCompositeBuffer: resolve_callback sent for request_id={}",
1368                req_id
1369            );
1370        }
1371    }
1372
1373    /// Handle the UpdateCompositeAlignment plugin command
1374    #[cfg(feature = "plugins")]
1375    pub(crate) fn handle_update_composite_alignment(
1376        &mut self,
1377        buffer_id: BufferId,
1378        hunk_configs: Vec<fresh_core::api::CompositeHunk>,
1379    ) {
1380        use crate::model::composite_buffer::{DiffHunk, LineAlignment};
1381
1382        if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1383            let diff_hunks: Vec<DiffHunk> = hunk_configs
1384                .into_iter()
1385                .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1386                .collect();
1387
1388            // Get line counts from source buffers
1389            let old_line_count = self
1390                .buffers()
1391                .get(&composite.sources[0].buffer_id)
1392                .and_then(|s| s.buffer.line_count())
1393                .unwrap_or(0);
1394            let new_line_count = self
1395                .buffers()
1396                .get(&composite.sources[1].buffer_id)
1397                .and_then(|s| s.buffer.line_count())
1398                .unwrap_or(0);
1399
1400            let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1401            self.active_window_mut()
1402                .set_composite_alignment(buffer_id, alignment);
1403        }
1404    }
1405
1406    /// Handle a mouse click in a composite buffer view
1407    pub(crate) fn handle_composite_click(
1408        &mut self,
1409        col: u16,
1410        row: u16,
1411        split_id: LeafId,
1412        buffer_id: BufferId,
1413        content_rect: ratatui::layout::Rect,
1414    ) -> AnyhowResult<()> {
1415        // Calculate which pane was clicked based on x coordinate
1416        let pane_idx = if let Some(view_state) = self
1417            .active_window()
1418            .composite_view_states
1419            .get(&(split_id, buffer_id))
1420        {
1421            let mut x = content_rect.x;
1422            let mut found_pane = 0;
1423            for (i, &width) in view_state.pane_widths.iter().enumerate() {
1424                if col >= x && col < x + width {
1425                    found_pane = i;
1426                    break;
1427                }
1428                x += width + 1; // +1 for separator
1429            }
1430            found_pane
1431        } else {
1432            0
1433        };
1434
1435        // Calculate the clicked row (relative to scroll position)
1436        // Subtract 1 for the header row ("OLD (HEAD)" / "NEW (Working)")
1437        let content_row = row.saturating_sub(content_rect.y).saturating_sub(1) as usize;
1438
1439        // Calculate column within the pane (accounting for gutter and horizontal scroll)
1440        let (pane_start_x, left_column) = if let Some(view_state) = self
1441            .active_window()
1442            .composite_view_states
1443            .get(&(split_id, buffer_id))
1444        {
1445            let mut x = content_rect.x;
1446            for (i, &width) in view_state.pane_widths.iter().enumerate() {
1447                if i == pane_idx {
1448                    break;
1449                }
1450                x += width + 1;
1451            }
1452            let left_col = view_state
1453                .pane_viewports
1454                .get(pane_idx)
1455                .map(|vp| vp.left_column)
1456                .unwrap_or(0);
1457            (x, left_col)
1458        } else {
1459            (content_rect.x, 0)
1460        };
1461        let gutter_width = 4; // Line number width
1462        let visual_col = col
1463            .saturating_sub(pane_start_x)
1464            .saturating_sub(gutter_width) as usize;
1465        // Convert visual column to actual column by adding horizontal scroll offset
1466        let click_col = left_column + visual_col;
1467
1468        // Get line length to clamp cursor position
1469        let display_row = if let Some(view_state) = self
1470            .active_window()
1471            .composite_view_states
1472            .get(&(split_id, buffer_id))
1473        {
1474            view_state.scroll_row + content_row
1475        } else {
1476            content_row
1477        };
1478
1479        let line_length =
1480            if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1481                composite
1482                    .alignment
1483                    .get_row(display_row)
1484                    .and_then(|row| row.get_pane_line(pane_idx))
1485                    .and_then(|line_ref| {
1486                        let source = composite.sources.get(pane_idx)?;
1487                        self.windows
1488                            .get(&self.active_window)
1489                            .map(|w| &w.buffers)
1490                            .expect("active window present")
1491                            .get(&source.buffer_id)?
1492                            .buffer
1493                            .get_line(line_ref.line)
1494                    })
1495                    .map(|bytes| {
1496                        let s = String::from_utf8_lossy(&bytes);
1497                        // Strip trailing newline - cursor shouldn't go past end of visible content
1498                        let trimmed = s.trim_end_matches('\n').trim_end_matches('\r');
1499                        trimmed.graphemes(true).count()
1500                    })
1501                    .unwrap_or(0)
1502            } else {
1503                0
1504            };
1505
1506        // Clamp click column to line length
1507        let clamped_col = click_col.min(line_length);
1508
1509        // Update composite buffer's active pane
1510        if let Some(composite) = self
1511            .active_window_mut()
1512            .composite_buffers
1513            .get_mut(&buffer_id)
1514        {
1515            composite.active_pane = pane_idx;
1516        }
1517
1518        // Update composite view state with click position
1519        if let Some(view_state) = self
1520            .active_window_mut()
1521            .composite_view_states
1522            .get_mut(&(split_id, buffer_id))
1523        {
1524            view_state.focused_pane = pane_idx;
1525            view_state.cursor_row = display_row;
1526            view_state.cursor_column = clamped_col;
1527            view_state.sticky_column = clamped_col;
1528
1529            // Clear selection on click (will start fresh selection on drag)
1530            view_state.clear_selection();
1531        }
1532
1533        // Store state for potential text selection drag
1534        self.active_window_mut().mouse_state.dragging_text_selection = false; // Disable regular text selection for composite
1535        self.active_window_mut().mouse_state.drag_selection_split = Some(split_id);
1536
1537        // Sync cursor position to EditorState for status bar display
1538        self.active_window_mut()
1539            .sync_editor_cursor_from_composite(split_id, buffer_id);
1540
1541        Ok(())
1542    }
1543}