@rightkit/system-ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE-APACHE +201 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +36 -2
  5. package/dist/activityLease.d.ts +1 -0
  6. package/dist/activityLease.js +20 -0
  7. package/dist/api.d.ts +1011 -0
  8. package/dist/api.js +296 -0
  9. package/dist/chart.d.ts +14 -0
  10. package/dist/chart.js +94 -0
  11. package/dist/components/Chart.d.ts +40 -0
  12. package/dist/components/Chart.js +109 -0
  13. package/dist/env.d.ts +60 -0
  14. package/dist/index.d.ts +26 -0
  15. package/dist/index.js +28 -0
  16. package/dist/kept.d.ts +3 -0
  17. package/dist/kept.js +21 -0
  18. package/dist/lib/chatKind.d.ts +4 -0
  19. package/dist/lib/chatKind.js +15 -0
  20. package/dist/lib/legal.d.ts +26 -0
  21. package/dist/lib/legal.js +39 -0
  22. package/dist/lib/onboardingSettings.d.ts +2 -0
  23. package/dist/lib/onboardingSettings.js +17 -0
  24. package/dist/lib/qr.d.ts +9 -0
  25. package/dist/lib/qr.js +286 -0
  26. package/dist/lib/rk.d.ts +6 -0
  27. package/dist/lib/rk.js +30 -0
  28. package/dist/metrics.d.ts +24 -0
  29. package/dist/metrics.js +76 -0
  30. package/dist/onboarding/flow.d.ts +31 -0
  31. package/dist/onboarding/flow.js +81 -0
  32. package/dist/onboarding/gate.d.ts +8 -0
  33. package/dist/onboarding/gate.js +52 -0
  34. package/dist/styles.css +8 -0
  35. package/dist/views/Activity.d.ts +3 -0
  36. package/dist/views/Activity.js +111 -0
  37. package/dist/views/Apps.d.ts +1 -0
  38. package/dist/views/Apps.js +478 -0
  39. package/dist/views/Chat.d.ts +1 -0
  40. package/dist/views/Chat.js +255 -0
  41. package/dist/views/ChromeSnapshots.d.ts +6 -0
  42. package/dist/views/ChromeSnapshots.js +19 -0
  43. package/dist/views/Cleanup.d.ts +4 -0
  44. package/dist/views/Cleanup.js +291 -0
  45. package/dist/views/DriveHealth.d.ts +14 -0
  46. package/dist/views/DriveHealth.js +81 -0
  47. package/dist/views/Duplicates.d.ts +2 -0
  48. package/dist/views/Duplicates.js +99 -0
  49. package/dist/views/LauncherSettings.d.ts +63 -0
  50. package/dist/views/LauncherSettings.js +242 -0
  51. package/dist/views/NearbySettings.d.ts +117 -0
  52. package/dist/views/NearbySettings.js +237 -0
  53. package/dist/views/Storage.d.ts +9 -0
  54. package/dist/views/Storage.js +916 -0
  55. package/dist/views/activity.css +94 -0
  56. package/dist/views/apps.css +98 -0
  57. package/dist/views/chat.css +61 -0
  58. package/dist/views/cleanup.css +49 -0
  59. package/dist/views/health.css +17 -0
  60. package/dist/views/membrane.css +23 -0
  61. package/dist/views/monitor.css +99 -0
  62. package/dist/views/settings.css +176 -0
  63. package/dist/views/settingsKit.d.ts +14 -0
  64. package/dist/views/settingsKit.js +43 -0
  65. package/dist/views/storage.css +121 -0
  66. package/dist/views/storageEstimate.d.ts +6 -0
  67. package/dist/views/storageEstimate.js +7 -0
  68. package/dist/visible.d.ts +3 -0
  69. package/dist/visible.js +42 -0
  70. package/package.json +110 -3
