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::{
12    FileTreeEntryRequest, FileTreeEvent, FileTreeExplorerFocusOrigin, FileTreeToggleEvent,
13};
14pub use fs::{FileIconStyle, FileKind};
15pub use git::{GitChangeState, GitFileStatus, GitIconStyle};
16pub(crate) use mod_private::FileTreeProps;
17
18pub(crate) const EXPLORER_INPUT_KEY: &str = "__ft_input";
19pub(crate) const TREE_INPUT_KEY: &str = "__ft_tree";
20use mod_private::{
21    default_git_style_added, default_git_style_conflicted, default_git_style_deleted,
22    default_git_style_modified, default_git_style_renamed, default_git_style_untracked,
23};
24
25use crate::callback::Callback;
26use crate::core::element::Element;
27use crate::style::{BorderStyle, Color, Length, Padding, ScrollbarConfig, Style, StyleSlot};
28use crate::widgets::{ScrollKeymap, TreeKeymap};
29use std::collections::{HashMap, HashSet};
30use std::sync::Arc;
31
32pub use crate::utils::file_icons::FileIconOverride;
33
34/// Optional style decorations for an exact [`FileTree`] path.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36pub struct FileTreeItemStyle {
37    /// Style applied to the full row.
38    pub row: Option<Style>,
39    /// Style patched onto the icon span.
40    pub icon: Option<Style>,
41    /// Style patched onto label spans before search highlighting.
42    pub label: Option<Style>,
43    /// Style patched onto right-aligned change metadata spans.
44    pub suffix: Option<Style>,
45}
46
47impl FileTreeItemStyle {
48    /// Create an empty item style decoration.
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Set the full-row style.
54    pub fn row(mut self, style: Style) -> Self {
55        self.row = Some(style);
56        self
57    }
58
59    /// Set the icon style patch.
60    pub fn icon(mut self, style: Style) -> Self {
61        self.icon = Some(style);
62        self
63    }
64
65    /// Set the label style patch.
66    pub fn label(mut self, style: Style) -> Self {
67        self.label = Some(style);
68        self
69    }
70
71    /// Set the right-side change metadata style patch.
72    pub fn suffix(mut self, style: Style) -> Self {
73        self.suffix = Some(style);
74        self
75    }
76}
77
78/// Truncation priority for right-aligned FileTree change metadata.
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
80pub enum FileTreeSuffixPriority {
81    /// Prefer keeping the file/directory label visible and truncate suffix metadata first.
82    #[default]
83    Label,
84    /// Prefer keeping right-aligned suffix metadata visible and truncate the label first.
85    Suffix,
86}
87
88/// Source-agnostic file tree change display mode.
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
90pub enum FileTreeChangeView {
91    /// Show all files under the configured root.
92    #[default]
93    AllFiles,
94    /// Show only changed files and ancestor directories needed to group them.
95    ChangedOnly,
96}
97
98/// Compatibility alias for the previous Git-specific display mode name.
99pub type FileTreeGitView = FileTreeChangeView;
100
101/// Source used to enumerate [`FileTree`] directory entries.
102#[derive(Clone, Debug, Default, PartialEq, Eq)]
103pub enum FileTreeEntrySource {
104    /// Enumerate the local filesystem on demand.
105    #[default]
106    Local,
107    /// Use directory listings supplied by the application.
108    ///
109    /// A directory absent from this collection is pending. The widget renders its loading row and
110    /// emits [`FileTreeEntryRequest`] through [`FileTree::on_entry_request`]. Add the completed
111    /// listing to this collection and rebuild the widget to deliver the result without blocking the
112    /// UI thread.
113    Provided(Vec<FileTreeDirectoryListing>),
114}
115
116impl FileTreeEntrySource {
117    /// Create an application-provided entry source from completed directory listings.
118    pub fn provided(listings: impl IntoIterator<Item = FileTreeDirectoryListing>) -> Self {
119        Self::Provided(listings.into_iter().collect())
120    }
121}
122
123/// One application-provided child entry in a [`FileTreeDirectoryListing`].
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct FileTreeEntry {
126    /// Entry name relative to the listed directory.
127    pub name: Arc<str>,
128    /// Whether the entry is a directory.
129    pub is_dir: bool,
130    /// Whether the entry is a symbolic link.
131    pub is_symlink: bool,
132    /// Git status supplied by the application, when any.
133    pub git_status: Option<GitFileStatus>,
134    /// Whether ignore rules mark this entry as ignored.
135    ///
136    /// Ignored entries remain visible in normal browsing, matching the local source, but are
137    /// excluded from explorer search results.
138    pub ignored: bool,
139}
140
141impl FileTreeEntry {
142    /// Create a provided file or directory entry.
143    pub fn new(name: impl Into<Arc<str>>, is_dir: bool) -> Self {
144        Self {
145            name: name.into(),
146            is_dir,
147            is_symlink: false,
148            git_status: None,
149            ignored: false,
150        }
151    }
152
153    /// Create a provided regular file entry.
154    pub fn file(name: impl Into<Arc<str>>) -> Self {
155        Self::new(name, false)
156    }
157
158    /// Create a provided directory entry.
159    pub fn directory(name: impl Into<Arc<str>>) -> Self {
160        Self::new(name, true)
161    }
162
163    /// Mark whether this entry is a symbolic link.
164    pub fn symlink(mut self, is_symlink: bool) -> Self {
165        self.is_symlink = is_symlink;
166        self
167    }
168
169    /// Set application-provided Git status for this entry.
170    pub fn git_status(mut self, status: GitFileStatus) -> Self {
171        self.git_status = Some(status);
172        self
173    }
174
175    /// Mark whether ignore rules classify this entry as ignored.
176    pub fn ignored(mut self, ignored: bool) -> Self {
177        self.ignored = ignored;
178        self
179    }
180}
181
182/// Completed application-provided listing for one directory path.
183#[derive(Clone, Debug)]
184pub struct FileTreeDirectoryListing {
185    /// Listed directory path, absolute or relative to the tree root.
186    pub path: Arc<str>,
187    /// Child entries, or an error message when listing failed.
188    pub entries: Result<Arc<[FileTreeEntry]>, Arc<str>>,
189}
190
191impl PartialEq for FileTreeDirectoryListing {
192    fn eq(&self, other: &Self) -> bool {
193        if self.path != other.path {
194            return false;
195        }
196        match (&self.entries, &other.entries) {
197            (Ok(left), Ok(right)) => Arc::ptr_eq(left, right) || left == right,
198            (Err(left), Err(right)) => Arc::ptr_eq(left, right) || left == right,
199            (Ok(_), Err(_)) | (Err(_), Ok(_)) => false,
200        }
201    }
202}
203
204impl Eq for FileTreeDirectoryListing {}
205
206impl FileTreeDirectoryListing {
207    /// Create a successful directory listing.
208    pub fn new(
209        path: impl Into<Arc<str>>,
210        entries: impl IntoIterator<Item = FileTreeEntry>,
211    ) -> Self {
212        Self {
213            path: path.into(),
214            entries: Ok(entries.into_iter().collect::<Vec<_>>().into()),
215        }
216    }
217
218    /// Create a failed directory listing.
219    pub fn error(path: impl Into<Arc<str>>, error: impl Into<Arc<str>>) -> Self {
220        Self {
221            path: path.into(),
222            entries: Err(error.into()),
223        }
224    }
225}
226
227/// Source used for file change decorations and changed-only projection.
228#[derive(Clone, Debug, Default, PartialEq, Eq)]
229pub enum FileTreeChangeSource {
230    /// Discover changes from the local Git repository containing the tree root.
231    #[default]
232    Git,
233    /// Use an app/server-provided virtual change set without local Git discovery.
234    Provided(Vec<FileTreeChange>),
235}
236
237impl FileTreeChangeSource {
238    /// Create a provided virtual change source.
239    pub fn provided(changes: impl IntoIterator<Item = FileTreeChange>) -> Self {
240        Self::Provided(changes.into_iter().collect())
241    }
242}
243
244/// Status for a source-agnostic changed file.
245#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
246pub enum FileTreeChangeStatus {
247    /// File was modified.
248    Modified,
249    /// File was added.
250    Added,
251    /// File was deleted.
252    Deleted,
253    /// File was renamed.
254    Renamed,
255    /// File is untracked/new to the source.
256    Untracked,
257    /// File has a conflict.
258    Conflicted,
259}
260
261impl From<FileTreeChangeStatus> for GitChangeState {
262    fn from(status: FileTreeChangeStatus) -> Self {
263        match status {
264            FileTreeChangeStatus::Modified => Self::Modified,
265            FileTreeChangeStatus::Added => Self::Added,
266            FileTreeChangeStatus::Deleted => Self::Deleted,
267            FileTreeChangeStatus::Renamed => Self::Renamed,
268            FileTreeChangeStatus::Untracked => Self::Untracked,
269            FileTreeChangeStatus::Conflicted => Self::Conflicted,
270        }
271    }
272}
273
274/// App/server-provided file change entry.
275#[derive(Clone, Debug, PartialEq, Eq)]
276pub struct FileTreeChange {
277    /// Changed path, either relative to the tree root or absolute under it.
278    pub path: Arc<str>,
279    /// Change status used for markers and styling.
280    pub status: FileTreeChangeStatus,
281    /// Optional virtual file kind. Defaults to `FileKind::File` for leaf rows.
282    pub kind: Option<FileKind>,
283    /// Added-line count for diff stats.
284    pub additions: usize,
285    /// Deleted-line count for diff stats.
286    pub deletions: usize,
287    /// Whether the status should render as staged. Defaults to unstaged.
288    pub staged: bool,
289}
290
291impl FileTreeChange {
292    /// Create a changed file entry.
293    pub fn new(path: impl Into<Arc<str>>, status: FileTreeChangeStatus) -> Self {
294        Self {
295            path: path.into(),
296            status,
297            kind: None,
298            additions: 0,
299            deletions: 0,
300            staged: false,
301        }
302    }
303
304    /// Set the virtual file kind for this changed path.
305    pub fn kind(mut self, kind: FileKind) -> Self {
306        self.kind = Some(kind);
307        self
308    }
309
310    /// Set diff statistics for this changed path.
311    pub fn diff_stat(mut self, additions: usize, deletions: usize) -> Self {
312        self.additions = additions;
313        self.deletions = deletions;
314        self
315    }
316
317    /// Set the added-line count for this changed path.
318    pub fn additions(mut self, additions: usize) -> Self {
319        self.additions = additions;
320        self
321    }
322
323    /// Set the deleted-line count for this changed path.
324    pub fn deletions(mut self, deletions: usize) -> Self {
325        self.deletions = deletions;
326        self
327    }
328
329    /// Mark this change as staged or unstaged.
330    pub fn staged(mut self, staged: bool) -> Self {
331        self.staged = staged;
332        self
333    }
334}
335
336/// Lazy-loading file explorer tree.
337#[derive(Clone)]
338pub struct FileTree {
339    props: FileTreeProps,
340}
341
342impl FileTree {
343    /// Create a new file tree rooted at `root`.
344    pub fn new(root: impl Into<Arc<str>>) -> Self {
345        Self {
346            props: FileTreeProps {
347                root: root.into(),
348                entry_source: FileTreeEntrySource::default(),
349                show_hidden: false,
350                max_entries_per_dir: 2_000,
351                show_icons: true,
352                icon_style: FileIconStyle::default(),
353                icon_palette: FileIconPalette::default(),
354                icon_overrides: HashMap::new(),
355                show_arrows: true,
356                indent_style: crate::widgets::IndentStyle::None,
357                indent_guide_style: Style::default(),
358                indent_guide_start_depth: 1,
359                indent_width: 2,
360                directory_icon: "[D]".into(),
361                opened_directory_icon: "[D]".into(),
362                file_icon: "[F]".into(),
363                symlink_icon: "[L]".into(),
364                other_icon: "[?]".into(),
365                directory_label_style: Style::default(),
366                file_label_style: Style::default(),
367                loading_label: "loading...".into(),
368                error_prefix: "error:".into(),
369                width: Length::Flex(1),
370                height: Length::Flex(1),
371                style: Style::default(),
372                hover_style: StyleSlot::Inherit,
373                item_hover_style: StyleSlot::Inherit,
374                selection_style: StyleSlot::Inherit,
375                unfocused_selection_style: StyleSlot::Inherit,
376                selected: None,
377                clear_selection: false,
378                selected_path: None,
379                reveal_path: None,
380                select_path: None,
381                force_scroll_to_selected: false,
382                expanded_paths: None,
383                initial_expanded_paths: HashSet::new(),
384                selection_symbol: None,
385                selection_symbol_style: None,
386                unfocused_selection_symbol_style: None,
387                scrollbar: true,
388                scrollbar_config: ScrollbarConfig::default(),
389                scroll_keys: ScrollKeymap::default(),
390                scroll_wheel: true,
391                show_scroll_indicators: false,
392                scroll_indicator_style: Style::default(),
393                empty_text: Some("Directory is empty".into()),
394                empty_text_style: Style::default(),
395                empty_text_padding: Padding::default(),
396                explorer: false,
397                tree_focus_key: TREE_INPUT_KEY.into(),
398                explorer_focus_key: EXPLORER_INPUT_KEY.into(),
399                explorer_placeholder: "Find files...".into(),
400                explorer_prefix: " ".into(),
401                explorer_input_border: false,
402                explorer_input_border_style: BorderStyle::Plain,
403                explorer_input_padding: Padding {
404                    left: 1,
405                    right: 0,
406                    top: 0,
407                    bottom: 0,
408                },
409                explorer_input_style: Style::default(),
410                explorer_input_focus_style: StyleSlot::Inherit,
411                explorer_input_focus_content_style: Style::default(),
412                explorer_placeholder_style: Style::default(),
413                explorer_focus_placeholder_style: Style::default(),
414                explorer_match_style: Style::default(),
415                explorer_divider: true,
416                explorer_divider_join_frame: true,
417                explorer_divider_char: '─',
418                explorer_divider_style: Style::default(),
419                on_explorer_focus: None,
420                on_explorer_blur: None,
421                on_explorer_escape: None,
422                activate_on_click: true,
423                focusable: true,
424                tab_stop: true,
425                on_focus: None,
426                on_blur: None,
427                keymap: TreeKeymap::default(),
428                git_status: true,
429                highlight_changed_labels: false,
430                change_source: FileTreeChangeSource::default(),
431                change_view: FileTreeChangeView::default(),
432                git_diff_stats: false,
433                git_icon_style: GitIconStyle::NerdFont,
434                git_refresh_nonce: 0,
435                git_marker_modified: "M".into(),
436                git_marker_added: "A".into(),
437                git_marker_deleted: "D".into(),
438                git_marker_renamed: "R".into(),
439                git_marker_untracked: "?".into(),
440                git_marker_conflicted: "!".into(),
441                // Git status colors - these are defaults that work standalone
442                // ThemeProvider will override these if they match the defaults
443                git_style_modified: default_git_style_modified(),
444                git_style_added: default_git_style_added(),
445                git_style_deleted: default_git_style_deleted(),
446                git_style_renamed: default_git_style_renamed(),
447                git_style_untracked: default_git_style_untracked(),
448                git_style_conflicted: default_git_style_conflicted(),
449                change_suffix_style: Style::default(),
450                change_suffix_priority: FileTreeSuffixPriority::default(),
451                path_styles: HashMap::new(),
452                on_select: None,
453                on_activate: None,
454                on_toggle: None,
455                on_entry_request: None,
456            },
457        }
458    }
459
460    /// Set the source used to enumerate directory entries.
461    ///
462    /// [`FileTreeEntrySource::Local`] is the default. With a provided source, missing expanded
463    /// directories render the loading row and are requested through
464    /// [`FileTree::on_entry_request`].
465    pub fn entry_source(mut self, source: FileTreeEntrySource) -> Self {
466        self.props.entry_source = source;
467        self
468    }
469
470    /// Toggle hidden entries (dotfiles).
471    pub fn show_hidden(mut self, show_hidden: bool) -> Self {
472        self.props.show_hidden = show_hidden;
473        self
474    }
475
476    /// Set max number of children loaded per directory.
477    pub fn max_entries_per_dir(mut self, max_entries: usize) -> Self {
478        self.props.max_entries_per_dir = max_entries.max(1);
479        self
480    }
481
482    /// Toggle icon rendering.
483    pub fn show_icons(mut self, show_icons: bool) -> Self {
484        self.props.show_icons = show_icons;
485        self
486    }
487
488    /// Set icon style for file tree items.
489    pub fn icon_style(mut self, style: FileIconStyle) -> Self {
490        self.props.icon_style = style;
491        self
492    }
493
494    /// Set the color palette for file icons.
495    pub fn icon_palette(mut self, palette: FileIconPalette) -> Self {
496        self.props.icon_palette = palette;
497        self
498    }
499
500    /// Add a custom icon override for a file extension or name.
501    ///
502    /// The `pattern` can be a file extension (e.g., "rs", "md") or a full filename (e.g., "README.md").
503    /// The icon will be used for files matching this pattern.
504    pub fn icon_override(
505        mut self,
506        pattern: impl Into<Arc<str>>,
507        icon: impl Into<Arc<str>>,
508        color: Option<Color>,
509    ) -> Self {
510        self.props.icon_overrides.insert(
511            pattern.into(),
512            FileIconOverride {
513                icon: icon.into(),
514                color,
515            },
516        );
517        self
518    }
519
520    /// Toggle expansion arrows before directories.
521    pub fn show_arrows(mut self, show: bool) -> Self {
522        self.props.show_arrows = show;
523        self
524    }
525
526    /// Set style of indentation guides.
527    pub fn indent_style(mut self, style: crate::widgets::IndentStyle) -> Self {
528        self.props.indent_style = style;
529        self
530    }
531
532    /// Set style for indent guides.
533    pub fn indent_guide_style(mut self, style: Style) -> Self {
534        self.props.indent_guide_style = style;
535        self
536    }
537
538    /// Set the first non-root depth that renders indentation guides (default 1).
539    pub fn indent_guide_start_depth(mut self, depth: usize) -> Self {
540        self.props.indent_guide_start_depth = depth.max(1);
541        self
542    }
543
544    /// Set indentation cells per hierarchy level (default 2).
545    pub fn indent_width(mut self, width: u16) -> Self {
546        self.props.indent_width = width;
547        self
548    }
549
550    /// Set directory icon.
551    pub fn directory_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
552        self.props.directory_icon = icon.into();
553        self
554    }
555
556    /// Set "opened directory" icon.
557    pub fn opened_directory_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
558        self.props.opened_directory_icon = icon.into();
559        self
560    }
561
562    /// Set regular file icon.
563    pub fn file_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
564        self.props.file_icon = icon.into();
565        self
566    }
567
568    /// Set symlink icon.
569    pub fn symlink_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
570        self.props.symlink_icon = icon.into();
571        self
572    }
573
574    /// Set "other type" icon.
575    pub fn other_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
576        self.props.other_icon = icon.into();
577        self
578    }
579
580    /// Set the label style for directory rows.
581    pub fn directory_label_style(mut self, style: Style) -> Self {
582        self.props.directory_label_style = style;
583        self
584    }
585
586    /// Set the label style for regular file rows.
587    pub fn file_label_style(mut self, style: Style) -> Self {
588        self.props.file_label_style = style;
589        self
590    }
591
592    /// Set item decorations for an exact path under the effective root.
593    pub fn path_style(mut self, path: impl Into<Arc<str>>, style: FileTreeItemStyle) -> Self {
594        self.props.path_styles.insert(path.into(), style);
595        self
596    }
597
598    /// Set item decorations for exact paths under the effective root.
599    pub fn path_styles(
600        mut self,
601        styles: impl IntoIterator<Item = (impl Into<Arc<str>>, FileTreeItemStyle)>,
602    ) -> Self {
603        self.props
604            .path_styles
605            .extend(styles.into_iter().map(|(path, style)| (path.into(), style)));
606        self
607    }
608
609    /// Set loading row label.
610    pub fn loading_label(mut self, label: impl Into<Arc<str>>) -> Self {
611        self.props.loading_label = label.into();
612        self
613    }
614
615    /// Set load-error prefix.
616    pub fn error_prefix(mut self, prefix: impl Into<Arc<str>>) -> Self {
617        self.props.error_prefix = prefix.into();
618        self
619    }
620
621    /// Set width.
622    pub fn width(mut self, width: Length) -> Self {
623        self.props.width = width;
624        self
625    }
626
627    /// Set height.
628    pub fn height(mut self, height: Length) -> Self {
629        self.props.height = height;
630        self
631    }
632
633    /// Set base style.
634    pub fn style(mut self, style: Style) -> Self {
635        self.props.style = style;
636        self
637    }
638
639    /// Set hovered style.
640    pub fn hover_style(mut self, style: Style) -> Self {
641        self.props.hover_style = StyleSlot::Replace(style);
642        self
643    }
644
645    /// Extend the themed hovered style.
646    pub fn extend_hover_style(mut self, style: Style) -> Self {
647        self.props.hover_style = StyleSlot::Extend(style);
648        self
649    }
650
651    /// Inherit the themed hovered style.
652    pub fn inherit_hover_style(mut self) -> Self {
653        self.props.hover_style = StyleSlot::Inherit;
654        self
655    }
656
657    /// Set hover style slot directly for composite forwarding.
658    pub fn hover_style_slot(mut self, slot: StyleSlot) -> Self {
659        self.props.hover_style = slot;
660        self
661    }
662
663    /// Set hovered row style.
664    pub fn item_hover_style(mut self, style: Style) -> Self {
665        self.props.item_hover_style = StyleSlot::Replace(style);
666        self
667    }
668
669    /// Extend the themed hovered row style.
670    pub fn extend_item_hover_style(mut self, style: Style) -> Self {
671        self.props.item_hover_style = StyleSlot::Extend(style);
672        self
673    }
674
675    /// Inherit the themed hovered row style.
676    pub fn inherit_item_hover_style(mut self) -> Self {
677        self.props.item_hover_style = StyleSlot::Inherit;
678        self
679    }
680
681    /// Set item-hover style slot directly for composite forwarding.
682    pub fn item_hover_style_slot(mut self, slot: StyleSlot) -> Self {
683        self.props.item_hover_style = slot;
684        self
685    }
686
687    /// Set selected row style.
688    pub fn selection_style(mut self, style: Style) -> Self {
689        self.props.selection_style = StyleSlot::Replace(style);
690        self
691    }
692
693    /// Extend the themed selected row style.
694    pub fn extend_selection_style(mut self, style: Style) -> Self {
695        self.props.selection_style = StyleSlot::Extend(style);
696        self
697    }
698
699    /// Inherit the themed selected row style.
700    pub fn inherit_selection_style(mut self) -> Self {
701        self.props.selection_style = StyleSlot::Inherit;
702        self
703    }
704
705    /// Set selected row style slot directly for composite forwarding.
706    pub fn selection_style_slot(mut self, slot: StyleSlot) -> Self {
707        self.props.selection_style = slot;
708        self
709    }
710
711    /// Set selected row style while the file tree is not focused.
712    pub fn unfocused_selection_style(mut self, style: Style) -> Self {
713        self.props.unfocused_selection_style = StyleSlot::Replace(style);
714        self
715    }
716
717    /// Extend the themed selected row style while the file tree is not focused.
718    pub fn extend_unfocused_selection_style(mut self, style: Style) -> Self {
719        self.props.unfocused_selection_style = StyleSlot::Extend(style);
720        self
721    }
722
723    /// Inherit the themed selected row style while the file tree is not focused.
724    pub fn inherit_unfocused_selection_style(mut self) -> Self {
725        self.props.unfocused_selection_style = StyleSlot::Inherit;
726        self
727    }
728
729    /// Set unfocused selected row style slot directly for composite forwarding.
730    pub fn unfocused_selection_style_slot(mut self, slot: StyleSlot) -> Self {
731        self.props.unfocused_selection_style = slot;
732        self
733    }
734
735    /// Set the selected visible row index.
736    pub fn selected(mut self, selected: usize) -> Self {
737        self.props.selected = Some(selected);
738        self
739    }
740
741    /// Clear the selection highlight (no current row).
742    ///
743    /// When `true`, this is authoritative over both the controlled `selected`
744    /// prop and internal selection state. See [`crate::widgets::Tree::clear_selection`].
745    pub fn clear_selection(mut self, clear: bool) -> Self {
746        self.props.clear_selection = clear;
747        self
748    }
749
750    /// Select a visible row by path.
751    ///
752    /// The path may be absolute under the tree root or relative to it. Selection is a no-op when
753    /// the normalized path is outside the root or is not present in the current visible projection.
754    pub fn selected_path(mut self, path: impl Into<Arc<str>>) -> Self {
755        self.props.selected_path = Some(path.into());
756        self
757    }
758
759    /// Reveal a path by expanding/loading ancestor directories when possible.
760    ///
761    /// The path may be absolute under the tree root or relative to it. Revealing is a no-op for
762    /// paths outside the root, paths hidden by `show_hidden(false)`, unreadable directories, capped
763    /// directory entries, and paths absent from the active all-files/changed-only projection.
764    pub fn reveal_path(mut self, path: impl Into<Arc<str>>) -> Self {
765        self.props.reveal_path = Some(path.into());
766        self
767    }
768
769    /// Reveal and select a path, forcing scroll to the row when it is visible.
770    ///
771    /// This combines `reveal_path` and `selected_path` behavior. With controlled
772    /// `expanded_paths`, the path can only be revealed when the controlled expansion set (plus this
773    /// reveal request for rendering) makes the ancestors available to load.
774    pub fn select_path(mut self, path: impl Into<Arc<str>>) -> Self {
775        self.props.select_path = Some(path.into());
776        self
777    }
778
779    /// Force scroll to make the selected item visible on next render.
780    pub fn force_scroll_to_selected(mut self, force: bool) -> Self {
781        self.props.force_scroll_to_selected = force;
782        self
783    }
784
785    /// Control expanded directory paths.
786    pub fn expanded_paths(mut self, paths: impl IntoIterator<Item = impl Into<Arc<str>>>) -> Self {
787        self.props.expanded_paths = Some(paths.into_iter().map(Into::into).collect::<HashSet<_>>());
788        self
789    }
790
791    /// Seed expansion state the tree then owns, so an application can restore what was expanded
792    /// before the tree was unmounted or re-rooted.
793    ///
794    /// Paths may be absolute under the tree root or relative to it; paths outside the root are
795    /// ignored. They are applied when the tree mounts and whenever the root changes, and never
796    /// again — later edits to this set do not disturb expansion the user has since changed. Pair it
797    /// with [`Self::on_toggle`] to record what to seed next time.
798    ///
799    /// Ancestors are deliberately *not* expanded along with a seeded path: collapsing a directory
800    /// leaves what is inside it expanded, so seeding a descendant must not reopen the parent the
801    /// user closed. A seeded path becomes visible when its parent is expanded again.
802    ///
803    /// Ignored when [`Self::expanded_paths`] controls expansion, which is authoritative on its own.
804    pub fn initial_expanded_paths(
805        mut self,
806        paths: impl IntoIterator<Item = impl Into<Arc<str>>>,
807    ) -> Self {
808        self.props.initial_expanded_paths = paths.into_iter().map(Into::into).collect();
809        self
810    }
811
812    /// Set selected row prefix symbol.
813    pub fn selection_symbol(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
814        self.props.selection_symbol = symbol.map(Into::into);
815        self
816    }
817
818    /// Set selected row prefix style.
819    pub fn selection_symbol_style(mut self, style: Option<Style>) -> Self {
820        self.props.selection_symbol_style = style;
821        self
822    }
823
824    /// Set selected row prefix style while the file tree is not focused.
825    pub fn unfocused_selection_symbol_style(mut self, style: Option<Style>) -> Self {
826        self.props.unfocused_selection_symbol_style = style;
827        self
828    }
829
830    /// Toggle scrollbar.
831    pub fn scrollbar(mut self, scrollbar: bool) -> Self {
832        self.props.scrollbar = scrollbar;
833        self
834    }
835
836    /// Set scrollbar configuration.
837    pub fn scrollbar_config(mut self, config: ScrollbarConfig) -> Self {
838        self.props.scrollbar_config = config;
839        self
840    }
841
842    /// Configure keyboard scrolling bindings.
843    pub fn scroll_keys(mut self, keys: ScrollKeymap) -> Self {
844        self.props.scroll_keys = keys;
845        self
846    }
847
848    /// Enable mouse wheel scrolling.
849    pub fn scroll_wheel(mut self, enabled: bool) -> Self {
850        self.props.scroll_wheel = enabled;
851        self
852    }
853
854    /// Toggle hidden-row indicators (`N more`).
855    pub fn show_scroll_indicators(mut self, show: bool) -> Self {
856        self.props.show_scroll_indicators = show;
857        self
858    }
859
860    /// Set hidden-row indicator style.
861    pub fn scroll_indicator_style(mut self, style: Style) -> Self {
862        self.props.scroll_indicator_style = style;
863        self
864    }
865
866    /// Set empty-state text.
867    pub fn empty_text(mut self, text: impl Into<Arc<str>>) -> Self {
868        self.props.empty_text = Some(text.into());
869        self
870    }
871
872    /// Set empty-state style.
873    pub fn empty_text_style(mut self, style: Style) -> Self {
874        self.props.empty_text_style = style;
875        self
876    }
877
878    /// Set the inset around the empty-state text.
879    ///
880    /// The tree's own rows are flush with the surface because the root row starts the hierarchy
881    /// there, but a placeholder is prose rather than a row, and applications usually align it with
882    /// their other empty states instead. Defaults to none, which keeps it flush with the rows.
883    pub fn empty_text_padding(mut self, padding: impl Into<Padding>) -> Self {
884        self.props.empty_text_padding = padding.into();
885        self
886    }
887
888    /// Toggle explorer filter input above the tree.
889    pub fn explorer(mut self, explorer: bool) -> Self {
890        self.props.explorer = explorer;
891        self
892    }
893
894    /// Set the key used to focus the tree inside this composite widget.
895    pub fn tree_focus_key(mut self, key: impl Into<Arc<str>>) -> Self {
896        self.props.tree_focus_key = key.into();
897        self
898    }
899
900    /// Set the key used to focus the explorer input inside this composite widget.
901    pub fn explorer_focus_key(mut self, key: impl Into<Arc<str>>) -> Self {
902        self.props.explorer_focus_key = key.into();
903        self
904    }
905
906    /// Set explorer placeholder text.
907    pub fn explorer_placeholder(mut self, text: impl Into<Arc<str>>) -> Self {
908        self.props.explorer_placeholder = text.into();
909        self
910    }
911
912    /// Set explorer input prefix text.
913    pub fn explorer_prefix(mut self, prefix: impl Into<Arc<str>>) -> Self {
914        self.props.explorer_prefix = prefix.into();
915        self
916    }
917
918    /// Toggle explorer input border.
919    pub fn explorer_input_border(mut self, border: bool) -> Self {
920        self.props.explorer_input_border = border;
921        self
922    }
923
924    /// Set explorer input border style.
925    pub fn explorer_input_border_style(mut self, style: BorderStyle) -> Self {
926        self.props.explorer_input_border_style = style;
927        self
928    }
929
930    /// Set explorer input padding.
931    pub fn explorer_input_padding(mut self, padding: impl Into<Padding>) -> Self {
932        self.props.explorer_input_padding = padding.into();
933        self
934    }
935
936    /// Set explorer input style.
937    pub fn explorer_input_style(mut self, style: Style) -> Self {
938        self.props.explorer_input_style = style;
939        self
940    }
941
942    /// Set explorer input style when focused.
943    pub fn explorer_input_focus_style(mut self, style: Style) -> Self {
944        self.props.explorer_input_focus_style = StyleSlot::Replace(style);
945        self
946    }
947
948    /// Extend the themed explorer input focus style.
949    pub fn extend_explorer_input_focus_style(mut self, style: Style) -> Self {
950        self.props.explorer_input_focus_style = StyleSlot::Extend(style);
951        self
952    }
953
954    /// Inherit the themed explorer input focus style.
955    pub fn inherit_explorer_input_focus_style(mut self) -> Self {
956        self.props.explorer_input_focus_style = StyleSlot::Inherit;
957        self
958    }
959
960    /// Set explorer input focus style slot directly for composite forwarding.
961    pub fn explorer_input_focus_style_slot(mut self, slot: StyleSlot) -> Self {
962        self.props.explorer_input_focus_style = slot;
963        self
964    }
965
966    /// Set focused explorer input content text style.
967    pub fn explorer_input_focus_content_style(mut self, style: Style) -> Self {
968        self.props.explorer_input_focus_content_style = style;
969        self
970    }
971
972    /// Set explorer placeholder style.
973    pub fn explorer_placeholder_style(mut self, style: Style) -> Self {
974        self.props.explorer_placeholder_style = style;
975        self
976    }
977
978    /// Set explorer placeholder style when focused.
979    pub fn explorer_focus_placeholder_style(mut self, style: Style) -> Self {
980        self.props.explorer_focus_placeholder_style = style;
981        self
982    }
983
984    /// Set explorer search match highlight style.
985    pub fn explorer_match_style(mut self, style: Style) -> Self {
986        self.props.explorer_match_style = style;
987        self
988    }
989
990    /// Toggle divider below the explorer input.
991    pub fn explorer_divider(mut self, show: bool) -> Self {
992        self.props.explorer_divider = show;
993        self
994    }
995
996    /// Toggle frame-join behavior for explorer divider.
997    pub fn explorer_divider_join_frame(mut self, join: bool) -> Self {
998        self.props.explorer_divider_join_frame = join;
999        self
1000    }
1001
1002    /// Set divider character for explorer divider.
1003    pub fn explorer_divider_char(mut self, ch: char) -> Self {
1004        self.props.explorer_divider_char = ch;
1005        self
1006    }
1007
1008    /// Set explorer divider style.
1009    pub fn explorer_divider_style(mut self, style: Style) -> Self {
1010        self.props.explorer_divider_style = style;
1011        self
1012    }
1013
1014    /// Set the callback fired when the explorer input receives focus.
1015    pub fn on_explorer_focus(mut self, cb: Callback<FileTreeExplorerFocusOrigin>) -> Self {
1016        self.props.on_explorer_focus = Some(cb);
1017        self
1018    }
1019
1020    /// Set the callback fired when the explorer input loses focus.
1021    pub fn on_explorer_blur(mut self, cb: Callback<()>) -> Self {
1022        self.props.on_explorer_blur = Some(cb);
1023        self
1024    }
1025
1026    /// Set the callback fired when Escape or an outside tree/divider click leaves an explorer
1027    /// focused directly by pointer.
1028    ///
1029    /// Explorer focus entered from the tree with `/` returns to the tree instead and does not emit
1030    /// this callback. Without a callback, pointer-entered explorer focus also returns to the tree.
1031    pub fn on_explorer_escape(mut self, cb: Callback<()>) -> Self {
1032        self.props.on_explorer_escape = Some(cb);
1033        self
1034    }
1035
1036    /// Set tree activation behavior for mouse clicks.
1037    pub fn activate_on_click(mut self, activate_on_click: bool) -> Self {
1038        self.props.activate_on_click = activate_on_click;
1039        self
1040    }
1041
1042    /// Control focusability.
1043    pub fn focusable(mut self, focusable: bool) -> Self {
1044        self.props.focusable = focusable;
1045        self
1046    }
1047
1048    /// Control whether the file tree participates in Tab / Shift+Tab traversal.
1049    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
1050        self.props.tab_stop = tab_stop;
1051        self
1052    }
1053
1054    /// Set the callback fired when the file tree gains focus.
1055    pub fn on_focus(mut self, cb: Callback<()>) -> Self {
1056        self.props.on_focus = Some(cb);
1057        self
1058    }
1059
1060    /// Set the callback fired when the file tree loses focus.
1061    pub fn on_blur(mut self, cb: Callback<()>) -> Self {
1062        self.props.on_blur = Some(cb);
1063        self
1064    }
1065
1066    /// Set the callback fired when a provided entry source needs a directory listing.
1067    ///
1068    /// The callback should only enqueue application work. Perform remote or other blocking I/O in
1069    /// a [`crate::Command`], then rebuild the widget with a matching
1070    /// [`FileTreeDirectoryListing`] in [`FileTreeEntrySource::Provided`].
1071    pub fn on_entry_request(mut self, cb: Callback<FileTreeEntryRequest>) -> Self {
1072        self.props.on_entry_request = Some(cb);
1073        self
1074    }
1075
1076    /// Configure expand/collapse keymap.
1077    pub fn keymap(mut self, keymap: TreeKeymap) -> Self {
1078        self.props.keymap = keymap;
1079        self
1080    }
1081
1082    /// Toggle Git status decorations.
1083    pub fn git_status(mut self, enabled: bool) -> Self {
1084        self.props.git_status = enabled;
1085        self
1086    }
1087
1088    /// Toggle applying change status styles to file and directory labels.
1089    ///
1090    /// Status indicators remain styled independently. The default is `false`, so
1091    /// file names keep their regular file-kind styling while dirty state is shown
1092    /// in the right-aligned metadata.
1093    pub fn highlight_changed_labels(mut self, enabled: bool) -> Self {
1094        self.props.highlight_changed_labels = enabled;
1095        self
1096    }
1097
1098    /// Set the changed-file display mode using the Git-compatible alias.
1099    pub fn git_view(mut self, view: FileTreeGitView) -> Self {
1100        self.props.change_view = view;
1101        self
1102    }
1103
1104    /// Set the source used for changed-file decorations and changed-only projection.
1105    pub fn change_source(mut self, source: FileTreeChangeSource) -> Self {
1106        self.props.change_source = source;
1107        self
1108    }
1109
1110    /// Set the source-agnostic changed-file display mode.
1111    pub fn change_view(mut self, view: FileTreeChangeView) -> Self {
1112        self.props.change_view = view;
1113        self
1114    }
1115
1116    /// Toggle changed-only mode using the Git-compatible builder name.
1117    pub fn git_changed_only(mut self, enabled: bool) -> Self {
1118        self.props.change_view = if enabled {
1119            FileTreeChangeView::ChangedOnly
1120        } else {
1121            FileTreeChangeView::AllFiles
1122        };
1123        self
1124    }
1125
1126    /// Toggle diff statistics decorations for any change source.
1127    pub fn show_diff_stats(self, enabled: bool) -> Self {
1128        self.git_diff_stats(enabled)
1129    }
1130
1131    /// Toggle diff statistics decorations using the Git-compatible builder name.
1132    pub fn git_diff_stats(mut self, enabled: bool) -> Self {
1133        self.props.git_diff_stats = enabled;
1134        self
1135    }
1136
1137    /// Set icon style for Git status indicators.
1138    pub fn git_icon_style(mut self, style: GitIconStyle) -> Self {
1139        self.props.git_icon_style = style;
1140        self
1141    }
1142
1143    /// Request an immediate Git status refresh.
1144    ///
1145    /// Call this when building the widget in response to a user action.
1146    pub fn refresh_git_status(mut self) -> Self {
1147        use std::sync::atomic::{AtomicU64, Ordering};
1148        static FILE_TREE_REFRESH_NONCE: AtomicU64 = AtomicU64::new(1);
1149        self.props.git_refresh_nonce = FILE_TREE_REFRESH_NONCE.fetch_add(1, Ordering::Relaxed);
1150        self
1151    }
1152
1153    /// Set explicit refresh token for Git status loading.
1154    ///
1155    /// When the token changes, the component triggers a new background refresh.
1156    pub fn git_refresh_token(mut self, token: u64) -> Self {
1157        self.props.git_refresh_nonce = token;
1158        self
1159    }
1160
1161    /// Set marker for `Modified` git status.
1162    pub fn git_marker_modified(mut self, marker: impl Into<Arc<str>>) -> Self {
1163        self.props.git_marker_modified = marker.into();
1164        self
1165    }
1166
1167    /// Set marker for `Added` git status.
1168    pub fn git_marker_added(mut self, marker: impl Into<Arc<str>>) -> Self {
1169        self.props.git_marker_added = marker.into();
1170        self
1171    }
1172
1173    /// Set marker for `Deleted` git status.
1174    pub fn git_marker_deleted(mut self, marker: impl Into<Arc<str>>) -> Self {
1175        self.props.git_marker_deleted = marker.into();
1176        self
1177    }
1178
1179    /// Set marker for `Renamed` git status.
1180    pub fn git_marker_renamed(mut self, marker: impl Into<Arc<str>>) -> Self {
1181        self.props.git_marker_renamed = marker.into();
1182        self
1183    }
1184
1185    /// Set marker for `Untracked` git status.
1186    pub fn git_marker_untracked(mut self, marker: impl Into<Arc<str>>) -> Self {
1187        self.props.git_marker_untracked = marker.into();
1188        self
1189    }
1190
1191    /// Set marker for `Conflicted` git status.
1192    pub fn git_marker_conflicted(mut self, marker: impl Into<Arc<str>>) -> Self {
1193        self.props.git_marker_conflicted = marker.into();
1194        self
1195    }
1196
1197    /// Set style for `Modified` git status marker.
1198    pub fn git_style_modified(mut self, style: Style) -> Self {
1199        self.props.git_style_modified = style;
1200        self
1201    }
1202
1203    /// Set style for `Added` git status marker.
1204    pub fn git_style_added(mut self, style: Style) -> Self {
1205        self.props.git_style_added = style;
1206        self
1207    }
1208
1209    /// Set style for `Deleted` git status marker.
1210    pub fn git_style_deleted(mut self, style: Style) -> Self {
1211        self.props.git_style_deleted = style;
1212        self
1213    }
1214
1215    /// Set style for `Renamed` git status marker.
1216    pub fn git_style_renamed(mut self, style: Style) -> Self {
1217        self.props.git_style_renamed = style;
1218        self
1219    }
1220
1221    /// Set style for `Untracked` git status marker.
1222    pub fn git_style_untracked(mut self, style: Style) -> Self {
1223        self.props.git_style_untracked = style;
1224        self
1225    }
1226
1227    /// Set style for `Conflicted` git status marker.
1228    pub fn git_style_conflicted(mut self, style: Style) -> Self {
1229        self.props.git_style_conflicted = style;
1230        self
1231    }
1232
1233    /// Set a source-agnostic style patch for right-aligned change metadata.
1234    pub fn change_suffix_style(mut self, style: Style) -> Self {
1235        self.props.change_suffix_style = style;
1236        self
1237    }
1238
1239    /// Set a Git-compatible style patch for right-aligned change metadata.
1240    pub fn git_suffix_style(self, style: Style) -> Self {
1241        self.change_suffix_style(style)
1242    }
1243
1244    /// Set whether labels or right-aligned change metadata win when rows are narrow.
1245    pub fn change_suffix_priority(mut self, priority: FileTreeSuffixPriority) -> Self {
1246        self.props.change_suffix_priority = priority;
1247        self
1248    }
1249
1250    /// Set Git-compatible truncation priority for right-aligned change metadata.
1251    pub fn git_suffix_priority(self, priority: FileTreeSuffixPriority) -> Self {
1252        self.change_suffix_priority(priority)
1253    }
1254
1255    /// Set selection callback.
1256    pub fn on_select(mut self, cb: Callback<FileTreeEvent>) -> Self {
1257        self.props.on_select = Some(cb);
1258        self
1259    }
1260
1261    /// Fired when a row is activated (Enter, or click when `activate_on_click` is true).
1262    pub fn on_activate(mut self, cb: Callback<FileTreeEvent>) -> Self {
1263        self.props.on_activate = Some(cb);
1264        self
1265    }
1266
1267    /// Set expand/collapse callback.
1268    pub fn on_toggle(mut self, cb: Callback<FileTreeToggleEvent>) -> Self {
1269        self.props.on_toggle = Some(cb);
1270        self
1271    }
1272}
1273
1274impl From<FileTree> for Element {
1275    fn from(file_tree: FileTree) -> Self {
1276        let root_key = file_tree.props.root.clone();
1277        crate::child(component::FileTreeComponent::new, file_tree.props).key(root_key)
1278    }
1279}
1280
1281#[cfg(test)]
1282mod tests {
1283    use super::*;
1284    use std::collections::{HashMap, HashSet};
1285    use std::sync::Arc;
1286
1287    #[test]
1288    fn parses_untracked_line() {
1289        use git::GitChangeState;
1290        use git::parse_git_porcelain_line;
1291        let parsed = parse_git_porcelain_line("?? src/new.rs");
1292        assert_eq!(
1293            parsed.map(|(p, s)| (p, s.unstaged)),
1294            Some(("src/new.rs", Some(GitChangeState::Untracked)))
1295        );
1296    }
1297
1298    #[test]
1299    fn parses_rename_destination() {
1300        use git::GitChangeState;
1301        use git::parse_git_porcelain_line;
1302        let parsed = parse_git_porcelain_line("R  old/name.rs -> new/name.rs");
1303        assert_eq!(
1304            parsed.map(|(p, s)| (p, s.staged)),
1305            Some(("new/name.rs", Some(GitChangeState::Renamed)))
1306        );
1307    }
1308
1309    #[test]
1310    fn conflicting_status_wins_priority() {
1311        use git::insert_status;
1312        use git::{GitChangeState, GitFileStatus};
1313        let mut statuses = HashMap::new();
1314        let key: Arc<str> = "/repo/src/app.rs".into();
1315
1316        insert_status(
1317            &mut statuses,
1318            key.clone(),
1319            GitFileStatus::new(None, Some(GitChangeState::Modified)),
1320        );
1321        insert_status(
1322            &mut statuses,
1323            key.clone(),
1324            GitFileStatus::new(None, Some(GitChangeState::Conflicted)),
1325        );
1326        insert_status(
1327            &mut statuses,
1328            key.clone(),
1329            GitFileStatus::new(None, Some(GitChangeState::Added)),
1330        );
1331
1332        assert_eq!(
1333            statuses.get(key.as_ref()).copied().and_then(|s| s.unstaged),
1334            Some(GitChangeState::Conflicted)
1335        );
1336    }
1337
1338    #[test]
1339    fn parses_numstat_line() {
1340        use git::parse_git_numstat_line;
1341
1342        let parsed = parse_git_numstat_line("30\t2\tsrc/lib.rs");
1343
1344        assert_eq!(
1345            parsed.map(|(path, stat)| (path, stat.added, stat.removed)),
1346            Some(("src/lib.rs".to_string(), 30, 2))
1347        );
1348    }
1349
1350    #[test]
1351    fn parses_braced_numstat_rename_destination() {
1352        use git::parse_git_numstat_line;
1353
1354        let parsed = parse_git_numstat_line("4\t1\tsrc/{old => new}/file.rs");
1355
1356        assert_eq!(
1357            parsed.map(|(path, stat)| (path, stat.added, stat.removed)),
1358            Some(("src/new/file.rs".to_string(), 4, 1))
1359        );
1360    }
1361
1362    #[test]
1363    fn ignores_binary_numstat_line() {
1364        use git::parse_git_numstat_line;
1365
1366        assert_eq!(parse_git_numstat_line("-\t-\tassets/logo.png"), None);
1367    }
1368
1369    #[test]
1370    fn git_view_builders_update_props() {
1371        let tree = FileTree::new(".")
1372            .git_changed_only(true)
1373            .git_diff_stats(true);
1374
1375        assert_eq!(tree.props.change_view, FileTreeChangeView::ChangedOnly);
1376        assert!(tree.props.git_diff_stats);
1377
1378        let tree = tree.git_changed_only(false);
1379        assert_eq!(tree.props.change_view, FileTreeChangeView::AllFiles);
1380    }
1381
1382    #[test]
1383    fn indent_width_defaults_to_two_and_can_be_compacted() {
1384        assert_eq!(FileTree::new(".").props.indent_width, 2);
1385        assert_eq!(FileTree::new(".").indent_width(1).props.indent_width, 1);
1386    }
1387
1388    #[test]
1389    fn highlight_changed_labels_builder_updates_props() {
1390        let tree = FileTree::new(".").highlight_changed_labels(true);
1391
1392        assert!(tree.props.highlight_changed_labels);
1393
1394        let tree = tree.highlight_changed_labels(false);
1395        assert!(!tree.props.highlight_changed_labels);
1396    }
1397
1398    #[test]
1399    fn label_style_builders_update_props() {
1400        let directory_style = Style::new().fg(Color::Blue).bold();
1401        let file_style = Style::new().fg(Color::Green);
1402
1403        let tree = FileTree::new(".")
1404            .directory_label_style(directory_style)
1405            .file_label_style(file_style);
1406
1407        assert_eq!(tree.props.directory_label_style, directory_style);
1408        assert_eq!(tree.props.file_label_style, file_style);
1409    }
1410
1411    #[test]
1412    fn item_style_builders_store_optional_styles() {
1413        let row = Style::new().bg(Color::Blue);
1414        let icon = Style::new().fg(Color::Cyan);
1415        let label = Style::new().fg(Color::Green).bold();
1416        let suffix = Style::new().dim();
1417
1418        let style = FileTreeItemStyle::new()
1419            .row(row)
1420            .icon(icon)
1421            .label(label)
1422            .suffix(suffix);
1423
1424        assert_eq!(style.row, Some(row));
1425        assert_eq!(style.icon, Some(icon));
1426        assert_eq!(style.label, Some(label));
1427        assert_eq!(style.suffix, Some(suffix));
1428    }
1429
1430    #[test]
1431    fn path_and_suffix_style_builders_update_props() {
1432        let item_style = FileTreeItemStyle::new().label(Style::new().fg(Color::Green));
1433        let suffix_style = Style::new().dim();
1434        let tree = FileTree::new("/repo")
1435            .path_style("src/main.rs", item_style)
1436            .path_styles([("src/lib.rs", item_style.suffix(suffix_style))])
1437            .change_suffix_style(suffix_style)
1438            .change_suffix_priority(FileTreeSuffixPriority::Suffix);
1439
1440        assert_eq!(tree.props.path_styles.get("src/main.rs"), Some(&item_style));
1441        assert_eq!(tree.props.change_suffix_style, suffix_style);
1442        assert_eq!(
1443            tree.props.change_suffix_priority,
1444            FileTreeSuffixPriority::Suffix
1445        );
1446
1447        let tree = tree
1448            .git_suffix_style(Style::new().italic())
1449            .git_suffix_priority(FileTreeSuffixPriority::Label);
1450        assert_eq!(tree.props.change_suffix_style, Style::new().italic());
1451        assert_eq!(
1452            tree.props.change_suffix_priority,
1453            FileTreeSuffixPriority::Label
1454        );
1455    }
1456
1457    #[test]
1458    fn crate_root_export_compiles() {
1459        let _: crate::FileTreeItemStyle = FileTreeItemStyle::new();
1460        let _: crate::FileTreeSuffixPriority = FileTreeSuffixPriority::Suffix;
1461    }
1462
1463    #[test]
1464    fn entry_source_defaults_to_local_and_builds_provided_listings() {
1465        let tree = FileTree::new("/repo");
1466        assert_eq!(tree.props.entry_source, FileTreeEntrySource::Local);
1467
1468        let listing = FileTreeDirectoryListing::new(
1469            ".",
1470            [
1471                FileTreeEntry::directory("src"),
1472                FileTreeEntry::file("README.md")
1473                    .git_status(GitFileStatus::new(None, Some(GitChangeState::Modified)))
1474                    .ignored(true),
1475            ],
1476        );
1477        let source = FileTreeEntrySource::provided([listing.clone()]);
1478        let tree = tree.entry_source(source.clone());
1479
1480        assert_eq!(source, FileTreeEntrySource::Provided(vec![listing]));
1481        assert_eq!(tree.props.entry_source, source);
1482    }
1483
1484    #[test]
1485    fn change_view_builders_update_source_agnostic_props() {
1486        let changes = vec![FileTreeChange::new(
1487            "src/main.rs",
1488            FileTreeChangeStatus::Modified,
1489        )];
1490        let tree = FileTree::new("/repo")
1491            .change_source(FileTreeChangeSource::Provided(changes.clone()))
1492            .change_view(FileTreeChangeView::ChangedOnly)
1493            .show_diff_stats(true);
1494
1495        assert_eq!(
1496            tree.props.change_source,
1497            FileTreeChangeSource::Provided(changes)
1498        );
1499        assert_eq!(tree.props.change_view, FileTreeChangeView::ChangedOnly);
1500        assert!(tree.props.git_diff_stats);
1501    }
1502
1503    #[test]
1504    fn controlled_tree_state_builders_update_props() {
1505        let tree = FileTree::new("/repo")
1506            .selected(3)
1507            .selected_path("src/main.rs")
1508            .reveal_path("src")
1509            .select_path("tests/tree.rs")
1510            .force_scroll_to_selected(true)
1511            .expanded_paths(["/repo/src", "/repo/tests"]);
1512
1513        assert_eq!(tree.props.selected, Some(3));
1514        assert_eq!(tree.props.selected_path.as_deref(), Some("src/main.rs"));
1515        assert_eq!(tree.props.reveal_path.as_deref(), Some("src"));
1516        assert_eq!(tree.props.select_path.as_deref(), Some("tests/tree.rs"));
1517        assert!(tree.props.force_scroll_to_selected);
1518        assert_eq!(
1519            tree.props.expanded_paths,
1520            Some(HashSet::from([
1521                Arc::<str>::from("/repo/src"),
1522                Arc::<str>::from("/repo/tests")
1523            ]))
1524        );
1525    }
1526
1527    /// The placeholder is prose rather than a row, so it insets independently of the tree, which
1528    /// stays flush with the surface.
1529    #[test]
1530    fn empty_state_builders_update_props() {
1531        let tree = FileTree::new("/repo");
1532        assert_eq!(tree.props.empty_text_padding, Padding::default());
1533
1534        let tree = tree
1535            .empty_text("No changes")
1536            .empty_text_style(Style::new().dim())
1537            .empty_text_padding((0, 0, 0, 1));
1538
1539        assert_eq!(tree.props.empty_text.as_deref(), Some("No changes"));
1540        assert_eq!(tree.props.empty_text_style, Style::new().dim());
1541        assert_eq!(tree.props.empty_text_padding, Padding::from((0, 0, 0, 1)));
1542    }
1543
1544    #[test]
1545    fn provided_snapshot_resolves_absolute_and_rejects_outside_paths() {
1546        let changes = vec![
1547            FileTreeChange::new("/repo/src/main.rs", FileTreeChangeStatus::Modified),
1548            FileTreeChange::new("/elsewhere/ignored.rs", FileTreeChangeStatus::Deleted),
1549            FileTreeChange::new("../escape.rs", FileTreeChangeStatus::Added),
1550        ];
1551
1552        let snapshot = git::provided_change_snapshot("/repo", &changes);
1553
1554        assert_eq!(snapshot.changed_paths, vec![Arc::from("/repo/src/main.rs")]);
1555    }
1556
1557    #[test]
1558    fn provided_snapshot_accepts_absolute_paths_under_relative_root() {
1559        let root = std::path::Path::new("relative-provided-root");
1560        let absolute = std::env::current_dir()
1561            .unwrap()
1562            .join(root)
1563            .join("src/main.rs");
1564        let changes = vec![FileTreeChange::new(
1565            absolute.to_string_lossy().into_owned(),
1566            FileTreeChangeStatus::Modified,
1567        )];
1568
1569        let snapshot = git::provided_change_snapshot(root.to_str().unwrap(), &changes);
1570
1571        assert_eq!(
1572            snapshot.changed_paths,
1573            vec![Arc::from(absolute.to_string_lossy().as_ref())]
1574        );
1575    }
1576
1577    #[test]
1578    fn crate_root_exports_file_tree_change_api() {
1579        let _tree: crate::FileTree = FileTree::new("/repo");
1580        let change =
1581            crate::FileTreeChange::new("src/main.rs", crate::FileTreeChangeStatus::Modified);
1582        let _source = crate::FileTreeChangeSource::provided([change]);
1583        let _view: crate::FileTreeChangeView = crate::FileTreeGitView::ChangedOnly;
1584        let _kind: crate::FileKind = FileKind::File;
1585        let listing =
1586            crate::FileTreeDirectoryListing::new(".", [crate::FileTreeEntry::file("README.md")]);
1587        let _entry_source = crate::FileTreeEntrySource::provided([listing]);
1588        let _request: Option<crate::FileTreeEntryRequest> = None;
1589    }
1590
1591    #[test]
1592    fn merges_status_and_diff_stat_decorations() {
1593        use git::{
1594            GitChangeState, GitDiffStat, GitFileDecorations, GitFileStatus, insert_decoration,
1595        };
1596
1597        let mut entries = HashMap::new();
1598        let key: Arc<str> = "/repo/src/app.rs".into();
1599
1600        insert_decoration(
1601            &mut entries,
1602            key.clone(),
1603            GitFileDecorations {
1604                status: GitFileStatus::new(None, Some(GitChangeState::Modified)),
1605                diff_stat: None,
1606                direct: true,
1607            },
1608        );
1609        insert_decoration(
1610            &mut entries,
1611            key.clone(),
1612            GitFileDecorations {
1613                status: GitFileStatus::new(None, None),
1614                diff_stat: Some(GitDiffStat {
1615                    added: 10,
1616                    removed: 4,
1617                }),
1618                direct: false,
1619            },
1620        );
1621
1622        let decoration = entries.get(key.as_ref()).copied().unwrap();
1623        assert_eq!(decoration.status.unstaged, Some(GitChangeState::Modified));
1624        assert_eq!(
1625            decoration.diff_stat,
1626            Some(GitDiffStat {
1627                added: 10,
1628                removed: 4
1629            })
1630        );
1631        assert!(decoration.direct);
1632    }
1633}