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
13pub 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#[derive(Debug, Default, Clone)]
127pub struct FileExplorerDecorationCache {
128 direct: HashMap<PathBuf, FileExplorerDecoration>,
129 bubbled: HashMap<PathBuf, FileExplorerDecoration>,
130}
131
132impl FileExplorerDecorationCache {
133 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 pub fn direct_for_path(&self, path: &Path) -> Option<&FileExplorerDecoration> {
179 self.direct.get(path)
180 }
181
182 pub fn bubbled_for_path(&self, path: &Path) -> Option<&FileExplorerDecoration> {
184 self.bubbled.get(path)
185 }
186
187 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 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}