@@ -0,0 +1,916 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { listen } from "@tauri-apps/api/event";
4
+ import { Badge, Button, ConfirmDialog, EmptyState, IconButton, Notice, Select, SegmentedControl, useContextMenu, Input } from "@rightkit/react-shell/react";
5
+ import { Activity, ChevronDown, ChevronRight, Copy, File, Folder as FolderIcon, FolderInput, FolderOpen, HardDrive, Info, MoreHorizontal, Network, RefreshCw, ShieldCheck, Terminal, Trash2, Undo2, Usb, X } from "lucide-react";
6
+ import { api, ago, bytes, isStale, isWindows, reclaimableNote, signedBytes, tone, } from "../api.js";
7
+ import { KINDS, kindOf, squarify } from "../chart.js";
8
+ import { readKept, writeKept } from "../kept.js";
9
+ import { ESTIMATE, trashFreesNothing, upTo } from "./storageEstimate.js";
10
+ // Windows has the Recycle Bin and File Explorer; the Mac has the Trash and Finder.
11
+ const TRASH = isWindows ? "Recycle Bin" : "Trash";
12
+ const FILE_MANAGER = isWindows ? "File Explorer" : "Finder";
13
+ import { ChromeSnapshotsLine } from "./ChromeSnapshots.js";
14
+ import { DriveHealthLine, DriveHealthPanel } from "./DriveHealth.js";
15
+ import { Duplicates } from "./Duplicates.js";
16
+ import { clearVisibleInterval, visibleInterval } from "../visible.js";
17
+ const shortPath = (path) => path.replace(/^\/Users\/[^/]+/, "~");
18
+ const VISIBLE_GROUPS = 6;
19
+ const VISIBLE_NOTES = 3;
20
+ /** Findings survive leaving and returning to Storage; they are only rescanned on demand. */
21
+ let cachedReport = null;
22
+ /** Without a live refresh, a saved scan younger than this is shown as it is on open. */
23
+ const SNAPSHOT_SECS = 6 * 60 * 60;
24
+ const isOld = (secs) => Date.now() / 1000 - secs > SNAPSHOT_SECS;
25
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
26
+ const plural = (n, word) => `${n} ${n === 1 ? word : /(ch|sh|s|x|z)$/.test(word) ? `${word}es` : `${word}s`}`;
27
+ /** Menu and keyboard actions apply to real items, never the "smaller files" total. */
28
+ const actionable = (r) => !r.summary;
29
+ /** The volume a saved scan belongs to: its external drive, else the startup disk. */
30
+ const mountFor = (root, list) => list.find((v) => !v.internal && v.mount_point === root)?.mount_point ?? "/";
31
+ // True for a path on a network volume: such a volume is never scanned without the person asking.
32
+ const onNetwork = (path, list) => list.some((v) => {
33
+ if (v.kind !== "network")
34
+ return false;
35
+ const root = v.mount_point.replace(/[\\/]+$/, "");
36
+ return path === root || path.startsWith(`${root}/`) || path.startsWith(`${root}\\`);
37
+ });
38
+ function groupFindings(list) {
39
+ const map = new Map();
40
+ for (const f of list) {
41
+ let g = map.get(f.rule_id);
42
+ if (!g) {
43
+ const label = f.rule_name || f.name;
44
+ g = { key: f.rule_id, label, items: [], bytes: 0, partial: false, risk: "safe", reason: f.reason, discovered: f.name.startsWith(`${label} · `) };
45
+ map.set(f.rule_id, g);
46
+ }
47
+ g.items.push(f);
48
+ g.bytes += f.bytes;
49
+ g.partial ||= f.partial;
50
+ if (f.risk !== "safe")
51
+ g.risk = "review";
52
+ }
53
+ return [...map.values()].sort((a, b) => b.bytes - a.bytes);
54
+ }
55
+ const groupCount = (g) => g.items.length === 1 ? "" : g.discovered ? plural(g.items.length, "project") : plural(g.items.length, "item");
56
+ function noteIcon(f) {
57
+ const text = `${f.action} ${f.reason}`.toLowerCase();
58
+ if (text.includes("xcrun") || text.includes("terminal") || text.includes("command"))
59
+ return Terminal;
60
+ if (text.includes("in use") || text.includes("running"))
61
+ return Activity;
62
+ return Info;
63
+ }
64
+ const KIND_FILTERS = [
65
+ { value: "all", label: "All kinds" },
66
+ { value: "folders", label: "Folders" },
67
+ { value: "documents", label: "Documents" },
68
+ { value: "images", label: "Images" },
69
+ { value: "video", label: "Video" },
70
+ { value: "audio", label: "Audio" },
71
+ { value: "archives", label: "Archives" },
72
+ { value: "code", label: "Code" },
73
+ { value: "apps", label: "Apps" },
74
+ { value: "other", label: "Other" },
75
+ ];
76
+ /** Extensions per file kind, checked in order. A file in none of them is Other. */
77
+ const FILE_KINDS = [
78
+ ["apps", ["dmg", "pkg", "exe", "msi"]],
79
+ ["documents", ["pdf", "doc", "docx", "pages", "rtf", "txt", "md", "odt", "xls", "xlsx", "numbers", "csv", "ppt", "pptx", "key", "epub"]],
80
+ ["images", ["jpg", "jpeg", "png", "gif", "heic", "heif", "tif", "tiff", "webp", "svg", "bmp", "raw", "cr2", "dng", "psd"]],
81
+ ["video", ["mov", "mp4", "m4v", "mkv", "avi", "webm", "mpg", "mpeg"]],
82
+ ["audio", ["mp3", "m4a", "wav", "flac", "aac", "aiff", "ogg"]],
83
+ ["archives", ["zip", "tar", "gz", "tgz", "bz2", "xz", "7z", "rar", "iso"]],
84
+ ["code", ["js", "jsx", "ts", "tsx", "rs", "swift", "py", "go", "java", "kt", "c", "h", "cpp", "hpp", "cs", "rb", "php", "sh", "json", "yaml", "yml", "toml", "html", "css", "scss", "xml", "sql"]],
85
+ ];
86
+ /** Size filter: an item must be larger than the threshold (0 means any size). Decimal units, as the page shows them. */
87
+ const SIZE_FILTERS = [
88
+ { value: 0, label: "Any size" },
89
+ { value: 100_000_000, label: "Over 100 MB" },
90
+ { value: 1_000_000_000, label: "Over 1 GB" },
91
+ { value: 10_000_000_000, label: "Over 10 GB" },
92
+ ];
93
+ const CHANGED_FILTERS = [
94
+ { value: "any", label: "Any time" },
95
+ { value: "week", label: "Last 7 days" },
96
+ { value: "month", label: "Last 30 days" },
97
+ { value: "year", label: "Last year" },
98
+ { value: "older", label: "Older than a year" },
99
+ ];
100
+ const DAY_SECONDS = 86_400;
101
+ /** The modified-time range a Changed choice means at `now` (Unix seconds); null for any time. */
102
+ function changedRange(filter, now) {
103
+ switch (filter) {
104
+ case "week": return { since: now - 7 * DAY_SECONDS };
105
+ case "month": return { since: now - 30 * DAY_SECONDS };
106
+ case "year": return { since: now - 365 * DAY_SECONDS };
107
+ case "older": return { before: now - 365 * DAY_SECONDS };
108
+ default: return null;
109
+ }
110
+ }
111
+ /** The filter kind an item belongs to. Only real items reach here (not the "smaller files" total). */
112
+ function kindFilterOf(r) {
113
+ if (r.is_dir)
114
+ return r.name.toLowerCase().endsWith(".app") ? "apps" : "folders";
115
+ const dot = r.name.lastIndexOf(".");
116
+ const ext = dot > 0 ? r.name.slice(dot + 1).toLowerCase() : "";
117
+ for (const [kind, exts] of FILE_KINDS) {
118
+ if (exts.includes(ext))
119
+ return kind;
120
+ }
121
+ return "other";
122
+ }
123
+ /** A time as the local date and time. */
124
+ const localTime = (secs) => new Date(secs * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
125
+ /** Most matches one search returns (the engine clamps to 1000). */
126
+ const SEARCH_LIMIT = 100;
127
+ const KEPT_SELECTED = "pulse.hub.storage.selected";
128
+ /** `autoScan` is Settings > General > Background > "Scan when Storage or Cleanup opens" (on by default). */
129
+ export function Storage({ autoScan = true }) {
130
+ const [volumes, setVolumes] = useState([]);
131
+ const [active, setActive] = useState("/");
132
+ const [folder, setFolder] = useState(null);
133
+ // The file access permission itself; null until the first answer.
134
+ const [access, setAccess] = useState(null);
135
+ useEffect(() => {
136
+ let live = true;
137
+ api.fullDiskAccessStatus().then((value) => live && setAccess(value)).catch(() => live && setAccess("unknown"));
138
+ return () => {
139
+ live = false;
140
+ };
141
+ }, [folder?.scanned_at]);
142
+ const [results, setResults] = useState(null);
143
+ // Which source answered the search on screen: Windows Search or the saved scan (Windows note).
144
+ const [searchSource, setSearchSource] = useState(null);
145
+ const [query, setQuery] = useState("");
146
+ const [growth, setGrowth] = useState(null);
147
+ const [report, setReport] = useState(cachedReport);
148
+ const [showAll, setShowAll] = useState(false);
149
+ const [showNotes, setShowNotes] = useState(false);
150
+ // One workspace at a time; Duplicates is the exact-content copy finder.
151
+ const [pane, setPane] = useState("findings");
152
+ const [health, setHealth] = useState(null);
153
+ // The volume whose drive-health panel is open, by mount point.
154
+ const [healthOpen, setHealthOpen] = useState(null);
155
+ const [openGroups, setOpenGroups] = useState(new Set());
156
+ const [pending, setPending] = useState(null);
157
+ // One item waiting on a Trash confirmation, or on confirming a copy across drives.
158
+ const [itemTrash, setItemTrash] = useState(null);
159
+ const [itemMove, setItemMove] = useState(null);
160
+ const [undo, setUndo] = useState(null);
161
+ const [busy, setBusy] = useState(false);
162
+ const [scanning, setScanning] = useState(false);
163
+ // True until the saved view has been asked for, so the page never shows an empty "Scanning…" first.
164
+ const [opening, setOpening] = useState(true);
165
+ const [findingsBusy, setFindingsBusy] = useState(false);
166
+ // Latest scan request; an older one that comes back (cancelled) is ignored.
167
+ const scanToken = useRef(0);
168
+ const alive = useRef(true);
169
+ const [error, setError] = useState(null);
170
+ // Image and video compression (the host app #202): the Settings switch, read again when a row is chosen, and the last result.
171
+ const [compressOn, setCompressOn] = useState(false);
172
+ const [compressNote, setCompressNote] = useState(null);
173
+ // The row the inspector shows (by path), and what the Rust side read about it.
174
+ const [selected, setSelected] = useState(null);
175
+ // The row selected before the window was closed, with its folder; restored once, when that folder opens.
176
+ const keptSelection = useRef(readKept(KEPT_SELECTED));
177
+ const [inspect, setInspect] = useState(null);
178
+ const [kindFilter, setKindFilter] = useState("all");
179
+ // Bytes an item must exceed; 0 means any size.
180
+ const [minSize, setMinSize] = useState(0);
181
+ const [changedFilter, setChangedFilter] = useState("any");
182
+ // Search matches the date range left out for having no date.
183
+ const [searchUndated, setSearchUndated] = useState(0);
184
+ // Bumped when the engine reports a change while a search is on screen, so the search runs again.
185
+ const [searchRefresh, setSearchRefresh] = useState(0);
186
+ // The query as the engine-event listener sees it (that listener subscribes once).
187
+ const queryNow = useRef(query);
188
+ useEffect(() => {
189
+ queryNow.current = query;
190
+ }, [query]);
191
+ // Only a scan that kept modified times can be filtered by date.
192
+ const hasDates = folder?.has_dates === true;
193
+ const run = async (work) => {
194
+ setBusy(true);
195
+ setError(null);
196
+ try {
197
+ await work();
198
+ }
199
+ catch (e) {
200
+ setError(String(e));
201
+ }
202
+ finally {
203
+ setBusy(false);
204
+ }
205
+ };
206
+ const refreshFindings = () => {
207
+ setFindingsBusy(true);
208
+ api
209
+ .cleanupScan()
210
+ .then((r) => {
211
+ cachedReport = r;
212
+ setReport(r);
213
+ })
214
+ .catch(() => { })
215
+ .finally(() => setFindingsBusy(false));
216
+ };
217
+ // Findings show at once from the saved copy. A background rescan follows when
218
+ // there is none, when it is old, or when a move has just changed the list.
219
+ const loadFindings = (force = false) => {
220
+ if (cachedReport) {
221
+ setReport(cachedReport);
222
+ if (force || (autoScan && isStale(cachedReport.scanned_at)))
223
+ refreshFindings();
224
+ return;
225
+ }
226
+ api
227
+ .cleanupCached()
228
+ .then((saved) => {
229
+ if (saved && !cachedReport) {
230
+ cachedReport = saved;
231
+ setReport(saved);
232
+ }
233
+ if (force || (autoScan && (!saved || isStale(saved.scanned_at))))
234
+ refreshFindings();
235
+ })
236
+ .catch(() => { if (autoScan || force)
237
+ refreshFindings(); });
238
+ };
239
+ const isInternal = (mount, list) => {
240
+ const volume = list.find((v) => v.mount_point === mount);
241
+ return !volume || volume.internal;
242
+ };
243
+ // Show data that is already scanned: pick the matching drive card, then the
244
+ // growth line (home only) and, once, the cleanup findings.
245
+ const show = (f, list) => {
246
+ const external = list.find((v) => !v.internal && v.mount_point === f.root);
247
+ const mount = external ? external.mount_point : (list.find((v) => v.internal)?.mount_point ?? "/");
248
+ setActive(mount);
249
+ setFolder(f);
250
+ if (external) {
251
+ setGrowth(null);
252
+ }
253
+ else {
254
+ api.growth().then(setGrowth).catch(() => setGrowth(null));
255
+ loadFindings();
256
+ }
257
+ };
258
+ // The startup disk is scanned from the home folder; other volumes from their root.
259
+ // Starting a scan cancels the one running; there is never more than one.
260
+ const scanVolume = async (mount, list = volumes) => {
261
+ const token = ++scanToken.current;
262
+ setActive(mount);
263
+ setQuery("");
264
+ setError(null);
265
+ setScanning(true);
266
+ try {
267
+ const f = await api.scan(isInternal(mount, list) ? undefined : mount);
268
+ if (token !== scanToken.current || !alive.current)
269
+ return;
270
+ show(f, list);
271
+ setScanning(false);
272
+ }
273
+ catch (e) {
274
+ if (token !== scanToken.current || !alive.current)
275
+ return;
276
+ setScanning(false);
277
+ if (!String(e).includes("cancelled"))
278
+ setError(String(e));
279
+ }
280
+ };
281
+ // Choosing a network volume only shows it; its scan waits for "Scan anyway".
282
+ const chooseVolume = (mount) => {
283
+ if (volumes.find((v) => v.mount_point === mount)?.kind !== "network") {
284
+ scanVolume(mount);
285
+ return;
286
+ }
287
+ setActive(mount);
288
+ setFolder(null);
289
+ setResults(null);
290
+ setSelected(null);
291
+ setQuery("");
292
+ setError(null);
293
+ };
294
+ // A scan started before this view was reopened keeps running; wait for it.
295
+ const follow = async (list) => {
296
+ const token = ++scanToken.current;
297
+ setScanning(true);
298
+ for (;;) {
299
+ await sleep(1500);
300
+ if (token !== scanToken.current || !alive.current)
301
+ return;
302
+ const status = await api.scanStatus().catch(() => null);
303
+ if (status && !status.running)
304
+ break;
305
+ }
306
+ const f = await api.lastScan().catch(() => null);
307
+ if (token !== scanToken.current || !alive.current)
308
+ return;
309
+ if (f)
310
+ show(f, list);
311
+ setScanning(false);
312
+ };
313
+ const open = (path) => run(async () => setFolder(await api.children(path)));
314
+ // Single-item actions. The identity is read when the person asks for an
315
+ // action; the Rust side checks it again right before anything moves.
316
+ const showInFinder = (path) => api.finderOpen(path).catch((e) => setError(String(e)));
317
+ const copyText = (text) => navigator.clipboard.writeText(text).catch((e) => setError(`Could not copy: ${String(e)}`));
318
+ // Drops a moved or trashed item from what is on screen. The saved scan index
319
+ // is not rewritten here, so a rescan (or re-drilling) shows it until then.
320
+ const dropRow = (path) => {
321
+ if (selected === path)
322
+ setSelected(null);
323
+ if (results)
324
+ setResults(results.filter((r) => r.path !== path));
325
+ else
326
+ setFolder((f) => (f ? { ...f, rows: f.rows.filter((r) => r.path !== path) } : f));
327
+ };
328
+ useEffect(() => {
329
+ api.mediaCompressStatus().then((s) => setCompressOn(s.enabled)).catch(() => setCompressOn(false));
330
+ }, [selected]);
331
+ // Writes a separate, smaller copy in a "the host app Compressed" folder beside the file; the original is never changed.
332
+ const compressItem = (row) => run(async () => {
333
+ setCompressNote(null);
334
+ const done = await api.mediaCompress(row.path);
335
+ setCompressNote(`Saved ${bytes(done.saved_bytes)}: ${shortPath(done.output)} is ${bytes(done.output_bytes)}, the original is unchanged.`);
336
+ });
337
+ const askTrash = (row) => run(async () => setItemTrash({ row, id: await api.fileIdentity(row.path) }));
338
+ const confirmItemTrash = () => {
339
+ const pending = itemTrash;
340
+ if (!pending)
341
+ return;
342
+ setItemTrash(null);
343
+ run(async () => {
344
+ try {
345
+ await api.fileTrash(pending.row.path, pending.id);
346
+ dropRow(pending.row.path);
347
+ }
348
+ finally {
349
+ api.volumes().then(setVolumes).catch(() => { });
350
+ }
351
+ });
352
+ };
353
+ const finishMove = async (row, id, destination, copyAcrossVolumes) => {
354
+ try {
355
+ await api.fileMove(row.path, id, destination, copyAcrossVolumes);
356
+ dropRow(row.path);
357
+ }
358
+ finally {
359
+ api.volumes().then(setVolumes).catch(() => { });
360
+ }
361
+ };
362
+ // Pick a destination, then move at once within one drive, or confirm a copy across drives first.
363
+ const moveItem = (row) => run(async () => {
364
+ const id = await api.fileIdentity(row.path);
365
+ const destination = await api.fileChooseFolder();
366
+ if (!destination)
367
+ return;
368
+ const plan = await api.fileMovePlan(row.path, id, destination);
369
+ if (plan.same_volume)
370
+ await finishMove(row, id, destination, false);
371
+ else
372
+ setItemMove({ row, id, destination, target: plan.target });
373
+ });
374
+ const confirmItemMove = () => {
375
+ const pending = itemMove;
376
+ if (!pending)
377
+ return;
378
+ setItemMove(null);
379
+ run(() => finishMove(pending.row, pending.id, pending.destination, true));
380
+ };
381
+ // Double-click and Return open in Finder; ⌘C copies the path; ⌘⌫ asks to trash.
382
+ const onRowKey = (e, row) => {
383
+ if (!actionable(row))
384
+ return;
385
+ const command = isWindows ? e.ctrlKey && !e.altKey && !e.metaKey : e.metaKey && !e.altKey && !e.ctrlKey;
386
+ if (command && e.key.toLowerCase() === "c") {
387
+ e.preventDefault();
388
+ copyText(row.path);
389
+ }
390
+ else if (e.key === "Enter") {
391
+ e.preventDefault();
392
+ showInFinder(row.path);
393
+ }
394
+ else if (command && e.key === "Backspace") {
395
+ e.preventDefault();
396
+ askTrash(row);
397
+ }
398
+ };
399
+ // Declared after the handlers above: the builder runs during render.
400
+ const itemMenu = useContextMenu((row) => [
401
+ { id: "finder", label: `${row.is_dir ? "Open" : "Reveal"} in ${FILE_MANAGER}`, icon: _jsx(FolderOpen, { size: 13 }), run: () => showInFinder(row.path) },
402
+ { id: "copy-path", label: "Copy path", icon: _jsx(Copy, { size: 13 }), shortcut: "mod+c", separatorBefore: true, run: () => copyText(row.path) },
403
+ { id: "copy-name", label: "Copy name", icon: _jsx(Copy, { size: 13 }), run: () => copyText(row.name) },
404
+ { id: "move", label: "Move to…", icon: _jsx(FolderInput, { size: 13 }), separatorBefore: true, run: () => moveItem(row) },
405
+ { id: "trash", label: `Move to ${TRASH}`, icon: _jsx(Trash2, { size: 13 }), shortcut: "mod+Backspace", danger: true, separatorBefore: true, run: () => askTrash(row) },
406
+ ], "Item actions",
407
+ // Fixed width: "Move to Recycle Bin" beside the Windows keycap "Ctrl+Backspace" needs more than the default 200.
408
+ { width: isWindows ? 280 : 200 });
409
+ useEffect(() => {
410
+ alive.current = true;
411
+ (async () => {
412
+ const list = await api.volumes().catch(() => []);
413
+ if (!alive.current)
414
+ return;
415
+ setVolumes(list);
416
+ const status = await api.scanStatus().catch(() => null);
417
+ if (!alive.current)
418
+ return;
419
+ if (status?.running) {
420
+ const root = status.running_root;
421
+ const external = list.find((v) => !v.internal && v.mount_point === root);
422
+ setActive(external ? external.mount_point : (list.find((v) => v.internal)?.mount_point ?? "/"));
423
+ setOpening(false);
424
+ follow(list);
425
+ return;
426
+ }
427
+ // The saved view opens at once. A rescan runs behind it only when the
428
+ // saved one is old; with no saved view at all, the first scan starts now.
429
+ // Without a live refresh a walk of the whole folder is long and nothing
430
+ // keeps the view current, so a recent saved scan waits for Rescan.
431
+ const saved = await api.lastScan().catch(() => null);
432
+ if (!alive.current)
433
+ return;
434
+ setOpening(false);
435
+ if (saved) {
436
+ show(saved, list);
437
+ const old = status?.live_refresh === false ? isOld(saved.scanned_at) : isStale(saved.scanned_at);
438
+ // A saved network scan is never refreshed behind the page; "Scan anyway" refreshes it.
439
+ if (old && autoScan && !onNetwork(saved.root, list))
440
+ scanVolume(mountFor(saved.root, list), list);
441
+ }
442
+ else if (autoScan) {
443
+ scanVolume("/", list);
444
+ }
445
+ })();
446
+ return () => {
447
+ alive.current = false;
448
+ };
449
+ }, []);
450
+ // The open folder, read by the event listener below without re-subscribing on every change.
451
+ const openFolder = useRef(null);
452
+ useEffect(() => {
453
+ openFolder.current = folder;
454
+ }, [folder]);
455
+ // The engine's live refresh (`storage-updated`, engine/src/watch.rs) names the folders whose
456
+ // numbers or rows changed. The open folder is read again when one of them is it or lies below
457
+ // it, or when the index says it is stale. Bursts are coalesced into one read a second after the
458
+ // last event. A failed read keeps the rows on screen. The same event (the file-name index reports
459
+ // its changes through it too) asks a search on screen to run again, once the burst is over.
460
+ useEffect(() => {
461
+ const within = (path, dir) => {
462
+ const base = dir.replace(/[\\/]+$/, "");
463
+ return path === dir || path === base || path.startsWith(`${base}/`) || path.startsWith(`${base}\\`);
464
+ };
465
+ let timer = null;
466
+ let searchTimer = null;
467
+ let disposed = false;
468
+ let unlisten = null;
469
+ const reread = () => {
470
+ timer = null;
471
+ const current = openFolder.current;
472
+ if (!current)
473
+ return;
474
+ api
475
+ .children(current.path)
476
+ .then((f) => {
477
+ if (!disposed && openFolder.current?.path === current.path)
478
+ setFolder(f);
479
+ })
480
+ .catch(() => { });
481
+ };
482
+ try {
483
+ void listen("storage-updated", (event) => {
484
+ if (queryNow.current.trim().length >= 2) {
485
+ if (searchTimer != null)
486
+ clearTimeout(searchTimer);
487
+ searchTimer = setTimeout(() => setSearchRefresh((n) => n + 1), 1000);
488
+ }
489
+ const current = openFolder.current;
490
+ if (!current)
491
+ return;
492
+ const { folders, stale } = event.payload;
493
+ if (!stale && !folders.some((p) => within(p, current.path)))
494
+ return;
495
+ if (timer != null)
496
+ clearTimeout(timer);
497
+ timer = setTimeout(reread, 1000);
498
+ })
499
+ .then((stop) => {
500
+ if (disposed)
501
+ stop();
502
+ else
503
+ unlisten = stop;
504
+ })
505
+ .catch(() => { });
506
+ }
507
+ catch {
508
+ // Outside the hub (a test page) there are no engine events; the page keeps what it shows.
509
+ }
510
+ return () => {
511
+ disposed = true;
512
+ if (timer != null)
513
+ clearTimeout(timer);
514
+ if (searchTimer != null)
515
+ clearTimeout(searchTimer);
516
+ unlisten?.();
517
+ };
518
+ }, []);
519
+ useEffect(() => {
520
+ if (!folder || query.trim().length < 2) {
521
+ setResults(null);
522
+ setSearchSource(null);
523
+ setSearchUndated(0);
524
+ return;
525
+ }
526
+ const handle = setTimeout(() => {
527
+ // The size bound and the result limit both go to the backend, which applies the bound before
528
+ // the limit (scanner::search) so the limit fills with matches.
529
+ const range = hasDates ? changedRange(changedFilter, Math.floor(Date.now() / 1000)) : null;
530
+ api
531
+ .searchChanged(query.trim(), minSize > 0 ? minSize : undefined, range ?? undefined, SEARCH_LIMIT)
532
+ .then((r) => {
533
+ setResults(r.rows);
534
+ setSearchSource(r.source ?? null);
535
+ setSearchUndated(r.undated);
536
+ })
537
+ .catch((e) => setError(String(e)));
538
+ }, 200);
539
+ return () => clearTimeout(handle);
540
+ }, [query, folder, minSize, changedFilter, hasDates, searchRefresh]);
541
+ // Selection belongs to one folder listing: moving to another folder or rescanning clears it.
542
+ useEffect(() => {
543
+ if (keptSelection.current) {
544
+ // While the kept selection is pending it must not be overwritten; choosing another row drops it.
545
+ if (!selected)
546
+ return;
547
+ keptSelection.current = null;
548
+ }
549
+ writeKept(KEPT_SELECTED, selected && folder ? { folder: folder.path, path: selected } : null);
550
+ }, [selected, folder?.path]);
551
+ // The kept row's folder is opened once (the view loads the drive root first); the row is restored when it is open.
552
+ const keptOpened = useRef(false);
553
+ useEffect(() => {
554
+ const kept = keptSelection.current;
555
+ if (kept && folder) {
556
+ if (folder.path === kept.folder) {
557
+ keptSelection.current = null;
558
+ setSelected(kept.path);
559
+ return;
560
+ }
561
+ const base = folder.path;
562
+ const below = kept.folder.startsWith(base) && (base.endsWith("/") || base.endsWith("\\") || /^[\\/]/.test(kept.folder.slice(base.length)));
563
+ if (below && !keptOpened.current) {
564
+ keptOpened.current = true;
565
+ api.children(kept.folder).then((f) => {
566
+ if (alive.current && keptSelection.current === kept)
567
+ setFolder(f);
568
+ }, () => {
569
+ if (keptSelection.current === kept)
570
+ keptSelection.current = null;
571
+ });
572
+ setSelected(null);
573
+ return;
574
+ }
575
+ keptSelection.current = null;
576
+ }
577
+ setSelected(null);
578
+ }, [folder?.path, folder?.scanned_at]);
579
+ // The inspector's facts are read once per selected item; a late answer for an item no longer shown is dropped.
580
+ useEffect(() => {
581
+ if (!selected) {
582
+ setInspect(null);
583
+ return;
584
+ }
585
+ let live = true;
586
+ setInspect(null);
587
+ api
588
+ .storageInspect(selected)
589
+ .then((data) => live && setInspect({ path: selected, data, error: null }))
590
+ .catch((e) => live && setInspect({ path: selected, data: null, error: String(e) }));
591
+ return () => {
592
+ live = false;
593
+ };
594
+ }, [selected]);
595
+ // Drive health for every drive card. The hub samples smartctl itself at most
596
+ // every ten minutes, so polling here only reads the saved view.
597
+ useEffect(() => {
598
+ const mounts = volumes.filter((v) => !v.disk_image && v.kind === "local").map((v) => v.mount_point);
599
+ if (mounts.length === 0)
600
+ return;
601
+ const run = () => api.driveHealth(mounts).then(setHealth).catch(() => { });
602
+ run();
603
+ const handle = visibleInterval(run, 60_000);
604
+ return () => clearVisibleInterval(handle);
605
+ }, [volumes]);
606
+ const crumbs = useMemo(() => {
607
+ if (!folder)
608
+ return [];
609
+ const parts = [];
610
+ let path = folder.path;
611
+ while (path && path.length >= folder.root.length) {
612
+ parts.unshift({ name: path === folder.root ? folder.root_label : path.split("/").pop() || path, path });
613
+ if (path === folder.root)
614
+ break;
615
+ path = path.slice(0, path.lastIndexOf("/")) || "/";
616
+ }
617
+ return parts;
618
+ }, [folder]);
619
+ const eligible = useMemo(() => (report?.findings ?? []).filter((f) => f.eligible).sort((a, b) => b.bytes - a.bytes), [report]);
620
+ const others = (report?.findings ?? []).filter((f) => !f.eligible && f.bytes > 0).sort((a, b) => b.bytes - a.bytes);
621
+ // With nothing to clear, open on the folders instead of an empty list (once,
622
+ // so a pane the user picked is never taken away).
623
+ const pickedPane = useRef(false);
624
+ useEffect(() => {
625
+ if (!pickedPane.current && report && eligible.length === 0)
626
+ setPane("folders");
627
+ }, [report, eligible.length]);
628
+ const safeItems = eligible.filter((f) => f.risk === "safe");
629
+ const freeable = report ? report.safe_bytes + report.review_bytes : 0;
630
+ const groups = useMemo(() => groupFindings(eligible), [eligible]);
631
+ const shown = showAll ? groups : groups.slice(0, VISIBLE_GROUPS);
632
+ const notes = showNotes ? others : others.slice(0, VISIBLE_NOTES);
633
+ const toggleGroup = (key) => setOpenGroups((prev) => {
634
+ const next = new Set(prev);
635
+ if (!next.delete(key))
636
+ next.add(key);
637
+ return next;
638
+ });
639
+ const internalActive = volumes.find((v) => v.mount_point === active)?.internal ?? active === "/";
640
+ const confirmMove = () => run(async () => {
641
+ const items = pending?.items ?? [];
642
+ setPending(null);
643
+ const result = await api.cleanupApply(items);
644
+ if (result.activity_id) {
645
+ setUndo({
646
+ id: result.activity_id,
647
+ text: `Moved to the ${TRASH}: ${upTo(result.moved_bytes)} on disk${result.skipped.length ? `; ${result.skipped.length} skipped` : ""}. ${trashFreesNothing(TRASH)}`,
648
+ });
649
+ }
650
+ else if (result.skipped.length) {
651
+ setError(result.skipped.map((s) => `${shortPath(s.path)}: ${s.reason}`).join("\n"));
652
+ }
653
+ api.volumes().then(setVolumes).catch(() => { });
654
+ loadFindings(true);
655
+ });
656
+ const undoMove = () => run(async () => {
657
+ if (!undo)
658
+ return;
659
+ await api.cleanupRestore(undo.id);
660
+ setUndo(null);
661
+ loadFindings(true);
662
+ });
663
+ const installers = volumes.filter((v) => v.disk_image);
664
+ const [ejecting, setEjecting] = useState(null);
665
+ const [ejectError, setEjectError] = useState(null);
666
+ const eject = async (v) => {
667
+ setEjecting(v.mount_point);
668
+ setEjectError(null);
669
+ try {
670
+ await api.eject(v.mount_point);
671
+ }
672
+ catch (e) {
673
+ setEjectError(`${v.name}: ${String(e)}`);
674
+ }
675
+ setEjecting(null);
676
+ api.volumes().then(setVolumes).catch(() => { });
677
+ };
678
+ const segments = (_jsx(SegmentedControl, { label: "Storage view", value: pane, options: [
679
+ { value: "findings", label: "Findings" },
680
+ { value: "folders", label: "Folders" },
681
+ { value: "changes", label: "Changes" },
682
+ { value: "duplicates", label: "Duplicates" },
683
+ ], onChange: (value) => {
684
+ pickedPane.current = true;
685
+ setPane(value);
686
+ } }));
687
+ if (pane === "duplicates") {
688
+ return (_jsxs("div", { className: "view st", children: [_jsx("div", { className: "st-bar", children: segments }), _jsx("div", { className: "st-scroll", tabIndex: 0, "aria-label": "Duplicates", children: _jsx(Duplicates, {}) })] }));
689
+ }
690
+ // Every row of the listing, then the rows the filters keep. Shares are of the whole listing.
691
+ const allRows = results ?? folder?.rows ?? [];
692
+ // A date choice made on an earlier scan does not apply once the scan has no dates.
693
+ const changed = hasDates ? changedFilter : "any";
694
+ const filtersOn = kindFilter !== "all" || minSize > 0 || changed !== "any";
695
+ // Search results were cut to the date range by the backend; a folder listing is cut here.
696
+ const listRange = results ? null : changedRange(changed, Math.floor(Date.now() / 1000));
697
+ let listUndated = 0;
698
+ const rows = filtersOn
699
+ ? allRows.filter((r) => {
700
+ if (r.summary || (kindFilter !== "all" && kindFilterOf(r) !== kindFilter) || r.bytes <= minSize)
701
+ return false;
702
+ if (!listRange)
703
+ return true;
704
+ const modified = r.modified ?? 0;
705
+ if (modified === 0) {
706
+ listUndated += 1;
707
+ return false;
708
+ }
709
+ return (listRange.since === undefined || modified >= listRange.since) && (listRange.before === undefined || modified < listRange.before);
710
+ })
711
+ : allRows;
712
+ const undated = changed === "any" ? 0 : results ? searchUndated : listUndated;
713
+ const largest = Math.max(1, ...rows.map((r) => r.bytes));
714
+ const total = Math.max(1, allRows.reduce((sum, r) => sum + r.bytes, 0));
715
+ const selectedRow = selected ? (allRows.find((r) => r.path === selected) ?? null) : null;
716
+ const selectedInfo = inspect && selectedRow && inspect.path === selectedRow.path ? inspect : null;
717
+ // Keys on the list: arrows and Home/End move the selection through the real items.
718
+ const onListKey = (e) => {
719
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key))
720
+ return;
721
+ const items = rows.filter(actionable);
722
+ if (items.length === 0)
723
+ return;
724
+ e.preventDefault();
725
+ const at = items.findIndex((r) => r.path === selected);
726
+ const last = items.length - 1;
727
+ const next = e.key === "Home" ? 0
728
+ : e.key === "End" ? last
729
+ : e.key === "ArrowDown" ? Math.min(at + 1, last)
730
+ : Math.max(at - 1, 0);
731
+ const target = items[next];
732
+ setSelected(target.path);
733
+ Array.from(e.currentTarget.querySelectorAll(".st-row")).find((el) => el.dataset.path === target.path)?.focus();
734
+ };
735
+ // Escape closes the inspector, unless a confirmation is open over it.
736
+ const onViewKey = (e) => {
737
+ if (e.key === "Escape" && selected && !itemTrash && !itemMove && !pending) {
738
+ e.preventDefault();
739
+ setSelected(null);
740
+ }
741
+ };
742
+ const clearFilters = () => {
743
+ setKindFilter("all");
744
+ setMinSize(0);
745
+ setChangedFilter("any");
746
+ };
747
+ const volumeList = volumes.filter((v) => !v.disk_image);
748
+ const localList = volumeList.filter((v) => v.kind === "local");
749
+ const networkList = volumeList.filter((v) => v.kind === "network");
750
+ const activeVolume = volumeList.find((v) => v.mount_point === active);
751
+ const ActiveIcon = activeVolume?.kind === "network" ? Network : activeVolume && !activeVolume.internal ? Usb : HardDrive;
752
+ const card = health?.drives.find((d) => d.mount === active);
753
+ const healthShown = healthOpen === active;
754
+ const toolAvailable = health?.tool_available ?? true;
755
+ const scanText = folder && scanning
756
+ ? `Updated ${ago(folder.scanned_at)} · Refreshing…`
757
+ : scanning
758
+ ? "Scanning…"
759
+ : folder
760
+ ? `Updated ${ago(folder.scanned_at)}${folder.from_snapshot ? " (saved)" : ""}`
761
+ : "";
762
+ const scopeText = internalActive ? "Home folder" : "Whole drive";
763
+ const changes = growth?.available ? [...growth.grown, ...growth.shrunk] : [];
764
+ const openFromChange = (path) => {
765
+ setPane("folders");
766
+ open(path);
767
+ };
768
+ const headline = !report ? "Looking for what is safe to clear…" : freeable > 0 ? `${upTo(freeable)} eligible to move` : "Nothing obvious to clear";
769
+ return (_jsxs("div", { className: "view st", onKeyDown: onViewKey, children: [_jsxs("section", { className: "st-volume", "aria-label": "Volume", children: [_jsxs("div", { className: "st-volume-line", children: [_jsx(ActiveIcon, { size: 15, strokeWidth: 1.75, className: "st-volume-icon", "aria-hidden": "true" }), volumeList.length > 0 ? (_jsx(Select, { className: "st-select", label: "Volume", value: active, disabled: busy, onChange: chooseVolume, options: [
770
+ ...localList.map((v) => ({
771
+ value: v.mount_point,
772
+ label: v.internal ? `${v.name} · Startup` : v.name,
773
+ })),
774
+ // Select has no optgroup (ui.d.ts:85): a disabled heading row keeps network volumes apart.
775
+ ...(networkList.length > 0 ? [{ value: "__network__", label: "Network", disabled: true }] : []),
776
+ ...networkList.map((v) => ({
777
+ value: v.mount_point,
778
+ disabled: v.state !== "ok",
779
+ label: v.state !== "ok"
780
+ ? `${v.name} · Not responding`
781
+ : v.available_bytes !== null
782
+ ? `${v.name} · ${bytes(v.available_bytes)} free`
783
+ : v.name,
784
+ })),
785
+ ] })) : (_jsx("span", { className: "strong", children: "No volumes" })), activeVolume?.kind === "network" && _jsx("span", { className: "st-net-label", children: "Network" }), activeVolume && (_jsx("span", { className: "st-free", children: activeVolume.state !== "ok" ? (_jsx("span", { className: "strong", children: "Not responding" })) : activeVolume.available_bytes !== null && activeVolume.total_bytes !== null ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "strong", children: [bytes(activeVolume.available_bytes), " free"] }), _jsxs("span", { className: "muted", children: ["of ", bytes(activeVolume.total_bytes)] }), reclaimableNote(activeVolume) && _jsxs("span", { className: "muted", children: ["\u00B7 ", reclaimableNote(activeVolume)] })] })) : (_jsx("span", { className: "muted", children: "Capacity unknown" })) })), _jsxs(Button, { size: "sm", onClick: () => scanVolume(active), disabled: busy || scanning || activeVolume?.state === "not responding", children: [_jsx(RefreshCw, { size: 12 }), " Rescan"] })] }), activeVolume && activeVolume.total_bytes !== null && activeVolume.available_bytes !== null && activeVolume.total_bytes > 0 && (_jsx(Bar, { fraction: 1 - activeVolume.available_bytes / activeVolume.total_bytes, height: 6 })), (card || health) && (_jsxs("div", { className: "st-volume-foot", children: [_jsx(DriveHealthLine, { card: card, toolAvailable: toolAvailable }), card && (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setHealthOpen(healthShown ? null : active), "aria-expanded": healthShown, children: healthShown ? "Hide drive health" : "Drive health" }))] }))] }), _jsxs("div", { className: "st-meta", children: [_jsx("span", { className: "st-meta-text", children: [scopeText, scanText].filter(Boolean).join(" · ") }), installers.length > 0 && _jsx("span", { className: "st-meta-text", children: "Mounted installers" }), installers.map((v) => (_jsxs("span", { className: "st-chip", title: v.mount_point, children: [_jsx("span", { className: "st-chip-name", children: v.name }), _jsx("span", { children: bytes(v.total_bytes ?? 0) }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => eject(v), disabled: ejecting === v.mount_point, children: "Eject" })] }, v.mount_point))), ejectError && _jsx("span", { className: "error", children: ejectError })] }), _jsx("div", { className: "st-bar", children: segments }), activeVolume?.kind === "network" && !scanning && !folder && (_jsx(Notice, { action: activeVolume.state === "ok" ? (_jsx(Button, { size: "sm", onClick: () => scanVolume(active), disabled: busy, children: "Scan anyway" })) : undefined, children: activeVolume.state === "ok"
786
+ ? "This is a network volume. Scanning it reads every folder over the network."
787
+ : "Not responding. The share did not answer in time, so nothing has been read from it." })), error && (_jsx(Notice, { tone: "bad", children: _jsx("span", { className: "ck-lines", children: error }) })), compressNote && (_jsx(Notice, { action: (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => setCompressNote(null), children: "Dismiss" })), children: compressNote })), undo && (_jsx(Notice, { action: (_jsxs(Button, { size: "sm", variant: "ghost", onClick: undoMove, disabled: busy, children: [_jsx(Undo2, { size: 12 }), " Undo"] })), children: _jsx("span", { title: ESTIMATE, children: undo.text }) })), folder?.needs_access && access !== null && access !== "granted" && !results && (_jsx(Notice, { tone: "warn", action: (_jsx(Button, { size: "sm", onClick: () => api.openFullDiskAccess(), children: isWindows ? "Open File system settings" : "Open Full Disk Access" })), children: isWindows ? "Some folders couldn't be read. Allow file system access in Windows Settings to include them." : "Some folders couldn't be read. Grant Full Disk Access to include them." })), folder?.incomplete && !results && !(folder.needs_access && access !== null && access !== "granted") && (_jsxs("div", { className: "st-sub", children: [folder.unread
788
+ ? `${folder.unread.toLocaleString()} ${folder.unread === 1 ? "item" : "items"} couldn't be read`
789
+ : "Some items couldn't be read", " ", "(long paths, access denied, or the scan's entry limit)"] })), folder && !folder.needs_access && folder.limited && !results && (_jsx("div", { className: "st-sub", children: "This is a very large folder, so sizes may be a little low." })), _jsxs("div", { className: "st-scroll", tabIndex: 0, "aria-label": "Storage content", children: [healthShown && (_jsx(DriveHealthPanel, { card: card, alerts: health?.alerts ?? [], toolAvailable: toolAvailable })), pane === "findings" &&
790
+ (internalActive ? (_jsxs("section", { className: "st-card", "aria-label": "Findings", children: [_jsxs("div", { className: "st-card-head", children: [_jsxs("div", { children: [_jsxs("h2", { className: "st-h", title: freeable > 0 ? ESTIMATE : undefined, children: [_jsx(ShieldCheck, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), headline] }), _jsxs("p", { className: "st-sub", children: ["Home", report ? ` · scanned ${ago(report.scanned_at)}` : "", report && findingsBusy ? " · Refreshing…" : "", report && !findingsBusy && isStale(report.scanned_at) ? " · saved list, may be out of date" : ""] })] }), safeItems.length > 1 && (_jsxs(Button, { size: "sm", variant: "primary", onClick: () => setPending({ label: "Safe items", items: safeItems }), disabled: busy, children: ["Clear all safe (", upTo(report?.safe_bytes ?? 0), ")"] }))] }), _jsx(ChromeSnapshotsLine, { info: report?.chrome_snapshots }), shown.map((g) => {
791
+ const expanded = openGroups.has(g.key);
792
+ const count = groupCount(g);
793
+ return (_jsxs("div", { className: "st-finding", children: [_jsxs(Button, { variant: "ghost", className: "st-disclose", onClick: () => toggleGroup(g.key), "aria-expanded": expanded, children: [expanded ? _jsx(ChevronDown, { size: 14, "aria-hidden": "true" }) : _jsx(ChevronRight, { size: 14, "aria-hidden": "true" }), _jsxs("span", { className: "st-finding-text", children: [_jsxs("span", { className: "st-finding-title", children: [_jsxs("span", { className: "strong", children: [g.label, count && _jsxs("span", { className: "muted", children: [" \u00B7 ", count] })] }), _jsx(Badge, { tone: g.risk === "safe" ? "ok" : "warn", children: g.risk === "safe" ? "Safe" : "Review" })] }), _jsx("span", { className: "st-reason", children: g.reason })] })] }), _jsxs("span", { className: "st-measure", children: [_jsx("span", { className: "st-size", title: ESTIMATE, children: upTo(g.bytes, g.partial) }), _jsxs(Button, { size: "sm", onClick: () => setPending({ label: g.label, items: g.items }), disabled: busy, children: ["Move to ", TRASH] })] }), expanded && (_jsx("div", { className: "st-sub-items", children: g.items.map((f) => (_jsxs("div", { className: "st-sub-item", children: [_jsxs("span", { children: [g.items.length > 1 && _jsx("span", { children: f.name }), _jsx("span", { className: "st-path", style: { display: "block" }, children: shortPath(f.path) })] }), _jsx("span", { className: "st-size", title: ESTIMATE, children: upTo(f.bytes, f.partial) }), _jsxs(Button, { size: "sm", variant: "ghost", onClick: () => setPending({ label: f.name, items: [f] }), disabled: busy, children: ["Move to ", TRASH] })] }, f.id))) }))] }, g.key));
794
+ }), groups.length > VISIBLE_GROUPS && (_jsx(Button, { variant: "ghost", size: "sm", className: "st-more-btn", onClick: () => setShowAll(!showAll), children: showAll ? "Show fewer" : `Show ${groups.length - VISIBLE_GROUPS} more` })), others.length > 0 && (_jsxs("div", { className: "st-notes", children: [_jsx("div", { className: "st-notes-head", children: "Notes" }), notes.map((f) => {
795
+ const Icon = noteIcon(f);
796
+ return (_jsxs("div", { className: "st-note", title: f.path, children: [_jsx(Icon, { size: 12, strokeWidth: 1.75, "aria-hidden": "true" }), _jsxs("span", { className: "st-note-text", children: [f.rule_name || f.name, " \u00B7 ", f.reason] }), _jsx("span", { className: "st-note-size", children: bytes(f.bytes) })] }, f.id));
797
+ }), others.length > VISIBLE_NOTES && (_jsx(Button, { variant: "ghost", size: "sm", className: "st-more-btn", onClick: () => setShowNotes(!showNotes), children: showNotes ? "Show fewer notes" : `Show ${others.length - VISIBLE_NOTES} more notes` }))] }))] })) : (_jsx(EmptyState, { icon: _jsx(ShieldCheck, { size: 22 }), title: "Findings cover your home folder", action: _jsx(Button, { size: "sm", onClick: () => setPane("folders"), children: "Browse this drive" }) }))), pane === "folders" && (_jsxs("section", { className: "st-card", "aria-label": "Folders", children: [results ? (_jsx("div", { className: "st-sub", children: plural(results.length, "match") })) : (_jsx("nav", { className: "st-crumbs", "aria-label": "Folder path", children: crumbs.map((c, i) => (_jsxs("span", { children: [i > 0 && _jsx(ChevronRight, { size: 11, className: "st-crumb-sep", "aria-hidden": "true" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => open(c.path), disabled: busy, "aria-current": i === crumbs.length - 1 ? "page" : undefined, children: c.name })] }, c.path))) })), _jsx(Input, { className: "search st-search", type: "search", placeholder: "Search files", "aria-label": "Search files", value: query, onChange: (e) => setQuery(e.target.value) }), isWindows && results !== null && searchSource === "windows_search" && (_jsx("p", { className: "st-coverage", children: "Names come from Windows Search. It finds only files in folders Windows indexes, so a folder it does not index is not found. Windows Indexing Options sets what it covers." })), isWindows && results !== null && searchSource === "saved_scan" && (_jsx("p", { className: "st-coverage", children: changedFilter !== "any"
798
+ ? "Names come from the last scan of this folder: the Changed filter needs modified dates, which Windows Search results do not carry."
799
+ : "Names come from the last scan of this folder, because Windows Search did not answer (it is stopped, or it does not index this folder). Windows Indexing Options sets what it covers." })), _jsxs("div", { className: "st-filters", role: "group", "aria-label": "Filters", children: [_jsx(Select, { className: "st-select", label: "Kind", value: kindFilter, onChange: setKindFilter, options: KIND_FILTERS }), _jsx(Select, { className: "st-select", label: "Size", value: String(minSize), onChange: (v) => setMinSize(Number(v)), options: SIZE_FILTERS.map((s) => ({ value: String(s.value), label: s.label })) }), _jsx(Select, { className: "st-select", label: "Changed", value: changed, disabled: !hasDates, onChange: setChangedFilter, options: CHANGED_FILTERS }), filtersOn && (_jsx(Button, { size: "sm", variant: "ghost", onClick: clearFilters, children: "Clear filters" })), allRows.length > 0 && (_jsxs("span", { className: "st-count", "aria-live": "polite", children: [rows.length.toLocaleString(), " of ", allRows.length.toLocaleString(), " shown", undated > 0 && ` · ${undated.toLocaleString()} ${undated === 1 ? "item has" : "items have"} no date`] }))] }), !folder && (opening ? _jsx("div", { className: "muted", children: "Loading\u2026" }) : scanning && _jsx("div", { className: "muted", children: "Scanning\u2026" })), folder && allRows.length === 0 && !busy && !scanning && (_jsx(EmptyState, { icon: _jsx(FolderIcon, { size: 22 }), title: results ? "No matches" : "This folder is empty" })), allRows.length > 0 && rows.length === 0 && (_jsx(EmptyState, { icon: _jsx(FolderIcon, { size: 22 }), title: "No items match these filters", action: _jsx(Button, { size: "sm", onClick: clearFilters, children: "Clear filters" }) })), rows.length > 0 && (_jsxs("div", { className: "st-split", children: [_jsxs("div", { className: "st-split-main", children: [!results && _jsx(Treemap, { rows: rows, select: setSelected, open: open, finder: showInFinder, menu: itemMenu.open }), _jsx("div", { className: "st-list", onKeyDown: onListKey, children: rows.map((r) => {
800
+ const kind = KINDS[kindOf(r.path, r.is_dir)];
801
+ const pct = (r.bytes / total) * 100;
802
+ const isSelected = selected === r.path;
803
+ return (_jsxs("div", { "data-path": r.path, className: `st-row${actionable(r) ? " clickable" : ""}${isSelected ? " selected" : ""}`, tabIndex: actionable(r) ? 0 : undefined, "aria-current": isSelected ? "true" : undefined, onClick: () => {
804
+ if (actionable(r))
805
+ setSelected(r.path);
806
+ }, onDoubleClick: () => {
807
+ // A folder opens in the list; a file (or a result) opens in Finder.
808
+ if (!actionable(r))
809
+ return;
810
+ if (r.is_dir && !results)
811
+ open(r.path);
812
+ else
813
+ showInFinder(r.path);
814
+ }, onKeyDown: (e) => onRowKey(e, r), onContextMenu: (e) => (actionable(r) ? itemMenu.open(e, r) : e.preventDefault()), title: `${r.path}\n${kind.label}`, children: [_jsxs("span", { className: "st-name", children: [_jsx("i", { className: "st-dot", style: { background: kind.color } }), r.is_dir ? _jsx(FolderIcon, { size: 13, "aria-hidden": "true" }) : _jsx(File, { size: 13, "aria-hidden": "true" }), _jsx("span", { children: results ? shortPath(r.path) : r.name })] }), _jsx(Bar, { fraction: r.bytes / largest, color: kind.color }), _jsx("span", { className: "st-pct", children: pct >= 1 ? `${Math.round(pct)}%` : "<1%" }), _jsx("span", { className: "st-size", children: bytes(r.bytes) }), actionable(r) ? (_jsx(IconButton, { label: `Actions for ${r.name}`, "aria-haspopup": "menu", onClick: (e) => {
815
+ e.stopPropagation();
816
+ itemMenu.open(e, r);
817
+ }, onKeyDown: (e) => {
818
+ // Arrow keys pass through so the list can move the selection from here too.
819
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp")
820
+ e.stopPropagation();
821
+ }, onDoubleClick: (e) => e.stopPropagation(), children: _jsx(MoreHorizontal, { size: 15, "aria-hidden": "true" }) })) : (_jsx("span", {}))] }, r.path));
822
+ }) })] }), selectedRow && (_jsx(ItemInspector, { row: selectedRow, info: selectedInfo, parentBytes: results ? null : total, network: onNetwork(selectedRow.path, volumes), busy: busy, onClose: () => setSelected(null), onOpen: open, onReveal: showInFinder, onCopy: copyText, onMove: moveItem, onTrash: askTrash, onCompress: compressOn && /\.(jpe?g|png|mp4|m4v|mov)$/i.test(selectedRow.path) ? compressItem : undefined }))] }))] })), pane === "changes" &&
823
+ (!internalActive ? (_jsx(EmptyState, { icon: _jsx(Info, { size: 22 }), title: "Changes are tracked for your home folder" })) : changes.length === 0 ? (_jsx(EmptyState, { icon: _jsx(Info, { size: 22 }), title: growth?.available ? "No changes since the last scan" : "No comparison yet", children: growth?.available ? null : (growth?.reason ?? "Changes appear after a second scan.") })) : (_jsxs("section", { className: "st-card", "aria-label": "Changes", children: [_jsx("div", { className: "st-card-head", children: _jsxs("div", { children: [_jsx("h2", { className: "st-h", children: "Home changes" }), _jsxs("p", { className: "st-sub", children: ["Since last scan", growth?.since ? ` (${new Date(growth.since * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric" })})` : ""] })] }) }), _jsx("div", { className: "st-changes", children: changes.map((c) => (_jsxs(Button, { variant: "ghost", className: "st-change", onClick: () => openFromChange(c.path), disabled: busy, title: c.path, children: [_jsx("span", { children: shortPath(c.path) }), _jsx("span", { className: `st-delta ${c.bytes > 0 ? "up" : "down"}`, children: signedBytes(c.bytes) })] }, c.path))) })] })))] }), itemMenu.element, pending && (_jsx(ConfirmDialog, { title: pending.items.length === 1 ? `Move ${pending.items[0].name} to the ${TRASH}?` : `Move ${pending.items.length} items to the ${TRASH}?`, description: `${pending.items.length > 1 ? `${pending.label}: ` : ""}${plural(pending.items.length, "item")}, ${upTo(pending.items.reduce((s, f) => s + f.bytes, 0), pending.items.some((f) => f.partial))} on disk will move to the ${TRASH} (${ESTIMATE}). Nothing is deleted: you can put it back, or empty the ${TRASH} yourself. ${trashFreesNothing(TRASH)}`, confirmLabel: `Move to ${TRASH}`, onConfirm: confirmMove, onCancel: () => setPending(null) })), itemTrash && (_jsx(ConfirmDialog, { title: `Move ${itemTrash.row.name} to the ${TRASH}?`, description: `${shortPath(itemTrash.row.path)}, ${upTo(itemTrash.row.bytes)} on disk will move to the ${TRASH} (${ESTIMATE}). Nothing is deleted: you can put it back, or empty the ${TRASH} yourself. ${trashFreesNothing(TRASH)}${onNetwork(itemTrash.row.path, volumes) ? " Network volumes usually delete at once instead of using the Trash." : ""}`, confirmLabel: `Move to ${TRASH}`, onConfirm: confirmItemTrash, onCancel: () => setItemTrash(null) })), itemMove && (_jsx(ConfirmDialog, { title: `Copy ${itemMove.row.name} to another drive?`, description: `${shortPath(itemMove.destination)} is on another drive, so ${itemMove.row.name} is copied to ${shortPath(itemMove.target)}, and the original moves to the ${TRASH}. ${trashFreesNothing(TRASH)}`, confirmLabel: `Copy and move to ${TRASH}`, onConfirm: confirmItemMove, onCancel: () => setItemMove(null) }))] }));
824
+ }
825
+ const W = 600;
826
+ const H = 96;
827
+ /**
828
+ * The current folder as one level of tiles, coloured by kind. Click a tile to
829
+ * select it for the inspector; double-click opens a folder or reveals a file;
830
+ * right-click for the item menu.
831
+ */
832
+ function Treemap({ rows, select, open, finder, menu, }) {
833
+ const items = rows.filter((r) => r.bytes > 0).slice(0, 40);
834
+ if (items.length === 0)
835
+ return null;
836
+ const tiles = squarify(items.map((r) => r.bytes), W, H);
837
+ return (_jsx("svg", { className: "st-treemap", viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": "Folder contents by size", children: items.map((r, i) => {
838
+ const t = tiles[i];
839
+ const kind = KINDS[kindOf(r.path, r.is_dir)];
840
+ const chars = Math.floor((t.w - 8) / 6.4);
841
+ return (_jsxs("g", { onClick: () => {
842
+ if (actionable(r))
843
+ select(r.path);
844
+ }, onDoubleClick: () => {
845
+ // A folder opens in the list; a file opens in Finder.
846
+ if (!actionable(r))
847
+ return;
848
+ if (r.is_dir)
849
+ open(r.path);
850
+ else
851
+ finder(r.path);
852
+ }, onContextMenu: (e) => (actionable(r) ? menu(e, r) : e.preventDefault()), style: { cursor: actionable(r) ? "pointer" : "default" }, children: [_jsx("title", { children: `${r.name}: ${bytes(r.bytes)}` }), _jsx("rect", { x: t.x + 0.5, y: t.y + 0.5, width: Math.max(t.w - 1, 0), height: Math.max(t.h - 1, 0), rx: 2, fill: kind.color, opacity: 0.88 }), t.w > 46 && t.h > 16 && chars > 3 && (_jsx("text", { x: t.x + 4, y: t.y + 13, fontSize: 11, fill: "#fff", style: { pointerEvents: "none" }, children: r.name.length > chars ? r.name.slice(0, chars - 1) + "…" : r.name }))] }, r.path));
853
+ }) }));
854
+ }
855
+ /** MIME type of an image the inspector previews; undefined for every other kind. HEIC, BMP and TIFF are not previewed. */
856
+ const PREVIEW_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" };
857
+ const previewType = (path) => PREVIEW_TYPES[/\.([^./\\]+)$/.exec(path)?.[1]?.toLowerCase() ?? ""];
858
+ /**
859
+ * The image itself, for a selected image file only. Nothing is read for other
860
+ * kinds, and nothing on a network volume. The object URL is revoked when the
861
+ * selection changes or the inspector closes.
862
+ */
863
+ function ItemPreview({ row, network }) {
864
+ const type = row.is_dir || network ? undefined : previewType(row.path);
865
+ const [loaded, setLoaded] = useState(null);
866
+ useEffect(() => {
867
+ if (!type)
868
+ return;
869
+ let live = true;
870
+ let url = null;
871
+ api
872
+ .storagePreview(row.path)
873
+ .then((buffer) => {
874
+ if (!live)
875
+ return;
876
+ url = URL.createObjectURL(new Blob([buffer], { type }));
877
+ setLoaded({ path: row.path, url, reason: null });
878
+ })
879
+ .catch((e) => live && setLoaded({ path: row.path, url: null, reason: String(e) }));
880
+ return () => {
881
+ live = false;
882
+ if (url)
883
+ URL.revokeObjectURL(url);
884
+ setLoaded(null);
885
+ };
886
+ }, [row.path, type]);
887
+ if (row.is_dir)
888
+ return null;
889
+ if (network && previewType(row.path))
890
+ return _jsx("div", { className: "st-sub", children: "No preview on network drives" });
891
+ if (!type)
892
+ return null;
893
+ const current = loaded?.path === row.path ? loaded : null;
894
+ if (!current)
895
+ return _jsx("div", { className: "st-sub", children: "Reading preview\u2026" });
896
+ if (current.url)
897
+ return _jsx("img", { className: "st-preview-image", src: current.url, alt: `Preview of ${row.name}` });
898
+ return _jsx("div", { className: "st-sub", children: current.reason });
899
+ }
900
+ /** Facts about one selected item, with the same actions and confirmations as its right-click menu. */
901
+ function ItemInspector({ row, info, parentBytes, network, busy, onClose, onOpen, onReveal, onCopy, onMove, onTrash, onCompress, }) {
902
+ const kind = KINDS[kindOf(row.path, row.is_dir)];
903
+ const data = info?.data ?? null;
904
+ // Unknown reads as "Unknown", never as zero; while reading, "Reading…".
905
+ const field = (pick) => (!info ? "Reading…" : data ? pick(data) : "Unknown");
906
+ const logical = data?.logical_bytes ?? null;
907
+ const difference = logical === null ? null : row.bytes - logical;
908
+ const share = parentBytes ? (row.bytes / parentBytes) * 100 : null;
909
+ return (_jsxs("aside", { className: "st-inspector", "aria-label": "Item details", children: [_jsxs("div", { className: "st-inspector-head", children: [_jsxs("span", { className: "st-inspector-title", children: [row.is_dir ? _jsx(FolderIcon, { size: 14, "aria-hidden": "true" }) : _jsx(File, { size: 14, "aria-hidden": "true" }), _jsx("span", { children: row.name })] }), _jsxs(Button, { size: "sm", variant: "ghost", onClick: onClose, children: [_jsx(X, { size: 12, "aria-hidden": "true" }), " Close"] })] }), _jsxs("div", { className: "st-inspector-path-line", children: [_jsx("span", { className: "st-inspector-path", children: row.path }), _jsxs(Button, { size: "sm", onClick: () => onCopy(row.path), children: [_jsx(Copy, { size: 12, "aria-hidden": "true" }), " Copy path"] })] }), _jsx(ItemPreview, { row: row, network: network }), _jsxs("dl", { className: "st-facts", children: [_jsx("dt", { children: "Kind" }), _jsx("dd", { children: kind.label }), _jsx("dt", { children: "Logical size" }), _jsx("dd", { children: row.is_dir ? (info ? "Unknown" : "Reading…") : field((d) => (d.logical_bytes == null ? "Unknown" : bytes(d.logical_bytes))) }), _jsx("dt", { children: "Allocated size" }), _jsx("dd", { children: bytes(row.bytes) }), difference !== null && Math.abs(difference) >= 1_000_000 && (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Allocated minus logical" }), _jsx("dd", { children: signedBytes(difference) })] })), share !== null && (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Share of this folder" }), _jsx("dd", { children: share >= 1 ? `${Math.round(share)}%` : "<1%" })] })), row.is_dir && (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Files directly inside" }), _jsx("dd", { children: field((d) => (d.files == null ? "Unknown" : d.files.toLocaleString())) }), _jsx("dt", { children: "Folders directly inside" }), _jsx("dd", { children: field((d) => (d.folders == null ? "Unknown" : d.folders.toLocaleString())) })] })), _jsx("dt", { children: "Created" }), _jsx("dd", { children: field((d) => (d.created_at == null ? "Unknown" : localTime(d.created_at))) }), _jsx("dt", { children: "Modified" }), _jsx("dd", { children: info || !row.modified
910
+ ? field((d) => (d.modified_at == null ? "Unknown" : localTime(d.modified_at)))
911
+ : `${localTime(row.modified)}${row.is_dir ? " (newest inside)" : ""}` })] }), info?.error && _jsx("div", { className: "st-sub", children: info.error }), _jsxs("div", { className: "st-inspector-actions", children: [row.is_dir && (_jsxs(Button, { size: "sm", onClick: () => onOpen(row.path), disabled: busy, children: [_jsx(FolderOpen, { size: 12, "aria-hidden": "true" }), " Open folder"] })), _jsxs(Button, { size: "sm", onClick: () => onReveal(row.path), children: [row.is_dir ? "Open" : "Reveal", " in ", FILE_MANAGER] }), _jsxs(Button, { size: "sm", onClick: () => onMove(row), disabled: busy, children: [_jsx(FolderInput, { size: 12, "aria-hidden": "true" }), " Move to\u2026"] }), onCompress && (_jsx(Button, { size: "sm", onClick: () => onCompress(row), disabled: busy, title: "Writes a smaller copy in a Pulse Compressed folder beside it. The original is not changed.", children: "Compress a copy" })), _jsxs(Button, { size: "sm", variant: "ghost", onClick: () => onTrash(row), disabled: busy, children: [_jsx(Trash2, { size: 12, "aria-hidden": "true" }), " Move to ", TRASH] })] }), _jsxs("p", { className: "st-help", children: ["Allocated is the disk space the scan counts for this item. Logical is the file's own length, which can be smaller for sparse or compressed files. Folders have no logical size here, because it is not added up. Removing this item could free up to its allocated size once the ", TRASH, " is emptied; hard links and clones can make the real gain smaller. This view does not show how the volume's free space changed. Folder counts cover direct children only."] })] }));
912
+ }
913
+ export function Bar({ fraction, height = 4, color }) {
914
+ const f = Math.min(Math.max(fraction, 0), 1);
915
+ return (_jsx("div", { className: "bar", style: { height }, children: _jsx("i", { style: { width: `${f * 100}%`, background: color ?? tone(f) } }) }));
916
+ }