Skip to main content

tui_lipan/widgets/file_tree/
mod.rs

1//! Lazy file tree widget.
2
3mod component;
4mod events;
5mod explorer;
6mod fs;
7mod git;
8mod mod_private;
9
10pub use crate::style::FileIconPalette;
11pub use events::{FileTreeEvent, FileTreeToggleEvent};
12pub use fs::{FileIconStyle, FileKind};
13pub use git::{GitChangeState, GitFileStatus, GitIconStyle};
14pub(crate) use mod_private::FileTreeProps;
15use mod_private::{
16    default_git_style_added, default_git_style_conflicted, default_git_style_deleted,
17    default_git_style_modified, default_git_style_renamed, default_git_style_untracked,
18};
19
20use crate::callback::Callback;
21use crate::core::element::Element;
22use crate::style::{BorderStyle, Color, Length, Padding, ScrollbarConfig, Style, StyleSlot};
23use crate::widgets::{ScrollKeymap, TreeKeymap};
24use std::collections::{HashMap, HashSet};
25use std::sync::Arc;
26
27pub use crate::utils::file_icons::FileIconOverride;
28
29/// Optional style decorations for an exact [`FileTree`] path.
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub struct FileTreeItemStyle {
32    /// Style applied to the full row.
33    pub row: Option<Style>,
34    /// Style patched onto the icon span.
35    pub icon: Option<Style>,
36    /// Style patched onto label spans before search highlighting.
37    pub label: Option<Style>,
38    /// Style patched onto right-aligned change metadata spans.
39    pub suffix: Option<Style>,
40}
41
42impl FileTreeItemStyle {
43    /// Create an empty item style decoration.
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Set the full-row style.
49    pub fn row(mut self, style: Style) -> Self {
50        self.row = Some(style);
51        self
52    }
53
54    /// Set the icon style patch.
55    pub fn icon(mut self, style: Style) -> Self {
56        self.icon = Some(style);
57        self
58    }
59
60    /// Set the label style patch.
61    pub fn label(mut self, style: Style) -> Self {
62        self.label = Some(style);
63        self
64    }
65
66    /// Set the right-side change metadata style patch.
67    pub fn suffix(mut self, style: Style) -> Self {
68        self.suffix = Some(style);
69        self
70    }
71}
72
73/// Truncation priority for right-aligned FileTree change metadata.
74#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
75pub enum FileTreeSuffixPriority {
76    /// Prefer keeping the file/directory label visible and truncate suffix metadata first.
77    #[default]
78    Label,
79    /// Prefer keeping right-aligned suffix metadata visible and truncate the label first.
80    Suffix,
81}
82
83/// Source-agnostic file tree change display mode.
84#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
85pub enum FileTreeChangeView {
86    /// Show all files under the configured root.
87    #[default]
88    AllFiles,
89    /// Show only changed files and ancestor directories needed to group them.
90    ChangedOnly,
91}
92
93/// Compatibility alias for the previous Git-specific display mode name.
94pub type FileTreeGitView = FileTreeChangeView;
95
96/// Source used for file change decorations and changed-only projection.
97#[derive(Clone, Debug, Default, PartialEq, Eq)]
98pub enum FileTreeChangeSource {
99    /// Discover changes from the local Git repository containing the tree root.
100    #[default]
101    Git,
102    /// Use an app/server-provided virtual change set without local Git discovery.
103    Provided(Vec<FileTreeChange>),
104}
105
106impl FileTreeChangeSource {
107    /// Create a provided virtual change source.
108    pub fn provided(changes: impl IntoIterator<Item = FileTreeChange>) -> Self {
109        Self::Provided(changes.into_iter().collect())
110    }
111}
112
113/// Status for a source-agnostic changed file.
114#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
115pub enum FileTreeChangeStatus {
116    /// File was modified.
117    Modified,
118    /// File was added.
119    Added,
120    /// File was deleted.
121    Deleted,
122    /// File was renamed.
123    Renamed,
124    /// File is untracked/new to the source.
125    Untracked,
126    /// File has a conflict.
127    Conflicted,
128}
129
130impl From<FileTreeChangeStatus> for GitChangeState {
131    fn from(status: FileTreeChangeStatus) -> Self {
132        match status {
133            FileTreeChangeStatus::Modified => Self::Modified,
134            FileTreeChangeStatus::Added => Self::Added,
135            FileTreeChangeStatus::Deleted => Self::Deleted,
136            FileTreeChangeStatus::Renamed => Self::Renamed,
137            FileTreeChangeStatus::Untracked => Self::Untracked,
138            FileTreeChangeStatus::Conflicted => Self::Conflicted,
139        }
140    }
141}
142
143/// App/server-provided file change entry.
144#[derive(Clone, Debug, PartialEq, Eq)]
145pub struct FileTreeChange {
146    /// Changed path, either relative to the tree root or absolute under it.
147    pub path: Arc<str>,
148    /// Change status used for markers and styling.
149    pub status: FileTreeChangeStatus,
150    /// Optional virtual file kind. Defaults to `FileKind::File` for leaf rows.
151    pub kind: Option<FileKind>,
152    /// Added-line count for diff stats.
153    pub additions: usize,
154    /// Deleted-line count for diff stats.
155    pub deletions: usize,
156    /// Whether the status should render as staged. Defaults to unstaged.
157    pub staged: bool,
158}
159
160impl FileTreeChange {
161    /// Create a changed file entry.
162    pub fn new(path: impl Into<Arc<str>>, status: FileTreeChangeStatus) -> Self {
163        Self {
164            path: path.into(),
165            status,
166            kind: None,
167            additions: 0,
168            deletions: 0,
169            staged: false,
170        }
171    }
172
173    /// Set the virtual file kind for this changed path.
174    pub fn kind(mut self, kind: FileKind) -> Self {
175        self.kind = Some(kind);
176        self
177    }
178
179    /// Set diff statistics for this changed path.
180    pub fn diff_stat(mut self, additions: usize, deletions: usize) -> Self {
181        self.additions = additions;
182        self.deletions = deletions;
183        self
184    }
185
186    /// Set the added-line count for this changed path.
187    pub fn additions(mut self, additions: usize) -> Self {
188        self.additions = additions;
189        self
190    }
191
192    /// Set the deleted-line count for this changed path.
193    pub fn deletions(mut self, deletions: usize) -> Self {
194        self.deletions = deletions;
195        self
196    }
197
198    /// Mark this change as staged or unstaged.
199    pub fn staged(mut self, staged: bool) -> Self {
200        self.staged = staged;
201        self
202    }
203}
204
205/// Lazy-loading file explorer tree.
206#[derive(Clone)]
207pub struct FileTree {
208    props: FileTreeProps,
209}
210
211impl FileTree {
212    /// Create a new file tree rooted at `root`.
213    pub fn new(root: impl Into<Arc<str>>) -> Self {
214        Self {
215            props: FileTreeProps {
216                root: root.into(),
217                show_hidden: false,
218                max_entries_per_dir: 2_000,
219                show_icons: true,
220                icon_style: FileIconStyle::default(),
221                icon_palette: FileIconPalette::default(),
222                icon_overrides: HashMap::new(),
223                show_arrows: true,
224                indent_style: crate::widgets::IndentStyle::None,
225                indent_guide_style: Style::default(),
226                directory_icon: "[D]".into(),
227                opened_directory_icon: "[D]".into(),
228                file_icon: "[F]".into(),
229                symlink_icon: "[L]".into(),
230                other_icon: "[?]".into(),
231                directory_label_style: Style::default(),
232                file_label_style: Style::default(),
233                loading_label: "loading...".into(),
234                error_prefix: "error:".into(),
235                width: Length::Flex(1),
236                height: Length::Flex(1),
237                style: Style::default(),
238                hover_style: StyleSlot::Inherit,
239                item_hover_style: StyleSlot::Inherit,
240                selection_style: StyleSlot::Inherit,
241                unfocused_selection_style: StyleSlot::Inherit,
242                selected: None,
243                selected_path: None,
244                reveal_path: None,
245                select_path: None,
246                force_scroll_to_selected: false,
247                expanded_paths: None,
248                selection_symbol: None,
249                selection_symbol_style: None,
250                unfocused_selection_symbol_style: None,
251                scrollbar: true,
252                scrollbar_config: ScrollbarConfig::default(),
253                scroll_keys: ScrollKeymap::default(),
254                scroll_wheel: true,
255                show_scroll_indicators: false,
256                scroll_indicator_style: Style::default(),
257                empty_text: Some("Directory is empty".into()),
258                empty_text_style: Style::default(),
259                explorer: false,
260                explorer_placeholder: "Find files...".into(),
261                explorer_prefix: " ".into(),
262                explorer_input_border: false,
263                explorer_input_border_style: BorderStyle::Plain,
264                explorer_input_padding: Padding {
265                    left: 1,
266                    right: 0,
267                    top: 0,
268                    bottom: 0,
269                },
270                explorer_input_style: Style::default(),
271                explorer_input_focus_style: StyleSlot::Inherit,
272                explorer_input_focus_content_style: Style::default(),
273                explorer_placeholder_style: Style::default(),
274                explorer_focus_placeholder_style: Style::default(),
275                explorer_match_style: Style::default(),
276                explorer_divider: true,
277                explorer_divider_join_frame: true,
278                explorer_divider_char: '─',
279                explorer_divider_style: Style::default(),
280                activate_on_click: true,
281                focusable: true,
282                keymap: TreeKeymap::default(),
283                git_status: true,
284                highlight_changed_labels: false,
285                change_source: FileTreeChangeSource::default(),
286                change_view: FileTreeChangeView::default(),
287                git_diff_stats: false,
288                git_icon_style: GitIconStyle::NerdFont,
289                git_refresh_nonce: 0,
290                git_marker_modified: "M".into(),
291                git_marker_added: "A".into(),
292                git_marker_deleted: "D".into(),
293                git_marker_renamed: "R".into(),
294                git_marker_untracked: "?".into(),
295                git_marker_conflicted: "!".into(),
296                // Git status colors - these are defaults that work standalone
297                // ThemeProvider will override these if they match the defaults
298                git_style_modified: default_git_style_modified(),
299                git_style_added: default_git_style_added(),
300                git_style_deleted: default_git_style_deleted(),
301                git_style_renamed: default_git_style_renamed(),
302                git_style_untracked: default_git_style_untracked(),
303                git_style_conflicted: default_git_style_conflicted(),
304                change_suffix_style: Style::default(),
305                change_suffix_priority: FileTreeSuffixPriority::default(),
306                path_styles: HashMap::new(),
307                on_select: None,
308                on_activate: None,
309                on_toggle: None,
310            },
311        }
312    }
313
314    /// Toggle hidden entries (dotfiles).
315    pub fn show_hidden(mut self, show_hidden: bool) -> Self {
316        self.props.show_hidden = show_hidden;
317        self
318    }
319
320    /// Set max number of children loaded per directory.
321    pub fn max_entries_per_dir(mut self, max_entries: usize) -> Self {
322        self.props.max_entries_per_dir = max_entries.max(1);
323        self
324    }
325
326    /// Toggle icon rendering.
327    pub fn show_icons(mut self, show_icons: bool) -> Self {
328        self.props.show_icons = show_icons;
329        self
330    }
331
332    /// Set icon style for file tree items.
333    pub fn icon_style(mut self, style: FileIconStyle) -> Self {
334        self.props.icon_style = style;
335        self
336    }
337
338    /// Set the color palette for file icons.
339    pub fn icon_palette(mut self, palette: FileIconPalette) -> Self {
340        self.props.icon_palette = palette;
341        self
342    }
343
344    /// Add a custom icon override for a file extension or name.
345    ///
346    /// The `pattern` can be a file extension (e.g., "rs", "md") or a full filename (e.g., "README.md").
347    /// The icon will be used for files matching this pattern.
348    pub fn icon_override(
349        mut self,
350        pattern: impl Into<Arc<str>>,
351        icon: impl Into<Arc<str>>,
352        color: Option<Color>,
353    ) -> Self {
354        self.props.icon_overrides.insert(
355            pattern.into(),
356            FileIconOverride {
357                icon: icon.into(),
358                color,
359            },
360        );
361        self
362    }
363
364    /// Toggle expansion arrows before directories.
365    pub fn show_arrows(mut self, show: bool) -> Self {
366        self.props.show_arrows = show;
367        self
368    }
369
370    /// Set style of indentation guides.
371    pub fn indent_style(mut self, style: crate::widgets::IndentStyle) -> Self {
372        self.props.indent_style = style;
373        self
374    }
375
376    /// Set style for indent guides.
377    pub fn indent_guide_style(mut self, style: Style) -> Self {
378        self.props.indent_guide_style = style;
379        self
380    }
381
382    /// Set directory icon.
383    pub fn directory_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
384        self.props.directory_icon = icon.into();
385        self
386    }
387
388    /// Set "opened directory" icon.
389    pub fn opened_directory_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
390        self.props.opened_directory_icon = icon.into();
391        self
392    }
393
394    /// Set regular file icon.
395    pub fn file_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
396        self.props.file_icon = icon.into();
397        self
398    }
399
400    /// Set symlink icon.
401    pub fn symlink_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
402        self.props.symlink_icon = icon.into();
403        self
404    }
405
406    /// Set "other type" icon.
407    pub fn other_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
408        self.props.other_icon = icon.into();
409        self
410    }
411
412    /// Set the label style for directory rows.
413    pub fn directory_label_style(mut self, style: Style) -> Self {
414        self.props.directory_label_style = style;
415        self
416    }
417
418    /// Set the label style for regular file rows.
419    pub fn file_label_style(mut self, style: Style) -> Self {
420        self.props.file_label_style = style;
421        self
422    }
423
424    /// Set item decorations for an exact path under the effective root.
425    pub fn path_style(mut self, path: impl Into<Arc<str>>, style: FileTreeItemStyle) -> Self {
426        self.props.path_styles.insert(path.into(), style);
427        self
428    }
429
430    /// Set item decorations for exact paths under the effective root.
431    pub fn path_styles(
432        mut self,
433        styles: impl IntoIterator<Item = (impl Into<Arc<str>>, FileTreeItemStyle)>,
434    ) -> Self {
435        self.props
436            .path_styles
437            .extend(styles.into_iter().map(|(path, style)| (path.into(), style)));
438        self
439    }
440
441    /// Set loading row label.
442    pub fn loading_label(mut self, label: impl Into<Arc<str>>) -> Self {
443        self.props.loading_label = label.into();
444        self
445    }
446
447    /// Set load-error prefix.
448    pub fn error_prefix(mut self, prefix: impl Into<Arc<str>>) -> Self {
449        self.props.error_prefix = prefix.into();
450        self
451    }
452
453    /// Set width.
454    pub fn width(mut self, width: Length) -> Self {
455        self.props.width = width;
456        self
457    }
458
459    /// Set height.
460    pub fn height(mut self, height: Length) -> Self {
461        self.props.height = height;
462        self
463    }
464
465    /// Set base style.
466    pub fn style(mut self, style: Style) -> Self {
467        self.props.style = style;
468        self
469    }
470
471    /// Set hovered style.
472    pub fn hover_style(mut self, style: Style) -> Self {
473        self.props.hover_style = StyleSlot::Replace(style);
474        self
475    }
476
477    /// Extend the themed hovered style.
478    pub fn extend_hover_style(mut self, style: Style) -> Self {
479        self.props.hover_style = StyleSlot::Extend(style);
480        self
481    }
482
483    /// Inherit the themed hovered style.
484    pub fn inherit_hover_style(mut self) -> Self {
485        self.props.hover_style = StyleSlot::Inherit;
486        self
487    }
488
489    /// Set hover style slot directly for composite forwarding.
490    pub fn hover_style_slot(mut self, slot: StyleSlot) -> Self {
491        self.props.hover_style = slot;
492        self
493    }
494
495    /// Set hovered row style.
496    pub fn item_hover_style(mut self, style: Style) -> Self {
497        self.props.item_hover_style = StyleSlot::Replace(style);
498        self
499    }
500
501    /// Extend the themed hovered row style.
502    pub fn extend_item_hover_style(mut self, style: Style) -> Self {
503        self.props.item_hover_style = StyleSlot::Extend(style);
504        self
505    }
506
507    /// Inherit the themed hovered row style.
508    pub fn inherit_item_hover_style(mut self) -> Self {
509        self.props.item_hover_style = StyleSlot::Inherit;
510        self
511    }
512
513    /// Set item-hover style slot directly for composite forwarding.
514    pub fn item_hover_style_slot(mut self, slot: StyleSlot) -> Self {
515        self.props.item_hover_style = slot;
516        self
517    }
518
519    /// Set selected row style.
520    pub fn selection_style(mut self, style: Style) -> Self {
521        self.props.selection_style = StyleSlot::Replace(style);
522        self
523    }
524
525    /// Extend the themed selected row style.
526    pub fn extend_selection_style(mut self, style: Style) -> Self {
527        self.props.selection_style = StyleSlot::Extend(style);
528        self
529    }
530
531    /// Inherit the themed selected row style.
532    pub fn inherit_selection_style(mut self) -> Self {
533        self.props.selection_style = StyleSlot::Inherit;
534        self
535    }
536
537    /// Set selected row style slot directly for composite forwarding.
538    pub fn selection_style_slot(mut self, slot: StyleSlot) -> Self {
539        self.props.selection_style = slot;
540        self
541    }
542
543    /// Set selected row style while the file tree is not focused.
544    pub fn unfocused_selection_style(mut self, style: Style) -> Self {
545        self.props.unfocused_selection_style = StyleSlot::Replace(style);
546        self
547    }
548
549    /// Extend the themed selected row style while the file tree is not focused.
550    pub fn extend_unfocused_selection_style(mut self, style: Style) -> Self {
551        self.props.unfocused_selection_style = StyleSlot::Extend(style);
552        self
553    }
554
555    /// Inherit the themed selected row style while the file tree is not focused.
556    pub fn inherit_unfocused_selection_style(mut self) -> Self {
557        self.props.unfocused_selection_style = StyleSlot::Inherit;
558        self
559    }
560
561    /// Set unfocused selected row style slot directly for composite forwarding.
562    pub fn unfocused_selection_style_slot(mut self, slot: StyleSlot) -> Self {
563        self.props.unfocused_selection_style = slot;
564        self
565    }
566
567    /// Set the selected visible row index.
568    pub fn selected(mut self, selected: usize) -> Self {
569        self.props.selected = Some(selected);
570        self
571    }
572
573    /// Select a visible row by path.
574    ///
575    /// The path may be absolute under the tree root or relative to it. Selection is a no-op when
576    /// the normalized path is outside the root or is not present in the current visible projection.
577    pub fn selected_path(mut self, path: impl Into<Arc<str>>) -> Self {
578        self.props.selected_path = Some(path.into());
579        self
580    }
581
582    /// Reveal a path by expanding/loading ancestor directories when possible.
583    ///
584    /// The path may be absolute under the tree root or relative to it. Revealing is a no-op for
585    /// paths outside the root, paths hidden by `show_hidden(false)`, unreadable directories, capped
586    /// directory entries, and paths absent from the active all-files/changed-only projection.
587    pub fn reveal_path(mut self, path: impl Into<Arc<str>>) -> Self {
588        self.props.reveal_path = Some(path.into());
589        self
590    }
591
592    /// Reveal and select a path, forcing scroll to the row when it is visible.
593    ///
594    /// This combines `reveal_path` and `selected_path` behavior. With controlled
595    /// `expanded_paths`, the path can only be revealed when the controlled expansion set (plus this
596    /// reveal request for rendering) makes the ancestors available to load.
597    pub fn select_path(mut self, path: impl Into<Arc<str>>) -> Self {
598        self.props.select_path = Some(path.into());
599        self
600    }
601
602    /// Force scroll to make the selected item visible on next render.
603    pub fn force_scroll_to_selected(mut self, force: bool) -> Self {
604        self.props.force_scroll_to_selected = force;
605        self
606    }
607
608    /// Control expanded directory paths.
609    pub fn expanded_paths(mut self, paths: impl IntoIterator<Item = impl Into<Arc<str>>>) -> Self {
610        self.props.expanded_paths = Some(paths.into_iter().map(Into::into).collect::<HashSet<_>>());
611        self
612    }
613
614    /// Set selected row prefix symbol.
615    pub fn selection_symbol(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
616        self.props.selection_symbol = symbol.map(Into::into);
617        self
618    }
619
620    /// Set selected row prefix style.
621    pub fn selection_symbol_style(mut self, style: Option<Style>) -> Self {
622        self.props.selection_symbol_style = style;
623        self
624    }
625
626    /// Set selected row prefix style while the file tree is not focused.
627    pub fn unfocused_selection_symbol_style(mut self, style: Option<Style>) -> Self {
628        self.props.unfocused_selection_symbol_style = style;
629        self
630    }
631
632    /// Toggle scrollbar.
633    pub fn scrollbar(mut self, scrollbar: bool) -> Self {
634        self.props.scrollbar = scrollbar;
635        self
636    }
637
638    /// Set scrollbar configuration.
639    pub fn scrollbar_config(mut self, config: ScrollbarConfig) -> Self {
640        self.props.scrollbar_config = config;
641        self
642    }
643
644    /// Configure keyboard scrolling bindings.
645    pub fn scroll_keys(mut self, keys: ScrollKeymap) -> Self {
646        self.props.scroll_keys = keys;
647        self
648    }
649
650    /// Enable mouse wheel scrolling.
651    pub fn scroll_wheel(mut self, enabled: bool) -> Self {
652        self.props.scroll_wheel = enabled;
653        self
654    }
655
656    /// Toggle hidden-row indicators (`N more`).
657    pub fn show_scroll_indicators(mut self, show: bool) -> Self {
658        self.props.show_scroll_indicators = show;
659        self
660    }
661
662    /// Set hidden-row indicator style.
663    pub fn scroll_indicator_style(mut self, style: Style) -> Self {
664        self.props.scroll_indicator_style = style;
665        self
666    }
667
668    /// Set empty-state text.
669    pub fn empty_text(mut self, text: impl Into<Arc<str>>) -> Self {
670        self.props.empty_text = Some(text.into());
671        self
672    }
673
674    /// Set empty-state style.
675    pub fn empty_text_style(mut self, style: Style) -> Self {
676        self.props.empty_text_style = style;
677        self
678    }
679
680    /// Toggle explorer filter input above the tree.
681    pub fn explorer(mut self, explorer: bool) -> Self {
682        self.props.explorer = explorer;
683        self
684    }
685
686    /// Set explorer placeholder text.
687    pub fn explorer_placeholder(mut self, text: impl Into<Arc<str>>) -> Self {
688        self.props.explorer_placeholder = text.into();
689        self
690    }
691
692    /// Set explorer input prefix text.
693    pub fn explorer_prefix(mut self, prefix: impl Into<Arc<str>>) -> Self {
694        self.props.explorer_prefix = prefix.into();
695        self
696    }
697
698    /// Toggle explorer input border.
699    pub fn explorer_input_border(mut self, border: bool) -> Self {
700        self.props.explorer_input_border = border;
701        self
702    }
703
704    /// Set explorer input border style.
705    pub fn explorer_input_border_style(mut self, style: BorderStyle) -> Self {
706        self.props.explorer_input_border_style = style;
707        self
708    }
709
710    /// Set explorer input padding.
711    pub fn explorer_input_padding(mut self, padding: impl Into<Padding>) -> Self {
712        self.props.explorer_input_padding = padding.into();
713        self
714    }
715
716    /// Set explorer input style.
717    pub fn explorer_input_style(mut self, style: Style) -> Self {
718        self.props.explorer_input_style = style;
719        self
720    }
721
722    /// Set explorer input style when focused.
723    pub fn explorer_input_focus_style(mut self, style: Style) -> Self {
724        self.props.explorer_input_focus_style = StyleSlot::Replace(style);
725        self
726    }
727
728    /// Extend the themed explorer input focus style.
729    pub fn extend_explorer_input_focus_style(mut self, style: Style) -> Self {
730        self.props.explorer_input_focus_style = StyleSlot::Extend(style);
731        self
732    }
733
734    /// Inherit the themed explorer input focus style.
735    pub fn inherit_explorer_input_focus_style(mut self) -> Self {
736        self.props.explorer_input_focus_style = StyleSlot::Inherit;
737        self
738    }
739
740    /// Set explorer input focus style slot directly for composite forwarding.
741    pub fn explorer_input_focus_style_slot(mut self, slot: StyleSlot) -> Self {
742        self.props.explorer_input_focus_style = slot;
743        self
744    }
745
746    /// Set focused explorer input content text style.
747    pub fn explorer_input_focus_content_style(mut self, style: Style) -> Self {
748        self.props.explorer_input_focus_content_style = style;
749        self
750    }
751
752    /// Set explorer placeholder style.
753    pub fn explorer_placeholder_style(mut self, style: Style) -> Self {
754        self.props.explorer_placeholder_style = style;
755        self
756    }
757
758    /// Set explorer placeholder style when focused.
759    pub fn explorer_focus_placeholder_style(mut self, style: Style) -> Self {
760        self.props.explorer_focus_placeholder_style = style;
761        self
762    }
763
764    /// Set explorer search match highlight style.
765    pub fn explorer_match_style(mut self, style: Style) -> Self {
766        self.props.explorer_match_style = style;
767        self
768    }
769
770    /// Toggle divider below the explorer input.
771    pub fn explorer_divider(mut self, show: bool) -> Self {
772        self.props.explorer_divider = show;
773        self
774    }
775
776    /// Toggle frame-join behavior for explorer divider.
777    pub fn explorer_divider_join_frame(mut self, join: bool) -> Self {
778        self.props.explorer_divider_join_frame = join;
779        self
780    }
781
782    /// Set divider character for explorer divider.
783    pub fn explorer_divider_char(mut self, ch: char) -> Self {
784        self.props.explorer_divider_char = ch;
785        self
786    }
787
788    /// Set explorer divider style.
789    pub fn explorer_divider_style(mut self, style: Style) -> Self {
790        self.props.explorer_divider_style = style;
791        self
792    }
793
794    /// Set tree activation behavior for mouse clicks.
795    pub fn activate_on_click(mut self, activate_on_click: bool) -> Self {
796        self.props.activate_on_click = activate_on_click;
797        self
798    }
799
800    /// Control focusability.
801    pub fn focusable(mut self, focusable: bool) -> Self {
802        self.props.focusable = focusable;
803        self
804    }
805
806    /// Configure expand/collapse keymap.
807    pub fn keymap(mut self, keymap: TreeKeymap) -> Self {
808        self.props.keymap = keymap;
809        self
810    }
811
812    /// Toggle Git status decorations.
813    pub fn git_status(mut self, enabled: bool) -> Self {
814        self.props.git_status = enabled;
815        self
816    }
817
818    /// Toggle applying change status styles to file and directory labels.
819    ///
820    /// Status indicators remain styled independently. The default is `false`, so
821    /// file names keep their regular file-kind styling while dirty state is shown
822    /// in the right-aligned metadata.
823    pub fn highlight_changed_labels(mut self, enabled: bool) -> Self {
824        self.props.highlight_changed_labels = enabled;
825        self
826    }
827
828    /// Set the changed-file display mode using the Git-compatible alias.
829    pub fn git_view(mut self, view: FileTreeGitView) -> Self {
830        self.props.change_view = view;
831        self
832    }
833
834    /// Set the source used for changed-file decorations and changed-only projection.
835    pub fn change_source(mut self, source: FileTreeChangeSource) -> Self {
836        self.props.change_source = source;
837        self
838    }
839
840    /// Set the source-agnostic changed-file display mode.
841    pub fn change_view(mut self, view: FileTreeChangeView) -> Self {
842        self.props.change_view = view;
843        self
844    }
845
846    /// Toggle changed-only mode using the Git-compatible builder name.
847    pub fn git_changed_only(mut self, enabled: bool) -> Self {
848        self.props.change_view = if enabled {
849            FileTreeChangeView::ChangedOnly
850        } else {
851            FileTreeChangeView::AllFiles
852        };
853        self
854    }
855
856    /// Toggle diff statistics decorations for any change source.
857    pub fn show_diff_stats(self, enabled: bool) -> Self {
858        self.git_diff_stats(enabled)
859    }
860
861    /// Toggle diff statistics decorations using the Git-compatible builder name.
862    pub fn git_diff_stats(mut self, enabled: bool) -> Self {
863        self.props.git_diff_stats = enabled;
864        self
865    }
866
867    /// Set icon style for Git status indicators.
868    pub fn git_icon_style(mut self, style: GitIconStyle) -> Self {
869        self.props.git_icon_style = style;
870        self
871    }
872
873    /// Request an immediate Git status refresh.
874    ///
875    /// Call this when building the widget in response to a user action.
876    pub fn refresh_git_status(mut self) -> Self {
877        use std::sync::atomic::{AtomicU64, Ordering};
878        static FILE_TREE_REFRESH_NONCE: AtomicU64 = AtomicU64::new(1);
879        self.props.git_refresh_nonce = FILE_TREE_REFRESH_NONCE.fetch_add(1, Ordering::Relaxed);
880        self
881    }
882
883    /// Set explicit refresh token for Git status loading.
884    ///
885    /// When the token changes, the component triggers a new background refresh.
886    pub fn git_refresh_token(mut self, token: u64) -> Self {
887        self.props.git_refresh_nonce = token;
888        self
889    }
890
891    /// Set marker for `Modified` git status.
892    pub fn git_marker_modified(mut self, marker: impl Into<Arc<str>>) -> Self {
893        self.props.git_marker_modified = marker.into();
894        self
895    }
896
897    /// Set marker for `Added` git status.
898    pub fn git_marker_added(mut self, marker: impl Into<Arc<str>>) -> Self {
899        self.props.git_marker_added = marker.into();
900        self
901    }
902
903    /// Set marker for `Deleted` git status.
904    pub fn git_marker_deleted(mut self, marker: impl Into<Arc<str>>) -> Self {
905        self.props.git_marker_deleted = marker.into();
906        self
907    }
908
909    /// Set marker for `Renamed` git status.
910    pub fn git_marker_renamed(mut self, marker: impl Into<Arc<str>>) -> Self {
911        self.props.git_marker_renamed = marker.into();
912        self
913    }
914
915    /// Set marker for `Untracked` git status.
916    pub fn git_marker_untracked(mut self, marker: impl Into<Arc<str>>) -> Self {
917        self.props.git_marker_untracked = marker.into();
918        self
919    }
920
921    /// Set marker for `Conflicted` git status.
922    pub fn git_marker_conflicted(mut self, marker: impl Into<Arc<str>>) -> Self {
923        self.props.git_marker_conflicted = marker.into();
924        self
925    }
926
927    /// Set style for `Modified` git status marker.
928    pub fn git_style_modified(mut self, style: Style) -> Self {
929        self.props.git_style_modified = style;
930        self
931    }
932
933    /// Set style for `Added` git status marker.
934    pub fn git_style_added(mut self, style: Style) -> Self {
935        self.props.git_style_added = style;
936        self
937    }
938
939    /// Set style for `Deleted` git status marker.
940    pub fn git_style_deleted(mut self, style: Style) -> Self {
941        self.props.git_style_deleted = style;
942        self
943    }
944
945    /// Set style for `Renamed` git status marker.
946    pub fn git_style_renamed(mut self, style: Style) -> Self {
947        self.props.git_style_renamed = style;
948        self
949    }
950
951    /// Set style for `Untracked` git status marker.
952    pub fn git_style_untracked(mut self, style: Style) -> Self {
953        self.props.git_style_untracked = style;
954        self
955    }
956
957    /// Set style for `Conflicted` git status marker.
958    pub fn git_style_conflicted(mut self, style: Style) -> Self {
959        self.props.git_style_conflicted = style;
960        self
961    }
962
963    /// Set a source-agnostic style patch for right-aligned change metadata.
964    pub fn change_suffix_style(mut self, style: Style) -> Self {
965        self.props.change_suffix_style = style;
966        self
967    }
968
969    /// Set a Git-compatible style patch for right-aligned change metadata.
970    pub fn git_suffix_style(self, style: Style) -> Self {
971        self.change_suffix_style(style)
972    }
973
974    /// Set whether labels or right-aligned change metadata win when rows are narrow.
975    pub fn change_suffix_priority(mut self, priority: FileTreeSuffixPriority) -> Self {
976        self.props.change_suffix_priority = priority;
977        self
978    }
979
980    /// Set Git-compatible truncation priority for right-aligned change metadata.
981    pub fn git_suffix_priority(self, priority: FileTreeSuffixPriority) -> Self {
982        self.change_suffix_priority(priority)
983    }
984
985    /// Set selection callback.
986    pub fn on_select(mut self, cb: Callback<FileTreeEvent>) -> Self {
987        self.props.on_select = Some(cb);
988        self
989    }
990
991    /// Fired when a row is activated (Enter, or click when `activate_on_click` is true).
992    pub fn on_activate(mut self, cb: Callback<FileTreeEvent>) -> Self {
993        self.props.on_activate = Some(cb);
994        self
995    }
996
997    /// Set expand/collapse callback.
998    pub fn on_toggle(mut self, cb: Callback<FileTreeToggleEvent>) -> Self {
999        self.props.on_toggle = Some(cb);
1000        self
1001    }
1002}
1003
1004impl From<FileTree> for Element {
1005    fn from(file_tree: FileTree) -> Self {
1006        let root_key = file_tree.props.root.clone();
1007        crate::child(component::FileTreeComponent::new, file_tree.props).key(root_key)
1008    }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014    use std::collections::{HashMap, HashSet};
1015    use std::sync::Arc;
1016
1017    #[test]
1018    fn parses_untracked_line() {
1019        use git::GitChangeState;
1020        use git::parse_git_porcelain_line;
1021        let parsed = parse_git_porcelain_line("?? src/new.rs");
1022        assert_eq!(
1023            parsed.map(|(p, s)| (p, s.unstaged)),
1024            Some(("src/new.rs", Some(GitChangeState::Untracked)))
1025        );
1026    }
1027
1028    #[test]
1029    fn parses_rename_destination() {
1030        use git::GitChangeState;
1031        use git::parse_git_porcelain_line;
1032        let parsed = parse_git_porcelain_line("R  old/name.rs -> new/name.rs");
1033        assert_eq!(
1034            parsed.map(|(p, s)| (p, s.staged)),
1035            Some(("new/name.rs", Some(GitChangeState::Renamed)))
1036        );
1037    }
1038
1039    #[test]
1040    fn conflicting_status_wins_priority() {
1041        use git::insert_status;
1042        use git::{GitChangeState, GitFileStatus};
1043        let mut statuses = HashMap::new();
1044        let key: Arc<str> = "/repo/src/app.rs".into();
1045
1046        insert_status(
1047            &mut statuses,
1048            key.clone(),
1049            GitFileStatus::new(None, Some(GitChangeState::Modified)),
1050        );
1051        insert_status(
1052            &mut statuses,
1053            key.clone(),
1054            GitFileStatus::new(None, Some(GitChangeState::Conflicted)),
1055        );
1056        insert_status(
1057            &mut statuses,
1058            key.clone(),
1059            GitFileStatus::new(None, Some(GitChangeState::Added)),
1060        );
1061
1062        assert_eq!(
1063            statuses.get(key.as_ref()).copied().and_then(|s| s.unstaged),
1064            Some(GitChangeState::Conflicted)
1065        );
1066    }
1067
1068    #[test]
1069    fn parses_numstat_line() {
1070        use git::parse_git_numstat_line;
1071
1072        let parsed = parse_git_numstat_line("30\t2\tsrc/lib.rs");
1073
1074        assert_eq!(
1075            parsed.map(|(path, stat)| (path, stat.added, stat.removed)),
1076            Some(("src/lib.rs".to_string(), 30, 2))
1077        );
1078    }
1079
1080    #[test]
1081    fn parses_braced_numstat_rename_destination() {
1082        use git::parse_git_numstat_line;
1083
1084        let parsed = parse_git_numstat_line("4\t1\tsrc/{old => new}/file.rs");
1085
1086        assert_eq!(
1087            parsed.map(|(path, stat)| (path, stat.added, stat.removed)),
1088            Some(("src/new/file.rs".to_string(), 4, 1))
1089        );
1090    }
1091
1092    #[test]
1093    fn ignores_binary_numstat_line() {
1094        use git::parse_git_numstat_line;
1095
1096        assert_eq!(parse_git_numstat_line("-\t-\tassets/logo.png"), None);
1097    }
1098
1099    #[test]
1100    fn git_view_builders_update_props() {
1101        let tree = FileTree::new(".")
1102            .git_changed_only(true)
1103            .git_diff_stats(true);
1104
1105        assert_eq!(tree.props.change_view, FileTreeChangeView::ChangedOnly);
1106        assert!(tree.props.git_diff_stats);
1107
1108        let tree = tree.git_changed_only(false);
1109        assert_eq!(tree.props.change_view, FileTreeChangeView::AllFiles);
1110    }
1111
1112    #[test]
1113    fn highlight_changed_labels_builder_updates_props() {
1114        let tree = FileTree::new(".").highlight_changed_labels(true);
1115
1116        assert!(tree.props.highlight_changed_labels);
1117
1118        let tree = tree.highlight_changed_labels(false);
1119        assert!(!tree.props.highlight_changed_labels);
1120    }
1121
1122    #[test]
1123    fn label_style_builders_update_props() {
1124        let directory_style = Style::new().fg(Color::Blue).bold();
1125        let file_style = Style::new().fg(Color::Green);
1126
1127        let tree = FileTree::new(".")
1128            .directory_label_style(directory_style)
1129            .file_label_style(file_style);
1130
1131        assert_eq!(tree.props.directory_label_style, directory_style);
1132        assert_eq!(tree.props.file_label_style, file_style);
1133    }
1134
1135    #[test]
1136    fn item_style_builders_store_optional_styles() {
1137        let row = Style::new().bg(Color::Blue);
1138        let icon = Style::new().fg(Color::Cyan);
1139        let label = Style::new().fg(Color::Green).bold();
1140        let suffix = Style::new().dim();
1141
1142        let style = FileTreeItemStyle::new()
1143            .row(row)
1144            .icon(icon)
1145            .label(label)
1146            .suffix(suffix);
1147
1148        assert_eq!(style.row, Some(row));
1149        assert_eq!(style.icon, Some(icon));
1150        assert_eq!(style.label, Some(label));
1151        assert_eq!(style.suffix, Some(suffix));
1152    }
1153
1154    #[test]
1155    fn path_and_suffix_style_builders_update_props() {
1156        let item_style = FileTreeItemStyle::new().label(Style::new().fg(Color::Green));
1157        let suffix_style = Style::new().dim();
1158        let tree = FileTree::new("/repo")
1159            .path_style("src/main.rs", item_style)
1160            .path_styles([("src/lib.rs", item_style.suffix(suffix_style))])
1161            .change_suffix_style(suffix_style)
1162            .change_suffix_priority(FileTreeSuffixPriority::Suffix);
1163
1164        assert_eq!(tree.props.path_styles.get("src/main.rs"), Some(&item_style));
1165        assert_eq!(tree.props.change_suffix_style, suffix_style);
1166        assert_eq!(
1167            tree.props.change_suffix_priority,
1168            FileTreeSuffixPriority::Suffix
1169        );
1170
1171        let tree = tree
1172            .git_suffix_style(Style::new().italic())
1173            .git_suffix_priority(FileTreeSuffixPriority::Label);
1174        assert_eq!(tree.props.change_suffix_style, Style::new().italic());
1175        assert_eq!(
1176            tree.props.change_suffix_priority,
1177            FileTreeSuffixPriority::Label
1178        );
1179    }
1180
1181    #[test]
1182    fn crate_root_export_compiles() {
1183        let _: crate::FileTreeItemStyle = FileTreeItemStyle::new();
1184        let _: crate::FileTreeSuffixPriority = FileTreeSuffixPriority::Suffix;
1185    }
1186
1187    #[test]
1188    fn change_view_builders_update_source_agnostic_props() {
1189        let changes = vec![FileTreeChange::new(
1190            "src/main.rs",
1191            FileTreeChangeStatus::Modified,
1192        )];
1193        let tree = FileTree::new("/repo")
1194            .change_source(FileTreeChangeSource::Provided(changes.clone()))
1195            .change_view(FileTreeChangeView::ChangedOnly)
1196            .show_diff_stats(true);
1197
1198        assert_eq!(
1199            tree.props.change_source,
1200            FileTreeChangeSource::Provided(changes)
1201        );
1202        assert_eq!(tree.props.change_view, FileTreeChangeView::ChangedOnly);
1203        assert!(tree.props.git_diff_stats);
1204    }
1205
1206    #[test]
1207    fn controlled_tree_state_builders_update_props() {
1208        let tree = FileTree::new("/repo")
1209            .selected(3)
1210            .selected_path("src/main.rs")
1211            .reveal_path("src")
1212            .select_path("tests/tree.rs")
1213            .force_scroll_to_selected(true)
1214            .expanded_paths(["/repo/src", "/repo/tests"]);
1215
1216        assert_eq!(tree.props.selected, Some(3));
1217        assert_eq!(tree.props.selected_path.as_deref(), Some("src/main.rs"));
1218        assert_eq!(tree.props.reveal_path.as_deref(), Some("src"));
1219        assert_eq!(tree.props.select_path.as_deref(), Some("tests/tree.rs"));
1220        assert!(tree.props.force_scroll_to_selected);
1221        assert_eq!(
1222            tree.props.expanded_paths,
1223            Some(HashSet::from([
1224                Arc::<str>::from("/repo/src"),
1225                Arc::<str>::from("/repo/tests")
1226            ]))
1227        );
1228    }
1229
1230    #[test]
1231    fn provided_snapshot_resolves_absolute_and_rejects_outside_paths() {
1232        let changes = vec![
1233            FileTreeChange::new("/repo/src/main.rs", FileTreeChangeStatus::Modified),
1234            FileTreeChange::new("/elsewhere/ignored.rs", FileTreeChangeStatus::Deleted),
1235            FileTreeChange::new("../escape.rs", FileTreeChangeStatus::Added),
1236        ];
1237
1238        let snapshot = git::provided_change_snapshot("/repo", &changes);
1239
1240        assert_eq!(snapshot.changed_paths, vec![Arc::from("/repo/src/main.rs")]);
1241    }
1242
1243    #[test]
1244    fn provided_snapshot_accepts_absolute_paths_under_relative_root() {
1245        let root = std::path::Path::new("relative-provided-root");
1246        let absolute = std::env::current_dir()
1247            .unwrap()
1248            .join(root)
1249            .join("src/main.rs");
1250        let changes = vec![FileTreeChange::new(
1251            absolute.to_string_lossy().into_owned(),
1252            FileTreeChangeStatus::Modified,
1253        )];
1254
1255        let snapshot = git::provided_change_snapshot(root.to_str().unwrap(), &changes);
1256
1257        assert_eq!(
1258            snapshot.changed_paths,
1259            vec![Arc::from(absolute.to_string_lossy().as_ref())]
1260        );
1261    }
1262
1263    #[test]
1264    fn crate_root_exports_file_tree_change_api() {
1265        let _tree: crate::FileTree = FileTree::new("/repo");
1266        let change =
1267            crate::FileTreeChange::new("src/main.rs", crate::FileTreeChangeStatus::Modified);
1268        let _source = crate::FileTreeChangeSource::provided([change]);
1269        let _view: crate::FileTreeChangeView = crate::FileTreeGitView::ChangedOnly;
1270        let _kind: crate::FileKind = FileKind::File;
1271    }
1272
1273    #[test]
1274    fn merges_status_and_diff_stat_decorations() {
1275        use git::{
1276            GitChangeState, GitDiffStat, GitFileDecorations, GitFileStatus, insert_decoration,
1277        };
1278
1279        let mut entries = HashMap::new();
1280        let key: Arc<str> = "/repo/src/app.rs".into();
1281
1282        insert_decoration(
1283            &mut entries,
1284            key.clone(),
1285            GitFileDecorations {
1286                status: GitFileStatus::new(None, Some(GitChangeState::Modified)),
1287                diff_stat: None,
1288                direct: true,
1289            },
1290        );
1291        insert_decoration(
1292            &mut entries,
1293            key.clone(),
1294            GitFileDecorations {
1295                status: GitFileStatus::new(None, None),
1296                diff_stat: Some(GitDiffStat {
1297                    added: 10,
1298                    removed: 4,
1299                }),
1300                direct: false,
1301            },
1302        );
1303
1304        let decoration = entries.get(key.as_ref()).copied().unwrap();
1305        assert_eq!(decoration.status.unstaged, Some(GitChangeState::Modified));
1306        assert_eq!(
1307            decoration.diff_stat,
1308            Some(GitDiffStat {
1309                added: 10,
1310                removed: 4
1311            })
1312        );
1313        assert!(decoration.direct);
1314    }
1315}