| 1 | import { useLocation, useSearchParams } from "@solidjs/router"; |
| 2 | import { Checkbox } from "./Checkbox.tsx"; |
| 3 | import { type InferResponseType, parseResponse } from "hono/client"; |
| 4 | import ChevronRight from "lucide-solid/icons/chevron-right"; |
| 5 | import CornerLeftUp from "lucide-solid/icons/corner-left-up"; |
| 6 | import Database from "lucide-solid/icons/database"; |
| 7 | import FileIcon from "lucide-solid/icons/file"; |
| 8 | import FileCode from "lucide-solid/icons/file-code"; |
| 9 | import FileImage from "lucide-solid/icons/file-image"; |
| 10 | import FileMusic from "lucide-solid/icons/file-music"; |
| 11 | import FilePlay from "lucide-solid/icons/file-play"; |
| 12 | import FileText from "lucide-solid/icons/file-text"; |
| 13 | import Folder from "lucide-solid/icons/folder"; |
| 14 | import History from "lucide-solid/icons/rotate-ccw-clock"; |
| 15 | import LayoutGrid from "lucide-solid/icons/layout-grid"; |
| 16 | import TextSearch from "lucide-solid/icons/text-search"; |
| 17 | import X from "lucide-solid/icons/x"; |
| 18 | import { |
| 19 | createEffect, createMemo, createResource, createSignal, For, type JSX, on, onCleanup, onMount, Show, |
| 20 | } from "solid-js"; |
| 21 | import { api, queries, query, reason } from "../api.ts"; |
| 22 | import { ago, bytes, count, percent, plural } from "../format.ts"; |
| 23 | import { Ago } from "./Ago.tsx"; |
| 24 | import { showConfirmDialog, showTextDialog } from "./Dialog.tsx"; |
| 25 | import { lastGood, Loaded } from "./Loaded.tsx"; |
| 26 | import { OpenApp } from "./OpenApp.tsx"; |
| 27 | import { Reveal } from "./Reveal.tsx"; |
| 28 | import { type Sort, SortHeader } from "./SortHeader.tsx"; |
| 29 | import { TabBar } from "./TabBar.tsx"; |
| 30 | import { toast } from "./Toast.tsx"; |
| 31 | import "./Explorer.css"; |
| 32 | |
| 33 | /** A file browser's routes: the library's, or the whole store's under storage. */ |
| 34 | type Client = typeof api.media | typeof api.storage.files; |
| 35 | type Entry = InferResponseType<typeof api.media.list.$get, 200>["entries"][number]; |
| 36 | type Tree = InferResponseType<typeof api.media.tree.$post, 200>; |
| 37 | type Key = "name" | "size" | "modified"; |
| 38 | type Op = { id: string; label: string }; |
| 39 | /** A visible line of the file tree; `path` is root-relative. */ |
| 40 | type 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. */ |
| 43 | export 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. */ |
| 52 | export 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. */ |
| 84 | export 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 | |
| 110 | const 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 | ]; |
| 117 | const 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. */ |
| 120 | const MAC = /^(Mac|iP)/.test(navigator.platform); |
| 121 | |
| 122 | /** Rows are keyed by entry object, so reading props once is safe. */ |
| 123 | function 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 | |
| 130 | const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name); |
| 131 | const parentOf = (rel: string) => rel.split("/").slice(0, -1).join("/"); |
| 132 | /** Whether `rel` is strictly inside `dir`. */ |
| 133 | const within = (dir: string, rel: string) => rel !== dir && (!dir || rel.startsWith(dir + "/")); |
| 134 | const 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. */ |
| 137 | const TINY = 0.004; |
| 138 | const REST = 0.04; |
| 139 | |
| 140 | /** The folder's entries as blocks sized by bytes, each colored as its row's icon. */ |
| 141 | function 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 | */ |
| 193 | export 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 | } |