Skip to main content

git_tailor/static_views/
fragmap.rs

1// Copyright 2026 Thomas Johannesson
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// Static (non-TUI) fragmap renderer.
16//
17// Renders the fragmap matrix to a plain String, mimicking the output of the
18// original fragmap tool. Each row shows: short SHA, commit title, then one
19// character per cluster column.
20//
21// When `colors` is true the output uses ANSI escape codes (cyan SHA, grey
22// title for fully-squashable commits, white/yellow/red background cells).
23// When `colors` is false Unicode block characters are used instead, which
24// produces clean readable output suitable for snapshot tests or plain-text
25// piping.
26
27use crate::{
28    CommitDiff,
29    fragmap::{self as fm, SquashableScope},
30};
31
32/// Symbol set used when rendering a row.
33struct Symbols {
34    /// ANSI prefix for the short SHA (e.g. cyan).
35    sha_start: &'static str,
36    /// ANSI reset / empty string.
37    reset: &'static str,
38    /// ANSI prefix for a fully-squashable commit's title (grey).
39    grey_start: &'static str,
40    /// String rendered when a commit directly touches a cluster.
41    cell_touch: &'static str,
42    /// String rendered for a squashable connector (between two touching commits with no conflict).
43    cell_squashable: &'static str,
44    /// String rendered for a conflicting connector.
45    cell_conflicting: &'static str,
46    /// String rendered for an empty cell.
47    cell_empty: &'static str,
48}
49
50const COLORED: Symbols = Symbols {
51    sha_start: "\x1b[36m",
52    reset: "\x1b[0m",
53    grey_start: "\x1b[90m",
54    cell_touch: "\x1b[47m \x1b[0m",
55    cell_squashable: "\x1b[43m \x1b[0m",
56    cell_conflicting: "\x1b[41m \x1b[0m",
57    cell_empty: ".",
58};
59
60const PLAIN: Symbols = Symbols {
61    sha_start: "",
62    reset: "",
63    grey_start: "",
64    cell_touch: "#",
65    cell_squashable: "^",
66    cell_conflicting: "|",
67    cell_empty: ".",
68};
69
70const PLAIN_REVERSE: Symbols = Symbols {
71    cell_squashable: "v",
72    ..PLAIN
73};
74
75/// Render the fragmap matrix for all `commit_diffs` to a `String`.
76///
77/// `commit_diffs` contains all commits in display order (regular commits
78/// followed by any synthetic rows such as staged/unstaged changes).
79/// `full` controls whether identical cluster columns are deduplicated.
80/// `colors` selects ANSI color output (matching the original fragmap tool)
81/// versus plain Unicode output (suitable for tests and `--no-color` piping).
82/// `reverse` prints rows oldest-first (highest matrix index first).
83/// `scope` controls what the squashable connector indicator means.
84/// `term_width` is the terminal column count used to compute the title column
85/// width dynamically (matching the original fragmap tool's layout algorithm).
86/// When `None` a fixed 26-character title width is used.
87pub fn render(
88    commit_diffs: &[CommitDiff],
89    full: bool,
90    colors: bool,
91    reverse: bool,
92    scope: SquashableScope,
93    term_width: Option<u16>,
94) -> String {
95    let s = if colors {
96        &COLORED
97    } else if reverse {
98        &PLAIN_REVERSE
99    } else {
100        &PLAIN
101    };
102
103    let fmap = fm::build_fragmap(commit_diffs, !full);
104    let n_clusters = fmap.clusters.len();
105
106    // Compute the title column width following the original fragmap tool's
107    // layout algorithm (fragmap/console_ui.py):
108    //
109    //   terminal_width = reported - 2   (ConEmu/Cmder wraps 2 cols early)
110    //   title_width = max(MIN, min(
111    //       longest_title + 1,
112    //       terminal_width / 2,
113    //       terminal_width - (8 + 1 + 1 + n_clusters),
114    //   ))
115    //
116    // Fields: SHA(8) + space(1) + title(title_width) + space(1) + matrix(n_clusters)
117    const FIXED_TITLE_WIDTH: usize = 26;
118    const MIN_TITLE_WIDTH: usize = 10;
119    const SHA_WIDTH: usize = 8;
120
121    let title_width = if let Some(tw) = term_width {
122        let terminal_width = (tw as usize).saturating_sub(2);
123        let max_actual = commit_diffs
124            .iter()
125            .map(|d| d.commit.summary.chars().count())
126            .max()
127            .unwrap_or(0)
128            + 1;
129        // space between SHA and title (1); no separator before matrix (matches existing format)
130        let overhead = SHA_WIDTH + 1 + n_clusters;
131        let dynamic = max_actual
132            .min(terminal_width / 2)
133            .min(terminal_width.saturating_sub(overhead));
134        dynamic.max(MIN_TITLE_WIDTH)
135    } else {
136        FIXED_TITLE_WIDTH
137    };
138
139    let mut out = String::new();
140
141    let indices: Box<dyn Iterator<Item = usize>> = if reverse {
142        Box::new((0..commit_diffs.len()).rev())
143    } else {
144        Box::new(0..commit_diffs.len())
145    };
146
147    for commit_idx in indices {
148        let diff = &commit_diffs[commit_idx];
149        let commit = &diff.commit;
150        let sha8 = &commit.oid[..8.min(commit.oid.len())];
151        let title: String = commit.summary.chars().take(title_width).collect();
152        let title_padded = format!("{title:<title_width$}");
153
154        out.push_str(s.sha_start);
155        out.push_str(sha8);
156        out.push_str(s.reset);
157        out.push(' ');
158
159        if fmap.is_fully_squashable(commit_idx) {
160            out.push_str(s.grey_start);
161            out.push_str(&title_padded);
162            out.push_str(s.reset);
163        } else {
164            out.push_str(&title_padded);
165        }
166
167        for cluster_idx in 0..fmap.clusters.len() {
168            if fmap.matrix[commit_idx][cluster_idx] != fm::TouchKind::None {
169                out.push_str(s.cell_touch);
170            } else {
171                let has_above =
172                    (0..commit_idx).any(|i| fmap.matrix[i][cluster_idx] != fm::TouchKind::None);
173                let below = ((commit_idx + 1)..commit_diffs.len())
174                    .find(|&i| fmap.matrix[i][cluster_idx] != fm::TouchKind::None);
175
176                if has_above && let Some(below_idx) = below {
177                    match fmap.connector_squashable(below_idx, cluster_idx, scope) {
178                        Some(true) => out.push_str(s.cell_squashable),
179                        Some(false) => out.push_str(s.cell_conflicting),
180                        None => out.push_str(s.cell_empty),
181                    }
182                } else {
183                    out.push_str(s.cell_empty);
184                }
185            }
186        }
187
188        out.push('\n');
189    }
190
191    out
192}