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, SplitId};
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: SplitId,
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: SplitId, 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: SplitId, 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: SplitId, 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: SplitId, 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: SplitId, 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: SplitId, 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: SplitId) -> 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: SplitId, 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: SplitId,
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();
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: SplitId, 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            state.cursors.primary_mut().position = cursor_column;
710        }
711    }
712
713    /// Handle cursor movement actions (both Move and Select variants)
714    fn handle_cursor_movement_action(
715        &mut self,
716        split_id: SplitId,
717        buffer_id: BufferId,
718        movement: CursorMovement,
719        extend_selection: bool,
720    ) -> Option<bool> {
721        let viewport_height = self.get_composite_viewport_height(split_id);
722
723        let line_info = self.get_cursor_line_info(split_id, buffer_id);
724
725        if extend_selection {
726            // Start visual selection if extending and not already in visual mode
727            if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
728                if !view_state.visual_mode {
729                    view_state.start_visual_selection();
730                }
731            }
732        } else {
733            // Clear selection when moving without shift
734            if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
735                if view_state.visual_mode {
736                    view_state.clear_selection();
737                }
738            }
739        }
740
741        self.apply_cursor_movement(split_id, buffer_id, movement, &line_info, viewport_height);
742        self.sync_editor_cursor_from_composite(split_id, buffer_id);
743
744        Some(true)
745    }
746
747    /// Handle an action for a composite buffer.
748    ///
749    /// For navigation and selection actions, this forwards to the focused source buffer
750    /// and syncs scroll between panes. Returns Some(true) if handled, None to fall through
751    /// to normal buffer handling.
752    pub fn handle_composite_action(
753        &mut self,
754        buffer_id: BufferId,
755        action: &crate::input::keybindings::Action,
756    ) -> Option<bool> {
757        use crate::input::keybindings::Action;
758
759        let split_id = self.split_manager.active_split();
760
761        // Verify this is a valid composite buffer
762        let _composite = self.composite_buffers.get(&buffer_id)?;
763        let _view_state = self.composite_view_states.get(&(split_id, buffer_id))?;
764
765        match action {
766            // Tab switches between panes
767            Action::InsertTab => {
768                self.composite_focus_next(split_id, buffer_id);
769                Some(true)
770            }
771
772            // Copy from the focused pane
773            Action::Copy => {
774                self.handle_composite_copy(split_id, buffer_id);
775                Some(true)
776            }
777
778            // Cursor movement (without selection)
779            Action::MoveDown => {
780                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, false)
781            }
782            Action::MoveUp => {
783                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, false)
784            }
785            Action::MoveLeft => {
786                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
787            }
788            Action::MoveRight => self.handle_cursor_movement_action(
789                split_id,
790                buffer_id,
791                CursorMovement::Right,
792                false,
793            ),
794            Action::MoveLineStart => self.handle_cursor_movement_action(
795                split_id,
796                buffer_id,
797                CursorMovement::LineStart,
798                false,
799            ),
800            Action::MoveLineEnd => self.handle_cursor_movement_action(
801                split_id,
802                buffer_id,
803                CursorMovement::LineEnd,
804                false,
805            ),
806            Action::MoveWordLeft => self.handle_cursor_movement_action(
807                split_id,
808                buffer_id,
809                CursorMovement::WordLeft,
810                false,
811            ),
812            Action::MoveWordRight => self.handle_cursor_movement_action(
813                split_id,
814                buffer_id,
815                CursorMovement::WordRight,
816                false,
817            ),
818            Action::MoveWordEnd => self.handle_cursor_movement_action(
819                split_id,
820                buffer_id,
821                CursorMovement::WordEnd,
822                false,
823            ),
824
825            // Cursor movement with selection
826            Action::SelectDown => {
827                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, true)
828            }
829            Action::SelectUp => {
830                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, true)
831            }
832            Action::SelectLeft => {
833                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, true)
834            }
835            Action::SelectRight => {
836                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Right, true)
837            }
838            Action::SelectLineStart => self.handle_cursor_movement_action(
839                split_id,
840                buffer_id,
841                CursorMovement::LineStart,
842                true,
843            ),
844            Action::SelectLineEnd => self.handle_cursor_movement_action(
845                split_id,
846                buffer_id,
847                CursorMovement::LineEnd,
848                true,
849            ),
850            Action::SelectWordLeft => self.handle_cursor_movement_action(
851                split_id,
852                buffer_id,
853                CursorMovement::WordLeft,
854                true,
855            ),
856            Action::SelectWordRight => self.handle_cursor_movement_action(
857                split_id,
858                buffer_id,
859                CursorMovement::WordRight,
860                true,
861            ),
862            Action::SelectWordEnd => self.handle_cursor_movement_action(
863                split_id,
864                buffer_id,
865                CursorMovement::WordEnd,
866                true,
867            ),
868
869            // Page navigation
870            Action::MovePageDown | Action::MovePageUp => {
871                let viewport_height = self.get_composite_viewport_height(split_id);
872
873                if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id))
874                {
875                    if matches!(action, Action::MovePageDown) {
876                        if let Some(composite) = self.composite_buffers.get(&buffer_id) {
877                            let max_row = composite.row_count().saturating_sub(1);
878                            view_state.page_down(viewport_height, max_row);
879                            view_state.cursor_row = view_state.scroll_row;
880                        }
881                    } else {
882                        view_state.page_up(viewport_height);
883                        view_state.cursor_row = view_state.scroll_row;
884                    }
885                }
886                self.sync_editor_cursor_from_composite(split_id, buffer_id);
887                Some(true)
888            }
889
890            // Document start/end
891            Action::MoveDocumentStart | Action::MoveDocumentEnd => {
892                let viewport_height = self.get_composite_viewport_height(split_id);
893
894                if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id))
895                {
896                    if matches!(action, Action::MoveDocumentStart) {
897                        view_state.move_cursor_to_top();
898                    } else if let Some(composite) = self.composite_buffers.get(&buffer_id) {
899                        let max_row = composite.row_count().saturating_sub(1);
900                        view_state.move_cursor_to_bottom(max_row, viewport_height);
901                    }
902                }
903                self.sync_editor_cursor_from_composite(split_id, buffer_id);
904                Some(true)
905            }
906
907            // Scroll without moving cursor
908            Action::ScrollDown | Action::ScrollUp => {
909                let delta = if matches!(action, Action::ScrollDown) {
910                    1
911                } else {
912                    -1
913                };
914                self.composite_scroll(split_id, buffer_id, delta);
915                Some(true)
916            }
917
918            // For other actions, return None to fall through to normal handling
919            _ => None,
920        }
921    }
922
923    /// Handle copy action for composite buffer
924    fn handle_composite_copy(&mut self, split_id: SplitId, buffer_id: BufferId) {
925        let text = {
926            let composite = match self.composite_buffers.get(&buffer_id) {
927                Some(c) => c,
928                None => return,
929            };
930            let view_state = match self.composite_view_states.get(&(split_id, buffer_id)) {
931                Some(vs) => vs,
932                None => return,
933            };
934
935            let (start_row, end_row) = match view_state.selection_row_range() {
936                Some(range) => range,
937                None => return,
938            };
939
940            let source = match composite.sources.get(view_state.focused_pane) {
941                Some(s) => s,
942                None => return,
943            };
944
945            let source_state = match self.buffers.get(&source.buffer_id) {
946                Some(s) => s,
947                None => return,
948            };
949
950            // Collect text from selected rows
951            let mut text = String::new();
952            for row in start_row..=end_row {
953                if let Some(aligned_row) = composite.alignment.rows.get(row) {
954                    if let Some(line_ref) = aligned_row.get_pane_line(view_state.focused_pane) {
955                        if let Some(line_bytes) = source_state.buffer.get_line(line_ref.line) {
956                            if !text.is_empty() {
957                                text.push('\n');
958                            }
959                            // Strip trailing newline from line content to avoid double newlines
960                            let line_str = String::from_utf8_lossy(&line_bytes);
961                            let line_trimmed = line_str.trim_end_matches(&['\n', '\r'][..]);
962                            text.push_str(line_trimmed);
963                        }
964                    }
965                }
966            }
967            text
968        };
969
970        if !text.is_empty() {
971            self.clipboard.copy(text);
972        }
973
974        // Don't clear selection after copy - user may want to continue working with it
975    }
976
977    // =========================================================================
978    // Plugin Command Handlers
979    // =========================================================================
980
981    /// Handle the CreateCompositeBuffer plugin command
982    pub(crate) fn handle_create_composite_buffer(
983        &mut self,
984        name: String,
985        mode: String,
986        layout_config: fresh_core::api::CompositeLayoutConfig,
987        source_configs: Vec<fresh_core::api::CompositeSourceConfig>,
988        hunks: Option<Vec<fresh_core::api::CompositeHunk>>,
989        _request_id: Option<u64>,
990    ) {
991        use crate::model::composite_buffer::{
992            CompositeLayout, DiffHunk, GutterStyle, LineAlignment, PaneStyle, SourcePane,
993        };
994
995        // Convert layout config
996        let layout = match layout_config.layout_type.as_str() {
997            "stacked" => CompositeLayout::Stacked {
998                spacing: layout_config.spacing.unwrap_or(1),
999            },
1000            "unified" => CompositeLayout::Unified,
1001            _ => CompositeLayout::SideBySide {
1002                ratios: layout_config.ratios.unwrap_or_else(|| vec![0.5, 0.5]),
1003                show_separator: layout_config.show_separator,
1004            },
1005        };
1006
1007        // Convert source configs
1008        let sources: Vec<SourcePane> = source_configs
1009            .into_iter()
1010            .map(|src| {
1011                let mut pane = SourcePane::new(BufferId(src.buffer_id), src.label, src.editable);
1012                if let Some(style_config) = src.style {
1013                    let gutter_style = match style_config.gutter_style.as_deref() {
1014                        Some("diff-markers") => GutterStyle::DiffMarkers,
1015                        Some("both") => GutterStyle::Both,
1016                        Some("none") => GutterStyle::None,
1017                        _ => GutterStyle::LineNumbers,
1018                    };
1019                    // Convert [u8; 3] arrays to (u8, u8, u8) tuples
1020                    let to_tuple = |arr: [u8; 3]| (arr[0], arr[1], arr[2]);
1021                    pane.style = PaneStyle {
1022                        add_bg: style_config.add_bg.map(to_tuple),
1023                        remove_bg: style_config.remove_bg.map(to_tuple),
1024                        modify_bg: style_config.modify_bg.map(to_tuple),
1025                        gutter_style,
1026                    };
1027                }
1028                pane
1029            })
1030            .collect();
1031
1032        // Create the composite buffer
1033        let buffer_id = self.create_composite_buffer(name.clone(), mode.clone(), layout, sources);
1034
1035        // Set alignment from hunks if provided
1036        if let Some(hunk_configs) = hunks {
1037            let diff_hunks: Vec<DiffHunk> = hunk_configs
1038                .into_iter()
1039                .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1040                .collect();
1041
1042            // Get line counts from source buffers
1043            let old_line_count = self
1044                .buffers
1045                .get(&self.composite_buffers.get(&buffer_id).unwrap().sources[0].buffer_id)
1046                .and_then(|s| s.buffer.line_count())
1047                .unwrap_or(0);
1048            let new_line_count = self
1049                .buffers
1050                .get(&self.composite_buffers.get(&buffer_id).unwrap().sources[1].buffer_id)
1051                .and_then(|s| s.buffer.line_count())
1052                .unwrap_or(0);
1053
1054            let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1055            self.set_composite_alignment(buffer_id, alignment);
1056        }
1057
1058        tracing::info!(
1059            "Created composite buffer '{}' with mode '{}' (id={:?})",
1060            name,
1061            mode,
1062            buffer_id
1063        );
1064
1065        // Resolve callback with buffer_id if request_id is provided
1066        if let Some(req_id) = _request_id {
1067            // Return just the buffer ID as a number (consistent with createVirtualBuffer)
1068            let result = buffer_id.0.to_string();
1069            self.plugin_manager
1070                .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), result);
1071            tracing::info!(
1072                "CreateCompositeBuffer: resolve_callback sent for request_id={}",
1073                req_id
1074            );
1075        }
1076    }
1077
1078    /// Handle the UpdateCompositeAlignment plugin command
1079    pub(crate) fn handle_update_composite_alignment(
1080        &mut self,
1081        buffer_id: BufferId,
1082        hunk_configs: Vec<fresh_core::api::CompositeHunk>,
1083    ) {
1084        use crate::model::composite_buffer::{DiffHunk, LineAlignment};
1085
1086        if let Some(composite) = self.composite_buffers.get(&buffer_id) {
1087            let diff_hunks: Vec<DiffHunk> = hunk_configs
1088                .into_iter()
1089                .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1090                .collect();
1091
1092            // Get line counts from source buffers
1093            let old_line_count = self
1094                .buffers
1095                .get(&composite.sources[0].buffer_id)
1096                .and_then(|s| s.buffer.line_count())
1097                .unwrap_or(0);
1098            let new_line_count = self
1099                .buffers
1100                .get(&composite.sources[1].buffer_id)
1101                .and_then(|s| s.buffer.line_count())
1102                .unwrap_or(0);
1103
1104            let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1105            self.set_composite_alignment(buffer_id, alignment);
1106        }
1107    }
1108
1109    /// Handle a mouse click in a composite buffer view
1110    pub(crate) fn handle_composite_click(
1111        &mut self,
1112        col: u16,
1113        row: u16,
1114        split_id: SplitId,
1115        buffer_id: BufferId,
1116        content_rect: ratatui::layout::Rect,
1117    ) -> AnyhowResult<()> {
1118        // Calculate which pane was clicked based on x coordinate
1119        let pane_idx =
1120            if let Some(view_state) = self.composite_view_states.get(&(split_id, buffer_id)) {
1121                let mut x = content_rect.x;
1122                let mut found_pane = 0;
1123                for (i, &width) in view_state.pane_widths.iter().enumerate() {
1124                    if col >= x && col < x + width {
1125                        found_pane = i;
1126                        break;
1127                    }
1128                    x += width + 1; // +1 for separator
1129                }
1130                found_pane
1131            } else {
1132                0
1133            };
1134
1135        // Calculate the clicked row (relative to scroll position)
1136        // Subtract 1 for the header row ("OLD (HEAD)" / "NEW (Working)")
1137        let content_row = row.saturating_sub(content_rect.y).saturating_sub(1) as usize;
1138
1139        // Calculate column within the pane (accounting for gutter and horizontal scroll)
1140        let (pane_start_x, left_column) =
1141            if let Some(view_state) = self.composite_view_states.get(&(split_id, buffer_id)) {
1142                let mut x = content_rect.x;
1143                for (i, &width) in view_state.pane_widths.iter().enumerate() {
1144                    if i == pane_idx {
1145                        break;
1146                    }
1147                    x += width + 1;
1148                }
1149                let left_col = view_state
1150                    .pane_viewports
1151                    .get(pane_idx)
1152                    .map(|vp| vp.left_column)
1153                    .unwrap_or(0);
1154                (x, left_col)
1155            } else {
1156                (content_rect.x, 0)
1157            };
1158        let gutter_width = 4; // Line number width
1159        let visual_col = col
1160            .saturating_sub(pane_start_x)
1161            .saturating_sub(gutter_width) as usize;
1162        // Convert visual column to actual column by adding horizontal scroll offset
1163        let click_col = left_column + visual_col;
1164
1165        // Get line length to clamp cursor position
1166        let display_row =
1167            if let Some(view_state) = self.composite_view_states.get(&(split_id, buffer_id)) {
1168                view_state.scroll_row + content_row
1169            } else {
1170                content_row
1171            };
1172
1173        let line_length = if let Some(composite) = self.composite_buffers.get(&buffer_id) {
1174            composite
1175                .alignment
1176                .get_row(display_row)
1177                .and_then(|row| row.get_pane_line(pane_idx))
1178                .and_then(|line_ref| {
1179                    let source = composite.sources.get(pane_idx)?;
1180                    self.buffers
1181                        .get(&source.buffer_id)?
1182                        .buffer
1183                        .get_line(line_ref.line)
1184                })
1185                .map(|bytes| {
1186                    let s = String::from_utf8_lossy(&bytes);
1187                    // Strip trailing newline - cursor shouldn't go past end of visible content
1188                    let trimmed = s.trim_end_matches('\n').trim_end_matches('\r');
1189                    trimmed.graphemes(true).count()
1190                })
1191                .unwrap_or(0)
1192        } else {
1193            0
1194        };
1195
1196        // Clamp click column to line length
1197        let clamped_col = click_col.min(line_length);
1198
1199        // Update composite buffer's active pane
1200        if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
1201            composite.active_pane = pane_idx;
1202        }
1203
1204        // Update composite view state with click position
1205        if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
1206            view_state.focused_pane = pane_idx;
1207            view_state.cursor_row = display_row;
1208            view_state.cursor_column = clamped_col;
1209            view_state.sticky_column = clamped_col;
1210
1211            // Clear selection on click (will start fresh selection on drag)
1212            view_state.clear_selection();
1213        }
1214
1215        // Store state for potential text selection drag
1216        self.mouse_state.dragging_text_selection = false; // Disable regular text selection for composite
1217        self.mouse_state.drag_selection_split = Some(split_id);
1218
1219        // Sync cursor position to EditorState for status bar display
1220        self.sync_editor_cursor_from_composite(split_id, buffer_id);
1221
1222        Ok(())
1223    }
1224}