Skip to main content

fresh/view/file_tree/
decorations.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use super::cache::{build_bubbled_cache, insert_with_aliases};
5use super::slots::{
6    ExplorerSlotContext, ExplorerTooltipSummary, ExplorerTrailingSlotPayload,
7    ExplorerTrailingSlotProvider, ExplorerTrailingSlotResolution,
8    COMPATIBILITY_TRAILING_SLOT_HIT_WIDTH,
9};
10use crate::view::theme::Theme;
11use ratatui::style::Color;
12
13// Re-export from fresh-core for shared type usage
14pub use fresh_core::file_explorer::FileExplorerDecoration;
15
16#[derive(Debug, Clone, Copy)]
17pub enum ResolvedExplorerStatus<'a> {
18    Unsaved,
19    Decoration(&'a FileExplorerDecoration),
20    BubbledDecoration(&'a FileExplorerDecoration),
21}
22
23#[derive(Debug, Clone, Copy)]
24pub struct ExplorerRowStatus<'a> {
25    resolved: Option<ResolvedExplorerStatus<'a>>,
26}
27
28impl<'a> ExplorerRowStatus<'a> {
29    pub fn resolve(
30        path: &Path,
31        is_dir: bool,
32        has_unsaved: bool,
33        decorations: &'a FileExplorerDecorationCache,
34    ) -> Self {
35        Self {
36            resolved: resolve_explorer_status(path, is_dir, has_unsaved, decorations),
37        }
38    }
39
40    pub fn resolved(&self) -> Option<ResolvedExplorerStatus<'a>> {
41        self.resolved
42    }
43
44    pub fn compatibility_trailing_slot(
45        &self,
46        theme: &Theme,
47        is_dir: bool,
48    ) -> Option<ExplorerTrailingSlotPayload> {
49        let (text, fg) = match self.resolved {
50            Some(ResolvedExplorerStatus::Unsaved) => ("●".to_string(), theme.diagnostic_warning_fg),
51            Some(ResolvedExplorerStatus::Decoration(decoration)) => (
52                decoration_symbol(&decoration.symbol),
53                compatibility_decoration_color(decoration, theme),
54            ),
55            Some(ResolvedExplorerStatus::BubbledDecoration(decoration)) => (
56                "●".to_string(),
57                compatibility_decoration_color(decoration, theme),
58            ),
59            None => return None,
60        };
61
62        Some(ExplorerTrailingSlotPayload {
63            text,
64            fg,
65            tooltip: self.tooltip_summary(is_dir),
66        })
67    }
68
69    pub fn tooltip_summary(&self, is_dir: bool) -> Option<ExplorerTooltipSummary> {
70        let mut lines = Vec::new();
71
72        match self.resolved {
73            Some(ResolvedExplorerStatus::Unsaved) => {
74                if is_dir {
75                    lines.push("● - Contains unsaved changes".to_string());
76                } else {
77                    lines.push("● - Unsaved changes in editor".to_string());
78                }
79            }
80            Some(ResolvedExplorerStatus::Decoration(decoration)) => {
81                lines.push(format!(
82                    "{} - {}",
83                    decoration_symbol(&decoration.symbol),
84                    decoration_tooltip(decoration)
85                ));
86            }
87            Some(ResolvedExplorerStatus::BubbledDecoration(_)) => {
88                lines.push("● - Contains modified files".to_string());
89            }
90            None => return None,
91        }
92
93        Some(ExplorerTooltipSummary {
94            title: "Git Status".to_string(),
95            lines,
96        })
97    }
98}
99
100pub struct CompatibilityTrailingSlotProvider;
101
102pub static COMPATIBILITY_TRAILING_SLOT_PROVIDER: CompatibilityTrailingSlotProvider =
103    CompatibilityTrailingSlotProvider;
104
105impl ExplorerTrailingSlotProvider for CompatibilityTrailingSlotProvider {
106    fn resolve(&self, context: &ExplorerSlotContext<'_>) -> ExplorerTrailingSlotResolution {
107        let row_status = ExplorerRowStatus::resolve(
108            context.path,
109            context.is_dir,
110            context.has_unsaved,
111            context.decorations,
112        );
113
114        ExplorerTrailingSlotResolution {
115            payload: row_status.compatibility_trailing_slot(context.theme, context.is_dir),
116            name_color_hint: None,
117        }
118    }
119
120    fn hit_test_width(&self) -> u16 {
121        COMPATIBILITY_TRAILING_SLOT_HIT_WIDTH
122    }
123}
124
125/// Cached decoration lookups for file explorer rendering.
126#[derive(Debug, Default, Clone)]
127pub struct FileExplorerDecorationCache {
128    direct: HashMap<PathBuf, FileExplorerDecoration>,
129    bubbled: HashMap<PathBuf, FileExplorerDecoration>,
130}
131
132impl FileExplorerDecorationCache {
133    /// Rebuild the cache from a list of decorations.
134    ///
135    /// `symlink_mappings` maps symlink paths to their canonical targets.
136    /// This allows decorations on canonical paths to also appear under symlink aliases.
137    pub fn rebuild<I>(
138        decorations: I,
139        root: &Path,
140        symlink_mappings: &HashMap<PathBuf, PathBuf>,
141    ) -> Self
142    where
143        I: IntoIterator<Item = FileExplorerDecoration>,
144    {
145        let mut direct = HashMap::new();
146        for decoration in decorations {
147            if !decoration.path.starts_with(root) {
148                continue;
149            }
150            insert_with_aliases(
151                &mut direct,
152                &decoration.path,
153                &decoration,
154                symlink_mappings,
155                |map, path, mut decoration| {
156                    decoration.path = path;
157                    insert_best(map, decoration);
158                },
159            );
160        }
161
162        let bubbled = build_bubbled_cache(
163            &direct,
164            root,
165            |map, _path, decoration| insert_best(map, decoration),
166            |ancestor, decoration| FileExplorerDecoration {
167                path: ancestor.to_path_buf(),
168                symbol: decoration.symbol.clone(),
169                color: decoration.color.clone(),
170                priority: decoration.priority,
171            },
172        );
173
174        Self { direct, bubbled }
175    }
176
177    /// Lookup a decoration for an exact path.
178    pub fn direct_for_path(&self, path: &Path) -> Option<&FileExplorerDecoration> {
179        self.direct.get(path)
180    }
181
182    /// Lookup a bubbled decoration for a path (direct or descendant).
183    pub fn bubbled_for_path(&self, path: &Path) -> Option<&FileExplorerDecoration> {
184        self.bubbled.get(path)
185    }
186
187    /// Direct decoration paths under `dir_path`, excluding `dir_path` itself.
188    pub fn direct_paths_under(&self, dir_path: &Path) -> Vec<PathBuf> {
189        let mut paths: Vec<PathBuf> = self
190            .direct
191            .keys()
192            .filter(|path| path_is_strict_child_of(path, dir_path))
193            .cloned()
194            .collect();
195        paths.sort();
196        paths
197    }
198}
199
200fn path_is_strict_child_of(child: &Path, parent: &Path) -> bool {
201    if child == parent {
202        return false;
203    }
204    if child.starts_with(parent) {
205        return true;
206    }
207
208    // Git and the filesystem can disagree on macOS (/var vs /private/var).
209    match (child.canonicalize(), parent.canonicalize()) {
210        (Ok(child), Ok(parent)) => child.starts_with(&parent) && child != parent,
211        _ => false,
212    }
213}
214
215pub fn resolve_explorer_status<'a>(
216    path: &Path,
217    is_dir: bool,
218    has_unsaved: bool,
219    decorations: &'a FileExplorerDecorationCache,
220) -> Option<ResolvedExplorerStatus<'a>> {
221    if has_unsaved {
222        return Some(ResolvedExplorerStatus::Unsaved);
223    }
224
225    if let Some(decoration) = decorations.direct_for_path(path) {
226        return Some(ResolvedExplorerStatus::Decoration(decoration));
227    }
228
229    if is_dir {
230        if let Some(decoration) = decorations.bubbled_for_path(path) {
231            return Some(ResolvedExplorerStatus::BubbledDecoration(decoration));
232        }
233    }
234
235    None
236}
237
238fn insert_best(
239    map: &mut HashMap<PathBuf, FileExplorerDecoration>,
240    decoration: FileExplorerDecoration,
241) {
242    let replace = match map.get(&decoration.path) {
243        Some(existing) => decoration.priority >= existing.priority,
244        None => true,
245    };
246
247    if replace {
248        map.insert(decoration.path.clone(), decoration);
249    }
250}
251
252pub fn compatibility_decoration_color(decoration: &FileExplorerDecoration, theme: &Theme) -> Color {
253    match &decoration.color {
254        fresh_core::api::OverlayColorSpec::Rgb(r, g, b) => Color::Rgb(*r, *g, *b),
255        fresh_core::api::OverlayColorSpec::ThemeKey(key) => {
256            theme.resolve_theme_key(key).unwrap_or(theme.editor_fg)
257        }
258    }
259}
260
261pub fn decoration_symbol(symbol: &str) -> String {
262    symbol
263        .chars()
264        .next()
265        .map(|c| c.to_string())
266        .unwrap_or_else(|| " ".to_string())
267}
268
269pub fn decoration_tooltip(decoration: &FileExplorerDecoration) -> &'static str {
270    match decoration.symbol.as_str() {
271        "U" => "Untracked - File is not tracked by git",
272        "M" if is_staged_modified_decoration(decoration) => "Modified - File has staged changes",
273        "M" => "Modified - File has unstaged changes",
274        "A" => "Added - File is staged for commit",
275        "D" => "Deleted - File is staged for deletion",
276        "R" => "Renamed - File has been renamed",
277        "C" => "Copied - File has been copied",
278        "!" => "Conflicted - File has merge conflicts",
279        "●" => "Has changes - Contains modified files",
280        _ => "Unknown status",
281    }
282}
283
284fn is_staged_modified_decoration(decoration: &FileExplorerDecoration) -> bool {
285    matches!(
286        &decoration.color,
287        fresh_core::api::OverlayColorSpec::ThemeKey(key)
288            if key == "ui.file_status_added_fg"
289    ) && decoration.symbol == "M"
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn resolves_unsaved_before_plugin_decoration() {
298        let path = PathBuf::from("/repo/file.rs");
299        let decorations = FileExplorerDecorationCache::rebuild(
300            vec![FileExplorerDecoration {
301                path: path.clone(),
302                symbol: "M".to_string(),
303                color: fresh_core::api::OverlayColorSpec::ThemeKey(
304                    "ui.file_status_modified_fg".into(),
305                ),
306                priority: 50,
307            }],
308            Path::new("/repo"),
309            &HashMap::new(),
310        );
311
312        let resolved = resolve_explorer_status(&path, false, true, &decorations);
313        assert!(matches!(resolved, Some(ResolvedExplorerStatus::Unsaved)));
314    }
315
316    #[test]
317    fn resolves_direct_decoration() {
318        let path = PathBuf::from("/repo/file.rs");
319        let decorations = FileExplorerDecorationCache::rebuild(
320            vec![FileExplorerDecoration {
321                path: path.clone(),
322                symbol: "P".to_string(),
323                color: fresh_core::api::OverlayColorSpec::ThemeKey(
324                    "ui.file_status_added_fg".into(),
325                ),
326                priority: 99,
327            }],
328            Path::new("/repo"),
329            &HashMap::new(),
330        );
331
332        let resolved = resolve_explorer_status(&path, false, false, &decorations);
333        assert!(matches!(
334            resolved,
335            Some(ResolvedExplorerStatus::Decoration(decoration)) if decoration.symbol == "P"
336        ));
337    }
338
339    #[test]
340    fn lists_direct_paths_under_directory_in_sorted_order() {
341        let cache = FileExplorerDecorationCache::rebuild(
342            vec![
343                FileExplorerDecoration {
344                    path: PathBuf::from("/repo/src/zeta.ts"),
345                    symbol: "M".to_string(),
346                    color: fresh_core::api::OverlayColorSpec::ThemeKey(
347                        "ui.file_status_modified_fg".into(),
348                    ),
349                    priority: 50,
350                },
351                FileExplorerDecoration {
352                    path: PathBuf::from("/repo/src/nested/alpha.ts"),
353                    symbol: "A".to_string(),
354                    color: fresh_core::api::OverlayColorSpec::ThemeKey(
355                        "ui.file_status_added_fg".into(),
356                    ),
357                    priority: 60,
358                },
359                FileExplorerDecoration {
360                    path: PathBuf::from("/repo/README.md"),
361                    symbol: "M".to_string(),
362                    color: fresh_core::api::OverlayColorSpec::ThemeKey(
363                        "ui.file_status_modified_fg".into(),
364                    ),
365                    priority: 50,
366                },
367            ],
368            Path::new("/repo"),
369            &HashMap::new(),
370        );
371
372        assert_eq!(
373            cache.direct_paths_under(Path::new("/repo/src")),
374            vec![
375                PathBuf::from("/repo/src/nested/alpha.ts"),
376                PathBuf::from("/repo/src/zeta.ts"),
377            ]
378        );
379    }
380
381    #[test]
382    fn decoration_tooltip_treats_git_explorer_staged_modified_as_staged() {
383        let decoration = FileExplorerDecoration {
384            path: PathBuf::from("/repo/file.rs"),
385            symbol: "M".to_string(),
386            color: fresh_core::api::OverlayColorSpec::ThemeKey("ui.file_status_added_fg".into()),
387            priority: 52,
388        };
389
390        assert_eq!(
391            decoration_tooltip(&decoration),
392            "Modified - File has staged changes"
393        );
394    }
395}