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