Skip to main content

strop_engine/editor/git_memory/
sidebar.rs

1//! The immutable commit-file sidebar tree (0032 §3): built once by the
2//! Git worker alongside the file list; the binary's renderer borrows
3//! visible rows. Native file indices preserve identity.
4use std::path::Path;
5
6use strop_core::layout::printable_grapheme;
7use strop_git::memory::ChangedFile;
8use unicode_segmentation::UnicodeSegmentation;
9use unicode_width::UnicodeWidthStr;
10
11/// Sidebar interior clamps (0032 §3): a two-file commit shouldn't pay
12/// a wide pane; a huge name clips instead of stealing one.
13const SIDEBAR_MIN: usize = 12;
14const SIDEBAR_MAX: usize = 24;
15
16/// One sidebar row in tree form (zed-lite: always-expanded, directory
17/// rows dim and shallow, files indented by depth).
18#[derive(Debug)]
19pub enum SidebarRow {
20    Dir {
21        name: String,
22        depth: usize,
23    },
24    File {
25        index: usize,
26        name: String,
27        depth: usize,
28    },
29}
30
31/// The immutable commit tree; native filenames stay in its owning file list.
32#[derive(Debug)]
33pub struct Sidebar {
34    rows: Vec<SidebarRow>,
35    /// Interior width in display cells (excludes the dividing rule).
36    width: usize,
37}
38
39impl Sidebar {
40    /// Flat file list → sorted tree rows: a directory row appears the
41    /// first time its component prefix shows up. Sorting and grouping
42    /// walk native path components, so distinct non-UTF-8 paths never
43    /// collapse into one lossy label.
44    pub fn build(files: &[ChangedFile]) -> Sidebar {
45        let mut order: Vec<usize> = (0..files.len()).collect();
46        // byte order, not `Path`'s component order: `src/a.rs` must
47        // stay ahead of `src/a/b.rs`, or the sibling file would render
48        // beneath its directory's children
49        order.sort_by(|&a, &b| {
50            files[a]
51                .path
52                .as_os_str()
53                .as_encoded_bytes()
54                .cmp(files[b].path.as_os_str().as_encoded_bytes())
55        });
56        let mut rows: Vec<SidebarRow> = Vec::with_capacity(files.len() + 1);
57        let mut previous_parent = Path::new("");
58        for &index in &order {
59            let path = &files[index].path;
60            let parent = path.parent().unwrap_or_else(|| Path::new(""));
61            let shared = parent
62                .components()
63                .zip(previous_parent.components())
64                .take_while(|(left, right)| left == right)
65                .count();
66            for (depth, component) in parent.components().enumerate().skip(shared) {
67                rows.push(SidebarRow::Dir {
68                    name: printable(component.as_os_str().to_string_lossy()),
69                    depth,
70                });
71            }
72            rows.push(SidebarRow::File {
73                index,
74                name: path
75                    .file_name()
76                    .map_or_else(String::new, |name| printable(name.to_string_lossy())),
77                depth: parent.components().count(),
78            });
79            previous_parent = parent;
80        }
81        Sidebar {
82            width: Self::measured_width(files) - 1,
83            rows,
84        }
85    }
86
87    /// Used only while preparing the immutable tree, never during painting.
88    pub fn measured_width(files: &[ChangedFile]) -> usize {
89        let longest = files
90            .iter()
91            .flat_map(|file| file.path.components().enumerate())
92            .map(|(depth, component)| {
93                1 + 2 * depth + width(&component.as_os_str().to_string_lossy())
94            })
95            .max()
96            .unwrap_or(0);
97        (longest + 2).clamp(SIDEBAR_MIN, SIDEBAR_MAX) + 1
98    }
99
100    /// Interior + the dividing rule — the exact cell count the pane's
101    /// `left_inset` (and through it, the caret geometry) reserves.
102    pub fn outer_width(&self) -> usize {
103        self.width + 1
104    }
105
106    /// Interior width in display cells — the renderer's padding math.
107    pub fn width(&self) -> usize {
108        self.width
109    }
110
111    /// The tree rows in paint order.
112    pub fn rows(&self) -> &[SidebarRow] {
113        &self.rows
114    }
115}
116
117/// One cluster's display cells: the printable form's terminal width — the
118/// same measure the renderer's `Span::width` applies at emission.
119fn grapheme_width(grapheme: &str) -> usize {
120    UnicodeWidthStr::width(printable_grapheme(grapheme))
121}
122
123fn width(text: &str) -> usize {
124    text.graphemes(true).map(grapheme_width).sum()
125}
126
127/// One emission-side label: the printable form of a (possibly lossy)
128/// component name — the same policy the buffer's path rows use, so a
129/// newline/tab in a native name becomes a one-cell replacement here
130/// and in the width math alike, never a raw byte or a tab stop.
131fn printable(name: std::borrow::Cow<'_, str>) -> String {
132    strop_core::layout::printable_text(name).into_owned()
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn file(path: &str) -> ChangedFile {
140        ChangedFile {
141            path: path.into(),
142            added: 1,
143            deleted: 0,
144        }
145    }
146
147    #[test]
148    fn tree_groups_by_directory_with_native_indices() {
149        let files = vec![
150            file("src/api/handlers.rs"),
151            file("README.md"),
152            file("src/main.rs"),
153            file("src/api/mod.rs"),
154        ];
155        let sidebar = Sidebar::build(&files);
156        let shape: Vec<String> = sidebar
157            .rows()
158            .iter()
159            .map(|r| match r {
160                SidebarRow::Dir { name, depth } => format!("{}{}/", "  ".repeat(*depth), name),
161                SidebarRow::File { index, name, depth } => {
162                    format!("{}{}#{}", "  ".repeat(*depth), name, index)
163                }
164            })
165            .collect();
166        assert_eq!(
167            shape,
168            [
169                "README.md#1", // root file, no dir row
170                "src/",
171                "  api/",
172                "    handlers.rs#0",
173                "    mod.rs#3",
174                "  main.rs#2",
175            ]
176        );
177    }
178
179    #[test]
180    fn narrow_commits_get_the_minimum_column() {
181        let files = [file("ab.rs")];
182        let sidebar = Sidebar::build(&files);
183        assert_eq!(sidebar.outer_width(), SIDEBAR_MIN + 1);
184    }
185
186    #[test]
187    fn wide_names_clamp_the_interior_at_the_maximum() {
188        // 12 CJK glyphs = 24 display cells: the widest possible label,
189        // clamping the interior at the maximum
190        let files = vec![ChangedFile {
191            path: "漢字漢字漢字漢字漢字漢字漢字漢字漢字漢字漢字漢字.rs".into(),
192            added: 1,
193            deleted: 0,
194        }];
195        let sidebar = Sidebar::build(&files);
196        assert_eq!(sidebar.width(), SIDEBAR_MAX);
197    }
198}