1use std::path::Path;
2
3use crate::primitives::display_width::str_width;
4use crate::view::theme::Theme;
5use fresh_core::api::OverlayColorSpec;
6use ratatui::style::Color;
7
8use super::{cache::insert_with_aliases, decorations::FileExplorerDecorationCache};
9
10pub const COMPATIBILITY_TRAILING_SLOT_HIT_WIDTH: u16 = 2;
11pub const DEFAULT_LEADING_SLOT_MIN_WIDTH: usize = 2;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ExplorerTooltipSummary {
15 pub title: String,
16 pub lines: Vec<String>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ExplorerLeadingSlotPayload {
21 pub text: String,
22 pub fg: Color,
23 pub min_width: usize,
24}
25
26impl ExplorerLeadingSlotPayload {
27 pub fn width(&self) -> usize {
28 str_width(&self.text).max(self.min_width)
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ExplorerTrailingSlotPayload {
34 pub text: String,
35 pub fg: Color,
36 pub tooltip: Option<ExplorerTooltipSummary>,
37}
38
39impl ExplorerTrailingSlotPayload {
40 pub fn width(&self) -> usize {
41 str_width(&self.text)
42 }
43}
44
45#[derive(Debug, Clone)]
46pub struct ExplorerTrailingSlotResolution {
47 pub payload: Option<ExplorerTrailingSlotPayload>,
48 pub name_color_hint: Option<Color>,
49}
50
51#[derive(Debug, Clone)]
52pub struct ExplorerSlotResolution {
53 pub leading: Option<ExplorerLeadingSlotPayload>,
54 pub trailing: Option<ExplorerTrailingSlotPayload>,
55 pub name_color_hint: Option<Color>,
56}
57
58pub struct ExplorerSlotContext<'a> {
59 pub path: &'a Path,
60 pub is_dir: bool,
61 pub has_unsaved: bool,
62 pub is_symlink: bool,
63 pub is_hidden: bool,
64 pub decorations: &'a FileExplorerDecorationCache,
65 pub slot_overrides: &'a FileExplorerSlotOverrideCache,
66 pub theme: &'a Theme,
67 pub neutral_fg: Color,
68}
69
70pub trait ExplorerLeadingSlotProvider {
71 fn resolve(&self, context: &ExplorerSlotContext<'_>) -> Option<ExplorerLeadingSlotPayload>;
72}
73
74pub trait ExplorerTrailingSlotProvider {
75 fn resolve(&self, context: &ExplorerSlotContext<'_>) -> ExplorerTrailingSlotResolution;
76
77 fn hit_test_width(&self) -> u16 {
78 COMPATIBILITY_TRAILING_SLOT_HIT_WIDTH
79 }
80}
81
82#[derive(Clone, Copy)]
83pub struct ExplorerSlotProviders {
84 pub leading: &'static dyn ExplorerLeadingSlotProvider,
85 pub trailing: &'static dyn ExplorerTrailingSlotProvider,
86}
87
88impl ExplorerSlotProviders {
89 pub fn resolver(self) -> ExplorerSlotResolver<'static> {
90 ExplorerSlotResolver::new(self.leading, self.trailing)
91 }
92}
93
94pub fn default_slot_providers() -> ExplorerSlotProviders {
95 ExplorerSlotProviders {
96 leading: &DEFAULT_LEADING_SLOT_PROVIDER,
97 trailing: &DEFAULT_TRAILING_SLOT_PROVIDER,
98 }
99}
100
101#[derive(Clone, Copy)]
102pub struct ExplorerSlotResolver<'a> {
103 leading: &'a dyn ExplorerLeadingSlotProvider,
104 trailing: &'a dyn ExplorerTrailingSlotProvider,
105}
106
107impl<'a> ExplorerSlotResolver<'a> {
108 pub fn new(
109 leading: &'a dyn ExplorerLeadingSlotProvider,
110 trailing: &'a dyn ExplorerTrailingSlotProvider,
111 ) -> Self {
112 Self { leading, trailing }
113 }
114
115 pub fn resolve(&self, context: &ExplorerSlotContext<'_>) -> ExplorerSlotResolution {
116 let trailing = self.trailing.resolve(context);
117 ExplorerSlotResolution {
118 leading: self.leading.resolve(context),
119 trailing: trailing.payload,
120 name_color_hint: trailing.name_color_hint,
121 }
122 }
123
124 pub fn trailing_hit_test_width(&self) -> u16 {
125 self.trailing.hit_test_width()
126 }
127}
128
129#[derive(Debug, Clone)]
130struct CachedLeadingSlot {
131 text: String,
132 color: OverlayColorSpec,
133 min_width: usize,
134}
135
136#[derive(Debug, Clone)]
137struct CachedTrailingSlot {
138 text: String,
139 color: OverlayColorSpec,
140 tooltip: Option<ExplorerTooltipSummary>,
141}
142
143#[derive(Debug, Clone)]
144struct CachedLeadingOverride {
145 slot: Option<CachedLeadingSlot>,
146 priority: i32,
147}
148
149#[derive(Debug, Clone)]
150struct CachedTrailingOverride {
151 slot: Option<CachedTrailingSlot>,
152 priority: i32,
153}
154
155#[derive(Debug, Clone)]
156struct CachedNameColorOverride {
157 color: Option<OverlayColorSpec>,
158 priority: i32,
159}
160
161#[derive(Debug, Default, Clone)]
162pub struct FileExplorerSlotOverrideCache {
163 direct_leading: std::collections::HashMap<std::path::PathBuf, CachedLeadingOverride>,
164 direct_trailing: std::collections::HashMap<std::path::PathBuf, CachedTrailingOverride>,
165 direct_name_color: std::collections::HashMap<std::path::PathBuf, CachedNameColorOverride>,
166}
167
168impl FileExplorerSlotOverrideCache {
169 pub fn rebuild<I>(
170 slots: I,
171 root: &Path,
172 symlink_mappings: &std::collections::HashMap<std::path::PathBuf, std::path::PathBuf>,
173 ) -> Self
174 where
175 I: IntoIterator<Item = fresh_core::file_explorer::FileExplorerSlotEntry>,
176 {
177 let mut direct_leading = std::collections::HashMap::new();
178 let mut direct_trailing = std::collections::HashMap::new();
179 let mut direct_name_color = std::collections::HashMap::new();
180
181 for slot in slots {
182 if !slot.path.starts_with(root) {
183 continue;
184 }
185
186 if slot.leading.is_some() || slot.suppress_leading {
187 let cached = CachedLeadingOverride {
188 slot: slot.leading.as_ref().map(|leading| CachedLeadingSlot {
189 text: leading.text.clone(),
190 color: leading.color.clone(),
191 min_width: leading.min_width,
192 }),
193 priority: slot.priority,
194 };
195 insert_with_aliases(
196 &mut direct_leading,
197 &slot.path,
198 &cached,
199 symlink_mappings,
200 |map, path, value| insert_best_cached(map, path, value, |entry| entry.priority),
201 );
202 }
203
204 if slot.trailing.is_some() || slot.suppress_trailing {
205 let cached = CachedTrailingOverride {
206 slot: slot.trailing.as_ref().map(|trailing| CachedTrailingSlot {
207 text: trailing.text.clone(),
208 color: trailing.color.clone(),
209 tooltip: trailing
210 .tooltip
211 .as_ref()
212 .map(|tooltip| ExplorerTooltipSummary {
213 title: tooltip.title.clone(),
214 lines: tooltip.lines.clone(),
215 }),
216 }),
217 priority: slot.priority,
218 };
219 insert_with_aliases(
220 &mut direct_trailing,
221 &slot.path,
222 &cached,
223 symlink_mappings,
224 |map, path, value| insert_best_cached(map, path, value, |entry| entry.priority),
225 );
226 }
227
228 if slot.name_color.is_some() || slot.suppress_name_color {
229 let cached = CachedNameColorOverride {
230 color: slot.name_color.clone(),
231 priority: slot.priority,
232 };
233 insert_with_aliases(
234 &mut direct_name_color,
235 &slot.path,
236 &cached,
237 symlink_mappings,
238 |map, path, value| insert_best_cached(map, path, value, |entry| entry.priority),
239 );
240 }
241 }
242
243 Self {
244 direct_leading,
245 direct_trailing,
246 direct_name_color,
247 }
248 }
249
250 fn leading_override_for_path(&self, path: &Path) -> Option<&CachedLeadingOverride> {
251 self.direct_leading.get(path)
252 }
253
254 fn trailing_override_for_path(&self, path: &Path) -> Option<&CachedTrailingOverride> {
255 self.direct_trailing.get(path)
256 }
257
258 fn name_color_override_for_path(&self, path: &Path) -> Option<&CachedNameColorOverride> {
259 self.direct_name_color.get(path)
260 }
261
262 pub fn has_trailing_override_for_path(&self, path: &Path) -> bool {
263 self.direct_trailing.contains_key(path)
264 }
265}
266
267pub struct DefaultLeadingSlotProvider;
268
269pub static DEFAULT_LEADING_SLOT_PROVIDER: DefaultLeadingSlotProvider = DefaultLeadingSlotProvider;
270
271impl ExplorerLeadingSlotProvider for DefaultLeadingSlotProvider {
272 fn resolve(&self, context: &ExplorerSlotContext<'_>) -> Option<ExplorerLeadingSlotPayload> {
273 context
274 .slot_overrides
275 .leading_override_for_path(context.path)
276 .and_then(|override_entry| {
277 override_entry
278 .slot
279 .as_ref()
280 .map(|slot| ExplorerLeadingSlotPayload {
281 text: slot.text.clone(),
282 fg: resolve_overlay_color(&slot.color, context.theme, context.neutral_fg),
283 min_width: slot.min_width,
284 })
285 })
286 }
287}
288
289pub struct DefaultTrailingSlotProvider;
290
291pub static DEFAULT_TRAILING_SLOT_PROVIDER: DefaultTrailingSlotProvider =
292 DefaultTrailingSlotProvider;
293
294impl ExplorerTrailingSlotProvider for DefaultTrailingSlotProvider {
295 fn resolve(&self, context: &ExplorerSlotContext<'_>) -> ExplorerTrailingSlotResolution {
296 let compatibility =
297 super::decorations::COMPATIBILITY_TRAILING_SLOT_PROVIDER.resolve(context);
298 let override_trailing = context
299 .slot_overrides
300 .trailing_override_for_path(context.path);
301 let override_name_color = context
302 .slot_overrides
303 .name_color_override_for_path(context.path);
304
305 ExplorerTrailingSlotResolution {
306 payload: match override_trailing {
307 Some(override_entry) => {
308 override_entry
309 .slot
310 .as_ref()
311 .map(|slot| ExplorerTrailingSlotPayload {
312 text: slot.text.clone(),
313 fg: resolve_overlay_color(
314 &slot.color,
315 context.theme,
316 context.neutral_fg,
317 ),
318 tooltip: slot.tooltip.clone(),
319 })
320 }
321 None => compatibility.payload,
322 },
323 name_color_hint: match override_name_color {
324 Some(override_entry) => override_entry
325 .color
326 .as_ref()
327 .map(|color| resolve_overlay_color(color, context.theme, context.neutral_fg)),
328 None => compatibility.name_color_hint,
329 },
330 }
331 }
332
333 fn hit_test_width(&self) -> u16 {
334 COMPATIBILITY_TRAILING_SLOT_HIT_WIDTH
335 }
336}
337
338fn resolve_overlay_color(spec: &OverlayColorSpec, theme: &Theme, fallback: Color) -> Color {
339 match spec {
340 OverlayColorSpec::Rgb(r, g, b) => Color::Rgb(*r, *g, *b),
341 OverlayColorSpec::ThemeKey(key) => theme.resolve_theme_key(key).unwrap_or(fallback),
342 }
343}
344
345fn insert_best_cached<T, FPriority>(
346 map: &mut std::collections::HashMap<std::path::PathBuf, T>,
347 path: std::path::PathBuf,
348 value: T,
349 priority: FPriority,
350) where
351 FPriority: Fn(&T) -> i32,
352{
353 let replace = match map.get(&path) {
354 Some(existing) => priority(&value) >= priority(existing),
355 None => true,
356 };
357
358 if replace {
359 map.insert(path, value);
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn slot_overrides_do_not_bubble_to_ancestors() {
369 let cache = FileExplorerSlotOverrideCache::rebuild(
370 vec![fresh_core::file_explorer::FileExplorerSlotEntry {
371 path: std::path::PathBuf::from("/repo/src/file.ts"),
372 leading: None,
373 suppress_leading: false,
374 trailing: Some(fresh_core::file_explorer::FileExplorerTrailingSlot {
375 text: "P".to_string(),
376 color: OverlayColorSpec::ThemeKey("syntax.string".into()),
377 tooltip: None,
378 }),
379 suppress_trailing: false,
380 name_color: Some(OverlayColorSpec::ThemeKey("syntax.type".into())),
381 suppress_name_color: false,
382 priority: 10,
383 }],
384 Path::new("/repo"),
385 &std::collections::HashMap::new(),
386 );
387
388 assert!(cache.has_trailing_override_for_path(Path::new("/repo/src/file.ts")));
389 assert!(!cache.has_trailing_override_for_path(Path::new("/repo/src")));
390 assert!(cache
391 .name_color_override_for_path(Path::new("/repo/src"))
392 .is_none());
393 }
394
395 #[test]
396 fn suppressed_trailing_and_name_color_block_compatibility_fallback() {
397 let theme = Theme::load_builtin("dark").unwrap();
398 let path = std::path::PathBuf::from("/repo/file.ts");
399 let decorations = FileExplorerDecorationCache::rebuild(
400 vec![fresh_core::file_explorer::FileExplorerDecoration {
401 path: path.clone(),
402 symbol: "M".to_string(),
403 color: OverlayColorSpec::ThemeKey("ui.file_status_modified_fg".into()),
404 priority: 50,
405 }],
406 Path::new("/repo"),
407 &std::collections::HashMap::new(),
408 );
409 let slot_overrides = FileExplorerSlotOverrideCache::rebuild(
410 vec![fresh_core::file_explorer::FileExplorerSlotEntry {
411 path: path.clone(),
412 leading: None,
413 suppress_leading: false,
414 trailing: None,
415 suppress_trailing: true,
416 name_color: None,
417 suppress_name_color: true,
418 priority: 10,
419 }],
420 Path::new("/repo"),
421 &std::collections::HashMap::new(),
422 );
423 let context = ExplorerSlotContext {
424 path: &path,
425 is_dir: false,
426 has_unsaved: false,
427 is_symlink: false,
428 is_hidden: false,
429 decorations: &decorations,
430 slot_overrides: &slot_overrides,
431 theme: &theme,
432 neutral_fg: theme.editor_fg,
433 };
434
435 let resolved = default_slot_providers().resolver().resolve(&context);
436 assert!(resolved.trailing.is_none());
437 assert!(resolved.name_color_hint.is_none());
438 }
439}