Skip to content

Commit

Permalink
Improve files table UX (fixes #121):
Browse files Browse the repository at this point in the history
- Add visual indication to deselected directories with selected subrows
- Automatically deselect directories whenever their children are
  deselected
- Automatically select directories as soon as all of their children are
  selected
- Change Ctrl-A behavior so that it only selects entries that match the
  entered search terms
- Deselect entries that become hidden while using the search bar
  • Loading branch information
jpovixwm authored and qu1ck committed Dec 19, 2023
1 parent 437b2a7 commit 666f7ef
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 28 deletions.
28 changes: 27 additions & 1 deletion src/cachedfiletree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import { fileSystemSafeName } from "trutil";
import { fileSystemSafeName, chainedIterables } from "trutil";
import type { Torrent, TorrentBase } from "./rpc/torrent";
import type { PriorityNumberType } from "rpc/transmission";

Expand Down Expand Up @@ -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<Entry>(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<DirEntry>();
if (verb === "set") {
this.setSelection(this.tree, false);
}
Expand All @@ -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);
Expand All @@ -351,6 +376,7 @@ export class CachedFileTree {
}
}
});
affectedParents.forEach(parent => { this.updateAncestorSelectionStates(parent); });
}

getSelected(): string[] {
Expand Down
38 changes: 35 additions & 3 deletions src/components/tables/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function useTable<TData>(
(o: ColumnOrderState) => void,
ColumnSizingState,
SortingState,
Set<string>,
] {
const config = useContext(ConfigContext);

Expand All @@ -72,6 +73,8 @@ function useTable<TData>(
const [sorting, setSorting] =
useState<SortingState>(config.getTableSortBy(tablename));
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [rowsWithSelectedDescendants, setRowsWithSelectedDescendants] =
useState<Set<string>>(new Set());

useEffect(() => {
config.setTableColumnVisibility(tablename, columnVisibility);
Expand Down Expand Up @@ -122,7 +125,34 @@ function useTable<TData>(
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<string>();

const recurse = (row: Row<TData>): 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 {
Expand Down Expand Up @@ -263,6 +293,7 @@ export function useRowSelected() {
function TableRow<TData>(props: {
row: Row<TData>,
selected: boolean,
descendantSelected: boolean,
expanded: boolean,
index: number,
start: number,
Expand Down Expand Up @@ -324,7 +355,7 @@ function TableRow<TData>(props: {

return (
<div ref={ref}
className={`tr${props.selected ? " selected" : ""}`}
className={`tr${props.selected ? " selected" : props.descendantSelected ? " descendant-selected" : ""}`}
style={{ height: `${props.height}px`, transform: `translateY(${props.start}px)` }}
onClick={onMouseEvent}
onContextMenu={onMouseEvent}
Expand Down Expand Up @@ -472,7 +503,7 @@ export function TrguiTable<TData>(props: {
onVisibilityChange?: React.Dispatch<VisibilityState>,
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) {
Expand Down Expand Up @@ -543,6 +574,7 @@ export function TrguiTable<TData>(props: {
return <MemoizedTableRow<TData> key={props.getRowId(row.original)} {...{
row,
selected: row.getIsSelected(),
descendantSelected: rowsWithSelectedDescendants.has(row.id),
expanded: row.getIsExpanded(),
index: virtualRow.index,
lastIndex,
Expand Down
68 changes: 44 additions & 24 deletions src/components/tables/filetreetable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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++) {
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
});
Expand All @@ -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([]);
Expand Down
10 changes: 10 additions & 0 deletions src/css/torrenttable.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/trutil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,7 @@ const badChars = /[<>:"\\/|?*]/g;
export function fileSystemSafeName(name: string) {
return name.replace(badChars, "_");
}

export function * chainedIterables<T>(...iterables: Array<Iterable<T>>) {
for (const iterable of iterables) yield * iterable;
}

0 comments on commit 666f7ef

Please sign in to comment.