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            if let Some(ops) = hunk.ops.as_deref() {
283                // Follow git's per-line classification so unchanged lines stay
284                // paired (a pure insertion shows blank-left / +line-right and
285                // leaves the surrounding code aligned), instead of a naive
286                // positional zip that mis-pairs shifted-but-identical lines.
287                let mut o = hunk.old_start;
288                let mut n = hunk.new_start;
289                for op in ops.chars() {
290                    match op {
291                        ' ' => {
292                            rows.push(AlignedRow::context(o, n));
293                            o += 1;
294                            n += 1;
295                        }
296                        '-' => {
297                            rows.push(AlignedRow {
298                                pane_lines: vec![
299                                    Some(SourceLineRef {
300                                        line: o,
301                                        byte_range: 0..0,
302                                    }),
303                                    None,
304                                ],
305                                row_type: RowType::Deletion,
306                            });
307                            o += 1;
308                        }
309                        '+' => {
310                            rows.push(AlignedRow {
311                                pane_lines: vec![
312                                    None,
313                                    Some(SourceLineRef {
314                                        line: n,
315                                        byte_range: 0..0,
316                                    }),
317                                ],
318                                row_type: RowType::Addition,
319                            });
320                            n += 1;
321                        }
322                        _ => {} // ignore '\' (no-newline marker) and stray chars
323                    }
324                }
325            } else {
326                // No per-line ops: fall back to a simple positional pairing.
327                let old_hunk_lines = old_end - hunk.old_start;
328                let new_hunk_lines = new_end - hunk.new_start;
329                let max_lines = old_hunk_lines.max(new_hunk_lines);
330
331                for i in 0..max_lines {
332                    let old_idx = if i < old_hunk_lines {
333                        Some(hunk.old_start + i)
334                    } else {
335                        None
336                    };
337                    let new_idx = if i < new_hunk_lines {
338                        Some(hunk.new_start + i)
339                    } else {
340                        None
341                    };
342
343                    let row_type = match (old_idx, new_idx) {
344                        (Some(_), Some(_)) => RowType::Modification,
345                        (Some(_), None) => RowType::Deletion,
346                        (None, Some(_)) => RowType::Addition,
347                        (None, None) => continue,
348                    };
349
350                    rows.push(AlignedRow {
351                        pane_lines: vec![
352                            old_idx.map(|l| SourceLineRef {
353                                line: l,
354                                byte_range: 0..0,
355                            }),
356                            new_idx.map(|l| SourceLineRef {
357                                line: l,
358                                byte_range: 0..0,
359                            }),
360                        ],
361                        row_type,
362                    });
363                }
364            }
365
366            old_line = old_end;
367            new_line = new_end;
368        }
369
370        // Add remaining context lines after last hunk
371        while old_line < old_line_count && new_line < new_line_count {
372            rows.push(AlignedRow::context(old_line, new_line));
373            old_line += 1;
374            new_line += 1;
375        }
376
377        // Handle trailing lines in either buffer
378        while old_line < old_line_count {
379            rows.push(AlignedRow {
380                pane_lines: vec![
381                    Some(SourceLineRef {
382                        line: old_line,
383                        byte_range: 0..0,
384                    }),
385                    None,
386                ],
387                row_type: RowType::Deletion,
388            });
389            old_line += 1;
390        }
391        while new_line < new_line_count {
392            rows.push(AlignedRow {
393                pane_lines: vec![
394                    None,
395                    Some(SourceLineRef {
396                        line: new_line,
397                        byte_range: 0..0,
398                    }),
399                ],
400                row_type: RowType::Addition,
401            });
402            new_line += 1;
403        }
404
405        Self { rows }
406    }
407
408    /// Get the aligned row at the given display index
409    pub fn get_row(&self, display_row: usize) -> Option<&AlignedRow> {
410        self.rows.get(display_row)
411    }
412
413    /// Get the number of display rows
414    pub fn row_count(&self) -> usize {
415        self.rows.len()
416    }
417
418    /// Find the next hunk header row after the given row
419    pub fn next_hunk_row(&self, after_row: usize) -> Option<usize> {
420        self.rows
421            .iter()
422            .enumerate()
423            .skip(after_row + 1)
424            .find(|(_, row)| row.row_type == RowType::HunkHeader)
425            .map(|(i, _)| i)
426    }
427
428    /// Find the previous hunk header row before the given row
429    pub fn prev_hunk_row(&self, before_row: usize) -> Option<usize> {
430        self.rows
431            .iter()
432            .enumerate()
433            .take(before_row)
434            .rev()
435            .find(|(_, row)| row.row_type == RowType::HunkHeader)
436            .map(|(i, _)| i)
437    }
438}
439
440/// A single aligned row mapping display to source lines
441#[derive(Debug, Clone)]
442pub struct AlignedRow {
443    /// Source line for each pane (None = padding)
444    pub pane_lines: Vec<Option<SourceLineRef>>,
445    /// Type of this row for styling
446    pub row_type: RowType,
447}
448
449impl AlignedRow {
450    /// Create a context row (both sides have content)
451    pub fn context(old_line: usize, new_line: usize) -> Self {
452        Self {
453            pane_lines: vec![
454                Some(SourceLineRef {
455                    line: old_line,
456                    byte_range: 0..0,
457                }),
458                Some(SourceLineRef {
459                    line: new_line,
460                    byte_range: 0..0,
461                }),
462            ],
463            row_type: RowType::Context,
464        }
465    }
466
467    /// Create a hunk header row
468    pub fn hunk_header() -> Self {
469        Self {
470            pane_lines: vec![None, None],
471            row_type: RowType::HunkHeader,
472        }
473    }
474
475    /// Get the source line for a specific pane
476    pub fn get_pane_line(&self, pane_index: usize) -> Option<&SourceLineRef> {
477        self.pane_lines.get(pane_index).and_then(|opt| opt.as_ref())
478    }
479
480    /// Check if this row has content in the given pane
481    pub fn has_content(&self, pane_index: usize) -> bool {
482        self.pane_lines
483            .get(pane_index)
484            .map(|opt| opt.is_some())
485            .unwrap_or(false)
486    }
487}
488
489/// Reference to a line in a source buffer
490#[derive(Debug, Clone)]
491pub struct SourceLineRef {
492    /// Line number in source buffer (0-indexed)
493    pub line: usize,
494    /// Byte range in source buffer (computed during render)
495    pub byte_range: Range<usize>,
496}
497
498/// Type of an aligned row for styling
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
500pub enum RowType {
501    /// Both sides have matching content
502    Context,
503    /// Line exists only in left/old (deletion)
504    Deletion,
505    /// Line exists only in right/new (addition)
506    Addition,
507    /// Line differs between sides
508    Modification,
509    /// Hunk separator/header
510    HunkHeader,
511}
512
513/// A diff hunk describing a contiguous change
514#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct DiffHunk {
516    /// Starting line in old buffer (0-indexed)
517    pub old_start: usize,
518    /// Number of lines in old buffer
519    pub old_count: usize,
520    /// Starting line in new buffer (0-indexed)
521    pub new_start: usize,
522    /// Number of lines in new buffer
523    pub new_count: usize,
524    /// Optional header text (function context)
525    pub header: Option<String>,
526    /// Per-line operations in git order (`' '`/`'-'`/`'+'`). When set, the
527    /// alignment follows git's classification so unchanged lines stay paired
528    /// instead of being positionally zipped.
529    pub ops: Option<String>,
530}
531
532impl DiffHunk {
533    /// Create a new diff hunk
534    pub fn new(old_start: usize, old_count: usize, new_start: usize, new_count: usize) -> Self {
535        Self {
536            old_start,
537            old_count,
538            new_start,
539            new_count,
540            header: None,
541            ops: None,
542        }
543    }
544
545    /// Attach per-line operations (`' '`/`'-'`/`'+'`).
546    pub fn with_ops(mut self, ops: Option<String>) -> Self {
547        self.ops = ops;
548        self
549    }
550
551    /// Set the header text
552    pub fn with_header(mut self, header: impl Into<String>) -> Self {
553        self.header = Some(header.into());
554        self
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    #[test]
563    fn test_line_alignment_from_hunks() {
564        // Test with a single hunk: old has 2 lines deleted, new has 3 lines added
565        let hunks = vec![DiffHunk::new(2, 2, 2, 3)];
566        let alignment = LineAlignment::from_hunks(&hunks, 5, 6);
567
568        // Should have:
569        // - 2 context rows (lines 0-1)
570        // - 1 hunk header
571        // - 3 hunk rows (max of 2 old, 3 new)
572        // - 1 context row (old line 4, new line 5)
573        assert!(alignment.rows.len() >= 7);
574
575        // First two rows should be context
576        assert_eq!(alignment.rows[0].row_type, RowType::Context);
577        assert_eq!(alignment.rows[1].row_type, RowType::Context);
578
579        // Third row should be hunk header
580        assert_eq!(alignment.rows[2].row_type, RowType::HunkHeader);
581    }
582
583    #[test]
584    fn test_composite_buffer_focus() {
585        let sources = vec![
586            SourcePane::new(BufferId(1), "OLD", false),
587            SourcePane::new(BufferId(2), "NEW", true),
588        ];
589        let mut composite = CompositeBuffer::new(
590            BufferId(0),
591            "Test".to_string(),
592            "diff-view".to_string(),
593            CompositeLayout::default(),
594            sources,
595        );
596
597        assert_eq!(composite.active_pane, 0);
598
599        composite.focus_next();
600        assert_eq!(composite.active_pane, 1);
601
602        composite.focus_next();
603        assert_eq!(composite.active_pane, 0); // Wraps around
604
605        composite.focus_prev();
606        assert_eq!(composite.active_pane, 1);
607    }
608}