1import { useLocation, useSearchParams } from "@solidjs/router";
2import { Checkbox } from "./Checkbox.tsx";
3import { type InferResponseType, parseResponse } from "hono/client";
4import ChevronRight from "lucide-solid/icons/chevron-right";
5import CornerLeftUp from "lucide-solid/icons/corner-left-up";
6import Database from "lucide-solid/icons/database";
7import FileIcon from "lucide-solid/icons/file";
8import FileCode from "lucide-solid/icons/file-code";
9import FileImage from "lucide-solid/icons/file-image";
10import FileMusic from "lucide-solid/icons/file-music";
11import FilePlay from "lucide-solid/icons/file-play";
12import FileText from "lucide-solid/icons/file-text";
13import Folder from "lucide-solid/icons/folder";
14import History from "lucide-solid/icons/rotate-ccw-clock";
15import LayoutGrid from "lucide-solid/icons/layout-grid";
16import TextSearch from "lucide-solid/icons/text-search";
17import X from "lucide-solid/icons/x";
18import {
19 createEffect, createMemo, createResource, createSignal, For, type JSX, on, onCleanup, onMount, Show,
20} from "solid-js";
21import { api, queries, query, reason } from "../api.ts";
22import { ago, bytes, count, percent, plural } from "../format.ts";
23import { Ago } from "./Ago.tsx";
24import { showConfirmDialog, showTextDialog } from "./Dialog.tsx";
25import { lastGood, Loaded } from "./Loaded.tsx";
26import { OpenApp } from "./OpenApp.tsx";
27import { Reveal } from "./Reveal.tsx";
28import { type Sort, SortHeader } from "./SortHeader.tsx";
29import { TabBar } from "./TabBar.tsx";
30import { toast } from "./Toast.tsx";
31import "./Explorer.css";
32
33/** A file browser's routes: the library's, or the whole store's under storage. */
34type Client = typeof api.media | typeof api.storage.files;
35type Entry = InferResponseType<typeof api.media.list.$get, 200>["entries"][number];
36type Tree = InferResponseType<typeof api.media.tree.$post, 200>;
37type Key = "name" | "size" | "modified";
38type Op = { id: string; label: string };
39/** A visible line of the file tree; `path` is root-relative. */
40type Row = { path: string; parent: string; depth: number; entry: Entry };
41
42/** The pool's main areas by mountpoint; a folder takes the color of the last that holds it, so Media leaves clover. */
43export const REGIONS = [
44 { root: "/srv/prod", label: "prod", series: 2 },
45 { root: "/srv/clover", label: "clover", series: 1 },
46 { root: "/srv/clover/Media", label: "media", series: 5 },
47 { root: "/srv/staging", label: "staging", series: 3 },
48 { root: "/srv/vm", label: "vm", series: 7 },
49] as const;
50
51/** Squarified treemap (Bruls et al.) of items already sorted by size, largest first; cells are relative to the box. */
52export function squarify<T extends { size: number }>(items: T[], width: number, height: number) {
53 const total = items.reduce((sum, item) => sum + item.size, 0);
54 const cells: { item: T; x: number; y: number; w: number; h: number }[] = [];
55 let [x, y, w, h] = [0, 0, width, height];
56 let rest = items.map((item) => ({ item, area: (item.size / total) * width * height }));
57 while (rest.length) {
58 const side = Math.min(w, h);
59 const worst = (sum: number, smallest: number) =>
60 Math.max((side * side * rest[0]!.area) / (sum * sum), (sum * sum) / (side * side * smallest));
61 let sum = rest[0]!.area;
62 let taken = 1;
63 while (taken < rest.length && worst(sum + rest[taken]!.area, rest[taken]!.area) <= worst(sum, rest[taken - 1]!.area)) {
64 sum += rest[taken]!.area;
65 taken++;
66 }
67 const thickness = sum / side;
68 let offset = 0;
69 for (const { item, area } of rest.slice(0, taken)) {
70 const length = area / thickness;
71 cells.push(w >= h
72 ? { item, x, y: y + offset, w: thickness, h: length }
73 : { item, x: x + offset, y, w: length, h: thickness });
74 offset += length;
75 }
76 if (w >= h) [x, w] = [x + thickness, w - thickness];
77 else [y, h] = [y + thickness, h - thickness];
78 rest = rest.slice(taken);
79 }
80 return cells;
81}
82
83/** Folder links for `path` under a root labelled `root`, keeping the page's other parameters. */
84export function Crumbs(props: { root: string; path: string; tip?: string }) {
85 const location = useLocation();
86 const names = () => (props.path ? props.path.split("/") : []);
87 const href = (path: string) => {
88 const query = new URLSearchParams(location.search);
89 if (path) query.set("path", path);
90 else query.delete("path");
91 return `${location.pathname}${query.size ? `?${query}` : ""}`;
92 };
93 return (
94 <nav class="crumbs" aria-label="Folder">
95 <a href={href("")} aria-current={names().length ? undefined : "page"} data-tip={props.tip}>{props.root}</a>
96 <For each={names()}>
97 {(name, i) => (
98 <>
99 <ChevronRight size={14} aria-hidden="true" />
100 <a href={href(names().slice(0, i() + 1).join("/"))} aria-current={i() === names().length - 1 ? "page" : undefined}>
101 {name}
102 </a>
103 </>
104 )}
105 </For>
106 </nav>
107 );
108}
109
110const KINDS: [RegExp, (props: { class: string }) => JSX.Element, string][] = [
111 [/\.(mkv|mp4|m4v|avi|webm|mov|ts)$/i, FilePlay, "var(--series-1)"],
112 [/\.(flac|mp3|m4a|opus|ogg|wav|aac)$/i, FileMusic, "var(--series-2)"],
113 [/\.(jpe?g|png|webp|gif|bmp|tbn|heic)$/i, FileImage, "var(--series-3)"],
114 [/\.(nfo|xml|json)$/i, FileCode, "var(--series-4)"],
115 [/\.(srt|ass|ssa|vtt|sub|idx|txt)$/i, FileText, "var(--series-5)"],
116];
117const TEXT = /\.(nfo|xml|json|srt|ass|ssa|vtt|txt|md|log|cue|m3u8?|html?|ya?ml|ini|conf)$/i;
118
119/** Finder's keys on Apple platforms, Explorer's everywhere else. */
120const MAC = /^(Mac|iP)/.test(navigator.platform);
121
122/** Rows are keyed by entry object, so reading props once is safe. */
123function EntryIcon(props: { entry: Entry; color?: string }) {
124 if (props.entry.dataset) return <Database class="icon" style={{ color: props.color ?? "var(--text-2)" }} />;
125 if (props.entry.dir) return <Folder class="icon" style={{ color: props.color ?? "var(--text-2)" }} />;
126 const [, Icon, color] = KINDS.find(([pattern]) => pattern.test(props.entry.name)) ?? [null, FileIcon, "var(--muted)"];
127 return <Icon class="icon" style={{ color }} />;
128}
129
130const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
131const parentOf = (rel: string) => rel.split("/").slice(0, -1).join("/");
132/** Whether `rel` is strictly inside `dir`. */
133const within = (dir: string, rel: string) => rel !== dir && (!dir || rel.startsWith(dir + "/"));
134const under = (dir: string, abs: string) => abs === dir || abs.startsWith(dir + "/");
135
136/** Entries under this share of the folder merge into one cell, drawn at least `REST` large so they stay findable. */
137const TINY = 0.004;
138const REST = 0.04;
139
140/** The folder's entries as blocks sized by bytes, each colored as its row's icon. */
141function FolderMap(props: {
142 items: { name: string; size: number; dir: boolean; files: number; dataset: string | null }[];
143 total: number;
144 hover: string | undefined;
145 cursor: string | undefined;
146 color: (name: string) => string;
147 onHover: (name: string | undefined) => void;
148 onPick: (name: string, open: boolean) => void;
149}) {
150 let box!: HTMLDivElement;
151 const [size, setSize] = createSignal({ width: 0, height: 0 });
152 onMount(() => {
153 const resizes = new ResizeObserver(([entry]) => setSize({ width: entry!.contentRect.width, height: entry!.contentRect.height }));
154 resizes.observe(box);
155 onCleanup(() => resizes.disconnect());
156 });
157 const layout = createMemo(() => {
158 const sized = props.items.filter((item) => item.size > 0).sort((a, b) => b.size - a.size);
159 const total = sized.reduce((sum, item) => sum + item.size, 0);
160 const shown = sized.filter((item) => item.size >= total * TINY);
161 const tiny = sized.slice(shown.length);
162 const rest = { name: "", size: Math.max(total * REST, tiny.reduce((sum, item) => sum + item.size, 0)), dir: false, files: 0, dataset: null };
163 const { width, height } = size();
164 return { cells: width ? squarify(tiny.length ? [...shown, rest] : shown, width, height) : [], tiny };
165 });
166 return (
167 <div ref={box} class="folder-map" aria-hidden="true">
168 <For each={layout().cells}>
169 {({ item, x, y, w, h }) => (
170 <div class="cell" classList={{ dir: item.dir, rest: !item.name, hover: !!item.name && props.hover === item.name,
171 cursor: !!item.name && props.cursor === item.name }}
172 style={{ left: `${x}px`, top: `${y}px`, width: `${w}px`, height: `${h}px`, "--kind": `var(${props.color(item.name)})` }}
173 data-tip={item.name
174 ? `${item.name}${item.dataset ? ` (${item.dataset})` : ""}: ${bytes(item.size)} (${percent((item.size / props.total) * 100)})`
175 + (item.dir ? `, ${plural(item.files, "file")}` : "")
176 : layout().tiny.slice(0, 4).map((tiny) => tiny.name).join(", ")
177 + (layout().tiny.length > 4 ? ` and ${count(layout().tiny.length - 4)} more` : "")}
178 onMouseEnter={() => props.onHover(item.name || undefined)} onMouseLeave={() => props.onHover(undefined)}
179 onClick={() => item.name && props.onPick(item.name, item.dir)}>
180 <Show when={w > 50 && h > 22}><span class="name">{item.name || `${layout().tiny.length} more`}</span></Show>
181 <Show when={item.name && w > 50 && h > 40}><span class="size">{bytes(item.size)}</span></Show>
182 </div>
183 )}
184 </For>
185 </div>
186 );
187}
188
189/**
190 * A file browser over a root the server picked: a tree of the folder in `path` with Finder or Explorer keys, a map of
191 * what takes the space, and undoable renames, moves and deletes. `id` keys what it remembers per page.
192 */
193export function Explorer(props: { id: string; client: Client; root: string }) {
194 const [params, setParams] = useSearchParams<{ path?: string; pattern?: string }>();
195 const here = () => params.path ?? "";
196 const pattern = () => params.pattern ?? "";
197 const folderName = () => here().split("/").at(-1) || props.root;
198 const open = (path: string) => setParams({ path: path || undefined });
199
200 const [list, { refetch }] = query(`${props.id} folder`, (path: string) =>
201 parseResponse(props.client.list.$get({ query: { path } }))).use(here);
202 /** `latest` rethrows a failed load. */
203 const listing = () => (list.state === "errored" ? undefined : list.latest);
204 const apps = lastGood(queries.launcher.use()[0]);
205 const [ops, { refetch: refetchOps }] = createResource(() => parseResponse(props.client.ops.$get()));
206 const lastOps = lastGood(ops);
207 const [matches, { refetch: refetchMatches }] = createResource(
208 () => (pattern() ? { path: here(), pattern: pattern() } : false),
209 (query) => parseResponse(props.client.match.$get({ query })),
210 );
211
212 /** Listings of expanded folders and totals of every folder shown, both root-relative. */
213 const [folders, setFolders] = createSignal<Tree["folders"]>({});
214 const [sizes, setSizes] = createSignal<Tree["sizes"]>({});
215 /** The folder whose totals have arrived, so the map knows missing ones are unmeasurable, not pending. */
216 const [measured, setMeasured] = createSignal<string>();
217 /** Why the folder shown couldn't be measured, until it changes. */
218 const [unmeasured, setUnmeasured] = createSignal<string>();
219 const [updated, setUpdated] = createSignal<number | null>(null);
220 const [expanded, setExpanded] = createSignal(new Set<string>());
221 const [expanding, setExpanding] = createSignal<string>();
222 const [sort, setSort] = createSignal<Sort<Key>>({ key: "name", desc: false });
223 const [selected, setSelected] = createSignal(new Set<string>());
224 const [cursor, setCursor] = createSignal<string>();
225 const [hover, setHover] = createSignal<string>();
226 const [editing, setEditing] = createSignal<{ rename: string } | "folder">();
227 const renaming = () => {
228 const edit = editing();
229 return typeof edit === "object" ? edit.rename : undefined;
230 };
231 const [clipboard, setClipboard] = createSignal<string[]>();
232 const [undoing, setUndoing] = createSignal<string>();
233 const mapKey = `explorer.${props.id}.map`;
234 const [mapShown, setMapShown] = createSignal(localStorage.getItem(mapKey) !== "hidden");
235 let anchor: string | undefined;
236 let table: HTMLTableElement | undefined;
237 let patternInput!: HTMLInputElement;
238
239 const known = new WeakMap<Entry, Row>();
240 const rows = createMemo(() => {
241 const { key, desc } = sort();
242 const value = (row: Row) =>
243 key === "size" ? (row.entry.size ?? sizes()[row.path]?.size ?? 0) : key === "modified" ? row.entry.modified : 0;
244 const out: Row[] = [];
245 const walk = (dir: string, entries: Entry[], depth: number) => {
246 const level = entries.map((entry) => {
247 let row = known.get(entry);
248 if (!row) known.set(entry, row = { path: join(dir, entry.name), parent: dir, depth, entry });
249 return row;
250 }).sort((a, b) =>
251 Number(b.entry.dir) - Number(a.entry.dir)
252 || (desc ? -1 : 1) * (value(a) - value(b) || a.entry.name.localeCompare(b.entry.name, undefined, { numeric: true })));
253 for (const row of level) {
254 out.push(row);
255 const inner = expanded().has(row.path) && folders()[row.path];
256 if (inner) walk(row.path, inner, depth + 1);
257 }
258 };
259 walk(here(), listing()?.entries ?? [], 0);
260 return out;
261 });
262 const current = () => rows().find((row) => row.path === cursor());
263 /** The selection in view, without anything inside a selected folder. */
264 const chosen = () => rows().filter((row) => selected().has(row.path) && ![...selected()].some((rel) => within(rel, row.path)));
265 const only = () => (chosen().length === 1 ? chosen()[0] : undefined);
266 /** A dataset in the selection, which only zfs renames, moves or deletes. */
267 const dataset = () => chosen().find((row) => row.entry.dataset);
268 const found = () => (pattern() && matches.state !== "errored" ? matches.latest : undefined);
269 const expandable = (row: Row) => row.entry.dir && row.entry.items !== 0;
270 const sizeOf = (row: Row) => row.entry.size ?? sizes()[row.path]?.size;
271 const total = (chosen: Row[]) => chosen.reduce((sum, row) => sum + (sizeOf(row) ?? 0), 0);
272
273 /**
274 * A top folder's color: its region's, else the first free one from a hash of its identity, which renames and growth
275 * leave alone. Folders claim colors in inode order, so a new one rarely takes an existing one's. A mounted dataset's
276 * root inode repeats across datasets, so datasets go by name.
277 */
278 const colors = createMemo(() => {
279 const host = listing()?.host ?? "";
280 const dirs = (listing()?.entries ?? []).filter((entry) => entry.dir)
281 .map((entry) => ({ name: entry.name, identity: entry.dataset ?? String(entry.inode) }))
282 .sort((a, b) => a.identity.localeCompare(b.identity, undefined, { numeric: true }));
283 const picked = new Map(dirs.flatMap(({ name }) => {
284 const region = REGIONS.find((region) => region.root === `${host}/${name}`);
285 return region ? [[name, region.series as number] as const] : [];
286 }));
287 const inRegion = REGIONS.findLast((region) => under(region.root, host));
288 for (const { name, identity } of dirs.filter(({ name }) => !picked.has(name))) {
289 const hash = [...identity].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 7);
290 const free = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => ((hash + i) % 8) + 1)
291 .find((n) => ![...picked.values()].includes(n) && n !== inRegion?.series);
292 if (free) picked.set(name, free);
293 }
294 return picked;
295 });
296 const colorOf = (name: string) => (colors().has(name) ? `--series-${colors().get(name)}` : "--other");
297 const mapItems = () => measured() === here() ? rows().filter((row) => !row.depth && sizeOf(row) !== undefined).map((row) => ({
298 name: row.entry.name,
299 size: sizeOf(row)!,
300 dir: row.entry.dir,
301 files: row.entry.dir ? sizes()[row.path]?.files ?? 0 : 1,
302 dataset: row.entry.dataset,
303 })) : undefined;
304
305 /** Folds `tree` in, newest listing winning; drops expansions it was asked for but no longer finds. */
306 const merge = (tree: Tree, asked: Set<string> = new Set()) => {
307 setFolders((known) => ({ ...known, ...tree.folders }));
308 setSizes((known) => ({ ...known, ...tree.sizes }));
309 setExpanded((open) => new Set([...open].filter((rel) => !asked.has(rel) || rel in tree.folders)));
310 };
311 const refreshTree = async () => {
312 const path = here();
313 const asked = new Set(expanded());
314 const tree = await parseResponse(props.client.tree.$post({ json: { path, expanded: [...asked] } })).catch((failure) => {
315 if (here() === path) setUnmeasured(reason(failure));
316 return null;
317 });
318 if (tree && here() === path) {
319 merge(tree, asked);
320 setMeasured(path);
321 setUpdated(tree.updated);
322 }
323 };
324
325 createEffect(on(here, (now, before) => {
326 setFolders({});
327 setSizes({});
328 setMeasured();
329 setUnmeasured();
330 setExpanded(new Set<string>());
331 setEditing();
332 /** Coming up out of a folder lands on it, like Finder. */
333 const from = before !== undefined && within(now, before)
334 ? join(now, before.slice(now ? now.length + 1 : 0).split("/")[0]!)
335 : undefined;
336 setSelected(new Set(from ? [from] : []));
337 setCursor(from);
338 anchor = from;
339 refreshTree();
340 }));
341
342 createEffect(on([cursor, rows], ([path, all]) => {
343 const index = all.findIndex((row) => row.path === path);
344 if (index >= 0) queueMicrotask(() => table?.querySelector(`[data-row="${index}"]`)?.scrollIntoView({ block: "nearest" }));
345 }));
346
347 const select = (row: Row, how: "only" | "toggle" | "range") => {
348 setCursor(row.path);
349 setEditing();
350 if (how === "range") {
351 const all = rows();
352 const at = all.indexOf(row);
353 const from = all.findIndex((other) => other.path === anchor);
354 const [start, end] = from < 0 ? [at, at] : [Math.min(from, at), Math.max(from, at)];
355 setSelected(new Set(all.slice(start, end + 1).map((other) => other.path)));
356 return;
357 }
358 anchor = row.path;
359 const next = new Set(how === "toggle" ? selected() : []);
360 if (how === "toggle" && next.has(row.path)) next.delete(row.path);
361 else next.add(row.path);
362 setSelected(next);
363 };
364
365 const step = (delta: number, extend: boolean) => {
366 const all = rows();
367 const at = all.findIndex((row) => row.path === cursor());
368 const next = all[Math.max(0, Math.min(all.length - 1, at < 0 && delta < 0 ? 0 : at + delta))];
369 if (next) select(next, extend ? "range" : "only");
370 };
371
372 const expandAll = async (path: string) => {
373 setExpanding(path);
374 try {
375 const tree = await parseResponse(props.client.tree.$post({ json: { path, expanded: "all" } }));
376 if (path !== here() && !within(here(), path)) return;
377 merge(tree);
378 setExpanded((open) => new Set([...open, ...Object.keys(tree.folders).filter((rel) => rel !== here())]));
379 if (!tree.complete) {
380 if (Object.values(tree.folders).some((entries) => entries.some((entry) => entry.dir && entry.items === null))) {
381 toast("Some folder counts are unavailable. Expand folders one at a time.");
382 return;
383 }
384 const depth = (rel: string) => (rel ? rel.split("/").length : 0);
385 const levels = Math.max(...Object.keys(tree.folders).map(depth)) - depth(path);
386 toast(levels
387 ? `Expanded ${plural(levels, "level")}. Deeper folders hold too many items to open at once.`
388 : "These folders hold too many items to open at once. Expand them one at a time.");
389 }
390 } catch (failure) {
391 toast(reason(failure));
392 } finally {
393 setExpanding();
394 }
395 };
396
397 const expand = async (row: Row) => {
398 if (!expandable(row)) return;
399 setExpanded((open) => new Set(open).add(row.path));
400 if (!folders()[row.path]) {
401 const found = await parseResponse(props.client.list.$get({ query: { path: row.path } })).catch((failure) => {
402 toast(reason(failure));
403 collapse(row);
404 });
405 if (found && within(here(), row.path)) setFolders((known) => ({ ...known, [row.path]: found.entries }));
406 }
407 const tree = await parseResponse(props.client.tree.$post({ json: { path: row.path, expanded: [] } })).catch(() => null);
408 if (tree && within(here(), row.path)) merge(tree);
409 };
410
411 /** Keeps what's open inside, so expanding again shows it the same way, unless `deep`. */
412 const collapse = (row: Row, deep = false) => {
413 setExpanded((open) => new Set([...open].filter((rel) => rel !== row.path && !(deep && within(row.path, rel)))));
414 setSelected((picked) => new Set([...picked].filter((rel) => !within(row.path, rel))));
415 if (within(row.path, cursor() ?? "")) setCursor(row.path);
416 };
417
418 const activate = (row: Row) => {
419 if (row.entry.dir) open(row.path);
420 else if (row.entry.download) window.open(row.entry.download, "_blank", "noreferrer");
421 };
422 const up = () => here() && open(parentOf(here()));
423
424 const reload = () => {
425 refetch();
426 refetchOps();
427 refetchMatches();
428 refreshTree();
429 };
430 const undo = (op: Op) => parseResponse(props.client.ops[":id"].undo.$post({ param: { id: op.id } })).finally(reload);
431
432 const done = (op: Op) => {
433 toast(op.label, { label: "undo", run: () => undo(op) });
434 setSelected(new Set<string>());
435 setEditing();
436 reload();
437 };
438
439 /** For changes that can fail partway, so the folder reloads either way; the failure reaches the dialog. */
440 const act = (change: Promise<Op>) => change.then(done, (failure) => {
441 reload();
442 throw failure;
443 });
444
445 const paths = () => chosen().map((row) => row.path);
446 const what = () => (only() ? only()!.entry.name : plural(chosen().length, "item"));
447 const refuseDataset = () => {
448 const row = dataset();
449 if (row) toast(`${row.entry.name} is the ${row.entry.dataset} dataset. Rename, move or destroy it with zfs instead.`);
450 return !!row;
451 };
452
453 const remove = () => {
454 const selection = paths();
455 if (!selection.length || refuseDataset()) return;
456 showConfirmDialog({
457 title: `Delete ${what()}?`,
458 description: () => (
459 <>
460 <Show when={selection.length > 1}>
461 <ul class="match-list mono">
462 <For each={selection}>{(rel) => <li>{rel.slice(here() ? here().length + 1 : 0)}</li>}</For>
463 </ul>
464 </Show>
465 <p>You can undo this from recent changes.</p>
466 </>
467 ),
468 confirmLabel: "delete",
469 destructive: true,
470 returnFocus: table,
471 onConfirm: () => act(parseResponse(props.client.delete.$post({ json: { paths: selection } }))),
472 });
473 };
474
475 const moveTo = (title: string, description: () => JSX.Element, run: (to: string) => Promise<Op>) => showTextDialog({
476 title,
477 description,
478 returnFocus: table,
479 label: "Destination folder",
480 initialValue: here() ? here() + "/" : "",
481 validateInput: () => true,
482 confirmLabel: "move",
483 onConfirm: (to) => act(run(to.trim().replace(/^\/+|\/+$/g, ""))),
484 });
485
486 const move = () => {
487 const selection = paths();
488 if (!selection.length || refuseDataset()) return;
489 moveTo(`Move ${what()}`, () => `Folder paths start from ${props.root}.`,
490 (to) => parseResponse(props.client.move.$post({ json: { paths: selection, to } })));
491 };
492
493 /** Where ⌘V lands: inside the folder under the cursor when it's open, else beside the cursor. */
494 const pasteTarget = () => {
495 const row = current();
496 if (!row) return here();
497 return row.entry.dir && expanded().has(row.path) ? row.path : row.parent;
498 };
499 const nameOf = (rel: string) => rel.split("/").at(-1) || props.root;
500
501 const paste = () => {
502 const to = pasteTarget();
503 const moving = (clipboard() ?? []).filter((rel) => parentOf(rel) !== to);
504 if (!clipboard()) return;
505 if (!moving.length) return toast(`Already in ${nameOf(to)}`);
506 showConfirmDialog({
507 title: `Move ${moving.length === 1 ? nameOf(moving[0]!) : plural(moving.length, "item")} to ${nameOf(to)}?`,
508 description: () => {
509 const from = new Set(moving.map(parentOf));
510 return (
511 <>
512 <ul class="match-list mono"><For each={moving}>{(rel) => <li>{nameOf(rel)}</li>}</For></ul>
513 <p>
514 {from.size === 1 ? `From ${[...from][0] || props.root} into` : "Into"} {to || props.root}.
515 You can undo this from recent changes.
516 </p>
517 </>
518 );
519 },
520 confirmLabel: "move",
521 returnFocus: table,
522 onConfirm: () => act(parseResponse(props.client.move.$post({ json: { paths: moving, to } }))).then(() => setClipboard()),
523 });
524 };
525
526 const bulk = (action: "delete" | "move") => {
527 const shown = found();
528 if (!shown?.count) return;
529 const query = { path: here(), pattern: pattern(), count: shown.count };
530 const list = () => (
531 <ul class="match-list mono">
532 <For each={shown.sample}>{(path) => <li>{path}</li>}</For>
533 <Show when={shown.count > shown.sample.length}>
534 <li class="more">and {count(shown.count - shown.sample.length)} more</li>
535 </Show>
536 </ul>
537 );
538 if (action === "move") {
539 return moveTo(`Move ${plural(shown.count, "match", "matches")}`, () => <>{list()}<p>Each keeps its subfolders.</p></>,
540 (to) => parseResponse(props.client.bulk.$post({ json: { ...query, action, to } })));
541 }
542 showTextDialog({
543 title: `Delete ${plural(shown.count, "match", "matches")}?`,
544 description: () => (
545 <>{list()}<p>{shown.size === null ? "" : `${bytes(shown.size)} in ${folderName()}. `}You can undo this from recent changes.</p></>
546 ),
547 returnFocus: table,
548 label: `Type ${shown.count} to confirm`,
549 validateInput: (value) => value.trim() === String(shown.count),
550 confirmLabel: "delete",
551 destructive: true,
552 onConfirm: () => act(parseResponse(props.client.bulk.$post({ json: { ...query, action } }))),
553 });
554 };
555
556 const bulkRename = () => {
557 const targets = chosen();
558 const [mode, setMode] = createSignal<"replace" | "format">("replace");
559 const [find, setFind] = createSignal("");
560 const [replacement, setReplacement] = createSignal("");
561 const [regex, setRegex] = createSignal(false);
562 const [format, setFormat] = createSignal("{name}");
563 const [start, setStart] = createSignal(1);
564 const rename = (name: string, index: number, dir: boolean) => {
565 if (mode() === "format") {
566 const dot = dir ? -1 : name.lastIndexOf(".");
567 const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
568 return format().replace(/\{(n+)\}/g, (_, digits: string) => String(start() + index).padStart(digits.length, "0"))
569 .replaceAll("{name}", stem) + ext;
570 }
571 if (!find()) return name;
572 return regex() ? name.replace(new RegExp(find(), "g"), replacement()) : name.replaceAll(find(), replacement());
573 };
574 const preview = () => {
575 let failure = "";
576 const renamed = targets.map((row, index) => {
577 try {
578 return { row, name: rename(row.entry.name, index, row.entry.dir) };
579 } catch (error) {
580 failure = `Check the regex: ${(error as Error).message}.`;
581 return { row, name: row.entry.name };
582 }
583 });
584 const taken = (row: Row) => new Set(((row.parent === here() ? listing()?.entries : folders()[row.parent]) ?? [])
585 .map((entry) => entry.name).filter((name) => name !== row.entry.name));
586 const clashes = renamed.map(({ row, name }) => {
587 if (!name.trim() || name.includes("/") || name === "." || name === "..") return "names can't be empty or contain /";
588 if (name === row.entry.name) return undefined;
589 if (taken(row).has(name)) return `${name} already exists`;
590 const twin = renamed.some((other) => other.row !== row && other.row.parent === row.parent && other.name === name);
591 if (twin) return "two items get this name";
592 });
593 const problems = clashes.filter(Boolean).length;
594 return {
595 renamed: renamed.map((item, index) => ({ ...item, clash: clashes[index] })),
596 failure: failure
597 || (problems ? `${plural(problems, "name")} can't be used. Change the pattern so each name is new and unique.` : ""),
598 };
599 };
600 let field: HTMLInputElement | undefined;
601 const input = (props: { label: string; value: () => string; set: (value: string) => void; autofocus?: boolean }) => (
602 <label class="field">
603 {props.label}
604 <input class="search mono" value={props.value()} spellcheck={false} autocomplete="off"
605 autofocus={props.autofocus} ref={(input) => props.autofocus && (field = input)}
606 onInput={(event) => props.set(event.currentTarget.value)} />
607 </label>
608 );
609 showConfirmDialog({
610 title: `Rename ${plural(targets.length, "item")}`,
611 returnFocus: table,
612 confirmLabel: "rename",
613 body: () => {
614 createEffect(() => field?.setCustomValidity(preview().failure));
615 return (
616 <div class="bulk-rename">
617 <TabBar label="Rename by">
618 <button type="button" aria-pressed={mode() === "replace"} onClick={() => setMode("replace")}>find and replace</button>
619 <button type="button" aria-pressed={mode() === "format"} onClick={() => setMode("format")}>format</button>
620 </TabBar>
621 <Show when={mode() === "replace"} fallback={
622 <div class="row format">
623 {input({ label: "Name", value: format, set: setFormat, autofocus: true })}
624 <label class="field">
625 Start at
626 <input class="search" type="number" min="0" value={start()}
627 onInput={(event) => setStart(Math.max(0, event.currentTarget.valueAsNumber || 0))} />
628 </label>
629 <span class="hint">{"{name}"} is the old name and {"{n}"} counts up; {"{nn}"} pads it to 2 digits</span>
630 </div>
631 }>
632 <div class="row">
633 {input({ label: "Find", value: find, set: setFind, autofocus: true })}
634 {input({ label: "Replace with", value: replacement, set: setReplacement })}
635 </div>
636 <Checkbox checked={regex()} onChange={setRegex}>regex, with $1 for groups</Checkbox>
637 </Show>
638 <div class="preview">
639 <table class="data">
640 <tbody>
641 <For each={preview().renamed}>
642 {(item) => (
643 <tr classList={{ same: item.name === item.row.entry.name, clash: !!item.clash }}>
644 <td class="mono">{item.row.entry.name}</td>
645 <td class="arrow">→</td>
646 <td class="mono" data-tip={item.clash}>{item.name === item.row.entry.name ? "unchanged" : item.name}</td>
647 </tr>
648 )}
649 </For>
650 </tbody>
651 </table>
652 </div>
653 </div>
654 );
655 },
656 onConfirm: () => {
657 const renames = preview().renamed.filter((item) => item.name !== item.row.entry.name)
658 .map((item) => ({ path: item.row.path, name: item.name }));
659 return renames.length && act(parseResponse(props.client.rename.$post({ json: { renames } })));
660 },
661 });
662 };
663
664 const rename = () => {
665 if (refuseDataset()) return;
666 if (chosen().length > 1) bulkRename();
667 else if (only()) setEditing({ rename: only()!.path });
668 };
669
670 const preview = (row: Row | undefined) => {
671 if (!row || row.entry.dir) return;
672 if (!TEXT.test(row.entry.name)) return toast(`No preview for ${row.entry.name}. Open it with ${MAC ? "⌘O" : "enter"}.`);
673 showConfirmDialog({
674 title: row.entry.name,
675 description: () => {
676 const [peek, { refetch }] = createResource(() => parseResponse(props.client.peek.$get({ query: { path: row.path } })));
677 return (
678 <Loaded data={peek} what="this file" retry={refetch}>
679 {(file) => (
680 <>
681 <pre class="peek">{file().text || "This file is empty."}</pre>
682 <Show when={file().more}><p class="muted">The first 64 KiB of {bytes(row.entry.size!)}.</p></Show>
683 </>
684 )}
685 </Loaded>
686 );
687 },
688 confirmLabel: "open",
689 returnFocus: table,
690 onConfirm: () => row.entry.download && window.open(row.entry.download, "_blank", "noreferrer"),
691 });
692 };
693
694 const onKey = (event: KeyboardEvent) => {
695 if (event.target !== table && event.target !== document.body) return;
696 const mod = MAC ? event.metaKey : event.ctrlKey;
697 const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
698 const row = current();
699 if ((key === "ArrowDown" || key === "ArrowUp") && !mod && !event.altKey) step(key === "ArrowDown" ? 1 : -1, event.shiftKey);
700 else if (key === "Home" || key === "End") step(key === "Home" ? -Infinity : Infinity, event.shiftKey);
701 else if (MAC ? mod && key === "ArrowUp" : (event.altKey && key === "ArrowUp") || (key === "Backspace" && !mod)) up();
702 else if (MAC ? mod && (key === "o" || key === "ArrowDown") : key === "Enter") row && activate(row);
703 else if (MAC ? key === "Enter" : key === "F2") rename();
704 else if (MAC ? mod && key === "Backspace" : key === "Delete") {
705 if (chosen().length) remove();
706 else bulk("delete");
707 } else if (key === "ArrowRight" && row?.entry.dir) {
708 if (event.altKey) expandAll(row.path);
709 else if (!expanded().has(row.path)) expand(row);
710 else if (rows()[rows().indexOf(row) + 1]?.parent === row.path) step(1, false);
711 } else if (key === "ArrowLeft" && row) {
712 if (expanded().has(row.path)) collapse(row, event.altKey);
713 else if (row.depth) select(rows().find((other) => other.path === row.parent)!, "only");
714 } else if (mod && key === "a") setSelected(new Set(rows().map((row) => row.path)));
715 else if (mod && key === "x") setClipboard(chosen().length ? paths() : undefined);
716 else if (mod && key === "v") paste();
717 else if (mod && key === "z") {
718 const last = lastOps()?.find((op) => !op.undone);
719 if (!last) toast("Nothing to undo here");
720 else undo(last).then(() => toast(`Undone: ${last.label}`), (failure) => toast(reason(failure)));
721 }
722 else if (key === " ") preview(row);
723 else if (key === "Escape") {
724 if (selected().size) setSelected(new Set<string>());
725 else if (clipboard()) setClipboard();
726 else clearPattern();
727 } else if (key === "/") patternInput.focus();
728 else return;
729 event.preventDefault();
730 };
731 document.addEventListener("keydown", onKey);
732 onCleanup(() => document.removeEventListener("keydown", onKey));
733
734 let debounce: ReturnType<typeof setTimeout> | undefined;
735 onCleanup(() => clearTimeout(debounce));
736 const usePattern = (value: string) => {
737 clearTimeout(debounce);
738 setParams({ pattern: value.trim() ? value : undefined }, { replace: true });
739 };
740 const clearPattern = () => {
741 patternInput.value = "";
742 usePattern("");
743 };
744
745 /** Enter on an unchanged rename just closes the field; a new name lands selected in `dir`. */
746 const nameInput = (dir: string, initial: string, submit: (value: string) => Promise<Op>) => {
747 let saving = false;
748 return (
749 <input class="search rename" value={initial} aria-label="Name" ref={(input) => queueMicrotask(() => {
750 input.focus();
751 input.setSelectionRange(0, initial.lastIndexOf(".") > 0 ? initial.lastIndexOf(".") : initial.length);
752 })} onKeyDown={(event) => {
753 const value = event.currentTarget.value.trim();
754 if (event.key === "Enter" && value === initial && renaming()) {
755 setEditing();
756 table?.focus();
757 } else if (event.key === "Enter" && value && !saving) {
758 saving = true;
759 submit(value).then((op) => {
760 done(op);
761 setSelected(new Set([join(dir, value)]));
762 setCursor(join(dir, value));
763 anchor = join(dir, value);
764 table?.focus();
765 }, (failure) => toast(reason(failure))).finally(() => (saving = false));
766 } else if (event.key === "Escape") table?.focus();
767 }} onBlur={() => !saving && setEditing()} />
768 );
769 };
770
771 const selectionTip = (verb: string) =>
772 chosen().length ? dataset() && `${dataset()!.entry.name} is a dataset; zfs manages it` : `select items to ${verb}`;
773 const collapsedFolders = () => rows().some((row) => expandable(row) && !expanded().has(row.path));
774 const keys: [JSX.Element, string][] = MAC
775 ? [[<><kbd>←</kbd><kbd>→</kbd></>, "fold"], [<kbd>⏎</kbd>, "rename"],
776 [<kbd>⌘O</kbd>, "open"], [<kbd>⌘↑</kbd>, "up"], [<kbd>⌘⌫</kbd>, "delete"], [<><kbd>⌘X</kbd><kbd>⌘V</kbd></>, "cut, paste"], [<kbd>⌘Z</kbd>, "undo"],
777 [<kbd>space</kbd>, "preview"], [<kbd>/</kbd>, "pattern"]]
778 : [[<><kbd>←</kbd><kbd>→</kbd></>, "fold"], [<kbd>enter</kbd>, "open"],
779 [<kbd>F2</kbd>, "rename"], [<kbd>⌫</kbd>, "up"], [<kbd>del</kbd>, "delete"], [<><kbd>ctrl X</kbd><kbd>ctrl V</kbd></>, "cut, paste"], [<kbd>ctrl Z</kbd>, "undo"],
780 [<kbd>space</kbd>, "preview"], [<kbd>/</kbd>, "pattern"]];
781 const historyId = `${props.id}-history`;
782 const folderTotal = () => sizes()[here()];
783
784 return (
785 <div class="explorer fill">
786 <div class="bar">
787 <Crumbs root={props.root} path={here()} tip={listing() && !here() ? listing()!.host : undefined} />
788 <Show when={folderTotal()}>
789 {(total) => (
790 <span class="total" data-tip={[
791 plural(total().files, "file"),
792 `${bytes(total().alloc)} on disk`,
793 total().size > total().alloc * 1.02 && `${(total().size / total().alloc).toFixed(2)}× compressed`,
794 updated() && `indexed ${ago(updated()!)}`,
795 ].filter(Boolean).join(", ")}>{bytes(total().size)}</span>
796 )}
797 </Show>
798 <span class="spacer" />
799 <Show when={listing()}>
800 <span class="count" data-tip={chosen().length ? bytes(total(chosen())) : undefined}>
801 {chosen().length ? `${count(chosen().length)} selected` : plural(listing()!.entries.length, "item")}
802 </span>
803 </Show>
804 <button class="button icon-only small" popovertarget={historyId} style={{ "anchor-name": `--${historyId}` }}
805 aria-label="Recent changes" data-tip="recent changes">
806 <History size={14} />
807 </button>
808 <div id={historyId} popover class="history" style={{ "position-anchor": `--${historyId}` }}
809 onToggle={(event) => (event as ToggleEvent).newState === "open" && refetchOps()}>
810 <Loaded data={ops} what="recent changes" retry={refetchOps}>
811 {(recent) => (
812 <Show when={recent().length} fallback={<p class="none">No changes yet. Renames, moves and deletes show here to undo.</p>}>
813 <For each={recent()}>
814 {(op) => (
815 <div class="op">
816 <span class="label" data-tip={op.count > 1
817 ? `${op.sample.join(", ")}${op.count > op.sample.length ? ` and ${count(op.count - op.sample.length)} more` : ""}`
818 : op.sample[0]}>{op.label}</span>
819 <span class="when"><Ago t={op.at} /></span>
820 <Show when={!op.undone} fallback={<span class="undone">undone</span>}>
821 <button class="button small" disabled={!!undoing()} aria-busy={undoing() === op.id} onClick={async () => {
822 setUndoing(op.id);
823 await undo(op).catch((failure) => toast(reason(failure)));
824 setUndoing();
825 }}>undo</button>
826 </Show>
827 </div>
828 )}
829 </For>
830 </Show>
831 )}
832 </Loaded>
833 </div>
834 <button class="button icon-only small" aria-pressed={mapShown()} aria-label={mapShown() ? "Hide map" : "Show map"}
835 data-tip={mapShown() ? "hide map" : "show map"} onClick={() => {
836 setMapShown(!mapShown());
837 localStorage.setItem(mapKey, mapShown() ? "shown" : "hidden");
838 }}>
839 <LayoutGrid size={14} />
840 </button>
841 <Show when={listing()?.link}>
842 {(link) => <OpenApp app={apps()?.find((app) => app.id === "copyparty") ?? { id: "copyparty", name: "Copyparty", icon: null }}
843 href={link()} />}
844 </Show>
845 </div>
846
847 <Reveal when={mapShown()}>
848 <div class="map-slot">
849 <Show when={mapItems()} fallback={unmeasured()
850 ? <div class="folder-map empty">
851 <Show when={!list.error}><p><span class="error">Couldn't measure this folder.</span> {unmeasured()}</p></Show>
852 </div>
853 : <div class="skeleton folder-map" />}>
854 {(items) => (
855 <Show when={folderTotal()?.size} fallback={
856 <div class="folder-map empty">{measured() === here() && !folderTotal() && listing()?.entries.length
857 ? "Too many files to measure here without the file index."
858 : "Nothing here takes space"}</div>
859 }>
860 <FolderMap items={items()} total={folderTotal()!.size} hover={hover()} color={colorOf}
861 cursor={current() && !current()!.depth ? current()!.entry.name : undefined}
862 onHover={setHover} onPick={(name, dir) => {
863 const row = rows().find((row) => !row.depth && row.entry.name === name);
864 if (dir) open(join(here(), name));
865 else if (row) {
866 select(row, "only");
867 table?.focus();
868 }
869 }} />
870 </Show>
871 )}
872 </Show>
873 </div>
874 </Reveal>
875
876 <div class="toolbar">
877 <Show when={pattern()} fallback={
878 <>
879 <button class="button" onClick={() => setEditing("folder")}>new folder</button>
880 <button class="button" disabled={!chosen().length || !!dataset()} data-tip={selectionTip("rename")} onClick={rename}>
881 rename
882 </button>
883 <button class="button" disabled={!chosen().length || !!dataset()} data-tip={selectionTip("move")} onClick={move}>
884 move
885 </button>
886 <Show when={chosen().length ? only()?.entry.download : listing()?.zip} fallback={
887 <button class="button" disabled data-tip="select one item to download">download</button>
888 }>
889 {(href) => (
890 <a class="button" href={href()} target={only()?.entry.dir === false ? "_blank" : undefined} rel="noreferrer"
891 data-tip={chosen().length ? undefined : `download ${folderName()} as a zip`}>
892 {only()?.entry.dir === false ? "download" : "zip"}
893 </a>
894 )}
895 </Show>
896 <button class="button danger" disabled={!chosen().length || !!dataset()} data-tip={selectionTip("delete")}
897 onClick={remove}>delete</button>
898 <Show when={rows().some(expandable)}>
899 <button class="button" disabled={!!expanding()} aria-busy={expanding() === here()}
900 data-tip={collapsedFolders() ? `${MAC ? "⌥" : "alt"} → on a folder expands just that one` : undefined}
901 onClick={() => (collapsedFolders() ? expandAll(here()) : setExpanded(new Set<string>()))}>
902 {collapsedFolders() ? "expand all" : "collapse all"}
903 </button>
904 </Show>
905 </>
906 }>
907 <Show when={matches.state !== "errored"} fallback={<span class="error">{reason(matches.error)}</span>}>
908 <span class="matched" aria-live="polite">
909 {!found() ? "matching…" : found()!.count ? plural(found()!.count, "match", "matches") : "no matches"}
910 <Show when={found()?.count && found()!.size !== null}> <span class="muted">({bytes(found()!.size!)})</span></Show>
911 </span>
912 </Show>
913 <button class="button" disabled={!found()?.count} data-tip={found()?.count ? undefined : "nothing matches"}
914 onClick={() => bulk("move")}>move matches</button>
915 <button class="button danger" disabled={!found()?.count} data-tip={found()?.count ? undefined : "nothing matches"}
916 onClick={() => bulk("delete")}>delete matches</button>
917 </Show>
918 <span class="spacer" />
919 <Show when={clipboard()}>
920 {(cut) => (
921 <span class="clip">
922 <button class="button" onClick={paste} data-tip={`${cut().slice(0, 3).map(nameOf).join(", ")
923 + (cut().length > 3 ? ` and ${count(cut().length - 3)} more` : "")} into ${nameOf(pasteTarget())}`}>
924 paste {count(cut().length)}
925 </button>
926 <button class="button icon-only" aria-label="Clear cut items" data-tip="esc" onClick={() => setClipboard()}>
927 <X size={14} />
928 </button>
929 </span>
930 )}
931 </Show>
932 <label class="search-box pattern" classList={{ active: !!pattern() }}>
933 <TextSearch size={14} aria-hidden="true" />
934 <input placeholder="select by pattern, like **/*.nfo" aria-label={`Match files in ${folderName()}`}
935 spellcheck={false} autocomplete="off" ref={patternInput} value={pattern()}
936 onInput={(event) => {
937 clearTimeout(debounce);
938 const value = event.currentTarget.value;
939 debounce = setTimeout(() => usePattern(value), 250);
940 }}
941 onKeyDown={(event) => {
942 if (event.key === "Escape") clearPattern();
943 if (event.key === "Enter" || event.key === "Escape") table?.focus();
944 }} />
945 <Show when={pattern()}>
946 <button class="clear" aria-label="Clear pattern" data-tip="esc" onClick={clearPattern}><X size={13} /></button>
947 </Show>
948 </label>
949 </div>
950
951 <div class="files-scroll">
952 <Loaded data={list} what="this folder" retry={refetch} skeleton={
953 <div class="files-skeleton">
954 <For each={[62, 48, 71, 55, 66, 40, 58]}>
955 {(width) => <div class="skeleton" style={{ width: `${width}%` }} />}
956 </For>
957 </div>
958 }>
959 {() => (
960 <table class="data files" classList={{ lens: !!found()?.count }} tabindex="0" role="treegrid" aria-label="Files"
961 aria-multiselectable="true" ref={table}>
962 <thead>
963 <tr>
964 <SortHeader column="name" label="name" sort={sort()} onSort={setSort} />
965 <SortHeader column="size" label="size" sort={sort()} onSort={setSort} num />
966 <SortHeader column="modified" label="modified" sort={sort()} onSort={setSort} num />
967 </tr>
968 </thead>
969 <tbody>
970 <Show when={here()}>
971 <tr class="up" onClick={up}>
972 <td colSpan={3}><div class="name">
973 <span class="twisty" />
974 <CornerLeftUp class="icon" />
975 <span class="text">(up to {nameOf(parentOf(here()))})</span>
976 </div></td>
977 </tr>
978 </Show>
979 <Show when={editing() === "folder"}>
980 <tr class="cursor">
981 <td><div class="name"><span class="twisty" /><Folder class="icon" />{nameInput(here(), "New folder", (name) =>
982 parseResponse(props.client.folder.$post({ json: { path: here(), name } })))}</div></td>
983 <td /><td />
984 </tr>
985 </Show>
986 <For each={rows()} fallback={
987 <Show when={editing() !== "folder" && !list.error}>
988 <tr class="empty-row"><td colSpan={3}>This folder is empty</td></tr>
989 </Show>
990 }>
991 {(row, index) => {
992 const hit = () => found()?.rows[row.path.slice(here() ? here().length + 1 : 0)];
993 const isOpen = () => expanded().has(row.path);
994 const total = () => sizes()[row.path];
995 return (
996 <tr data-row={index()} aria-level={row.depth + 1} aria-selected={selected().has(row.path)}
997 aria-expanded={expandable(row) ? isOpen() : undefined}
998 classList={{
999 selected: selected().has(row.path),
1000 cursor: cursor() === row.path,
1001 hover: !row.depth && hover() === row.entry.name,
1002 hit: !!hit(),
1003 cut: !!clipboard()?.some((rel) => rel === row.path || within(rel, row.path)),
1004 }}
1005 onMouseEnter={() => !row.depth && setHover(row.entry.name)} onMouseLeave={() => setHover()}
1006 onClick={(event) =>
1007 select(row, event.shiftKey ? "range" : (MAC ? event.metaKey : event.ctrlKey) ? "toggle" : "only")}
1008 onDblClick={() => activate(row)}>
1009 <td><div class="name" style={{ "--depth": row.depth }}>
1010 <Show when={expandable(row)} fallback={<span class="twisty" />}>
1011 <button class="twisty" classList={{ open: isOpen() }} tabindex="-1"
1012 onMouseDown={(event) => event.preventDefault()}
1013 aria-label={isOpen() ? "Collapse" : "Expand"} aria-busy={expanding() === row.path}
1014 onClick={(event) => {
1015 event.stopPropagation();
1016 if (event.altKey) (isOpen() ? collapse(row, true) : expandAll(row.path));
1017 else if (isOpen()) collapse(row);
1018 else expand(row);
1019 }}>
1020 <ChevronRight size={14} />
1021 </button>
1022 </Show>
1023 <span class="kind" data-tip={row.entry.dataset ? `dataset ${row.entry.dataset}` : undefined}>
1024 <EntryIcon entry={row.entry} color={row.depth ? undefined : `var(${colorOf(row.entry.name)})`} />
1025 </span>
1026 <Show when={renaming() === row.path}
1027 fallback={<span class="text">{row.entry.name}</span>}>
1028 {nameInput(row.parent, row.entry.name, (name) =>
1029 parseResponse(props.client.rename.$post({ json: { renames: [{ path: row.path, name }] } })))}
1030 </Show>
1031 <Show when={hit()} keyed>
1032 {(hit) => hit === "self"
1033 ? <span class="badge">match</span>
1034 : <span class="badge" data-tip={`${plural(hit, "match", "matches")} inside`}>{count(hit)}</span>}
1035 </Show>
1036 </div></td>
1037 <Show when={row.entry.dir} fallback={
1038 <td class="num" data-tip={[
1039 row.entry.size! >= 1024 && `${count(row.entry.size!)} bytes`,
1040 row.entry.alloc! < row.entry.size! * 0.98 && `${bytes(row.entry.alloc!)} on disk`,
1041 ].filter(Boolean).join(", ") || undefined}>
1042 {bytes(row.entry.size!)}
1043 </td>
1044 }>
1045 <Show when={total()} fallback={<td class="num muted">{row.entry.items === null ? "–" : plural(row.entry.items, "item")}</td>}>
1046 {(total) => (
1047 <td class="num" data-tip={[
1048 row.entry.items !== null && plural(row.entry.items, "item"),
1049 total().files !== row.entry.items && `${plural(total().files, "file")} in all`,
1050 total().alloc < total().size * 0.98 && `${bytes(total().alloc)} on disk`,
1051 ].filter(Boolean).join(", ")}>
1052 {bytes(total().size)}
1053 </td>
1054 )}
1055 </Show>
1056 </Show>
1057 <td class="num"><Ago t={row.entry.modified} /></td>
1058 </tr>
1059 );
1060 }}
1061 </For>
1062 </tbody>
1063 </table>
1064 )}
1065 </Loaded>
1066 </div>
1067
1068 <div class="legend">
1069 <For each={keys}>{([key, label]) => <span>{key} {label}</span>}</For>
1070 </div>
1071 </div>
1072 );
1073}