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