1mod 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36pub struct FileTreeItemStyle {
37 pub row: Option<Style>,
39 pub icon: Option<Style>,
41 pub label: Option<Style>,
43 pub suffix: Option<Style>,
45}
46
47impl FileTreeItemStyle {
48 pub fn new() -> Self {
50 Self::default()
51 }
52
53 pub fn row(mut self, style: Style) -> Self {
55 self.row = Some(style);
56 self
57 }
58
59 pub fn icon(mut self, style: Style) -> Self {
61 self.icon = Some(style);
62 self
63 }
64
65 pub fn label(mut self, style: Style) -> Self {
67 self.label = Some(style);
68 self
69 }
70
71 pub fn suffix(mut self, style: Style) -> Self {
73 self.suffix = Some(style);
74 self
75 }
76}
77
78#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
80pub enum FileTreeSuffixPriority {
81 #[default]
83 Label,
84 Suffix,
86}
87
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
90pub enum FileTreeChangeView {
91 #[default]
93 AllFiles,
94 ChangedOnly,
96}
97
98pub type FileTreeGitView = FileTreeChangeView;
100
101#[derive(Clone, Debug, Default, PartialEq, Eq)]
103pub enum FileTreeEntrySource {
104 #[default]
106 Local,
107 Provided(Vec<FileTreeDirectoryListing>),
114}
115
116impl FileTreeEntrySource {
117 pub fn provided(listings: impl IntoIterator<Item = FileTreeDirectoryListing>) -> Self {
119 Self::Provided(listings.into_iter().collect())
120 }
121}
122
123#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct FileTreeEntry {
126 pub name: Arc<str>,
128 pub is_dir: bool,
130 pub is_symlink: bool,
132 pub git_status: Option<GitFileStatus>,
134 pub ignored: bool,
139}
140
141impl FileTreeEntry {
142 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 pub fn file(name: impl Into<Arc<str>>) -> Self {
155 Self::new(name, false)
156 }
157
158 pub fn directory(name: impl Into<Arc<str>>) -> Self {
160 Self::new(name, true)
161 }
162
163 pub fn symlink(mut self, is_symlink: bool) -> Self {
165 self.is_symlink = is_symlink;
166 self
167 }
168
169 pub fn git_status(mut self, status: GitFileStatus) -> Self {
171 self.git_status = Some(status);
172 self
173 }
174
175 pub fn ignored(mut self, ignored: bool) -> Self {
177 self.ignored = ignored;
178 self
179 }
180}
181
182#[derive(Clone, Debug)]
184pub struct FileTreeDirectoryListing {
185 pub path: Arc<str>,
187 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 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 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
229pub enum FileTreeChangeSource {
230 #[default]
232 Git,
233 Provided(Vec<FileTreeChange>),
235}
236
237impl FileTreeChangeSource {
238 pub fn provided(changes: impl IntoIterator<Item = FileTreeChange>) -> Self {
240 Self::Provided(changes.into_iter().collect())
241 }
242}
243
244#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
246pub enum FileTreeChangeStatus {
247 Modified,
249 Added,
251 Deleted,
253 Renamed,
255 Untracked,
257 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#[derive(Clone, Debug, PartialEq, Eq)]
276pub struct FileTreeChange {
277 pub path: Arc<str>,
279 pub status: FileTreeChangeStatus,
281 pub kind: Option<FileKind>,
283 pub additions: usize,
285 pub deletions: usize,
287 pub staged: bool,
289}
290
291impl FileTreeChange {
292 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 pub fn kind(mut self, kind: FileKind) -> Self {
306 self.kind = Some(kind);
307 self
308 }
309
310 pub fn diff_stat(mut self, additions: usize, deletions: usize) -> Self {
312 self.additions = additions;
313 self.deletions = deletions;
314 self
315 }
316
317 pub fn additions(mut self, additions: usize) -> Self {
319 self.additions = additions;
320 self
321 }
322
323 pub fn deletions(mut self, deletions: usize) -> Self {
325 self.deletions = deletions;
326 self
327 }
328
329 pub fn staged(mut self, staged: bool) -> Self {
331 self.staged = staged;
332 self
333 }
334}
335
336#[derive(Clone)]
338pub struct FileTree {
339 props: FileTreeProps,
340}
341
342impl FileTree {
343 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_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 pub fn entry_source(mut self, source: FileTreeEntrySource) -> Self {
466 self.props.entry_source = source;
467 self
468 }
469
470 pub fn show_hidden(mut self, show_hidden: bool) -> Self {
472 self.props.show_hidden = show_hidden;
473 self
474 }
475
476 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 pub fn show_icons(mut self, show_icons: bool) -> Self {
484 self.props.show_icons = show_icons;
485 self
486 }
487
488 pub fn icon_style(mut self, style: FileIconStyle) -> Self {
490 self.props.icon_style = style;
491 self
492 }
493
494 pub fn icon_palette(mut self, palette: FileIconPalette) -> Self {
496 self.props.icon_palette = palette;
497 self
498 }
499
500 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 pub fn show_arrows(mut self, show: bool) -> Self {
522 self.props.show_arrows = show;
523 self
524 }
525
526 pub fn indent_style(mut self, style: crate::widgets::IndentStyle) -> Self {
528 self.props.indent_style = style;
529 self
530 }
531
532 pub fn indent_guide_style(mut self, style: Style) -> Self {
534 self.props.indent_guide_style = style;
535 self
536 }
537
538 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 pub fn indent_width(mut self, width: u16) -> Self {
546 self.props.indent_width = width;
547 self
548 }
549
550 pub fn directory_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
552 self.props.directory_icon = icon.into();
553 self
554 }
555
556 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 pub fn file_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
564 self.props.file_icon = icon.into();
565 self
566 }
567
568 pub fn symlink_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
570 self.props.symlink_icon = icon.into();
571 self
572 }
573
574 pub fn other_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
576 self.props.other_icon = icon.into();
577 self
578 }
579
580 pub fn directory_label_style(mut self, style: Style) -> Self {
582 self.props.directory_label_style = style;
583 self
584 }
585
586 pub fn file_label_style(mut self, style: Style) -> Self {
588 self.props.file_label_style = style;
589 self
590 }
591
592 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 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 pub fn loading_label(mut self, label: impl Into<Arc<str>>) -> Self {
611 self.props.loading_label = label.into();
612 self
613 }
614
615 pub fn error_prefix(mut self, prefix: impl Into<Arc<str>>) -> Self {
617 self.props.error_prefix = prefix.into();
618 self
619 }
620
621 pub fn width(mut self, width: Length) -> Self {
623 self.props.width = width;
624 self
625 }
626
627 pub fn height(mut self, height: Length) -> Self {
629 self.props.height = height;
630 self
631 }
632
633 pub fn style(mut self, style: Style) -> Self {
635 self.props.style = style;
636 self
637 }
638
639 pub fn hover_style(mut self, style: Style) -> Self {
641 self.props.hover_style = StyleSlot::Replace(style);
642 self
643 }
644
645 pub fn extend_hover_style(mut self, style: Style) -> Self {
647 self.props.hover_style = StyleSlot::Extend(style);
648 self
649 }
650
651 pub fn inherit_hover_style(mut self) -> Self {
653 self.props.hover_style = StyleSlot::Inherit;
654 self
655 }
656
657 pub fn hover_style_slot(mut self, slot: StyleSlot) -> Self {
659 self.props.hover_style = slot;
660 self
661 }
662
663 pub fn item_hover_style(mut self, style: Style) -> Self {
665 self.props.item_hover_style = StyleSlot::Replace(style);
666 self
667 }
668
669 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 pub fn inherit_item_hover_style(mut self) -> Self {
677 self.props.item_hover_style = StyleSlot::Inherit;
678 self
679 }
680
681 pub fn item_hover_style_slot(mut self, slot: StyleSlot) -> Self {
683 self.props.item_hover_style = slot;
684 self
685 }
686
687 pub fn selection_style(mut self, style: Style) -> Self {
689 self.props.selection_style = StyleSlot::Replace(style);
690 self
691 }
692
693 pub fn extend_selection_style(mut self, style: Style) -> Self {
695 self.props.selection_style = StyleSlot::Extend(style);
696 self
697 }
698
699 pub fn inherit_selection_style(mut self) -> Self {
701 self.props.selection_style = StyleSlot::Inherit;
702 self
703 }
704
705 pub fn selection_style_slot(mut self, slot: StyleSlot) -> Self {
707 self.props.selection_style = slot;
708 self
709 }
710
711 pub fn unfocused_selection_style(mut self, style: Style) -> Self {
713 self.props.unfocused_selection_style = StyleSlot::Replace(style);
714 self
715 }
716
717 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 pub fn inherit_unfocused_selection_style(mut self) -> Self {
725 self.props.unfocused_selection_style = StyleSlot::Inherit;
726 self
727 }
728
729 pub fn unfocused_selection_style_slot(mut self, slot: StyleSlot) -> Self {
731 self.props.unfocused_selection_style = slot;
732 self
733 }
734
735 pub fn selected(mut self, selected: usize) -> Self {
737 self.props.selected = Some(selected);
738 self
739 }
740
741 pub fn clear_selection(mut self, clear: bool) -> Self {
746 self.props.clear_selection = clear;
747 self
748 }
749
750 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 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 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 pub fn force_scroll_to_selected(mut self, force: bool) -> Self {
781 self.props.force_scroll_to_selected = force;
782 self
783 }
784
785 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 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 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 pub fn selection_symbol_style(mut self, style: Option<Style>) -> Self {
820 self.props.selection_symbol_style = style;
821 self
822 }
823
824 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 pub fn scrollbar(mut self, scrollbar: bool) -> Self {
832 self.props.scrollbar = scrollbar;
833 self
834 }
835
836 pub fn scrollbar_config(mut self, config: ScrollbarConfig) -> Self {
838 self.props.scrollbar_config = config;
839 self
840 }
841
842 pub fn scroll_keys(mut self, keys: ScrollKeymap) -> Self {
844 self.props.scroll_keys = keys;
845 self
846 }
847
848 pub fn scroll_wheel(mut self, enabled: bool) -> Self {
850 self.props.scroll_wheel = enabled;
851 self
852 }
853
854 pub fn show_scroll_indicators(mut self, show: bool) -> Self {
856 self.props.show_scroll_indicators = show;
857 self
858 }
859
860 pub fn scroll_indicator_style(mut self, style: Style) -> Self {
862 self.props.scroll_indicator_style = style;
863 self
864 }
865
866 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 pub fn empty_text_style(mut self, style: Style) -> Self {
874 self.props.empty_text_style = style;
875 self
876 }
877
878 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 pub fn explorer(mut self, explorer: bool) -> Self {
890 self.props.explorer = explorer;
891 self
892 }
893
894 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 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 pub fn explorer_placeholder(mut self, text: impl Into<Arc<str>>) -> Self {
908 self.props.explorer_placeholder = text.into();
909 self
910 }
911
912 pub fn explorer_prefix(mut self, prefix: impl Into<Arc<str>>) -> Self {
914 self.props.explorer_prefix = prefix.into();
915 self
916 }
917
918 pub fn explorer_input_border(mut self, border: bool) -> Self {
920 self.props.explorer_input_border = border;
921 self
922 }
923
924 pub fn explorer_input_border_style(mut self, style: BorderStyle) -> Self {
926 self.props.explorer_input_border_style = style;
927 self
928 }
929
930 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 pub fn explorer_input_style(mut self, style: Style) -> Self {
938 self.props.explorer_input_style = style;
939 self
940 }
941
942 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 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 pub fn inherit_explorer_input_focus_style(mut self) -> Self {
956 self.props.explorer_input_focus_style = StyleSlot::Inherit;
957 self
958 }
959
960 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 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 pub fn explorer_placeholder_style(mut self, style: Style) -> Self {
974 self.props.explorer_placeholder_style = style;
975 self
976 }
977
978 pub fn explorer_focus_placeholder_style(mut self, style: Style) -> Self {
980 self.props.explorer_focus_placeholder_style = style;
981 self
982 }
983
984 pub fn explorer_match_style(mut self, style: Style) -> Self {
986 self.props.explorer_match_style = style;
987 self
988 }
989
990 pub fn explorer_divider(mut self, show: bool) -> Self {
992 self.props.explorer_divider = show;
993 self
994 }
995
996 pub fn explorer_divider_join_frame(mut self, join: bool) -> Self {
998 self.props.explorer_divider_join_frame = join;
999 self
1000 }
1001
1002 pub fn explorer_divider_char(mut self, ch: char) -> Self {
1004 self.props.explorer_divider_char = ch;
1005 self
1006 }
1007
1008 pub fn explorer_divider_style(mut self, style: Style) -> Self {
1010 self.props.explorer_divider_style = style;
1011 self
1012 }
1013
1014 pub fn on_explorer_focus(mut self, cb: Callback<FileTreeExplorerFocusOrigin>) -> Self {
1016 self.props.on_explorer_focus = Some(cb);
1017 self
1018 }
1019
1020 pub fn on_explorer_blur(mut self, cb: Callback<()>) -> Self {
1022 self.props.on_explorer_blur = Some(cb);
1023 self
1024 }
1025
1026 pub fn on_explorer_escape(mut self, cb: Callback<()>) -> Self {
1032 self.props.on_explorer_escape = Some(cb);
1033 self
1034 }
1035
1036 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 pub fn focusable(mut self, focusable: bool) -> Self {
1044 self.props.focusable = focusable;
1045 self
1046 }
1047
1048 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
1050 self.props.tab_stop = tab_stop;
1051 self
1052 }
1053
1054 pub fn on_focus(mut self, cb: Callback<()>) -> Self {
1056 self.props.on_focus = Some(cb);
1057 self
1058 }
1059
1060 pub fn on_blur(mut self, cb: Callback<()>) -> Self {
1062 self.props.on_blur = Some(cb);
1063 self
1064 }
1065
1066 pub fn on_entry_request(mut self, cb: Callback<FileTreeEntryRequest>) -> Self {
1072 self.props.on_entry_request = Some(cb);
1073 self
1074 }
1075
1076 pub fn keymap(mut self, keymap: TreeKeymap) -> Self {
1078 self.props.keymap = keymap;
1079 self
1080 }
1081
1082 pub fn git_status(mut self, enabled: bool) -> Self {
1084 self.props.git_status = enabled;
1085 self
1086 }
1087
1088 pub fn highlight_changed_labels(mut self, enabled: bool) -> Self {
1094 self.props.highlight_changed_labels = enabled;
1095 self
1096 }
1097
1098 pub fn git_view(mut self, view: FileTreeGitView) -> Self {
1100 self.props.change_view = view;
1101 self
1102 }
1103
1104 pub fn change_source(mut self, source: FileTreeChangeSource) -> Self {
1106 self.props.change_source = source;
1107 self
1108 }
1109
1110 pub fn change_view(mut self, view: FileTreeChangeView) -> Self {
1112 self.props.change_view = view;
1113 self
1114 }
1115
1116 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 pub fn show_diff_stats(self, enabled: bool) -> Self {
1128 self.git_diff_stats(enabled)
1129 }
1130
1131 pub fn git_diff_stats(mut self, enabled: bool) -> Self {
1133 self.props.git_diff_stats = enabled;
1134 self
1135 }
1136
1137 pub fn git_icon_style(mut self, style: GitIconStyle) -> Self {
1139 self.props.git_icon_style = style;
1140 self
1141 }
1142
1143 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 pub fn git_refresh_token(mut self, token: u64) -> Self {
1157 self.props.git_refresh_nonce = token;
1158 self
1159 }
1160
1161 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 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 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 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 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 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 pub fn git_style_modified(mut self, style: Style) -> Self {
1199 self.props.git_style_modified = style;
1200 self
1201 }
1202
1203 pub fn git_style_added(mut self, style: Style) -> Self {
1205 self.props.git_style_added = style;
1206 self
1207 }
1208
1209 pub fn git_style_deleted(mut self, style: Style) -> Self {
1211 self.props.git_style_deleted = style;
1212 self
1213 }
1214
1215 pub fn git_style_renamed(mut self, style: Style) -> Self {
1217 self.props.git_style_renamed = style;
1218 self
1219 }
1220
1221 pub fn git_style_untracked(mut self, style: Style) -> Self {
1223 self.props.git_style_untracked = style;
1224 self
1225 }
1226
1227 pub fn git_style_conflicted(mut self, style: Style) -> Self {
1229 self.props.git_style_conflicted = style;
1230 self
1231 }
1232
1233 pub fn change_suffix_style(mut self, style: Style) -> Self {
1235 self.props.change_suffix_style = style;
1236 self
1237 }
1238
1239 pub fn git_suffix_style(self, style: Style) -> Self {
1241 self.change_suffix_style(style)
1242 }
1243
1244 pub fn change_suffix_priority(mut self, priority: FileTreeSuffixPriority) -> Self {
1246 self.props.change_suffix_priority = priority;
1247 self
1248 }
1249
1250 pub fn git_suffix_priority(self, priority: FileTreeSuffixPriority) -> Self {
1252 self.change_suffix_priority(priority)
1253 }
1254
1255 pub fn on_select(mut self, cb: Callback<FileTreeEvent>) -> Self {
1257 self.props.on_select = Some(cb);
1258 self
1259 }
1260
1261 pub fn on_activate(mut self, cb: Callback<FileTreeEvent>) -> Self {
1263 self.props.on_activate = Some(cb);
1264 self
1265 }
1266
1267 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 #[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}