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 filesScroll!: HTMLDivElement;
238 let revealCursor: string | undefined;
239 let patternInput!: HTMLInputElement;
240
241 const known = new WeakMap<Entry, Row>();
242 const rows = createMemo(() => {
243 const { key, desc } = sort();
244 const value = (row: Row) =>
245 key === "size" ? (row.entry.size ?? sizes()[row.path]?.size ?? 0) : key === "modified" ? row.entry.modified : 0;
246 const out: Row[] = [];
247 const walk = (dir: string, entries: Entry[], depth: number) => {
248 const level = entries.map((entry) => {
249 let row = known.get(entry);
250 if (!row) known.set(entry, row = { path: join(dir, entry.name), parent: dir, depth, entry });
251 return row;
252 }).sort((a, b) =>
253 Number(b.entry.dir) - Number(a.entry.dir)
254 || (desc ? -1 : 1) * (value(a) - value(b) || a.entry.name.localeCompare(b.entry.name, undefined, { numeric: true })));
255 for (const row of level) {
256 out.push(row);
257 const inner = expanded().has(row.path) && folders()[row.path];
258 if (inner) walk(row.path, inner, depth + 1);
259 }
260 };
261 walk(here(), listing()?.entries ?? [], 0);
262 return out;
263 });
264 const current = () => rows().find((row) => row.path === cursor());
265 /** The selection in view, without anything inside a selected folder. */
266 const chosen = () => rows().filter((row) => selected().has(row.path) && ![...selected()].some((rel) => within(rel, row.path)));
267 const only = () => (chosen().length === 1 ? chosen()[0] : undefined);
268 /** A dataset in the selection, which only zfs renames, moves or deletes. */
269 const dataset = () => chosen().find((row) => row.entry.dataset);
270 const found = () => (pattern() && matches.state !== "errored" ? matches.latest : undefined);
271 const expandable = (row: Row) => row.entry.dir && row.entry.items !== 0;
272 const sizeOf = (row: Row) => row.entry.size ?? sizes()[row.path]?.size;
273 const total = (chosen: Row[]) => chosen.reduce((sum, row) => sum + (sizeOf(row) ?? 0), 0);
274
275 /**
276 * A top folder's color: its region's, else the first free one from a hash of its identity, which renames and growth
277 * leave alone. Folders claim colors in inode order, so a new one rarely takes an existing one's. A mounted dataset's
278 * root inode repeats across datasets, so datasets go by name.
279 */
280 const colors = createMemo(() => {
281 const host = listing()?.host ?? "";
282 const dirs = (listing()?.entries ?? []).filter((entry) => entry.dir)
283 .map((entry) => ({ name: entry.name, identity: entry.dataset ?? String(entry.inode) }))
284 .sort((a, b) => a.identity.localeCompare(b.identity, undefined, { numeric: true }));
285 const picked = new Map(dirs.flatMap(({ name }) => {
286 const region = REGIONS.find((region) => region.root === `${host}/${name}`);
287 return region ? [[name, region.series as number] as const] : [];
288 }));
289 const inRegion = REGIONS.findLast((region) => under(region.root, host));
290 for (const { name, identity } of dirs.filter(({ name }) => !picked.has(name))) {
291 const hash = [...identity].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 7);
292 const free = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => ((hash + i) % 8) + 1)
293 .find((n) => ![...picked.values()].includes(n) && n !== inRegion?.series);
294 if (free) picked.set(name, free);
295 }
296 return picked;
297 });
298 const colorOf = (name: string) => (colors().has(name) ? `--series-${colors().get(name)}` : "--other");
299 const mapItems = () => measured() === here() ? rows().filter((row) => !row.depth && sizeOf(row) !== undefined).map((row) => ({
300 name: row.entry.name,
301 size: sizeOf(row)!,
302 dir: row.entry.dir,
303 files: row.entry.dir ? sizes()[row.path]?.files ?? 0 : 1,
304 dataset: row.entry.dataset,
305 })) : undefined;
306
307 /** Folds `tree` in, newest listing winning; drops expansions it was asked for but no longer finds. */
308 const merge = (tree: Tree, asked: Set<string> = new Set()) => {
309 setFolders((known) => ({ ...known, ...tree.folders }));
310 setSizes((known) => ({ ...known, ...tree.sizes }));
311 setExpanded((open) => new Set([...open].filter((rel) => !asked.has(rel) || rel in tree.folders)));
312 };
313 const refreshTree = async () => {
314 const path = here();
315 const asked = new Set(expanded());
316 const tree = await parseResponse(props.client.tree.$post({ json: { path, expanded: [...asked] } })).catch((failure) => {
317 if (here() === path) setUnmeasured(reason(failure));
318 return null;
319 });
320 if (tree && here() === path) {
321 merge(tree, asked);
322 setMeasured(path);
323 setUpdated(tree.updated);
324 }
325 };
326
327 createEffect(on(here, (now, before) => {
328 setFolders({});
329 setSizes({});
330 setMeasured();
331 setUnmeasured();
332 setExpanded(new Set<string>());
333 setEditing();
334 /** Coming up out of a folder lands on it, like Finder. */
335 const from = before !== undefined && within(now, before)
336 ? join(now, before.slice(now ? now.length + 1 : 0).split("/")[0]!)
337 : undefined;
338 setSelected(new Set(from ? [from] : []));
339 setCursor(from);
340 anchor = from;
341 refreshTree();
342 }));
343
344 // Folder updates must not scroll back to a selection the user has scrolled away from.
345 createEffect(on([cursor, rows], ([path, all], before) => {
346 if (path !== before?.[0]) revealCursor = path;
347 if (!path || revealCursor !== path) return;
348 const index = all.findIndex((row) => row.path === path);
349 if (index < 0) return;
350 revealCursor = undefined;
351 queueMicrotask(() => {
352 if (cursor() !== path) return;
353 const currentIndex = rows().findIndex((row) => row.path === path);
354 const row = table?.querySelector(`[data-row="${currentIndex}"]`);
355 if (!row || !filesScroll) return;
356 const rect = row.getBoundingClientRect();
357 const view = filesScroll.getBoundingClientRect();
358 const top = view.top + filesScroll.clientTop + (table?.tHead?.getBoundingClientRect().height ?? 0);
359 const bottom = view.top + filesScroll.clientTop + filesScroll.clientHeight;
360 if (rect.top < top) filesScroll.scrollTop += rect.top - top;
361 else if (rect.bottom > bottom) filesScroll.scrollTop += rect.bottom - bottom;
362 });
363 }));
364
365 const select = (row: Row, how: "only" | "toggle" | "range") => {
366 setCursor(row.path);
367 setEditing();
368 if (how === "range") {
369 const all = rows();
370 const at = all.indexOf(row);
371 const from = all.findIndex((other) => other.path === anchor);
372 const [start, end] = from < 0 ? [at, at] : [Math.min(from, at), Math.max(from, at)];
373 setSelected(new Set(all.slice(start, end + 1).map((other) => other.path)));
374 return;
375 }
376 anchor = row.path;
377 const next = new Set(how === "toggle" ? selected() : []);
378 if (how === "toggle" && next.has(row.path)) next.delete(row.path);
379 else next.add(row.path);
380 setSelected(next);
381 };
382
383 const step = (delta: number, extend: boolean) => {
384 const all = rows();
385 const at = all.findIndex((row) => row.path === cursor());
386 const next = all[Math.max(0, Math.min(all.length - 1, at < 0 && delta < 0 ? 0 : at + delta))];
387 if (next) select(next, extend ? "range" : "only");
388 };
389
390 const expandAll = async (path: string) => {
391 setExpanding(path);
392 try {
393 const tree = await parseResponse(props.client.tree.$post({ json: { path, expanded: "all" } }));
394 if (path !== here() && !within(here(), path)) return;
395 merge(tree);
396 setExpanded((open) => new Set([...open, ...Object.keys(tree.folders).filter((rel) => rel !== here())]));
397 if (!tree.complete) {
398 if (Object.values(tree.folders).some((entries) => entries.some((entry) => entry.dir && entry.items === null))) {
399 toast("Some folder counts are unavailable. Expand folders one at a time.");
400 return;
401 }
402 const depth = (rel: string) => (rel ? rel.split("/").length : 0);
403 const levels = Math.max(...Object.keys(tree.folders).map(depth)) - depth(path);
404 toast(levels
405 ? `Expanded ${plural(levels, "level")}. Deeper folders hold too many items to open at once.`
406 : "These folders hold too many items to open at once. Expand them one at a time.");
407 }
408 } catch (failure) {
409 toast(reason(failure));
410 } finally {
411 setExpanding();
412 }
413 };
414
415 const expand = async (row: Row) => {
416 if (!expandable(row)) return;
417 setExpanded((open) => new Set(open).add(row.path));
418 if (!folders()[row.path]) {
419 const found = await parseResponse(props.client.list.$get({ query: { path: row.path } })).catch((failure) => {
420 toast(reason(failure));
421 collapse(row);
422 });
423 if (found && within(here(), row.path)) setFolders((known) => ({ ...known, [row.path]: found.entries }));
424 }
425 const tree = await parseResponse(props.client.tree.$post({ json: { path: row.path, expanded: [] } })).catch(() => null);
426 if (tree && within(here(), row.path)) merge(tree);
427 };
428
429 /** Keeps what's open inside, so expanding again shows it the same way, unless `deep`. */
430 const collapse = (row: Row, deep = false) => {
431 setExpanded((open) => new Set([...open].filter((rel) => rel !== row.path && !(deep && within(row.path, rel)))));
432 setSelected((picked) => new Set([...picked].filter((rel) => !within(row.path, rel))));
433 if (within(row.path, cursor() ?? "")) setCursor(row.path);
434 };
435
436 const activate = (row: Row) => {
437 if (row.entry.dir) open(row.path);
438 else if (row.entry.download) window.open(row.entry.download, "_blank", "noreferrer");
439 };
440 const up = () => here() && open(parentOf(here()));
441
442 const reload = () => {
443 refetch();
444 refetchOps();
445 refetchMatches();
446 refreshTree();
447 };
448 const undo = (op: Op) => parseResponse(props.client.ops[":id"].undo.$post({ param: { id: op.id } })).finally(reload);
449
450 const done = (op: Op) => {
451 toast(op.label, { label: "undo", run: () => undo(op) });
452 setSelected(new Set<string>());
453 setEditing();
454 reload();
455 };
456
457 /** For changes that can fail partway, so the folder reloads either way; the failure reaches the dialog. */
458 const act = (change: Promise<Op>) => change.then(done, (failure) => {
459 reload();
460 throw failure;
461 });
462
463 const paths = () => chosen().map((row) => row.path);
464 const what = () => (only() ? only()!.entry.name : plural(chosen().length, "item"));
465 const refuseDataset = () => {
466 const row = dataset();
467 if (row) toast(`${row.entry.name} is the ${row.entry.dataset} dataset. Rename, move or destroy it with zfs instead.`);
468 return !!row;
469 };
470
471 const remove = () => {
472 const selection = paths();
473 if (!selection.length || refuseDataset()) return;
474 showConfirmDialog({
475 title: `Delete ${what()}?`,
476 description: () => (
477 <>
478 <Show when={selection.length > 1}>
479 <ul class="match-list mono">
480 <For each={selection}>{(rel) => <li>{rel.slice(here() ? here().length + 1 : 0)}</li>}</For>
481 </ul>
482 </Show>
483 <p>You can undo this from recent changes.</p>
484 </>
485 ),
486 confirmLabel: "delete",
487 destructive: true,
488 returnFocus: table,
489 onConfirm: () => act(parseResponse(props.client.delete.$post({ json: { paths: selection } }))),
490 });
491 };
492
493 const moveTo = (title: string, description: () => JSX.Element, run: (to: string) => Promise<Op>) => showTextDialog({
494 title,
495 description,
496 returnFocus: table,
497 label: "Destination folder",
498 initialValue: here() ? here() + "/" : "",
499 validateInput: () => true,
500 confirmLabel: "move",
501 onConfirm: (to) => act(run(to.trim().replace(/^\/+|\/+$/g, ""))),
502 });
503
504 const move = () => {
505 const selection = paths();
506 if (!selection.length || refuseDataset()) return;
507 moveTo(`Move ${what()}`, () => `Folder paths start from ${props.root}.`,
508 (to) => parseResponse(props.client.move.$post({ json: { paths: selection, to } })));
509 };
510
511 /** Where ⌘V lands: inside the folder under the cursor when it's open, else beside the cursor. */
512 const pasteTarget = () => {
513 const row = current();
514 if (!row) return here();
515 return row.entry.dir && expanded().has(row.path) ? row.path : row.parent;
516 };
517 const nameOf = (rel: string) => rel.split("/").at(-1) || props.root;
518
519 const paste = () => {
520 const to = pasteTarget();
521 const moving = (clipboard() ?? []).filter((rel) => parentOf(rel) !== to);
522 if (!clipboard()) return;
523 if (!moving.length) return toast(`Already in ${nameOf(to)}`);
524 showConfirmDialog({
525 title: `Move ${moving.length === 1 ? nameOf(moving[0]!) : plural(moving.length, "item")} to ${nameOf(to)}?`,
526 description: () => {
527 const from = new Set(moving.map(parentOf));
528 return (
529 <>
530 <ul class="match-list mono"><For each={moving}>{(rel) => <li>{nameOf(rel)}</li>}</For></ul>
531 <p>
532 {from.size === 1 ? `From ${[...from][0] || props.root} into` : "Into"} {to || props.root}.
533 You can undo this from recent changes.
534 </p>
535 </>
536 );
537 },
538 confirmLabel: "move",
539 returnFocus: table,
540 onConfirm: () => act(parseResponse(props.client.move.$post({ json: { paths: moving, to } }))).then(() => setClipboard()),
541 });
542 };
543
544 const bulk = (action: "delete" | "move") => {
545 const shown = found();
546 if (!shown?.count) return;
547 const query = { path: here(), pattern: pattern(), count: shown.count };
548 const list = () => (
549 <ul class="match-list mono">
550 <For each={shown.sample}>{(path) => <li>{path}</li>}</For>
551 <Show when={shown.count > shown.sample.length}>
552 <li class="more">and {count(shown.count - shown.sample.length)} more</li>
553 </Show>
554 </ul>
555 );
556 if (action === "move") {
557 return moveTo(`Move ${plural(shown.count, "match", "matches")}`, () => <>{list()}<p>Each keeps its subfolders.</p></>,
558 (to) => parseResponse(props.client.bulk.$post({ json: { ...query, action, to } })));
559 }
560 showTextDialog({
561 title: `Delete ${plural(shown.count, "match", "matches")}?`,
562 description: () => (
563 <>{list()}<p>{shown.size === null ? "" : `${bytes(shown.size)} in ${folderName()}. `}You can undo this from recent changes.</p></>
564 ),
565 returnFocus: table,
566 label: `Type ${shown.count} to confirm`,
567 validateInput: (value) => value.trim() === String(shown.count),
568 confirmLabel: "delete",
569 destructive: true,
570 onConfirm: () => act(parseResponse(props.client.bulk.$post({ json: { ...query, action } }))),
571 });
572 };
573
574 const bulkRename = () => {
575 const targets = chosen();
576 const [mode, setMode] = createSignal<"replace" | "format">("replace");
577 const [find, setFind] = createSignal("");
578 const [replacement, setReplacement] = createSignal("");
579 const [regex, setRegex] = createSignal(false);
580 const [format, setFormat] = createSignal("{name}");
581 const [start, setStart] = createSignal(1);
582 const rename = (name: string, index: number, dir: boolean) => {
583 if (mode() === "format") {
584 const dot = dir ? -1 : name.lastIndexOf(".");
585 const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
586 return format().replace(/\{(n+)\}/g, (_, digits: string) => String(start() + index).padStart(digits.length, "0"))
587 .replaceAll("{name}", stem) + ext;
588 }
589 if (!find()) return name;
590 return regex() ? name.replace(new RegExp(find(), "g"), replacement()) : name.replaceAll(find(), replacement());
591 };
592 const preview = () => {
593 let failure = "";
594 const renamed = targets.map((row, index) => {
595 try {
596 return { row, name: rename(row.entry.name, index, row.entry.dir) };
597 } catch (error) {
598 failure = `Check the regex: ${(error as Error).message}.`;
599 return { row, name: row.entry.name };
600 }
601 });
602 const taken = (row: Row) => new Set(((row.parent === here() ? listing()?.entries : folders()[row.parent]) ?? [])
603 .map((entry) => entry.name).filter((name) => name !== row.entry.name));
604 const clashes = renamed.map(({ row, name }) => {
605 if (!name.trim() || name.includes("/") || name === "." || name === "..") return "names can't be empty or contain /";
606 if (name === row.entry.name) return undefined;
607 if (taken(row).has(name)) return `${name} already exists`;
608 const twin = renamed.some((other) => other.row !== row && other.row.parent === row.parent && other.name === name);
609 if (twin) return "two items get this name";
610 });
611 const problems = clashes.filter(Boolean).length;
612 return {
613 renamed: renamed.map((item, index) => ({ ...item, clash: clashes[index] })),
614 failure: failure
615 || (problems ? `${plural(problems, "name")} can't be used. Change the pattern so each name is new and unique.` : ""),
616 };
617 };
618 let field: HTMLInputElement | undefined;
619 const input = (props: { label: string; value: () => string; set: (value: string) => void; autofocus?: boolean }) => (
620 <label class="field">
621 {props.label}
622 <input class="search mono" value={props.value()} spellcheck={false} autocomplete="off"
623 autofocus={props.autofocus} ref={(input) => props.autofocus && (field = input)}
624 onInput={(event) => props.set(event.currentTarget.value)} />
625 </label>
626 );
627 showConfirmDialog({
628 title: `Rename ${plural(targets.length, "item")}`,
629 returnFocus: table,
630 confirmLabel: "rename",
631 body: () => {
632 createEffect(() => field?.setCustomValidity(preview().failure));
633 return (
634 <div class="bulk-rename">
635 <TabBar label="Rename by">
636 <button type="button" aria-pressed={mode() === "replace"} onClick={() => setMode("replace")}>find and replace</button>
637 <button type="button" aria-pressed={mode() === "format"} onClick={() => setMode("format")}>format</button>
638 </TabBar>
639 <Show when={mode() === "replace"} fallback={
640 <div class="row format">
641 {input({ label: "Name", value: format, set: setFormat, autofocus: true })}
642 <label class="field">
643 Start at
644 <input class="search" type="number" min="0" value={start()}
645 onInput={(event) => setStart(Math.max(0, event.currentTarget.valueAsNumber || 0))} />
646 </label>
647 <span class="hint">{"{name}"} is the old name and {"{n}"} counts up; {"{nn}"} pads it to 2 digits</span>
648 </div>
649 }>
650 <div class="row">
651 {input({ label: "Find", value: find, set: setFind, autofocus: true })}
652 {input({ label: "Replace with", value: replacement, set: setReplacement })}
653 </div>
654 <Checkbox checked={regex()} onChange={setRegex}>regex, with $1 for groups</Checkbox>
655 </Show>
656 <div class="preview">
657 <table class="data">
658 <tbody>
659 <For each={preview().renamed}>
660 {(item) => (
661 <tr classList={{ same: item.name === item.row.entry.name, clash: !!item.clash }}>
662 <td class="mono">{item.row.entry.name}</td>
663 <td class="arrow">→</td>
664 <td class="mono" data-tip={item.clash}>{item.name === item.row.entry.name ? "unchanged" : item.name}</td>
665 </tr>
666 )}
667 </For>
668 </tbody>
669 </table>
670 </div>
671 </div>
672 );
673 },
674 onConfirm: () => {
675 const renames = preview().renamed.filter((item) => item.name !== item.row.entry.name)
676 .map((item) => ({ path: item.row.path, name: item.name }));
677 return renames.length && act(parseResponse(props.client.rename.$post({ json: { renames } })));
678 },
679 });
680 };
681
682 const rename = () => {
683 if (refuseDataset()) return;
684 if (chosen().length > 1) bulkRename();
685 else if (only()) setEditing({ rename: only()!.path });
686 };
687
688 const preview = (row: Row | undefined) => {
689 if (!row || row.entry.dir) return;
690 if (!TEXT.test(row.entry.name)) return toast(`No preview for ${row.entry.name}. Open it with ${MAC ? "⌘O" : "enter"}.`);
691 showConfirmDialog({
692 title: row.entry.name,
693 description: () => {
694 const [peek, { refetch }] = createResource(() => parseResponse(props.client.peek.$get({ query: { path: row.path } })));
695 return (
696 <Loaded data={peek} what="this file" retry={refetch}>
697 {(file) => (
698 <>
699 <pre class="peek">{file().text || "This file is empty."}</pre>
700 <Show when={file().more}><p class="muted">The first 64 KiB of {bytes(row.entry.size!)}.</p></Show>
701 </>
702 )}
703 </Loaded>
704 );
705 },
706 confirmLabel: "open",
707 returnFocus: table,
708 onConfirm: () => row.entry.download && window.open(row.entry.download, "_blank", "noreferrer"),
709 });
710 };
711
712 const onKey = (event: KeyboardEvent) => {
713 if (event.target !== table && event.target !== document.body) return;
714 const mod = MAC ? event.metaKey : event.ctrlKey;
715 const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
716 const row = current();
717 if ((key === "ArrowDown" || key === "ArrowUp") && !mod && !event.altKey) step(key === "ArrowDown" ? 1 : -1, event.shiftKey);
718 else if (key === "Home" || key === "End") step(key === "Home" ? -Infinity : Infinity, event.shiftKey);
719 else if (MAC ? mod && key === "ArrowUp" : (event.altKey && key === "ArrowUp") || (key === "Backspace" && !mod)) up();
720 else if (MAC ? mod && (key === "o" || key === "ArrowDown") : key === "Enter") row && activate(row);
721 else if (MAC ? key === "Enter" : key === "F2") rename();
722 else if (MAC ? mod && key === "Backspace" : key === "Delete") {
723 if (chosen().length) remove();
724 else bulk("delete");
725 } else if (key === "ArrowRight" && row?.entry.dir) {
726 if (event.altKey) expandAll(row.path);
727 else if (!expanded().has(row.path)) expand(row);
728 else if (rows()[rows().indexOf(row) + 1]?.parent === row.path) step(1, false);
729 } else if (key === "ArrowLeft" && row) {
730 if (expanded().has(row.path)) collapse(row, event.altKey);
731 else if (row.depth) select(rows().find((other) => other.path === row.parent)!, "only");
732 } else if (mod && key === "a") setSelected(new Set(rows().map((row) => row.path)));
733 else if (mod && key === "x") setClipboard(chosen().length ? paths() : undefined);
734 else if (mod && key === "v") paste();
735 else if (mod && key === "z") {
736 const last = lastOps()?.find((op) => !op.undone);
737 if (!last) toast("Nothing to undo here");
738 else undo(last).then(() => toast(`Undone: ${last.label}`), (failure) => toast(reason(failure)));
739 }
740 else if (key === " ") preview(row);
741 else if (key === "Escape") {
742 if (selected().size) setSelected(new Set<string>());
743 else if (clipboard()) setClipboard();
744 else clearPattern();
745 } else if (key === "/") patternInput.focus();
746 else return;
747 event.preventDefault();
748 };
749 document.addEventListener("keydown", onKey);
750 onCleanup(() => document.removeEventListener("keydown", onKey));
751
752 let debounce: ReturnType<typeof setTimeout> | undefined;
753 onCleanup(() => clearTimeout(debounce));
754 const usePattern = (value: string) => {
755 clearTimeout(debounce);
756 setParams({ pattern: value.trim() ? value : undefined }, { replace: true });
757 };
758 const clearPattern = () => {
759 patternInput.value = "";
760 usePattern("");
761 };
762
763 /** Enter on an unchanged rename just closes the field; a new name lands selected in `dir`. */
764 const nameInput = (dir: string, initial: string, submit: (value: string) => Promise<Op>) => {
765 let saving = false;
766 return (
767 <input class="search rename" value={initial} aria-label="Name" ref={(input) => queueMicrotask(() => {
768 input.focus();
769 input.setSelectionRange(0, initial.lastIndexOf(".") > 0 ? initial.lastIndexOf(".") : initial.length);
770 })} onKeyDown={(event) => {
771 const value = event.currentTarget.value.trim();
772 if (event.key === "Enter" && value === initial && renaming()) {
773 setEditing();
774 table?.focus();
775 } else if (event.key === "Enter" && value && !saving) {
776 saving = true;
777 submit(value).then((op) => {
778 done(op);
779 setSelected(new Set([join(dir, value)]));
780 setCursor(join(dir, value));
781 anchor = join(dir, value);
782 table?.focus();
783 }, (failure) => toast(reason(failure))).finally(() => (saving = false));
784 } else if (event.key === "Escape") table?.focus();
785 }} onBlur={() => !saving && setEditing()} />
786 );
787 };
788
789 const selectionTip = (verb: string) =>
790 chosen().length ? dataset() && `${dataset()!.entry.name} is a dataset; zfs manages it` : `select items to ${verb}`;
791 const collapsedFolders = () => rows().some((row) => expandable(row) && !expanded().has(row.path));
792 const keys: [JSX.Element, string][] = MAC
793 ? [[<><kbd>←</kbd><kbd>→</kbd></>, "fold"], [<kbd>⏎</kbd>, "rename"],
794 [<kbd>⌘O</kbd>, "open"], [<kbd>⌘↑</kbd>, "up"], [<kbd>⌘⌫</kbd>, "delete"], [<><kbd>⌘X</kbd><kbd>⌘V</kbd></>, "cut, paste"], [<kbd>⌘Z</kbd>, "undo"],
795 [<kbd>space</kbd>, "preview"], [<kbd>/</kbd>, "pattern"]]
796 : [[<><kbd>←</kbd><kbd>→</kbd></>, "fold"], [<kbd>enter</kbd>, "open"],
797 [<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"],
798 [<kbd>space</kbd>, "preview"], [<kbd>/</kbd>, "pattern"]];
799 const historyId = `${props.id}-history`;
800 const folderTotal = () => sizes()[here()];
801
802 return (
803 <div class="explorer fill">
804 <div class="bar">
805 <Crumbs root={props.root} path={here()} tip={listing() && !here() ? listing()!.host : undefined} />
806 <Show when={folderTotal()}>
807 {(total) => (
808 <span class="total" data-tip={[
809 plural(total().files, "file"),
810 `${bytes(total().alloc)} on disk`,
811 total().size > total().alloc * 1.02 && `${(total().size / total().alloc).toFixed(2)}× compressed`,
812 updated() && `indexed ${ago(updated()!)}`,
813 ].filter(Boolean).join(", ")}>{bytes(total().size)}</span>
814 )}
815 </Show>
816 <span class="spacer" />
817 <Show when={listing()}>
818 <span class="count" data-tip={chosen().length ? bytes(total(chosen())) : undefined}>
819 {chosen().length ? `${count(chosen().length)} selected` : plural(listing()!.entries.length, "item")}
820 </span>
821 </Show>
822 <button class="button icon-only small" popovertarget={historyId} style={{ "anchor-name": `--${historyId}` }}
823 aria-label="Recent changes" data-tip="recent changes">
824 <History size={14} />
825 </button>
826 <div id={historyId} popover class="history" style={{ "position-anchor": `--${historyId}` }}
827 onToggle={(event) => (event as ToggleEvent).newState === "open" && refetchOps()}>
828 <Loaded data={ops} what="recent changes" retry={refetchOps}>
829 {(recent) => (
830 <Show when={recent().length} fallback={<p class="none">No changes yet. Renames, moves and deletes show here to undo.</p>}>
831 <For each={recent()}>
832 {(op) => (
833 <div class="op">
834 <span class="label" data-tip={op.count > 1
835 ? `${op.sample.join(", ")}${op.count > op.sample.length ? ` and ${count(op.count - op.sample.length)} more` : ""}`
836 : op.sample[0]}>{op.label}</span>
837 <span class="when"><Ago t={op.at} /></span>
838 <Show when={!op.undone} fallback={<span class="undone">undone</span>}>
839 <button class="button small" disabled={!!undoing()} aria-busy={undoing() === op.id} onClick={async () => {
840 setUndoing(op.id);
841 await undo(op).catch((failure) => toast(reason(failure)));
842 setUndoing();
843 }}>undo</button>
844 </Show>
845 </div>
846 )}
847 </For>
848 </Show>
849 )}
850 </Loaded>
851 </div>
852 <button class="button icon-only small" aria-pressed={mapShown()} aria-label={mapShown() ? "Hide map" : "Show map"}
853 data-tip={mapShown() ? "hide map" : "show map"} onClick={() => {
854 setMapShown(!mapShown());
855 localStorage.setItem(mapKey, mapShown() ? "shown" : "hidden");
856 }}>
857 <LayoutGrid size={14} />
858 </button>
859 <Show when={listing()?.link}>
860 {(link) => <OpenApp app={apps()?.find((app) => app.id === "copyparty") ?? { id: "copyparty", name: "Copyparty", icon: null }}
861 href={link()} />}
862 </Show>
863 </div>
864
865 <Reveal when={mapShown()}>
866 <div class="map-slot">
867 <Show when={mapItems()} fallback={unmeasured()
868 ? <div class="folder-map empty">
869 <Show when={!list.error}><p><span class="error">Couldn't measure this folder.</span> {unmeasured()}</p></Show>
870 </div>
871 : <div class="skeleton folder-map" />}>
872 {(items) => (
873 <Show when={folderTotal()?.size} fallback={
874 <div class="folder-map empty">{measured() === here() && !folderTotal() && listing()?.entries.length
875 ? "Too many files to measure here without the file index."
876 : "Nothing here takes space"}</div>
877 }>
878 <FolderMap items={items()} total={folderTotal()!.size} hover={hover()} color={colorOf}
879 cursor={current() && !current()!.depth ? current()!.entry.name : undefined}
880 onHover={setHover} onPick={(name, dir) => {
881 const row = rows().find((row) => !row.depth && row.entry.name === name);
882 if (dir) open(join(here(), name));
883 else if (row) {
884 select(row, "only");
885 table?.focus();
886 }
887 }} />
888 </Show>
889 )}
890 </Show>
891 </div>
892 </Reveal>
893
894 <div class="toolbar">
895 <Show when={pattern()} fallback={
896 <>
897 <button class="button" onClick={() => setEditing("folder")}>new folder</button>
898 <button class="button" disabled={!chosen().length || !!dataset()} data-tip={selectionTip("rename")} onClick={rename}>
899 rename
900 </button>
901 <button class="button" disabled={!chosen().length || !!dataset()} data-tip={selectionTip("move")} onClick={move}>
902 move
903 </button>
904 <Show when={chosen().length ? only()?.entry.download : listing()?.zip} fallback={
905 <button class="button" disabled data-tip="select one item to download">download</button>
906 }>
907 {(href) => (
908 <a class="button" href={href()} target={only()?.entry.dir === false ? "_blank" : undefined} rel="noreferrer"
909 data-tip={chosen().length ? undefined : `download ${folderName()} as a zip`}>
910 {only()?.entry.dir === false ? "download" : "zip"}
911 </a>
912 )}
913 </Show>
914 <button class="button danger" disabled={!chosen().length || !!dataset()} data-tip={selectionTip("delete")}
915 onClick={remove}>delete</button>
916 <Show when={rows().some(expandable)}>
917 <button class="button" disabled={!!expanding()} aria-busy={expanding() === here()}
918 data-tip={collapsedFolders() ? `${MAC ? "⌥" : "alt"} → on a folder expands just that one` : undefined}
919 onClick={() => (collapsedFolders() ? expandAll(here()) : setExpanded(new Set<string>()))}>
920 {collapsedFolders() ? "expand all" : "collapse all"}
921 </button>
922 </Show>
923 </>
924 }>
925 <Show when={matches.state !== "errored"} fallback={<span class="error">{reason(matches.error)}</span>}>
926 <span class="matched" aria-live="polite">
927 {!found() ? "matching…" : found()!.count ? plural(found()!.count, "match", "matches") : "no matches"}
928 <Show when={found()?.count && found()!.size !== null}> <span class="muted">({bytes(found()!.size!)})</span></Show>
929 </span>
930 </Show>
931 <button class="button" disabled={!found()?.count} data-tip={found()?.count ? undefined : "nothing matches"}
932 onClick={() => bulk("move")}>move matches</button>
933 <button class="button danger" disabled={!found()?.count} data-tip={found()?.count ? undefined : "nothing matches"}
934 onClick={() => bulk("delete")}>delete matches</button>
935 </Show>
936 <span class="spacer" />
937 <Show when={clipboard()}>
938 {(cut) => (
939 <span class="clip">
940 <button class="button" onClick={paste} data-tip={`${cut().slice(0, 3).map(nameOf).join(", ")
941 + (cut().length > 3 ? ` and ${count(cut().length - 3)} more` : "")} into ${nameOf(pasteTarget())}`}>
942 paste {count(cut().length)}
943 </button>
944 <button class="button icon-only" aria-label="Clear cut items" data-tip="esc" onClick={() => setClipboard()}>
945 <X size={14} />
946 </button>
947 </span>
948 )}
949 </Show>
950 <label class="search-box pattern" classList={{ active: !!pattern() }}>
951 <TextSearch size={14} aria-hidden="true" />
952 <input placeholder="select by pattern, like **/*.nfo" aria-label={`Match files in ${folderName()}`}
953 spellcheck={false} autocomplete="off" ref={patternInput} value={pattern()}
954 onInput={(event) => {
955 clearTimeout(debounce);
956 const value = event.currentTarget.value;
957 debounce = setTimeout(() => usePattern(value), 250);
958 }}
959 onKeyDown={(event) => {
960 if (event.key === "Escape") clearPattern();
961 if (event.key === "Enter" || event.key === "Escape") table?.focus();
962 }} />
963 <Show when={pattern()}>
964 <button class="clear" aria-label="Clear pattern" data-tip="esc" onClick={clearPattern}><X size={13} /></button>
965 </Show>
966 </label>
967 </div>
968
969 <div class="files-scroll" ref={filesScroll}>
970 <Loaded data={list} what="this folder" retry={refetch} skeleton={
971 <div class="files-skeleton">
972 <For each={[62, 48, 71, 55, 66, 40, 58]}>
973 {(width) => <div class="skeleton" style={{ width: `${width}%` }} />}
974 </For>
975 </div>
976 }>
977 {() => (
978 <table class="data files" classList={{ lens: !!found()?.count }} tabindex="0" role="treegrid" aria-label="Files"
979 aria-multiselectable="true" ref={table}>
980 <thead>
981 <tr>
982 <SortHeader column="name" label="name" sort={sort()} onSort={setSort} />
983 <SortHeader column="size" label="size" sort={sort()} onSort={setSort} num />
984 <SortHeader column="modified" label="modified" sort={sort()} onSort={setSort} num />
985 </tr>
986 </thead>
987 <tbody>
988 <Show when={here()}>
989 <tr class="up" onClick={up}>
990 <td colSpan={3}><div class="name">
991 <span class="twisty" />
992 <CornerLeftUp class="icon" />
993 <span class="text">(up to {nameOf(parentOf(here()))})</span>
994 </div></td>
995 </tr>
996 </Show>
997 <Show when={editing() === "folder"}>
998 <tr class="cursor">
999 <td><div class="name"><span class="twisty" /><Folder class="icon" />{nameInput(here(), "New folder", (name) =>
1000 parseResponse(props.client.folder.$post({ json: { path: here(), name } })))}</div></td>
1001 <td /><td />
1002 </tr>
1003 </Show>
1004 <For each={rows()} fallback={
1005 <Show when={editing() !== "folder" && !list.error}>
1006 <tr class="empty-row"><td colSpan={3}>This folder is empty</td></tr>
1007 </Show>
1008 }>
1009 {(row, index) => {
1010 const hit = () => found()?.rows[row.path.slice(here() ? here().length + 1 : 0)];
1011 const isOpen = () => expanded().has(row.path);
1012 const total = () => sizes()[row.path];
1013 return (
1014 <tr data-row={index()} aria-level={row.depth + 1} aria-selected={selected().has(row.path)}
1015 aria-expanded={expandable(row) ? isOpen() : undefined}
1016 classList={{
1017 selected: selected().has(row.path),
1018 cursor: cursor() === row.path,
1019 hover: !row.depth && hover() === row.entry.name,
1020 hit: !!hit(),
1021 cut: !!clipboard()?.some((rel) => rel === row.path || within(rel, row.path)),
1022 }}
1023 onMouseEnter={() => !row.depth && setHover(row.entry.name)} onMouseLeave={() => setHover()}
1024 onClick={(event) =>
1025 select(row, event.shiftKey ? "range" : (MAC ? event.metaKey : event.ctrlKey) ? "toggle" : "only")}
1026 onDblClick={() => activate(row)}>
1027 <td><div class="name" style={{ "--depth": row.depth }}>
1028 <Show when={expandable(row)} fallback={<span class="twisty" />}>
1029 <button class="twisty" classList={{ open: isOpen() }} tabindex="-1"
1030 onMouseDown={(event) => event.preventDefault()}
1031 aria-label={isOpen() ? "Collapse" : "Expand"} aria-busy={expanding() === row.path}
1032 onClick={(event) => {
1033 event.stopPropagation();
1034 if (event.altKey) (isOpen() ? collapse(row, true) : expandAll(row.path));
1035 else if (isOpen()) collapse(row);
1036 else expand(row);
1037 }}>
1038 <ChevronRight size={14} />
1039 </button>
1040 </Show>
1041 <span class="kind" data-tip={row.entry.dataset ? `dataset ${row.entry.dataset}` : undefined}>
1042 <EntryIcon entry={row.entry} color={row.depth ? undefined : `var(${colorOf(row.entry.name)})`} />
1043 </span>
1044 <Show when={renaming() === row.path}
1045 fallback={<span class="text">{row.entry.name}</span>}>
1046 {nameInput(row.parent, row.entry.name, (name) =>
1047 parseResponse(props.client.rename.$post({ json: { renames: [{ path: row.path, name }] } })))}
1048 </Show>
1049 <Show when={hit()} keyed>
1050 {(hit) => hit === "self"
1051 ? <span class="badge">match</span>
1052 : <span class="badge" data-tip={`${plural(hit, "match", "matches")} inside`}>{count(hit)}</span>}
1053 </Show>
1054 </div></td>
1055 <Show when={row.entry.dir} fallback={
1056 <td class="num" data-tip={[
1057 row.entry.size! >= 1024 && `${count(row.entry.size!)} bytes`,
1058 row.entry.alloc! < row.entry.size! * 0.98 && `${bytes(row.entry.alloc!)} on disk`,
1059 ].filter(Boolean).join(", ") || undefined}>
1060 {bytes(row.entry.size!)}
1061 </td>
1062 }>
1063 <Show when={total()} fallback={<td class="num muted">{row.entry.items === null ? "–" : plural(row.entry.items, "item")}</td>}>
1064 {(total) => (
1065 <td class="num" data-tip={[
1066 row.entry.items !== null && plural(row.entry.items, "item"),
1067 total().files !== row.entry.items && `${plural(total().files, "file")} in all`,
1068 total().alloc < total().size * 0.98 && `${bytes(total().alloc)} on disk`,
1069 ].filter(Boolean).join(", ")}>
1070 {bytes(total().size)}
1071 </td>
1072 )}
1073 </Show>
1074 </Show>
1075 <td class="num"><Ago t={row.entry.modified} /></td>
1076 </tr>
1077 );
1078 }}
1079 </For>
1080 </tbody>
1081 </table>
1082 )}
1083 </Loaded>
1084 </div>
1085
1086 <div class="legend">
1087 <For each={keys}>{([key, label]) => <span>{key} {label}</span>}</For>
1088 </div>
1089 </div>
1090 );
1091}