Skip to main content

fresh/model/
composite_buffer.rs

1//! Composite buffer for displaying multiple source buffers in a single view
2//!
3//! A composite buffer synthesizes its view from multiple source buffers,
4//! enabling side-by-side diff, unified diff, 3-way merge, and code review views
5//! within a single tab.
6
7use crate::model::event::BufferId;
8use serde::{Deserialize, Serialize};
9use std::ops::Range;
10
11/// A buffer that composes content from multiple source buffers
12#[derive(Debug, Clone)]
13pub struct CompositeBuffer {
14    /// Unique ID for this composite buffer
15    pub id: BufferId,
16
17    /// Display name (shown in tab bar)
18    pub name: String,
19
20    /// Layout mode for this composite
21    pub layout: CompositeLayout,
22
23    /// Source buffer configurations
24    pub sources: Vec<SourcePane>,
25
26    /// Line alignment map (for side-by-side diff)
27    /// Maps display_line -> (left_source_line, right_source_line)
28    pub alignment: LineAlignment,
29
30    /// Which pane currently has focus (for input routing)
31    pub active_pane: usize,
32
33    /// Mode for keybindings
34    pub mode: String,
35
36    /// When set, the first render will scroll to center this hunk (0-indexed)
37    /// and then clear this field. This avoids timing issues where imperative
38    /// scroll commands depend on render-created state (viewport, view state).
39    pub initial_focus_hunk: Option<usize>,
40}
41
42impl CompositeBuffer {
43    /// Create a new composite buffer
44    pub fn new(
45        id: BufferId,
46        name: String,
47        mode: String,
48        layout: CompositeLayout,
49        sources: Vec<SourcePane>,
50    ) -> Self {
51        let pane_count = sources.len();
52        Self {
53            id,
54            name,
55            mode,
56            layout,
57            sources,
58            alignment: LineAlignment::empty(pane_count),
59            active_pane: 0,
60            initial_focus_hunk: None,
61        }
62    }
63
64    /// Get the number of source panes
65    pub fn pane_count(&self) -> usize {
66        self.sources.len()
67    }
68
69    /// Get the source pane at the given index
70    pub fn get_pane(&self, index: usize) -> Option<&SourcePane> {
71        self.sources.get(index)
72    }
73
74    /// Get the currently focused pane
75    pub fn focused_pane(&self) -> Option<&SourcePane> {
76        self.sources.get(self.active_pane)
77    }
78
79    /// Switch focus to the next pane
80    pub fn focus_next(&mut self) {
81        if !self.sources.is_empty() {
82            self.active_pane = (self.active_pane + 1) % self.sources.len();
83        }
84    }
85
86    /// Switch focus to the previous pane
87    pub fn focus_prev(&mut self) {
88        if !self.sources.is_empty() {
89            self.active_pane = (self.active_pane + self.sources.len() - 1) % self.sources.len();
90        }
91    }
92
93    /// Set the line alignment
94    pub fn set_alignment(&mut self, alignment: LineAlignment) {
95        self.alignment = alignment;
96    }
97
98    /// Get the total number of display rows
99    pub fn row_count(&self) -> usize {
100        self.alignment.rows.len()
101    }
102}
103
104/// How the composite buffer arranges its source panes
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub enum CompositeLayout {
107    /// Side-by-side columns (for diff view)
108    SideBySide {
109        /// Width ratio for each pane (must sum to 1.0)
110        ratios: Vec<f32>,
111        /// Show separator between panes
112        show_separator: bool,
113    },
114    /// Vertically stacked sections (for notebook cells)
115    Stacked {
116        /// Spacing between sections (in lines)
117        spacing: u16,
118    },
119    /// Interleaved lines (for unified diff)
120    Unified,
121}
122
123impl Default for CompositeLayout {
124    fn default() -> Self {
125        CompositeLayout::SideBySide {
126            ratios: vec![0.5, 0.5],
127            show_separator: true,
128        }
129    }
130}
131
132/// Configuration for a single source pane within the composite
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct SourcePane {
135    /// ID of the source buffer
136    pub buffer_id: BufferId,
137
138    /// Human-readable label (e.g., "OLD", "NEW", "BASE")
139    pub label: String,
140
141    /// Whether this pane accepts edits
142    pub editable: bool,
143
144    /// Visual style for this pane
145    pub style: PaneStyle,
146
147    /// Byte range in source buffer to display (None = entire buffer)
148    pub range: Option<Range<usize>>,
149}
150
151impl SourcePane {
152    /// Create a new source pane
153    pub fn new(buffer_id: BufferId, label: impl Into<String>, editable: bool) -> Self {
154        Self {
155            buffer_id,
156            label: label.into(),
157            editable,
158            style: PaneStyle::default(),
159            range: None,
160        }
161    }
162
163    /// Set the visual style
164    pub fn with_style(mut self, style: PaneStyle) -> Self {
165        self.style = style;
166        self
167    }
168
169    /// Set the byte range to display
170    pub fn with_range(mut self, range: Range<usize>) -> Self {
171        self.range = Some(range);
172        self
173    }
174}
175
176/// Visual styling for a pane
177#[derive(Debug, Clone, Default, Serialize, Deserialize)]
178pub struct PaneStyle {
179    /// Background color for added lines (RGB)
180    pub add_bg: Option<(u8, u8, u8)>,
181    /// Background color for removed lines (RGB)
182    pub remove_bg: Option<(u8, u8, u8)>,
183    /// Background color for modified lines (RGB)
184    pub modify_bg: Option<(u8, u8, u8)>,
185    /// Gutter indicator style
186    pub gutter_style: GutterStyle,
187}
188
189impl PaneStyle {
190    /// Create a style for the "old" side of a diff
191    pub fn old_diff() -> Self {
192        Self {
193            remove_bg: Some((80, 30, 30)),
194            gutter_style: GutterStyle::Both,
195            ..Default::default()
196        }
197    }
198
199    /// Create a style for the "new" side of a diff
200    pub fn new_diff() -> Self {
201        Self {
202            add_bg: Some((30, 80, 30)),
203            modify_bg: Some((80, 80, 30)),
204            gutter_style: GutterStyle::Both,
205            ..Default::default()
206        }
207    }
208}
209
210/// Gutter display style
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
212pub enum GutterStyle {
213    /// Show line numbers
214    #[default]
215    LineNumbers,
216    /// Show diff markers (+/-/~)
217    DiffMarkers,
218    /// Show both line numbers and markers
219    Both,
220    /// Hide gutter
221    None,
222}
223
224// ============================================================================
225// Line Alignment
226// ============================================================================
227
228/// Alignment information for side-by-side views
229#[derive(Debug, Clone, Default)]
230pub struct LineAlignment {
231    /// Each entry maps a display row to source lines in each pane
232    /// None means padding (blank line) for that pane
233    pub rows: Vec<AlignedRow>,
234}
235
236impl LineAlignment {
237    /// Create an empty alignment for the given number of panes
238    pub fn empty(_pane_count: usize) -> Self {
239        Self { rows: Vec::new() }
240    }
241
242    /// Create alignment from simple line-by-line mapping (no diff)
243    /// Assumes both buffers have the same number of lines
244    pub fn simple(line_count: usize, pane_count: usize) -> Self {
245        let rows = (0..line_count)
246            .map(|line| AlignedRow {
247                pane_lines: (0..pane_count)
248                    .map(|_| {
249                        Some(SourceLineRef {
250                            line,
251                            byte_range: 0..0, // Will be filled in during render
252                        })
253                    })
254                    .collect(),
255                row_type: RowType::Context,
256            })
257            .collect();
258        Self { rows }
259    }
260
261    /// Create alignment from diff hunks
262    pub fn from_hunks(hunks: &[DiffHunk], old_line_count: usize, new_line_count: usize) -> Self {
263        let mut rows = Vec::new();
264        let mut old_line = 0usize;
265        let mut new_line = 0usize;
266
267        for hunk in hunks {
268            // Add context lines before this hunk
269            while old_line < hunk.old_start && new_line < hunk.new_start {
270                rows.push(AlignedRow::context(old_line, new_line));
271                old_line += 1;
272                new_line += 1;
273            }
274
275            // Add hunk header
276            rows.push(AlignedRow::hunk_header());
277
278            // Process hunk lines
279            let old_end = hunk.old_start + hunk.old_count;
280            let new_end = hunk.new_start + hunk.new_count;
281
282            // Use a simple alignment: pair lines where possible, then pad
283            let old_hunk_lines = old_end - hunk.old_start;
284            let new_hunk_lines = new_end - hunk.new_start;
285            let max_lines = old_hunk_lines.max(new_hunk_lines);
286
287            for i in 0..max_lines {
288                let old_idx = if i < old_hunk_lines {
289                    Some(hunk.old_start + i)
290                } else {
291                    None
292                };
293                let new_idx = if i < new_hunk_lines {
294                    Some(hunk.new_start + i)
295                } else {
296                    None
297                };
298
299                let row_type = match (old_idx, new_idx) {
300                    (Some(_), Some(_)) => RowType::Modification,
301                    (Some(_), None) => RowType::Deletion,
302                    (None, Some(_)) => RowType::Addition,
303                    (None, None) => continue,
304                };
305
306                rows.push(AlignedRow {
307                    pane_lines: vec![
308                        old_idx.map(|l| SourceLineRef {
309                            line: l,
310                            byte_range: 0..0,
311                        }),
312                        new_idx.map(|l| SourceLineRef {
313                            line: l,
314                            byte_range: 0..0,
315                        }),
316                    ],
317                    row_type,
318                });
319            }
320
321            old_line = old_end;
322            new_line = new_end;
323        }
324
325        // Add remaining context lines after last hunk
326        while old_line < old_line_count && new_line < new_line_count {
327            rows.push(AlignedRow::context(old_line, new_line));
328            old_line += 1;
329            new_line += 1;
330        }
331
332        // Handle trailing lines in either buffer
333        while old_line < old_line_count {
334            rows.push(AlignedRow {
335                pane_lines: vec![
336                    Some(SourceLineRef {
337                        line: old_line,
338                        byte_range: 0..0,
339                    }),
340                    None,
341                ],
342                row_type: RowType::Deletion,
343            });
344            old_line += 1;
345        }
346        while new_line < new_line_count {
347            rows.push(AlignedRow {
348                pane_lines: vec![
349                    None,
350                    Some(SourceLineRef {
351                        line: new_line,
352                        byte_range: 0..0,
353                    }),
354                ],
355                row_type: RowType::Addition,
356            });
357            new_line += 1;
358        }
359
360        Self { rows }
361    }
362
363    /// Get the aligned row at the given display index
364    pub fn get_row(&self, display_row: usize) -> Option<&AlignedRow> {
365        self.rows.get(display_row)
366    }
367
368    /// Get the number of display rows
369    pub fn row_count(&self) -> usize {
370        self.rows.len()
371    }
372
373    /// Find the next hunk header row after the given row
374    pub fn next_hunk_row(&self, after_row: usize) -> Option<usize> {
375        self.rows
376            .iter()
377            .enumerate()
378            .skip(after_row + 1)
379            .find(|(_, row)| row.row_type == RowType::HunkHeader)
380            .map(|(i, _)| i)
381    }
382
383    /// Find the previous hunk header row before the given row
384    pub fn prev_hunk_row(&self, before_row: usize) -> Option<usize> {
385        self.rows
386            .iter()
387            .enumerate()
388            .take(before_row)
389            .rev()
390            .find(|(_, row)| row.row_type == RowType::HunkHeader)
391            .map(|(i, _)| i)
392    }
393}
394
395/// A single aligned row mapping display to source lines
396#[derive(Debug, Clone)]
397pub struct AlignedRow {
398    /// Source line for each pane (None = padding)
399    pub pane_lines: Vec<Option<SourceLineRef>>,
400    /// Type of this row for styling
401    pub row_type: RowType,
402}
403
404impl AlignedRow {
405    /// Create a context row (both sides have content)
406    pub fn context(old_line: usize, new_line: usize) -> Self {
407        Self {
408            pane_lines: vec![
409                Some(SourceLineRef {
410                    line: old_line,
411                    byte_range: 0..0,
412                }),
413                Some(SourceLineRef {
414                    line: new_line,
415                    byte_range: 0..0,
416                }),
417            ],
418            row_type: RowType::Context,
419        }
420    }
421
422    /// Create a hunk header row
423    pub fn hunk_header() -> Self {
424        Self {
425            pane_lines: vec![None, None],
426            row_type: RowType::HunkHeader,
427        }
428    }
429
430    /// Get the source line for a specific pane
431    pub fn get_pane_line(&self, pane_index: usize) -> Option<&SourceLineRef> {
432        self.pane_lines.get(pane_index).and_then(|opt| opt.as_ref())
433    }
434
435    /// Check if this row has content in the given pane
436    pub fn has_content(&self, pane_index: usize) -> bool {
437        self.pane_lines
438            .get(pane_index)
439            .map(|opt| opt.is_some())
440            .unwrap_or(false)
441    }
442}
443
444/// Reference to a line in a source buffer
445#[derive(Debug, Clone)]
446pub struct SourceLineRef {
447    /// Line number in source buffer (0-indexed)
448    pub line: usize,
449    /// Byte range in source buffer (computed during render)
450    pub byte_range: Range<usize>,
451}
452
453/// Type of an aligned row for styling
454#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
455pub enum RowType {
456    /// Both sides have matching content
457    Context,
458    /// Line exists only in left/old (deletion)
459    Deletion,
460    /// Line exists only in right/new (addition)
461    Addition,
462    /// Line differs between sides
463    Modification,
464    /// Hunk separator/header
465    HunkHeader,
466}
467
468/// A diff hunk describing a contiguous change
469#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct DiffHunk {
471    /// Starting line in old buffer (0-indexed)
472    pub old_start: usize,
473    /// Number of lines in old buffer
474    pub old_count: usize,
475    /// Starting line in new buffer (0-indexed)
476    pub new_start: usize,
477    /// Number of lines in new buffer
478    pub new_count: usize,
479    /// Optional header text (function context)
480    pub header: Option<String>,
481}
482
483impl DiffHunk {
484    /// Create a new diff hunk
485    pub fn new(old_start: usize, old_count: usize, new_start: usize, new_count: usize) -> Self {
486        Self {
487            old_start,
488            old_count,
489            new_start,
490            new_count,
491            header: None,
492        }
493    }
494
495    /// Set the header text
496    pub fn with_header(mut self, header: impl Into<String>) -> Self {
497        self.header = Some(header.into());
498        self
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    #[test]
507    fn test_line_alignment_from_hunks() {
508        // Test with a single hunk: old has 2 lines deleted, new has 3 lines added
509        let hunks = vec![DiffHunk::new(2, 2, 2, 3)];
510        let alignment = LineAlignment::from_hunks(&hunks, 5, 6);
511
512        // Should have:
513        // - 2 context rows (lines 0-1)
514        // - 1 hunk header
515        // - 3 hunk rows (max of 2 old, 3 new)
516        // - 1 context row (old line 4, new line 5)
517        assert!(alignment.rows.len() >= 7);
518
519        // First two rows should be context
520        assert_eq!(alignment.rows[0].row_type, RowType::Context);
521        assert_eq!(alignment.rows[1].row_type, RowType::Context);
522
523        // Third row should be hunk header
524        assert_eq!(alignment.rows[2].row_type, RowType::HunkHeader);
525    }
526
527    #[test]
528    fn test_composite_buffer_focus() {
529        let sources = vec![
530            SourcePane::new(BufferId(1), "OLD", false),
531            SourcePane::new(BufferId(2), "NEW", true),
532        ];
533        let mut composite = CompositeBuffer::new(
534            BufferId(0),
535            "Test".to_string(),
536            "diff-view".to_string(),
537            CompositeLayout::default(),
538            sources,
539        );
540
541        assert_eq!(composite.active_pane, 0);
542
543        composite.focus_next();
544        assert_eq!(composite.active_pane, 1);
545
546        composite.focus_next();
547        assert_eq!(composite.active_pane, 0); // Wraps around
548
549        composite.focus_prev();
550        assert_eq!(composite.active_pane, 1);
551    }
552}