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