diff --git a/src/cachedfiletree.ts b/src/cachedfiletree.ts index 39e8d74..c76460e 100644 --- a/src/cachedfiletree.ts +++ b/src/cachedfiletree.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { fileSystemSafeName } from "trutil"; +import { fileSystemSafeName, chainedIterables } from "trutil"; import type { Torrent, TorrentBase } from "./rpc/torrent"; import type { PriorityNumberType } from "rpc/transmission"; @@ -327,7 +327,31 @@ export class CachedFileTree { dir.isSelected = value; } + deselectAllAncestors(entry: Entry) { + let parent = entry.parent; + while (parent != null) { + parent.isSelected = false; + parent = parent.parent; + } + } + + updateAncestorSelectionStates(ancestor: DirEntry) { + let dirEntry: DirEntry | undefined = ancestor; + while (dirEntry != null && dirEntry !== this.tree) { + dirEntry.isSelected = true; + for (const child of chainedIterables(dirEntry.subdirs.values(), dirEntry.files.values())) { + if (!child.isSelected) { + dirEntry.isSelected = false; + this.deselectAllAncestors(dirEntry); + return; + } + } + dirEntry = dirEntry.parent; + } + } + selectAction({ verb, ids }: { verb: "add" | "set" | "toggle", ids: string[] }) { + const affectedParents = new Set(); if (verb === "set") { this.setSelection(this.tree, false); } @@ -337,6 +361,7 @@ export class CachedFileTree { console.log("What the horse?", id); return; } + if (entry.parent != null) affectedParents.add(entry.parent); if (verb !== "toggle" || !entry.isSelected) { if (isDirEntry(entry)) { this.setSelection(entry, true); @@ -351,6 +376,7 @@ export class CachedFileTree { } } }); + affectedParents.forEach(parent => { this.updateAncestorSelectionStates(parent); }); } getSelected(): string[] { diff --git a/src/components/tables/common.tsx b/src/components/tables/common.tsx index 7e487de..61bac8d 100644 --- a/src/components/tables/common.tsx +++ b/src/components/tables/common.tsx @@ -60,6 +60,7 @@ function useTable( (o: ColumnOrderState) => void, ColumnSizingState, SortingState, + Set, ] { const config = useContext(ConfigContext); @@ -72,6 +73,8 @@ function useTable( const [sorting, setSorting] = useState(config.getTableSortBy(tablename)); const [rowSelection, setRowSelection] = useState({}); + const [rowsWithSelectedDescendants, setRowsWithSelectedDescendants] = + useState>(new Set()); useEffect(() => { config.setTableColumnVisibility(tablename, columnVisibility); @@ -122,7 +125,34 @@ function useTable( if (columnOrder.length === 0) setColumnOrder(table.getAllLeafColumns().map((c) => c.id)); }, [columnOrder, table]); - return [table, columnVisibility, setColumnVisibility, columnOrder, setColumnOrder, columnSizing, sorting]; + useEffect(() => { + if (!table.getCanSomeRowsExpand()) return; + + const newRowsWithSelectedDescendants = new Set(); + + const recurse = (row: Row): boolean => { + if (rowSelection[row.id]) return true; + + let hasSelectedDescendant = false; + row.subRows.forEach(subrow => { + if (recurse(subrow)) { + hasSelectedDescendant = true; + } + }); + + if (hasSelectedDescendant) { + newRowsWithSelectedDescendants.add(row.id); + return true; + } + + return false; + }; + table.getCoreRowModel().rows.forEach(recurse); + + setRowsWithSelectedDescendants(newRowsWithSelectedDescendants); + }, [table, rowSelection]); + + return [table, columnVisibility, setColumnVisibility, columnOrder, setColumnOrder, columnSizing, sorting, rowsWithSelectedDescendants]; } interface HIDEvent { @@ -263,6 +293,7 @@ export function useRowSelected() { function TableRow(props: { row: Row, selected: boolean, + descendantSelected: boolean, expanded: boolean, index: number, start: number, @@ -324,7 +355,7 @@ function TableRow(props: { return (
(props: { onVisibilityChange?: React.Dispatch, scrollToRow?: { id: string }, }) { - const [table, columnVisibility, setColumnVisibility, columnOrder, setColumnOrder, columnSizing, sorting] = + const [table, columnVisibility, setColumnVisibility, columnOrder, setColumnOrder, columnSizing, sorting, rowsWithSelectedDescendants] = useTable(props.tablename, props.columns, props.data, props.selected, props.getRowId, props.getSubRows, props.onVisibilityChange); if (props.tableRef !== undefined) { @@ -543,6 +574,7 @@ export function TrguiTable(props: { return key={props.getRowId(row.original)} {...{ row, selected: row.getIsSelected(), + descendantSelected: rowsWithSelectedDescendants.has(row.id), expanded: row.getIsExpanded(), index: virtualRow.index, lastIndex, diff --git a/src/components/tables/filetreetable.tsx b/src/components/tables/filetreetable.tsx index 0211ae7..9d4d8a3 100644 --- a/src/components/tables/filetreetable.tsx +++ b/src/components/tables/filetreetable.tsx @@ -164,6 +164,11 @@ interface FileTreeTableProps { brief?: boolean, } +function entryMatchesSearchTerms(entry: FileDirEntryView, searchTerms: string[]) { + const path = entry.fullpath.toLowerCase().substring(entry.fullpath.indexOf("/") + 1); + return searchTerms.every(term => path.includes(term)); +} + export function useUnwantedFiles(ft: CachedFileTree, setUpdating: boolean): EntryWantedChangeHandler { const changeHandler = useCallback((entryPath: string, state: boolean) => { ft.setWanted(entryPath, state, setUpdating); @@ -172,7 +177,7 @@ export function useUnwantedFiles(ft: CachedFileTree, setUpdating: boolean): Entr return changeHandler; } -function useSelected(props: FileTreeTableProps) { +function useSelected(data: FileDirEntryView[], fileTree: CachedFileTree, searchTerms: string[]) { const [selected, setSelected] = useReducer((prev: string[], next: string[]) => { if (prev.length === next.length) { for (let i = 0; i < prev.length; i++) { @@ -183,26 +188,49 @@ function useSelected(props: FileTreeTableProps) { return next; }, []); + const deriveNewSelection = useRef((_: boolean) => [] as string[]); + deriveNewSelection.current = useCallback((selectAll: boolean) => { + const result: string[] = []; + const recurse = (entry: FileDirEntryView) => { + if ( + (selectAll || fileTree.findEntry(entry.fullpath)?.isSelected === true) && + (entry.subrows.length === 0 || entryMatchesSearchTerms(entry, searchTerms)) + ) { + result.push(entry.fullpath); + return; + } + entry.subrows.forEach(recurse); + }; + data.forEach(recurse); + return result; + }, [data, fileTree, searchTerms]); + + useEffect(() => { + if (searchTerms.length === 0) return; + + fileTree.selectAction({ verb: "set", ids: deriveNewSelection.current(false) }); + setSelected(fileTree.getSelected()); + }, [fileTree, searchTerms]); + const selectAll = useRef(() => { }); const hk = useHotkeysContext(); selectAll.current = useCallback(() => { - const ids = props.data.map((entry) => entry.fullpath); - props.fileTree.selectAction({ verb: "set", ids }); - setSelected(props.fileTree.getSelected()); - }, [props.data, props.fileTree]); + fileTree.selectAction({ verb: "set", ids: deriveNewSelection.current(true) }); + setSelected(fileTree.getSelected()); + }, [fileTree]); useEffect(() => { return () => { hk.handlers.selectAll = () => { }; }; }, [hk]); const selectedReducer = useCallback((action: { verb: "add" | "set" | "toggle", ids: string[], isReset?: boolean }) => { - props.fileTree.selectAction(action); - setSelected(props.fileTree.getSelected()); + fileTree.selectAction(action); + setSelected(fileTree.getSelected()); if (action.isReset !== true) { hk.handlers.selectAll = () => { selectAll.current?.(); }; } - }, [props.fileTree, hk]); + }, [fileTree, hk]); return { selected, selectedReducer }; } @@ -290,12 +318,6 @@ export function FileTreeTable(props: FileTreeTableProps) { const getRowId = useCallback((row: FileDirEntryView) => row.fullpath, []); const getSubRows = useCallback((row: FileDirEntryView) => row.subrows, []); - const { selected, selectedReducer } = useSelected(props); - - useEffect(() => { - selectedReducer({ verb: "set", ids: [], isReset: true }); - }, [props.fileTree.torrenthash, selectedReducer]); - const onEntryOpen = useCallback((rowPath: string, reveal: boolean = false) => { if (TAURI) { if (props.downloadDir === undefined || props.downloadDir === "") return; @@ -329,24 +351,16 @@ export function FileTreeTable(props: FileTreeTableProps) { const data = useMemo(() => { if (searchTerms.length === 0) return props.data; - const matches = (entry: FileDirEntryView) => { - let matchesAll = true; - const path = entry.fullpath.toLowerCase().substring(entry.fullpath.indexOf("/") + 1); - searchTerms.forEach((term) => { - if (!path.includes(term)) matchesAll = false; - }); - return matchesAll; - }; const filter = (entries: FileDirEntryView[]) => { const result: FileDirEntryView[] = []; entries.forEach((entry) => { if (entry.subrows.length > 0) { const copy = { ...entry }; copy.subrows = filter(copy.subrows); - if (matches(copy) || copy.subrows.length > 0) { + if (copy.subrows.length > 0 || entryMatchesSearchTerms(copy, searchTerms)) { result.push(copy); } - } else if (matches(entry)) { + } else if (entryMatchesSearchTerms(entry, searchTerms)) { result.push(entry); } }); @@ -361,6 +375,12 @@ export function FileTreeTable(props: FileTreeTableProps) { else tableRef.current?.setExpanded(false); }, [searchTerms]); + const { selected, selectedReducer } = useSelected(data, props.fileTree, searchTerms); + + useEffect(() => { + selectedReducer({ verb: "set", ids: [], isReset: true }); + }, [props.fileTree.torrenthash, selectedReducer]); + const [showFileSearchBox, toggleFileSearchBox] = useReducer((shown: boolean) => { const show = !shown; if (!show) setSearchTerms([]); diff --git a/src/css/torrenttable.css b/src/css/torrenttable.css index 675f9ce..b422353 100644 --- a/src/css/torrenttable.css +++ b/src/css/torrenttable.css @@ -84,6 +84,16 @@ box-shadow: inset 0 0 0 9999px rgba(133, 133, 133, 0.1); } +.torrent-table .tr.descendant-selected { + background-color: rgb(50, 80, 170); + color: white; + box-shadow: inset 0 0 0 9999px rgba(133, 133, 133, 0.7); +} + +.torrent-table .tr.descendant-selected:nth-child(odd) { + box-shadow: inset 0 0 0 9999px rgba(133, 133, 133, 0.8); +} + .resizer { display: block; background: rgba(124, 124, 124, 0.4); diff --git a/src/trutil.ts b/src/trutil.ts index 565d161..028cdd1 100644 --- a/src/trutil.ts +++ b/src/trutil.ts @@ -227,3 +227,7 @@ const badChars = /[<>:"\\/|?*]/g; export function fileSystemSafeName(name: string) { return name.replace(badChars, "_"); } + +export function * chainedIterables(...iterables: Array>) { + for (const iterable of iterables) yield * iterable; +}