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 Editor {
127    // =========================================================================
128    // Layout Flush (synchronous state materialization)
129    // =========================================================================
130
131    /// Force-materialize render-dependent state for all visible splits.
132    ///
133    /// This is the editor's equivalent of iOS `layoutIfNeeded()` or browser
134    /// forced reflow. It ensures that `CompositeViewState` entries exist for
135    /// any visible composite buffer, using the split's current viewport
136    /// dimensions. After calling this, commands like `compositeNextHunk` can
137    /// safely read and modify view state that would otherwise only exist after
138    /// the next render cycle.
139    pub fn flush_layout(&mut self) {
140        use crate::view::composite_view::CompositeViewState;
141
142        let visible = self
143            .split_manager
144            .get_visible_buffers(ratatui::layout::Rect {
145                x: 0,
146                y: 0,
147                width: self.terminal_width,
148                height: self.terminal_height,
149            });
150
151        for (split_id, buffer_id, _area) in &visible {
152            // Only process composite buffers
153            if let Some(composite) = self.composite_buffers.get(buffer_id) {
154                let pane_count = composite.pane_count();
155                self.composite_view_states
156                    .entry((*split_id, *buffer_id))
157                    .or_insert_with(|| CompositeViewState::new(*buffer_id, pane_count));
158            }
159        }
160    }
161
162    // =========================================================================
163    // Composite Buffer Methods
164    // =========================================================================
165
166    /// Check if a buffer is a composite buffer
167    pub fn is_composite_buffer(&self, buffer_id: BufferId) -> bool {
168        self.composite_buffers.contains_key(&buffer_id)
169    }
170
171    /// Get a composite buffer by ID
172    pub fn get_composite(&self, buffer_id: BufferId) -> Option<&CompositeBuffer> {
173        self.composite_buffers.get(&buffer_id)
174    }
175
176    /// Get a mutable composite buffer by ID
177    pub fn get_composite_mut(&mut self, buffer_id: BufferId) -> Option<&mut CompositeBuffer> {
178        self.composite_buffers.get_mut(&buffer_id)
179    }
180
181    /// Get or create composite view state for a split
182    pub fn get_composite_view_state(
183        &mut self,
184        split_id: LeafId,
185        buffer_id: BufferId,
186    ) -> Option<&mut CompositeViewState> {
187        if !self.composite_buffers.contains_key(&buffer_id) {
188            return None;
189        }
190
191        let pane_count = self.composite_buffers.get(&buffer_id)?.pane_count();
192
193        Some(
194            self.composite_view_states
195                .entry((split_id, buffer_id))
196                .or_insert_with(|| CompositeViewState::new(buffer_id, pane_count)),
197        )
198    }
199
200    /// Create a new composite buffer
201    ///
202    /// # Arguments
203    /// * `name` - Display name for the composite buffer (shown in tab)
204    /// * `mode` - Mode for keybindings (e.g., "diff-view")
205    /// * `layout` - How panes are arranged (side-by-side, stacked, unified)
206    /// * `sources` - Source panes to display
207    ///
208    /// # Returns
209    /// The ID of the newly created composite buffer
210    pub fn create_composite_buffer(
211        &mut self,
212        name: String,
213        mode: String,
214        layout: CompositeLayout,
215        sources: Vec<SourcePane>,
216    ) -> BufferId {
217        let buffer_id = BufferId(self.next_buffer_id);
218        self.next_buffer_id += 1;
219
220        let composite =
221            CompositeBuffer::new(buffer_id, name.clone(), mode.clone(), layout, sources);
222        self.composite_buffers.insert(buffer_id, composite);
223
224        // Add metadata for display
225        // Note: We use virtual_buffer() but override hidden_from_tabs since composite buffers
226        // should be visible in tabs (unlike their hidden source panes)
227        let mut metadata = BufferMetadata::virtual_buffer(name.clone(), mode.clone(), true);
228        metadata.hidden_from_tabs = false;
229        self.buffer_metadata.insert(buffer_id, metadata);
230
231        // Create an EditorState entry so the buffer can be shown in tabs and via showBuffer()
232        // The actual content rendering is handled by the composite renderer
233        let mut state = crate::state::EditorState::new(
234            80,
235            24,
236            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
237            std::sync::Arc::clone(&self.filesystem),
238        );
239        state.is_composite_buffer = true;
240        state.editing_disabled = true;
241        state.mode = mode;
242        self.buffers.insert(buffer_id, state);
243
244        // Create an event log entry (required for many editor operations)
245        self.event_logs
246            .insert(buffer_id, crate::model::event::EventLog::new());
247
248        // Register with the active split so it appears in tabs
249        let split_id = self.split_manager.active_split();
250        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
251            view_state.add_buffer(buffer_id);
252        }
253
254        buffer_id
255    }
256
257    /// Set the line alignment for a composite buffer
258    ///
259    /// The alignment determines how lines from different source buffers
260    /// are paired up for display (important for diff views).
261    pub fn set_composite_alignment(&mut self, buffer_id: BufferId, alignment: LineAlignment) {
262        if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
263            composite.set_alignment(alignment);
264        }
265    }
266
267    /// Close a composite buffer and clean up associated state
268    pub fn close_composite_buffer(&mut self, buffer_id: BufferId) {
269        self.composite_buffers.remove(&buffer_id);
270        self.buffer_metadata.remove(&buffer_id);
271
272        // Remove all view states for this buffer
273        self.composite_view_states
274            .retain(|(_, bid), _| *bid != buffer_id);
275    }
276
277    /// Switch focus to the next pane in a composite buffer
278    pub fn composite_focus_next(&mut self, split_id: LeafId, buffer_id: BufferId) {
279        if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
280            composite.focus_next();
281        }
282        // Also update the view state's focused_pane (used by renderer)
283        if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
284            view_state.focus_next_pane();
285        }
286    }
287
288    /// Switch focus to the previous pane in a composite buffer
289    pub fn composite_focus_prev(&mut self, split_id: LeafId, buffer_id: BufferId) {
290        if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
291            composite.focus_prev();
292        }
293        // Also update the view state's focused_pane (used by renderer)
294        if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
295            view_state.focus_prev_pane();
296        }
297    }
298
299    /// Navigate to the next hunk using the active split.
300    pub fn composite_next_hunk_active(&mut self, buffer_id: BufferId) -> bool {
301        let split_id = self.split_manager.active_split();
302        self.composite_next_hunk(split_id, buffer_id)
303    }
304
305    /// Navigate to the previous hunk using the active split.
306    pub fn composite_prev_hunk_active(&mut self, buffer_id: BufferId) -> bool {
307        let split_id = self.split_manager.active_split();
308        self.composite_prev_hunk(split_id, buffer_id)
309    }
310
311    /// Navigate to the next hunk in a composite buffer's diff view.
312    /// Centers the hunk header in the viewport and moves the cursor to it.
313    pub fn composite_next_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
314        let viewport_height = self.get_composite_viewport_height(split_id);
315        let moved = if let (Some(composite), Some(view_state)) = (
316            self.composite_buffers.get(&buffer_id),
317            self.composite_view_states.get_mut(&(split_id, buffer_id)),
318        ) {
319            // Search from cursor position (not scroll position) to avoid
320            // finding the same hunk when scroll is offset for centering
321            if let Some(next_row) = composite.alignment.next_hunk_row(view_state.cursor_row) {
322                view_state.cursor_row = next_row;
323                // Scroll so the hunk header is ~1/3 from the top of the viewport
324                let context_above = viewport_height / 3;
325                view_state.scroll_row = next_row.saturating_sub(context_above);
326                true
327            } else {
328                false
329            }
330        } else {
331            false
332        };
333        // Keep the EditorState / split-view cursor in lockstep with the
334        // composite view so the status bar's Ln/Col reflects the new row.
335        // Arrow keys do this via handle_cursor_movement_action; hunk
336        // navigation used to skip it, leaving "Ln 1" stale after n/p.
337        if moved {
338            self.sync_editor_cursor_from_composite(split_id, buffer_id);
339        }
340        moved
341    }
342
343    /// Navigate to the previous hunk in a composite buffer's diff view.
344    /// Centers the hunk header in the viewport and moves the cursor to it.
345    pub fn composite_prev_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
346        let viewport_height = self.get_composite_viewport_height(split_id);
347        let moved = if let (Some(composite), Some(view_state)) = (
348            self.composite_buffers.get(&buffer_id),
349            self.composite_view_states.get_mut(&(split_id, buffer_id)),
350        ) {
351            if let Some(prev_row) = composite.alignment.prev_hunk_row(view_state.cursor_row) {
352                view_state.cursor_row = prev_row;
353                let context_above = viewport_height / 3;
354                view_state.scroll_row = prev_row.saturating_sub(context_above);
355                true
356            } else {
357                false
358            }
359        } else {
360            false
361        };
362        if moved {
363            self.sync_editor_cursor_from_composite(split_id, buffer_id);
364        }
365        moved
366    }
367
368    /// Scroll a composite buffer view
369    pub fn composite_scroll(&mut self, split_id: LeafId, buffer_id: BufferId, delta: isize) {
370        if let (Some(composite), Some(view_state)) = (
371            self.composite_buffers.get(&buffer_id),
372            self.composite_view_states.get_mut(&(split_id, buffer_id)),
373        ) {
374            let max_row = composite.row_count().saturating_sub(1);
375            view_state.scroll(delta, max_row);
376        }
377    }
378
379    /// Scroll composite buffer to a specific row
380    pub fn composite_scroll_to(&mut self, split_id: LeafId, buffer_id: BufferId, row: usize) {
381        if let (Some(composite), Some(view_state)) = (
382            self.composite_buffers.get(&buffer_id),
383            self.composite_view_states.get_mut(&(split_id, buffer_id)),
384        ) {
385            let max_row = composite.row_count().saturating_sub(1);
386            view_state.set_scroll_row(row, max_row);
387        }
388    }
389
390    // =========================================================================
391    // Action Handling for Composite Buffers
392    // =========================================================================
393
394    /// Get the effective viewport height for composite buffer scrolling.
395    /// This accounts for the composite header row showing pane labels (e.g., "OLD (HEAD)" / "NEW (Working)")
396    fn get_composite_viewport_height(&self, split_id: LeafId) -> usize {
397        const COMPOSITE_HEADER_HEIGHT: u16 = 1;
398        const DEFAULT_VIEWPORT_HEIGHT: usize = 24;
399
400        self.split_view_states
401            .get(&split_id)
402            .map(|vs| vs.viewport.height.saturating_sub(COMPOSITE_HEADER_HEIGHT) as usize)
403            .unwrap_or(DEFAULT_VIEWPORT_HEIGHT)
404    }
405
406    /// Get information about the line at the cursor position
407    fn get_cursor_line_info(&self, split_id: LeafId, buffer_id: BufferId) -> CursorLineInfo {
408        let composite = self.composite_buffers.get(&buffer_id);
409        let view_state = self.composite_view_states.get(&(split_id, buffer_id));
410
411        if let (Some(composite), Some(view_state)) = (composite, view_state) {
412            let pane_line = composite
413                .alignment
414                .get_row(view_state.cursor_row)
415                .and_then(|row| row.get_pane_line(view_state.focused_pane));
416
417            tracing::debug!(
418                "get_cursor_line_info: cursor_row={}, focused_pane={}, pane_line={:?}",
419                view_state.cursor_row,
420                view_state.focused_pane,
421                pane_line
422            );
423
424            let line_bytes = pane_line.and_then(|line_ref| {
425                let source = composite.sources.get(view_state.focused_pane)?;
426                self.buffers
427                    .get(&source.buffer_id)?
428                    .buffer
429                    .get_line(line_ref.line)
430            });
431
432            let content = line_bytes
433                .as_ref()
434                .map(|b| {
435                    let s = String::from_utf8_lossy(b).to_string();
436                    // Strip trailing newline - cursor shouldn't go past end of visible content
437                    s.trim_end_matches('\n').trim_end_matches('\r').to_string()
438                })
439                .unwrap_or_default();
440            let length = content.graphemes(true).count();
441            let pane_width = view_state
442                .pane_widths
443                .get(view_state.focused_pane)
444                .copied()
445                .unwrap_or(40) as usize;
446
447            CursorLineInfo {
448                content,
449                length,
450                pane_width,
451            }
452        } else {
453            CursorLineInfo {
454                content: String::new(),
455                length: 0,
456                pane_width: 40,
457            }
458        }
459    }
460
461    /// Apply a cursor movement to a composite view state
462    fn apply_cursor_movement(
463        &mut self,
464        split_id: LeafId,
465        buffer_id: BufferId,
466        movement: CursorMovement,
467        line_info: &CursorLineInfo,
468        viewport_height: usize,
469    ) {
470        let max_row = self
471            .composite_buffers
472            .get(&buffer_id)
473            .map(|c| c.row_count().saturating_sub(1))
474            .unwrap_or(0);
475
476        let is_vertical = matches!(movement, CursorMovement::Up | CursorMovement::Down);
477        let mut wrapped_to_new_line = false;
478
479        // Get alignment reference for wrap checks
480        let composite = self.composite_buffers.get(&buffer_id);
481
482        if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
483            match movement {
484                CursorMovement::Down => {
485                    view_state.move_cursor_down(max_row, viewport_height);
486                }
487                CursorMovement::Up => {
488                    view_state.move_cursor_up(viewport_height);
489                }
490                CursorMovement::Left => {
491                    if view_state.cursor_column > 0 {
492                        view_state.move_cursor_left();
493                    } else if view_state.cursor_row > 0 {
494                        // Try to wrap to end of previous line - find a row with content
495                        if let Some(composite) = composite {
496                            let focused_pane = view_state.focused_pane;
497                            let mut target_row = view_state.cursor_row - 1;
498                            while target_row > 0 {
499                                if let Some(row) = composite.alignment.get_row(target_row) {
500                                    if row.get_pane_line(focused_pane).is_some() {
501                                        break;
502                                    }
503                                }
504                                target_row -= 1;
505                            }
506                            // Only wrap if target row has content
507                            if let Some(row) = composite.alignment.get_row(target_row) {
508                                if row.get_pane_line(focused_pane).is_some() {
509                                    view_state.cursor_row = target_row;
510                                    if view_state.cursor_row < view_state.scroll_row {
511                                        view_state.scroll_row = view_state.cursor_row;
512                                    }
513                                    wrapped_to_new_line = true;
514                                }
515                            }
516                        }
517                    }
518                }
519                CursorMovement::Right => {
520                    if view_state.cursor_column < line_info.length {
521                        view_state.move_cursor_right(line_info.length, line_info.pane_width);
522                    } else if view_state.cursor_row < max_row {
523                        // Try to wrap to start of next line - find a row with content
524                        if let Some(composite) = composite {
525                            let focused_pane = view_state.focused_pane;
526                            let mut target_row = view_state.cursor_row + 1;
527                            while target_row < max_row {
528                                if let Some(row) = composite.alignment.get_row(target_row) {
529                                    if row.get_pane_line(focused_pane).is_some() {
530                                        break;
531                                    }
532                                }
533                                target_row += 1;
534                            }
535                            // Only wrap if target row has content
536                            if let Some(row) = composite.alignment.get_row(target_row) {
537                                if row.get_pane_line(focused_pane).is_some() {
538                                    view_state.cursor_row = target_row;
539                                    view_state.cursor_column = 0;
540                                    view_state.sticky_column = 0;
541                                    if view_state.cursor_row
542                                        >= view_state.scroll_row + viewport_height
543                                    {
544                                        view_state.scroll_row = view_state
545                                            .cursor_row
546                                            .saturating_sub(viewport_height - 1);
547                                    }
548                                    // Reset horizontal scroll for ALL panes
549                                    for viewport in &mut view_state.pane_viewports {
550                                        viewport.left_column = 0;
551                                    }
552                                }
553                            }
554                        }
555                    }
556                }
557                CursorMovement::LineStart => {
558                    view_state.move_cursor_to_line_start();
559                }
560                CursorMovement::LineEnd => {
561                    view_state.move_cursor_to_line_end(line_info.length, line_info.pane_width);
562                }
563                CursorMovement::WordLeft => {
564                    let new_col =
565                        find_word_boundary_left(&line_info.content, view_state.cursor_column);
566                    if new_col < view_state.cursor_column {
567                        view_state.cursor_column = new_col;
568                        view_state.sticky_column = new_col;
569                        // Update horizontal scroll for ALL panes to keep cursor visible
570                        let current_left = view_state
571                            .pane_viewports
572                            .get(view_state.focused_pane)
573                            .map(|v| v.left_column)
574                            .unwrap_or(0);
575                        if view_state.cursor_column < current_left {
576                            for viewport in &mut view_state.pane_viewports {
577                                viewport.left_column = view_state.cursor_column;
578                            }
579                        }
580                    } else if view_state.cursor_row > 0 {
581                        // At start of line, wrap to end of previous line - find a row with content
582                        if let Some(composite) = composite {
583                            let focused_pane = view_state.focused_pane;
584                            let mut target_row = view_state.cursor_row - 1;
585                            while target_row > 0 {
586                                if let Some(row) = composite.alignment.get_row(target_row) {
587                                    if row.get_pane_line(focused_pane).is_some() {
588                                        break;
589                                    }
590                                }
591                                target_row -= 1;
592                            }
593                            // Only wrap if target row has content
594                            if let Some(row) = composite.alignment.get_row(target_row) {
595                                if row.get_pane_line(focused_pane).is_some() {
596                                    view_state.cursor_row = target_row;
597                                    if view_state.cursor_row < view_state.scroll_row {
598                                        view_state.scroll_row = view_state.cursor_row;
599                                    }
600                                    wrapped_to_new_line = true;
601                                }
602                            }
603                        }
604                    }
605                }
606                CursorMovement::WordRight => {
607                    let new_col = find_word_boundary_right(
608                        &line_info.content,
609                        view_state.cursor_column,
610                        line_info.length,
611                    );
612                    if new_col > view_state.cursor_column {
613                        view_state.cursor_column = new_col;
614                        view_state.sticky_column = new_col;
615                        // Update horizontal scroll for ALL panes to keep cursor visible
616                        let visible_width = line_info.pane_width.saturating_sub(4);
617                        let current_left = view_state
618                            .pane_viewports
619                            .get(view_state.focused_pane)
620                            .map(|v| v.left_column)
621                            .unwrap_or(0);
622                        if visible_width > 0
623                            && view_state.cursor_column >= current_left + visible_width
624                        {
625                            let new_left = view_state
626                                .cursor_column
627                                .saturating_sub(visible_width.saturating_sub(1));
628                            for viewport in &mut view_state.pane_viewports {
629                                viewport.left_column = new_left;
630                            }
631                        }
632                    } else if view_state.cursor_row < max_row {
633                        // At end of line, wrap to start of next line - find a row with content
634                        if let Some(composite) = composite {
635                            let focused_pane = view_state.focused_pane;
636                            let mut target_row = view_state.cursor_row + 1;
637                            while target_row < max_row {
638                                if let Some(row) = composite.alignment.get_row(target_row) {
639                                    if row.get_pane_line(focused_pane).is_some() {
640                                        break;
641                                    }
642                                }
643                                target_row += 1;
644                            }
645                            // Only wrap if target row has content
646                            if let Some(row) = composite.alignment.get_row(target_row) {
647                                if row.get_pane_line(focused_pane).is_some() {
648                                    view_state.cursor_row = target_row;
649                                    view_state.cursor_column = 0;
650                                    view_state.sticky_column = 0;
651                                    if view_state.cursor_row
652                                        >= view_state.scroll_row + viewport_height
653                                    {
654                                        view_state.scroll_row = view_state
655                                            .cursor_row
656                                            .saturating_sub(viewport_height - 1);
657                                    }
658                                    // Reset horizontal scroll for ALL panes
659                                    for viewport in &mut view_state.pane_viewports {
660                                        viewport.left_column = 0;
661                                    }
662                                }
663                            }
664                        }
665                    }
666                }
667                CursorMovement::WordEnd => {
668                    let new_col = find_word_end_right(
669                        &line_info.content,
670                        view_state.cursor_column,
671                        line_info.length,
672                    );
673                    if new_col > view_state.cursor_column {
674                        view_state.cursor_column = new_col;
675                        view_state.sticky_column = new_col;
676                        // Update horizontal scroll for ALL panes to keep cursor visible
677                        let visible_width = line_info.pane_width.saturating_sub(4);
678                        let current_left = view_state
679                            .pane_viewports
680                            .get(view_state.focused_pane)
681                            .map(|v| v.left_column)
682                            .unwrap_or(0);
683                        if visible_width > 0
684                            && view_state.cursor_column >= current_left + visible_width
685                        {
686                            let new_left = view_state
687                                .cursor_column
688                                .saturating_sub(visible_width.saturating_sub(1));
689                            for viewport in &mut view_state.pane_viewports {
690                                viewport.left_column = new_left;
691                            }
692                        }
693                    } else if view_state.cursor_row < max_row {
694                        // At end of line, wrap to start of next line - find a row with content
695                        if let Some(composite) = composite {
696                            let focused_pane = view_state.focused_pane;
697                            let mut target_row = view_state.cursor_row + 1;
698                            while target_row < max_row {
699                                if let Some(row) = composite.alignment.get_row(target_row) {
700                                    if row.get_pane_line(focused_pane).is_some() {
701                                        break;
702                                    }
703                                }
704                                target_row += 1;
705                            }
706                            // Only wrap if target row has content
707                            if let Some(row) = composite.alignment.get_row(target_row) {
708                                if row.get_pane_line(focused_pane).is_some() {
709                                    view_state.cursor_row = target_row;
710                                    view_state.cursor_column = 0;
711                                    view_state.sticky_column = 0;
712                                    if view_state.cursor_row
713                                        >= view_state.scroll_row + viewport_height
714                                    {
715                                        view_state.scroll_row = view_state
716                                            .cursor_row
717                                            .saturating_sub(viewport_height - 1);
718                                    }
719                                    // Reset horizontal scroll for ALL panes
720                                    for viewport in &mut view_state.pane_viewports {
721                                        viewport.left_column = 0;
722                                    }
723                                }
724                            }
725                        }
726                    }
727                }
728            }
729        }
730
731        // For vertical movement or line wrap, get line info for the NEW row and clamp/set cursor column
732        if is_vertical || wrapped_to_new_line {
733            let new_line_info = self.get_cursor_line_info(split_id, buffer_id);
734            if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
735                if wrapped_to_new_line
736                    && matches!(movement, CursorMovement::Left | CursorMovement::WordLeft)
737                {
738                    // Wrapping left goes to end of previous line
739                    tracing::debug!(
740                        "Wrap left to row {}, setting column to line length {}",
741                        view_state.cursor_row,
742                        new_line_info.length
743                    );
744                    view_state.cursor_column = new_line_info.length;
745                    view_state.sticky_column = new_line_info.length;
746                    // Scroll ALL panes horizontally to show cursor at end of line
747                    let visible_width = new_line_info.pane_width.saturating_sub(4);
748                    if visible_width > 0 && view_state.cursor_column >= visible_width {
749                        let new_left = view_state
750                            .cursor_column
751                            .saturating_sub(visible_width.saturating_sub(1));
752                        for viewport in &mut view_state.pane_viewports {
753                            viewport.left_column = new_left;
754                        }
755                    }
756                } else {
757                    view_state.clamp_cursor_to_line(new_line_info.length);
758                }
759            }
760        }
761    }
762
763    /// Sync the EditorState cursor with CompositeViewState (for status bar display)
764    fn sync_editor_cursor_from_composite(&mut self, split_id: LeafId, buffer_id: BufferId) {
765        let (cursor_row, cursor_column, focused_pane) = self
766            .composite_view_states
767            .get(&(split_id, buffer_id))
768            .map(|vs| (vs.cursor_row, vs.cursor_column, vs.focused_pane))
769            .unwrap_or((0, 0, 0));
770
771        // Look up the actual file line number from the alignment
772        // The cursor_row is an alignment row index, which may include hunk headers
773        // We want to display the actual source file line number
774        let display_line = self
775            .composite_buffers
776            .get(&buffer_id)
777            .and_then(|composite| composite.alignment.get_row(cursor_row))
778            .and_then(|row| row.get_pane_line(focused_pane))
779            .map(|line_ref| line_ref.line)
780            .unwrap_or(cursor_row); // Fall back to cursor_row if no source line
781
782        if let Some(state) = self.buffers.get_mut(&buffer_id) {
783            state.primary_cursor_line_number =
784                crate::model::buffer::LineNumber::Absolute(display_line);
785        }
786        // Update cursor position in SplitViewState
787        let active_split = self.split_manager.active_split();
788        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
789            view_state.cursors.primary_mut().position = cursor_column;
790        }
791    }
792
793    /// Handle cursor movement actions (both Move and Select variants)
794    fn handle_cursor_movement_action(
795        &mut self,
796        split_id: LeafId,
797        buffer_id: BufferId,
798        movement: CursorMovement,
799        extend_selection: bool,
800    ) -> Option<bool> {
801        let viewport_height = self.get_composite_viewport_height(split_id);
802
803        let line_info = self.get_cursor_line_info(split_id, buffer_id);
804
805        if extend_selection {
806            // Start visual selection if extending and not already in visual mode
807            if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
808                if !view_state.visual_mode {
809                    view_state.start_visual_selection();
810                }
811            }
812        } else {
813            // Clear selection when moving without shift
814            if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
815                if view_state.visual_mode {
816                    view_state.clear_selection();
817                }
818            }
819        }
820
821        self.apply_cursor_movement(split_id, buffer_id, movement, &line_info, viewport_height);
822        self.sync_editor_cursor_from_composite(split_id, buffer_id);
823
824        Some(true)
825    }
826
827    /// Handle an action for a composite buffer.
828    ///
829    /// For navigation and selection actions, this forwards to the focused source buffer
830    /// and syncs scroll between panes. Returns Some(true) if handled, None to fall through
831    /// to normal buffer handling.
832    pub fn handle_composite_action(
833        &mut self,
834        buffer_id: BufferId,
835        action: &crate::input::keybindings::Action,
836    ) -> Option<bool> {
837        use crate::input::keybindings::Action;
838
839        let split_id = self.split_manager.active_split();
840
841        // Verify this is a valid composite buffer
842        let _composite = self.composite_buffers.get(&buffer_id)?;
843        let _view_state = self.composite_view_states.get(&(split_id, buffer_id))?;
844
845        match action {
846            // Tab switches between panes
847            Action::InsertTab => {
848                self.composite_focus_next(split_id, buffer_id);
849                Some(true)
850            }
851
852            // Copy from the focused pane
853            Action::Copy => {
854                self.handle_composite_copy(split_id, buffer_id);
855                Some(true)
856            }
857
858            // Cursor movement (without selection)
859            Action::MoveDown => {
860                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, false)
861            }
862            Action::MoveUp => {
863                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, false)
864            }
865            Action::MoveLeft => {
866                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
867            }
868            Action::MoveRight => self.handle_cursor_movement_action(
869                split_id,
870                buffer_id,
871                CursorMovement::Right,
872                false,
873            ),
874            Action::MoveLineStart | Action::SmartHome => self.handle_cursor_movement_action(
875                split_id,
876                buffer_id,
877                CursorMovement::LineStart,
878                false,
879            ),
880            Action::MoveLineEnd => self.handle_cursor_movement_action(
881                split_id,
882                buffer_id,
883                CursorMovement::LineEnd,
884                false,
885            ),
886            Action::MoveWordLeft => self.handle_cursor_movement_action(
887                split_id,
888                buffer_id,
889                CursorMovement::WordLeft,
890                false,
891            ),
892            Action::MoveWordRight => self.handle_cursor_movement_action(
893                split_id,
894                buffer_id,
895                CursorMovement::WordRight,
896                false,
897            ),
898            Action::MoveWordEnd | Action::ViMoveWordEnd => self.handle_cursor_movement_action(
899                split_id,
900                buffer_id,
901                CursorMovement::WordEnd,
902                false,
903            ),
904            Action::MoveLeftInLine => {
905                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
906            }
907            Action::MoveRightInLine => self.handle_cursor_movement_action(
908                split_id,
909                buffer_id,
910                CursorMovement::Right,
911                false,
912            ),
913
914            // Cursor movement with selection
915            Action::SelectDown => {
916                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, true)
917            }
918            Action::SelectUp => {
919                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, true)
920            }
921            Action::SelectLeft => {
922                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, true)
923            }
924            Action::SelectRight => {
925                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Right, true)
926            }
927            Action::SelectLineStart => self.handle_cursor_movement_action(
928                split_id,
929                buffer_id,
930                CursorMovement::LineStart,
931                true,
932            ),
933            Action::SelectLineEnd => self.handle_cursor_movement_action(
934                split_id,
935                buffer_id,
936                CursorMovement::LineEnd,
937                true,
938            ),
939            Action::SelectWordLeft => self.handle_cursor_movement_action(
940                split_id,
941                buffer_id,
942                CursorMovement::WordLeft,
943                true,
944            ),
945            Action::SelectWordRight => self.handle_cursor_movement_action(
946                split_id,
947                buffer_id,
948                CursorMovement::WordRight,
949                true,
950            ),
951            Action::SelectWordEnd | Action::ViSelectWordEnd => self.handle_cursor_movement_action(
952                split_id,
953                buffer_id,
954                CursorMovement::WordEnd,
955                true,
956            ),
957
958            // Page navigation
959            Action::MovePageDown | Action::MovePageUp => {
960                let viewport_height = self.get_composite_viewport_height(split_id);
961
962                if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id))
963                {
964                    if matches!(action, Action::MovePageDown) {
965                        if let Some(composite) = self.composite_buffers.get(&buffer_id) {
966                            let max_row = composite.row_count().saturating_sub(1);
967                            view_state.page_down(viewport_height, max_row);
968                            view_state.cursor_row = view_state.scroll_row;
969                        }
970                    } else {
971                        view_state.page_up(viewport_height);
972                        view_state.cursor_row = view_state.scroll_row;
973                    }
974                }
975                self.sync_editor_cursor_from_composite(split_id, buffer_id);
976                Some(true)
977            }
978
979            // Document start/end
980            Action::MoveDocumentStart | Action::MoveDocumentEnd => {
981                let viewport_height = self.get_composite_viewport_height(split_id);
982
983                if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id))
984                {
985                    if matches!(action, Action::MoveDocumentStart) {
986                        view_state.move_cursor_to_top();
987                    } else if let Some(composite) = self.composite_buffers.get(&buffer_id) {
988                        let max_row = composite.row_count().saturating_sub(1);
989                        view_state.move_cursor_to_bottom(max_row, viewport_height);
990                    }
991                }
992                self.sync_editor_cursor_from_composite(split_id, buffer_id);
993                Some(true)
994            }
995
996            // Scroll without moving cursor
997            Action::ScrollDown | Action::ScrollUp => {
998                let delta = if matches!(action, Action::ScrollDown) {
999                    1
1000                } else {
1001                    -1
1002                };
1003                self.composite_scroll(split_id, buffer_id, delta);
1004                Some(true)
1005            }
1006
1007            // For other actions, return None to fall through to normal handling
1008            _ => None,
1009        }
1010    }
1011
1012    /// Handle copy action for composite buffer
1013    fn handle_composite_copy(&mut self, split_id: LeafId, buffer_id: BufferId) {
1014        let text = {
1015            let composite = match self.composite_buffers.get(&buffer_id) {
1016                Some(c) => c,
1017                None => return,
1018            };
1019            let view_state = match self.composite_view_states.get(&(split_id, buffer_id)) {
1020                Some(vs) => vs,
1021                None => return,
1022            };
1023
1024            let (start_row, end_row) = match view_state.selection_row_range() {
1025                Some(range) => range,
1026                None => return,
1027            };
1028
1029            let source = match composite.sources.get(view_state.focused_pane) {
1030                Some(s) => s,
1031                None => return,
1032            };
1033
1034            let source_state = match self.buffers.get(&source.buffer_id) {
1035                Some(s) => s,
1036                None => return,
1037            };
1038
1039            // Collect text from selected rows
1040            let mut text = String::new();
1041            for row in start_row..=end_row {
1042                if let Some(aligned_row) = composite.alignment.rows.get(row) {
1043                    if let Some(line_ref) = aligned_row.get_pane_line(view_state.focused_pane) {
1044                        if let Some(line_bytes) = source_state.buffer.get_line(line_ref.line) {
1045                            if !text.is_empty() {
1046                                text.push('\n');
1047                            }
1048                            // Strip trailing newline from line content to avoid double newlines
1049                            let line_str = String::from_utf8_lossy(&line_bytes);
1050                            let line_trimmed = line_str.trim_end_matches(&['\n', '\r'][..]);
1051                            text.push_str(line_trimmed);
1052                        }
1053                    }
1054                }
1055            }
1056            text
1057        };
1058
1059        if !text.is_empty() {
1060            self.clipboard.copy(text);
1061        }
1062
1063        // Don't clear selection after copy - user may want to continue working with it
1064    }
1065
1066    // =========================================================================
1067    // Plugin Command Handlers
1068    // =========================================================================
1069
1070    /// Handle the CreateCompositeBuffer plugin command
1071    pub(crate) fn handle_create_composite_buffer(
1072        &mut self,
1073        name: String,
1074        mode: String,
1075        layout_config: fresh_core::api::CompositeLayoutConfig,
1076        source_configs: Vec<fresh_core::api::CompositeSourceConfig>,
1077        hunks: Option<Vec<fresh_core::api::CompositeHunk>>,
1078        initial_focus_hunk: Option<usize>,
1079        _request_id: Option<u64>,
1080    ) {
1081        use crate::model::composite_buffer::{
1082            CompositeLayout, DiffHunk, GutterStyle, LineAlignment, PaneStyle, SourcePane,
1083        };
1084
1085        // Convert layout config
1086        let layout = match layout_config.layout_type.as_str() {
1087            "stacked" => CompositeLayout::Stacked {
1088                spacing: layout_config.spacing.unwrap_or(1),
1089            },
1090            "unified" => CompositeLayout::Unified,
1091            _ => CompositeLayout::SideBySide {
1092                ratios: layout_config.ratios.unwrap_or_else(|| vec![0.5, 0.5]),
1093                show_separator: layout_config.show_separator,
1094            },
1095        };
1096
1097        // Convert source configs
1098        let sources: Vec<SourcePane> = source_configs
1099            .into_iter()
1100            .map(|src| {
1101                let mut pane = SourcePane::new(BufferId(src.buffer_id), src.label, src.editable);
1102                if let Some(style_config) = src.style {
1103                    let gutter_style = match style_config.gutter_style.as_deref() {
1104                        Some("diff-markers") => GutterStyle::DiffMarkers,
1105                        Some("both") => GutterStyle::Both,
1106                        Some("none") => GutterStyle::None,
1107                        _ => GutterStyle::LineNumbers,
1108                    };
1109                    // Convert [u8; 3] arrays to (u8, u8, u8) tuples
1110                    let to_tuple = |arr: [u8; 3]| (arr[0], arr[1], arr[2]);
1111                    pane.style = PaneStyle {
1112                        add_bg: style_config.add_bg.map(to_tuple),
1113                        remove_bg: style_config.remove_bg.map(to_tuple),
1114                        modify_bg: style_config.modify_bg.map(to_tuple),
1115                        gutter_style,
1116                    };
1117                }
1118                pane
1119            })
1120            .collect();
1121
1122        // Create the composite buffer
1123        let buffer_id = self.create_composite_buffer(name.clone(), mode.clone(), layout, sources);
1124
1125        // Set alignment from hunks if provided
1126        if let Some(hunk_configs) = hunks {
1127            let diff_hunks: Vec<DiffHunk> = hunk_configs
1128                .into_iter()
1129                .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1130                .collect();
1131
1132            // Get line counts from source buffers
1133            let old_line_count = self
1134                .buffers
1135                .get(&self.composite_buffers.get(&buffer_id).unwrap().sources[0].buffer_id)
1136                .and_then(|s| s.buffer.line_count())
1137                .unwrap_or(0);
1138            let new_line_count = self
1139                .buffers
1140                .get(&self.composite_buffers.get(&buffer_id).unwrap().sources[1].buffer_id)
1141                .and_then(|s| s.buffer.line_count())
1142                .unwrap_or(0);
1143
1144            let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1145            self.set_composite_alignment(buffer_id, alignment);
1146        }
1147
1148        // Store initial focus hunk for the first render to apply
1149        if initial_focus_hunk.is_some() {
1150            if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
1151                composite.initial_focus_hunk = initial_focus_hunk;
1152            }
1153        }
1154
1155        tracing::info!(
1156            "Created composite buffer '{}' with mode '{}' (id={:?})",
1157            name,
1158            mode,
1159            buffer_id
1160        );
1161
1162        // Resolve callback with buffer_id if request_id is provided
1163        if let Some(req_id) = _request_id {
1164            // Return just the buffer ID as a number (consistent with createVirtualBuffer)
1165            let result = buffer_id.0.to_string();
1166            self.plugin_manager
1167                .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), result);
1168            tracing::info!(
1169                "CreateCompositeBuffer: resolve_callback sent for request_id={}",
1170                req_id
1171            );
1172        }
1173    }
1174
1175    /// Handle the UpdateCompositeAlignment plugin command
1176    pub(crate) fn handle_update_composite_alignment(
1177        &mut self,
1178        buffer_id: BufferId,
1179        hunk_configs: Vec<fresh_core::api::CompositeHunk>,
1180    ) {
1181        use crate::model::composite_buffer::{DiffHunk, LineAlignment};
1182
1183        if let Some(composite) = self.composite_buffers.get(&buffer_id) {
1184            let diff_hunks: Vec<DiffHunk> = hunk_configs
1185                .into_iter()
1186                .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1187                .collect();
1188
1189            // Get line counts from source buffers
1190            let old_line_count = self
1191                .buffers
1192                .get(&composite.sources[0].buffer_id)
1193                .and_then(|s| s.buffer.line_count())
1194                .unwrap_or(0);
1195            let new_line_count = self
1196                .buffers
1197                .get(&composite.sources[1].buffer_id)
1198                .and_then(|s| s.buffer.line_count())
1199                .unwrap_or(0);
1200
1201            let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1202            self.set_composite_alignment(buffer_id, alignment);
1203        }
1204    }
1205
1206    /// Handle a mouse click in a composite buffer view
1207    pub(crate) fn handle_composite_click(
1208        &mut self,
1209        col: u16,
1210        row: u16,
1211        split_id: LeafId,
1212        buffer_id: BufferId,
1213        content_rect: ratatui::layout::Rect,
1214    ) -> AnyhowResult<()> {
1215        // Calculate which pane was clicked based on x coordinate
1216        let pane_idx =
1217            if let Some(view_state) = self.composite_view_states.get(&(split_id, buffer_id)) {
1218                let mut x = content_rect.x;
1219                let mut found_pane = 0;
1220                for (i, &width) in view_state.pane_widths.iter().enumerate() {
1221                    if col >= x && col < x + width {
1222                        found_pane = i;
1223                        break;
1224                    }
1225                    x += width + 1; // +1 for separator
1226                }
1227                found_pane
1228            } else {
1229                0
1230            };
1231
1232        // Calculate the clicked row (relative to scroll position)
1233        // Subtract 1 for the header row ("OLD (HEAD)" / "NEW (Working)")
1234        let content_row = row.saturating_sub(content_rect.y).saturating_sub(1) as usize;
1235
1236        // Calculate column within the pane (accounting for gutter and horizontal scroll)
1237        let (pane_start_x, left_column) =
1238            if let Some(view_state) = self.composite_view_states.get(&(split_id, buffer_id)) {
1239                let mut x = content_rect.x;
1240                for (i, &width) in view_state.pane_widths.iter().enumerate() {
1241                    if i == pane_idx {
1242                        break;
1243                    }
1244                    x += width + 1;
1245                }
1246                let left_col = view_state
1247                    .pane_viewports
1248                    .get(pane_idx)
1249                    .map(|vp| vp.left_column)
1250                    .unwrap_or(0);
1251                (x, left_col)
1252            } else {
1253                (content_rect.x, 0)
1254            };
1255        let gutter_width = 4; // Line number width
1256        let visual_col = col
1257            .saturating_sub(pane_start_x)
1258            .saturating_sub(gutter_width) as usize;
1259        // Convert visual column to actual column by adding horizontal scroll offset
1260        let click_col = left_column + visual_col;
1261
1262        // Get line length to clamp cursor position
1263        let display_row =
1264            if let Some(view_state) = self.composite_view_states.get(&(split_id, buffer_id)) {
1265                view_state.scroll_row + content_row
1266            } else {
1267                content_row
1268            };
1269
1270        let line_length = if let Some(composite) = self.composite_buffers.get(&buffer_id) {
1271            composite
1272                .alignment
1273                .get_row(display_row)
1274                .and_then(|row| row.get_pane_line(pane_idx))
1275                .and_then(|line_ref| {
1276                    let source = composite.sources.get(pane_idx)?;
1277                    self.buffers
1278                        .get(&source.buffer_id)?
1279                        .buffer
1280                        .get_line(line_ref.line)
1281                })
1282                .map(|bytes| {
1283                    let s = String::from_utf8_lossy(&bytes);
1284                    // Strip trailing newline - cursor shouldn't go past end of visible content
1285                    let trimmed = s.trim_end_matches('\n').trim_end_matches('\r');
1286                    trimmed.graphemes(true).count()
1287                })
1288                .unwrap_or(0)
1289        } else {
1290            0
1291        };
1292
1293        // Clamp click column to line length
1294        let clamped_col = click_col.min(line_length);
1295
1296        // Update composite buffer's active pane
1297        if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
1298            composite.active_pane = pane_idx;
1299        }
1300
1301        // Update composite view state with click position
1302        if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
1303            view_state.focused_pane = pane_idx;
1304            view_state.cursor_row = display_row;
1305            view_state.cursor_column = clamped_col;
1306            view_state.sticky_column = clamped_col;
1307
1308            // Clear selection on click (will start fresh selection on drag)
1309            view_state.clear_selection();
1310        }
1311
1312        // Store state for potential text selection drag
1313        self.mouse_state.dragging_text_selection = false; // Disable regular text selection for composite
1314        self.mouse_state.drag_selection_split = Some(split_id);
1315
1316        // Sync cursor position to EditorState for status bar display
1317        self.sync_editor_cursor_from_composite(split_id, buffer_id);
1318
1319        Ok(())
1320    }
1321}