| 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 | |
| 8 | import type {Comparison} from 'shared/Comparison'; |
| 9 | import type {ChangedFilesDisplayType} from './ChangedFileDisplayTypePicker'; |
| 10 | import type {Place, UIChangedFile} from './UncommittedChanges'; |
| 11 | import type {UseUncommittedSelection} from './partialSelection'; |
| 12 | import type {PathTree} from './pathTree'; |
| 13 | |
| 14 | import {Button} from 'isl-components/Button'; |
| 15 | import {Checkbox} from 'isl-components/Checkbox'; |
| 16 | import {Icon} from 'isl-components/Icon'; |
| 17 | import {useMemo, useState} from 'react'; |
| 18 | import {mapIterable} from 'shared/utils'; |
| 19 | import {File} from './ChangedFile'; |
| 20 | import {buildPathTree, calculateTreeSelectionStates} from './pathTree'; |
| 21 | |
| 22 | export 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 | |
| 52 | function* 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 | |
| 62 | export 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 | |