4.2 KB138 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {Comparison} from 'shared/Comparison';
9import type {ChangedFilesDisplayType} from './ChangedFileDisplayTypePicker';
10import type {Place, UIChangedFile} from './UncommittedChanges';
11import type {UseUncommittedSelection} from './partialSelection';
12import type {PathTree} from './pathTree';
13
14import {Button} from 'isl-components/Button';
15import {Checkbox} from 'isl-components/Checkbox';
16import {Icon} from 'isl-components/Icon';
17import {useMemo, useState} from 'react';
18import {mapIterable} from 'shared/utils';
19import {File} from './ChangedFile';
20import {buildPathTree, calculateTreeSelectionStates} from './pathTree';
21
22export function FileTreeFolderHeader({
23 isCollapsed,
24 toggleCollapsed,
25 checkedState,
26 toggleChecked,
27 folder,
28}: {
29 isCollapsed: boolean;
30 toggleCollapsed: () => void;
31 checkedState?: true | false | 'indeterminate';
32 toggleChecked?: (checked: boolean) => void;
33 folder: string;
34}) {
35 return (
36 <span className="file-tree-folder-path">
37 {checkedState != null && toggleChecked != null && (
38 <Checkbox
39 checked={checkedState === true}
40 indeterminate={checkedState === 'indeterminate'}
41 onChange={() => toggleChecked(checkedState !== true)}
42 />
43 )}
44 <Button icon onClick={toggleCollapsed}>
45 <Icon icon={isCollapsed ? 'chevron-right' : 'chevron-down'} slot="start" />
46 {folder}
47 </Button>
48 </span>
49 );
50}
51
52function* iteratePathTree(tree: PathTree<UIChangedFile>): Generator<UIChangedFile> {
53 for (const node of tree.values()) {
54 if (node instanceof Map) {
55 yield* iteratePathTree(node);
56 } else {
57 yield node;
58 }
59 }
60}
61
62export function FileTree(props: {
63 files: Array<UIChangedFile>;
64 displayType: ChangedFilesDisplayType;
65 comparison: Comparison;
66 selection?: UseUncommittedSelection;
67 place?: Place;
68}) {
69 const {files, ...rest} = props;
70
71 const tree = useMemo(
72 () => buildPathTree(Object.fromEntries(files.map(file => [file.path, file]))),
73 [files],
74 );
75
76 const directoryCheckedStates = useMemo(
77 () => (props.selection == null ? null : calculateTreeSelectionStates(tree, props.selection)),
78 [tree, props.selection],
79 );
80
81 const [collapsed, setCollapsed] = useState(new Set());
82
83 function renderTree(tree: PathTree<UIChangedFile>, accumulatedPath = '') {
84 return (
85 <div className="file-tree">
86 {[...tree.entries()].map(([folder, inner]) => {
87 const folderKey = `${accumulatedPath}/${folder}`;
88 const isCollapsed = collapsed.has(folderKey);
89
90 let content;
91 if (inner instanceof Map) {
92 const checkedState = directoryCheckedStates?.get(folderKey);
93 content = (
94 <>
95 <FileTreeFolderHeader
96 isCollapsed={isCollapsed}
97 checkedState={checkedState}
98 toggleChecked={
99 rest.selection == null
100 ? undefined
101 : checked => {
102 const paths = mapIterable(iteratePathTree(inner), file => file.path);
103 if (checked) {
104 rest.selection?.select(...paths);
105 } else {
106 rest.selection?.deselect(...paths);
107 }
108 }
109 }
110 toggleCollapsed={() => {
111 setCollapsed(last =>
112 isCollapsed
113 ? new Set([...last].filter(v => v !== folderKey))
114 : new Set([...last, folderKey]),
115 );
116 }}
117 folder={folder}
118 />
119 {isCollapsed ? null : renderTree(inner, folderKey)}
120 </>
121 );
122 } else {
123 content = <File key={inner.path} {...rest} file={inner} />;
124 }
125
126 return (
127 <div className="file-tree-level" key={folderKey}>
128 {content}
129 </div>
130 );
131 })}
132 </div>
133 );
134 }
135
136 return renderTree(tree);
137}
138