@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,255 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { invoke } from "@tauri-apps/api/core";
4
+ import { Button, ConfirmDialog, Input } from "@rightkit/react-shell/react";
5
+ import { ChevronDown, ChevronRight, Search } from "lucide-react";
6
+ import { clearVisibleInterval, visibleInterval } from "../visible.js";
7
+ import { chatKindName } from "../lib/chatKind.js";
8
+ /** The hub's web view is destroyed on window close, so the chosen chat and the unsent draft are kept here. */
9
+ const KEPT_CHAT = "pulse.hub.chat.selected";
10
+ const KEPT_DRAFT = "pulse.hub.chat.draft";
11
+ const readKept = (key) => {
12
+ try {
13
+ return localStorage.getItem(key);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ };
19
+ const writeKept = (key, value) => {
20
+ try {
21
+ if (value)
22
+ localStorage.setItem(key, value);
23
+ else
24
+ localStorage.removeItem(key);
25
+ }
26
+ catch {
27
+ // Storage unavailable: the draft is lost on close, as before.
28
+ }
29
+ };
30
+ /** The receipt states that mean the chat's side took the message, so the draft can go. */
31
+ const TOOK_IT = new Set(["delivered", "sent", "queued", "held"]);
32
+ /** What each receipt state means, in plain words. The receipt's own detail follows it. */
33
+ const RECEIPT_WORDS = {
34
+ delivered: "Delivered.",
35
+ sent: "Sent, no verdict. The chat raised no objection, but it has not confirmed it read the message.",
36
+ queued: "Queued. It shows at the chat's next turn; reading is not confirmed.",
37
+ held: "Held. It will not be read until its owner continues or approves it.",
38
+ refused: "Refused.",
39
+ unsupported: "Unsupported. That chat cannot take messages this way.",
40
+ unknown: "Unknown. It may or may not have arrived. Check the inbox, then send once more.",
41
+ };
42
+ /** "42s", "5min", "3h" or "2d" since `ms`. */
43
+ function ageOf(ms) {
44
+ const secs = Math.max(0, Math.floor((Date.now() - ms) / 1000));
45
+ if (secs < 60)
46
+ return `${secs}s`;
47
+ if (secs < 3600)
48
+ return `${Math.floor(secs / 60)}min`;
49
+ if (secs < 86400)
50
+ return `${Math.floor(secs / 3600)}h`;
51
+ return `${Math.floor(secs / 86400)}d`;
52
+ }
53
+ function lastSeen(ms) {
54
+ return ms ? `${ageOf(ms)} ago` : "not seen";
55
+ }
56
+ function plural(count, word) {
57
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
58
+ }
59
+ function linkLine(link) {
60
+ if (link.online)
61
+ return `${link.device}: reached ${link.lastOkMs ? ageOf(link.lastOkMs) : "just now"} ago`;
62
+ const since = link.lastOkMs ? `, last reached ${ageOf(link.lastOkMs)} ago` : ", never reached";
63
+ return `${link.device}: offline${since}`;
64
+ }
65
+ /** Chats grouped by computer: this computer first, then the linked ones in the order given. */
66
+ function groupByDevice(chats) {
67
+ const groups = new Map();
68
+ for (const chat of chats) {
69
+ const rows = groups.get(chat.device) ?? [];
70
+ rows.push(chat);
71
+ groups.set(chat.device, rows);
72
+ }
73
+ const isLocal = (rows) => (rows.some((c) => c.local) ? 1 : 0);
74
+ return Array.from(groups.entries()).sort(([, a], [, b]) => isLocal(b) - isLocal(a));
75
+ }
76
+ export function Chat() {
77
+ const [status, setStatus] = useState(null);
78
+ const [liveError, setLiveError] = useState(null);
79
+ const [inbox, setInbox] = useState(null);
80
+ const [inboxOpen, setInboxOpen] = useState(false);
81
+ const [peers, setPeers] = useState(null);
82
+ const [peersError, setPeersError] = useState(null);
83
+ const [query, setQuery] = useState("");
84
+ const [selectedId, setSelectedId] = useState(() => readKept(KEPT_CHAT));
85
+ const [draft, setDraft] = useState(() => readKept(KEPT_DRAFT) ?? "");
86
+ const [sending, setSending] = useState(false);
87
+ const [receipt, setReceipt] = useState(null);
88
+ const [sendError, setSendError] = useState(null);
89
+ const [linkDevice, setLinkDevice] = useState("");
90
+ const [linkSsh, setLinkSsh] = useState("");
91
+ const [linkPulse, setLinkPulse] = useState("");
92
+ const [linking, setLinking] = useState(false);
93
+ const [linkNote, setLinkNote] = useState(null);
94
+ const [linkError, setLinkError] = useState(null);
95
+ const [unlinking, setUnlinking] = useState(null);
96
+ // Set while the section is shown; a late answer after it closes is dropped.
97
+ const alive = useRef(false);
98
+ // One chat listing at a time: linked computers answer over ssh, which can take seconds.
99
+ const peersBusy = useRef(false);
100
+ useEffect(() => {
101
+ alive.current = true;
102
+ return () => {
103
+ alive.current = false;
104
+ };
105
+ }, []);
106
+ const refreshLive = useCallback(() => {
107
+ invoke("chat_status")
108
+ .then((value) => {
109
+ if (!alive.current)
110
+ return;
111
+ setStatus(value);
112
+ setLiveError(null);
113
+ })
114
+ .catch((e) => {
115
+ if (alive.current)
116
+ setLiveError(String(e));
117
+ });
118
+ invoke("chat_inbox")
119
+ .then((value) => {
120
+ if (alive.current)
121
+ setInbox(value);
122
+ })
123
+ .catch((e) => {
124
+ if (alive.current)
125
+ setLiveError(String(e));
126
+ });
127
+ }, []);
128
+ const refreshPeers = useCallback(() => {
129
+ if (peersBusy.current)
130
+ return;
131
+ peersBusy.current = true;
132
+ invoke("chat_peers")
133
+ .then((list) => {
134
+ if (!alive.current)
135
+ return;
136
+ setPeers(list);
137
+ setPeersError(null);
138
+ })
139
+ .catch((e) => {
140
+ if (alive.current)
141
+ setPeersError(String(e));
142
+ })
143
+ .finally(() => {
144
+ peersBusy.current = false;
145
+ });
146
+ }, []);
147
+ // Local state every 3 seconds (as the other pages poll); linked computers every 10 seconds.
148
+ // The timers run only while this section is shown.
149
+ useEffect(() => {
150
+ refreshLive();
151
+ const timer = visibleInterval(refreshLive, 3000);
152
+ return () => clearVisibleInterval(timer);
153
+ }, [refreshLive]);
154
+ useEffect(() => {
155
+ refreshPeers();
156
+ const timer = visibleInterval(refreshPeers, 30000);
157
+ return () => clearVisibleInterval(timer);
158
+ }, [refreshPeers]);
159
+ const list = peers ?? [];
160
+ const q = query.trim().toLowerCase();
161
+ const visible = q
162
+ ? list.filter((c) => [c.name, c.device, c.kind, c.folder, c.state].join(" ").toLowerCase().includes(q))
163
+ : list;
164
+ useEffect(() => writeKept(KEPT_CHAT, selectedId), [selectedId]);
165
+ useEffect(() => writeKept(KEPT_DRAFT, draft), [draft]);
166
+ const chosen = list.find((c) => c.id === selectedId) ?? null;
167
+ const canSend = chosen !== null && draft.trim() !== "" && !sending;
168
+ const send = () => {
169
+ if (!chosen || !canSend)
170
+ return;
171
+ const text = draft;
172
+ setSending(true);
173
+ setSendError(null);
174
+ invoke("chat_send", { target: chosen.id, text })
175
+ .then((value) => {
176
+ if (!alive.current)
177
+ return;
178
+ setReceipt(value);
179
+ // Keep the draft when the chat did not take it, so it can be sent again.
180
+ if (TOOK_IT.has(value.state))
181
+ setDraft((current) => (current === text ? "" : current));
182
+ })
183
+ .catch((e) => {
184
+ if (alive.current)
185
+ setSendError(String(e));
186
+ })
187
+ .finally(() => {
188
+ if (alive.current)
189
+ setSending(false);
190
+ });
191
+ };
192
+ const onDraftKey = (e) => {
193
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
194
+ e.preventDefault();
195
+ send();
196
+ }
197
+ };
198
+ const canLink = linkDevice.trim() !== "" && linkSsh.trim() !== "" && !linking;
199
+ const linkComputer = () => {
200
+ if (!canLink)
201
+ return;
202
+ setLinking(true);
203
+ setLinkError(null);
204
+ setLinkNote(null);
205
+ invoke("chat_link", { device: linkDevice, sshHost: linkSsh, pulse: linkPulse.trim() || null })
206
+ .then((value) => {
207
+ if (!alive.current)
208
+ return;
209
+ setLinkNote(`Linked ${value.device} (${value.ssh}): ${plural(value.chats, "chat")} there now.`);
210
+ setLinkDevice("");
211
+ setLinkSsh("");
212
+ setLinkPulse("");
213
+ refreshLive();
214
+ refreshPeers();
215
+ })
216
+ .catch((e) => {
217
+ if (alive.current)
218
+ setLinkError(String(e));
219
+ })
220
+ .finally(() => {
221
+ if (alive.current)
222
+ setLinking(false);
223
+ });
224
+ };
225
+ const unlink = (device) => {
226
+ setUnlinking(null);
227
+ setLinkError(null);
228
+ setLinkNote(null);
229
+ invoke("chat_unlink", { device })
230
+ .then(() => {
231
+ if (!alive.current)
232
+ return;
233
+ setLinkNote(`Unlinked ${device}.`);
234
+ refreshLive();
235
+ refreshPeers();
236
+ })
237
+ .catch((e) => {
238
+ if (alive.current)
239
+ setLinkError(String(e));
240
+ });
241
+ };
242
+ let statusLine = "Reading…";
243
+ if (status && !status.enabled) {
244
+ statusLine = "Chat is off on this computer, so messages are refused. Turn it on in Settings, under General.";
245
+ }
246
+ else if (status) {
247
+ statusLine = `${plural(status.chatsHere, "chat")} on ${status.device}.`;
248
+ if (!status.hubRunning) {
249
+ statusLine += " Pulse's hub is not reporting as running, so messages to Claude chats may be held until it is.";
250
+ }
251
+ }
252
+ const groups = groupByDevice(visible);
253
+ const inboxCount = inbox?.messages.length ?? 0;
254
+ return (_jsxs("div", { className: "view ck-chat", children: [_jsx("p", { className: "ck-chat-intro", children: "Chat sends a message from one agent chat to another, on this computer or a linked one. The receiver is another agent, not a person. A reply arrives in the chat that sent the message, not in this window." }), _jsxs("section", { className: "ck-chat-panel", "aria-label": "Chat status", children: [_jsx("div", { className: "ck-chat-status", role: "status", children: statusLine }), liveError && _jsx("div", { className: "error", role: "alert", children: liveError }), status?.enabled && status.links.map((link) => (_jsxs("div", { className: "ck-chat-link-line", children: [_jsx("span", { children: linkLine(link) }), !link.online && link.lastError && _jsx("span", { className: "ck-chat-link-error", children: link.lastError })] }, link.device)))] }), _jsxs("div", { className: "ck-chat-main", children: [_jsxs("section", { className: "ck-chat-panel ck-chat-chats", "aria-label": "Chats", children: [_jsx("h2", { children: "Chats" }), _jsxs("label", { className: "ck-chat-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx(Input, { type: "search", "aria-label": "Search chats", placeholder: "Search by name, computer or folder", value: query, onChange: (e) => setQuery(e.target.value) })] }), peersError && _jsx("div", { className: "error", role: "alert", children: peersError }), peers === null && !peersError && _jsx("p", { className: "ck-sub", children: "Reading chats\u2026" }), peers !== null && list.length === 0 && (_jsx("p", { className: "ck-sub", children: "No chats found. Open a Claude, Codex or Muse chat on this computer, or link another computer." })), peers !== null && list.length > 0 && visible.length === 0 && _jsx("p", { className: "ck-sub", children: "No chat matches the search." }), groups.map(([device, rows]) => (_jsxs("div", { className: "ck-chat-device", children: [_jsx("h3", { children: device }), _jsx("ul", { className: "ck-chat-list", children: rows.map((c) => (_jsx("li", { children: _jsxs(Button, { variant: "ghost", className: "ck-chat-item", "aria-pressed": c.id === selectedId, onClick: () => setSelectedId(c.id), children: [_jsx("span", { className: `ck-chat-kind ck-chat-kind-${c.kind}`, children: chatKindName(c.kind) }), _jsx("span", { className: "ck-chat-name", title: c.folder || undefined, children: c.name }), _jsxs("span", { className: "ck-chat-meta", children: [c.state, c.liveness !== "unknown" ? ` · ${c.liveness}` : "", " \u00B7 ", lastSeen(c.updatedMs)] })] }) }, c.id))) })] }, device)))] }), _jsxs("section", { className: "ck-chat-panel ck-chat-compose", "aria-label": "Message", children: [_jsx("h2", { children: chosen ? `Message to ${chosen.display}` : "Message" }), !chosen && _jsx("p", { className: "ck-sub", children: selectedId ? "That chat is no longer listed." : "Choose a chat to message." }), _jsxs("label", { className: "ck-chat-field", children: [_jsx("span", { children: "Message text" }), _jsx("textarea", { rows: 5, value: draft, disabled: !chosen, placeholder: "Written for another agent, not a person", onChange: (e) => setDraft(e.target.value), onKeyDown: onDraftKey })] }), _jsxs("div", { className: "ck-chat-compose-foot", children: [_jsx("span", { className: "ck-sub", children: "Cmd+Enter or Ctrl+Enter sends. Sent from here, the message has no sending chat, so a reply has nowhere to return." }), _jsx(Button, { variant: "primary", size: "sm", disabled: !canSend, onClick: send, children: sending ? "Sending…" : "Send" })] }), sendError && _jsx("div", { className: "error", role: "alert", children: sendError }), receipt && (_jsxs("div", { className: "ck-chat-receipt", role: "status", children: [_jsxs("strong", { children: [receipt.chat, ":"] }), " ", RECEIPT_WORDS[receipt.state] ?? receipt.state, " ", receipt.detail] }))] })] }), _jsxs("section", { className: "ck-chat-panel", "aria-label": "Inbox", children: [_jsxs(Button, { variant: "ghost", className: "ck-chat-disclosure", "aria-expanded": inboxOpen, "aria-controls": "ck-chat-inbox-body", onClick: () => setInboxOpen((open) => !open), children: [inboxOpen ? _jsx(ChevronDown, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }) : _jsx(ChevronRight, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), "Inbox", inbox ? ` (${inboxCount})` : ""] }), inboxOpen && (_jsxs("div", { id: "ck-chat-inbox-body", className: "ck-chat-inbox", children: [_jsx("p", { className: "ck-sub", children: "Messages for chats on this computer that they have not read yet. Opening this list does not mark them read." }), inboxCount === 0 && _jsx("p", { className: "ck-sub", children: "No unread messages." }), inbox?.messages.map((m) => (_jsxs("div", { className: "ck-chat-message", children: [_jsxs("div", { className: "ck-sub", children: [m.from, " to ", m.chat, " \u00B7 ", lastSeen(m.receivedMs)] }), _jsx("pre", { children: m.body })] }, m.id))), inbox && inbox.evicted > 0 && (_jsxs("p", { className: "ck-sub", children: [plural(inbox.evicted, "older message"), " dropped to keep the inbox small."] }))] }))] }), _jsxs("section", { className: "ck-chat-panel", "aria-label": "Linked computers", children: [_jsx("h2", { children: "Link a computer" }), _jsx("p", { className: "ck-sub", children: "Link each computer to the other, so replies can come back. The computer must be reachable over ssh with keys that work. Checking the link can take a little while." }), status?.links.map((link) => (_jsxs("div", { className: "ck-chat-link-row", children: [_jsxs("span", { className: "ck-chat-link-name", children: [link.device, " ", _jsx("span", { className: "ck-sub", children: link.ssh })] }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setUnlinking(link.device), children: "Unlink" })] }, link.device))), status && status.links.length === 0 && _jsx("p", { className: "ck-sub", children: "No linked computers yet." }), _jsxs("div", { className: "ck-chat-form", children: [_jsxs("label", { className: "ck-chat-field", children: [_jsx("span", { children: "Computer name" }), _jsx(Input, { type: "text", value: linkDevice, placeholder: "mac-mini", onChange: (e) => setLinkDevice(e.target.value) })] }), _jsxs("label", { className: "ck-chat-field", children: [_jsx("span", { children: "ssh host" }), _jsx(Input, { type: "text", value: linkSsh, placeholder: "mac-mini.local", onChange: (e) => setLinkSsh(e.target.value) })] }), _jsxs("label", { className: "ck-chat-field", children: [_jsx("span", { children: "Path of pulse there (optional)" }), _jsx(Input, { type: "text", value: linkPulse, placeholder: "pulse", onChange: (e) => setLinkPulse(e.target.value) })] }), _jsx(Button, { size: "sm", variant: "primary", disabled: !canLink, onClick: linkComputer, children: linking ? "Linking…" : "Link" })] }), linkNote && _jsx("div", { className: "ck-sub", role: "status", children: linkNote }), linkError && _jsx("div", { className: "error", role: "alert", children: linkError })] }), unlinking && (_jsx(ConfirmDialog, { danger: true, title: `Unlink ${unlinking}?`, description: "Chats there stop being reachable from this computer until you link it again.", confirmLabel: "Unlink", onConfirm: () => unlink(unlinking), onCancel: () => setUnlinking(null) }))] }));
255
+ }
@@ -0,0 +1,6 @@
1
+ import { type ChromeSnapshots } from "../api.js";
2
+ /** Chrome's leftover signing snapshots as a normal finding row: how many, the
3
+ * change since the last check, and what moving them would really give back. */
4
+ export declare function ChromeSnapshotsLine({ info }: {
5
+ info: ChromeSnapshots | null | undefined;
6
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { bytes } from "../api.js";
3
+ const dateText = (secs) => new Date(secs * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric" });
4
+ /** Chrome's leftover signing snapshots as a normal finding row: how many, the
5
+ * change since the last check, and what moving them would really give back. */
6
+ export function ChromeSnapshotsLine({ info }) {
7
+ if (!info || info.count === 0)
8
+ return null;
9
+ const n = info.count;
10
+ const delta = info.since_at != null && info.since_count != null ? n - info.since_count : null;
11
+ const since = info.since_at != null ? dateText(info.since_at) : "";
12
+ const change = delta == null || delta === 0 ? null : delta < 0 ? `${-delta} fewer since ${since}` : `+${delta} since ${since}`;
13
+ const detail = !info.reclaimable_known
14
+ ? `Apparent ${bytes(info.apparent_bytes)}, much less to free; shares space with Chrome`
15
+ : info.reclaimable_bytes === 0
16
+ ? "Nothing to free; shares space with Chrome"
17
+ : `About ${bytes(info.reclaimable_bytes)} to gain (apparent ${bytes(info.apparent_bytes)}, shares space with Chrome)`;
18
+ return (_jsxs("div", { className: "finding", title: "Temporary copies of the Chrome app left behind when Chrome is force-quit", children: [_jsx("span", {}), _jsxs("div", { className: "finding-main", children: [_jsxs("span", { className: "name", children: ["Chrome ", n === 1 ? "snapshot" : "snapshots", _jsxs("span", { className: "muted", children: [" \u00B7 ", n, change ? ` (${change})` : ""] })] }), _jsxs("span", { className: "muted small finding-reason", children: [detail, info.running && ` · Close Chrome to clear ${n === 1 ? "it" : "them"}`] })] }), _jsx("span", { className: "size strong", children: info.reclaimable_known ? bytes(info.reclaimable_bytes) : "" }), _jsx("span", {})] }));
19
+ }
@@ -0,0 +1,4 @@
1
+ /** `autoScan` is Settings > General > Background > "Scan when Storage or Cleanup opens" (on by default). */
2
+ export declare function Cleanup({ autoScan }: {
3
+ autoScan?: boolean;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,291 @@
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 { Badge, Button, Checkbox, ConfirmDialog, Notice, SegmentedControl } from "@rightkit/react-shell/react";
4
+ import { invoke } from "@tauri-apps/api/core";
5
+ import { ChevronRight } from "lucide-react";
6
+ import { ago, bytes, isStale, isWindows } from "../api.js";
7
+ import { readKept, writeKept } from "../kept.js";
8
+ // Windows moves files to the Recycle Bin; the Mac moves them to the Trash.
9
+ const TRASH = isWindows ? "Recycle Bin" : "Trash";
10
+ const KEPT_SELECTED = "pulse.hub.cleanup.selected";
11
+ /** How each past move was measured, by activity id: a lower bound (≥) or an upper bound (up to). Absent means exact. */
12
+ const KEPT_BOUNDS = "pulse.hub.cleanup.bounds";
13
+ import { ChromeSnapshotsLine } from "./ChromeSnapshots.js";
14
+ const cleanup = {
15
+ scan: () => invoke("cleanup_scan"),
16
+ cached: () => invoke("cleanup_cached"),
17
+ apply: (items) => invoke("cleanup_apply", { items }),
18
+ history: () => invoke("cleanup_history"),
19
+ restore: (id) => invoke("cleanup_restore", { id }),
20
+ };
21
+ const shortPath = (path) => path.replace(/^\/Users\/[^/]+/, "~");
22
+ const items = (n) => `${n} item${n === 1 ? "" : "s"}`;
23
+ // The plain-words warning for one run, or null when nothing needs saying.
24
+ const runWarning = (a) => {
25
+ const parts = [];
26
+ const unknown = a.unknown ?? 0;
27
+ const skipped = a.skipped ?? 0;
28
+ if (unknown > 0)
29
+ parts.push(`${items(unknown)} could not be confirmed`);
30
+ if (skipped > 0)
31
+ parts.push(`${items(skipped)} ${skipped === 1 ? "was" : "were"} skipped`);
32
+ const detail = parts.join(" and ");
33
+ if (a.recovered) {
34
+ const lead = "Pulse was interrupted during this cleanup";
35
+ return detail ? `${lead}; ${detail}.` : `${lead}.`;
36
+ }
37
+ if (!detail)
38
+ return null;
39
+ return `${detail.charAt(0).toUpperCase()}${detail.slice(1)}.`;
40
+ };
41
+ // One predicate for what can be selected, totalled and submitted. The backend
42
+ // still decides again when the move runs, and unknown liveness never passes.
43
+ const movable = (f) => f.eligible && f.action === "Move to Trash" && (f.risk === "safe" || f.risk === "review") && !!f.dev && !!f.ino;
44
+ const total = (xs) => {
45
+ const sum = xs.reduce((s, f) => s + f.bytes, 0);
46
+ const prefix = xs.some((f) => f.partial) ? "≥ " : xs.some((f) => f.upper_bound) ? "up to " : "";
47
+ return `${prefix}${bytes(sum)}`;
48
+ };
49
+ // The plain facts under an item's name: what it is, how safe it is to move, who made it, when it was last written.
50
+ const facts = (f) => [f.kind, f.safety, f.owner ? `from ${f.owner}` : null, f.last_used ? `last used ${ago(f.last_used)}` : "last use unknown"]
51
+ .filter(Boolean)
52
+ .join(" · ");
53
+ // Wording for a size the scan could not prove would be freed.
54
+ const ESTIMATE = "An estimate from allocated size on disk, not file size. Hard links and clones can make the real gain smaller.";
55
+ // The drive's own free-space reading around a run: observed, and moved by anything else using the drive too.
56
+ const freeSpaceLine = (v) => {
57
+ const change = v.after_available - v.before_available;
58
+ const amount = change === 0 ? "none" : `${change > 0 ? "+" : "-"}${bytes(Math.abs(change))}`;
59
+ return `Free space on ${v.volume} changed by ${amount} (observed).`;
60
+ };
61
+ const FREE_SPACE_CAVEAT = `Items in the ${TRASH} stay on the drive, so space is not freed until the ${TRASH} is emptied. Other activity on the drive also moves this number.`;
62
+ function GroupHead({ title, xs, selected, onToggle, disabled, }) {
63
+ const count = xs.filter((f) => selected.has(f.id)).length;
64
+ return (_jsxs("div", { className: "ck-group-head", children: [_jsx(Checkbox, { checked: count === xs.length, indeterminate: count > 0 && count < xs.length, onChange: onToggle, disabled: disabled, label: _jsx("strong", { "aria-label": `Select all ${title.toLowerCase()} items`, children: title }) }), _jsx("span", { className: "muted ck-num", children: total(xs) })] }));
65
+ }
66
+ /** `autoScan` is Settings > General > Background > "Scan when Storage or Cleanup opens" (on by default). */
67
+ export function Cleanup({ autoScan = true }) {
68
+ const [report, setReport] = useState(null);
69
+ const [history, setHistory] = useState([]);
70
+ const [historyWarnings, setHistoryWarnings] = useState([]);
71
+ // Kept apart from `error`, so a rescan's clear does not hide a failed history load.
72
+ const [historyError, setHistoryError] = useState(null);
73
+ const [selected, setSelected] = useState(new Set());
74
+ // The selection from before the window was closed; it replaces the preselection on the first scan.
75
+ const kept = useRef(readKept(KEPT_SELECTED));
76
+ const [pending, setPending] = useState(null);
77
+ const [pane, setPane] = useState("findings");
78
+ const [busy, setBusy] = useState(false);
79
+ const [refreshing, setRefreshing] = useState(false);
80
+ const [error, setError] = useState(null);
81
+ const [note, setNote] = useState(null);
82
+ const [restoreIssue, setRestoreIssue] = useState({});
83
+ // Recorded when each move ran, since the history itself does not carry the scan's bounds.
84
+ const [bounds, setBounds] = useState(() => readKept(KEPT_BOUNDS) ?? {});
85
+ const seeded = useRef(false);
86
+ // One findings scan at a time; a request made while one runs asks for another after it.
87
+ const scanning = useRef(false);
88
+ const again = useRef(false);
89
+ useEffect(() => {
90
+ // Until the first scan has replaced the kept selection, the empty set must not overwrite it.
91
+ if (!seeded.current)
92
+ return;
93
+ writeKept(KEPT_SELECTED, selected.size ? [...selected] : null);
94
+ }, [selected]);
95
+ const run = async (work) => {
96
+ setBusy(true);
97
+ setError(null);
98
+ try {
99
+ await work();
100
+ }
101
+ catch (e) {
102
+ setError(String(e));
103
+ }
104
+ finally {
105
+ setBusy(false);
106
+ }
107
+ };
108
+ // Preselected items are chosen once; later scans keep only what is still movable.
109
+ // A move waiting on confirmation keeps the items that are still offered.
110
+ const show = (next) => {
111
+ const first = !seeded.current;
112
+ seeded.current = true;
113
+ setReport(next);
114
+ const now = next.findings.filter(movable);
115
+ const restored = first ? kept.current : null;
116
+ if (first)
117
+ kept.current = null;
118
+ setSelected((prev) => first
119
+ ? restored
120
+ ? new Set(now.filter((f) => restored.includes(f.id)).map((f) => f.id))
121
+ : new Set(now.filter((f) => f.preselected).map((f) => f.id))
122
+ : new Set(now.filter((f) => prev.has(f.id)).map((f) => f.id)));
123
+ setPending((prev) => {
124
+ const kept = prev ? now.filter((f) => prev.some((p) => p.id === f.id)) : [];
125
+ return kept.length ? kept : null;
126
+ });
127
+ };
128
+ // A rescan runs behind the page, which stays usable while it runs: the saved
129
+ // findings can be selected and moved meanwhile, since every item is checked
130
+ // again when it moves.
131
+ const refresh = async () => {
132
+ if (scanning.current) {
133
+ again.current = true;
134
+ return;
135
+ }
136
+ scanning.current = true;
137
+ setRefreshing(true);
138
+ setError(null);
139
+ try {
140
+ do {
141
+ again.current = false;
142
+ show(await cleanup.scan());
143
+ } while (again.current);
144
+ }
145
+ catch (e) {
146
+ setError(String(e));
147
+ }
148
+ finally {
149
+ scanning.current = false;
150
+ setRefreshing(false);
151
+ }
152
+ };
153
+ // A failed history load stays on screen until a later load succeeds.
154
+ const loadHistory = () => cleanup
155
+ .history()
156
+ .then((next) => {
157
+ setHistory(next.actions);
158
+ setHistoryWarnings(next.warnings);
159
+ setHistoryError(null);
160
+ })
161
+ .catch((e) => setHistoryError(String(e)));
162
+ // Saved findings show at once. A rescan follows only when there are none or they are old.
163
+ useEffect(() => {
164
+ void loadHistory();
165
+ cleanup
166
+ .cached()
167
+ .then((saved) => {
168
+ if (saved)
169
+ show(saved);
170
+ if (autoScan && (!saved || isStale(saved.scanned_at)))
171
+ void refresh();
172
+ })
173
+ .catch(() => { if (autoScan)
174
+ void refresh(); });
175
+ }, []);
176
+ const all = report?.findings ?? [];
177
+ const eligible = all.filter(movable);
178
+ const safe = eligible.filter((f) => f.risk === "safe");
179
+ const review = eligible.filter((f) => f.risk === "review");
180
+ const held = all.filter((f) => !movable(f));
181
+ const chosen = useMemo(() => eligible.filter((f) => selected.has(f.id)), [report, selected]);
182
+ // Where the space is: every item the scan found, by category, whether or not it can be moved.
183
+ const byCategory = useMemo(() => {
184
+ const groups = new Map();
185
+ for (const f of all)
186
+ groups.set(f.category, [...(groups.get(f.category) ?? []), f]);
187
+ return [...groups.entries()]
188
+ .map(([category, xs]) => ({
189
+ category,
190
+ xs,
191
+ bytes: xs.reduce((s, f) => s + f.bytes, 0),
192
+ safety: [...new Set(xs.map((f) => f.safety).filter(Boolean))].join(", "),
193
+ }))
194
+ .sort((a, b) => b.bytes - a.bytes);
195
+ }, [report]);
196
+ const toggle = (id) => setSelected((prev) => {
197
+ const next = new Set(prev);
198
+ if (next.has(id))
199
+ next.delete(id);
200
+ else
201
+ next.add(id);
202
+ return next;
203
+ });
204
+ // One button for the common case: select the safe items and Chrome's signing copies for review.
205
+ // It only selects; nothing moves until the person confirms the move.
206
+ const freeable = eligible.filter((f) => f.risk === "safe" || f.rule_id === "chrome-signing-copies");
207
+ const freeUp = () => setSelected(new Set(freeable.map((f) => f.id)));
208
+ const toggleGroup = (xs, on) => setSelected((prev) => {
209
+ const next = new Set(prev);
210
+ xs.forEach((f) => (on ? next.add(f.id) : next.delete(f.id)));
211
+ return next;
212
+ });
213
+ const move = (picked) => {
214
+ setPending(null);
215
+ return run(async () => {
216
+ const sent = picked.filter(movable);
217
+ const result = await cleanup.apply(sent.map((f) => ({ rule_id: f.rule_id, path: f.path, dev: f.dev, ino: f.ino })));
218
+ const warnings = result.warnings ?? [];
219
+ if (result.activity_id) {
220
+ const bound = sent.some((f) => f.partial) ? "lower" : sent.some((f) => f.upper_bound) ? "upper" : null;
221
+ if (bound) {
222
+ const next = { ...bounds, [result.activity_id]: bound };
223
+ setBounds(next);
224
+ writeKept(KEPT_BOUNDS, next);
225
+ }
226
+ }
227
+ const detail = [...result.skipped.map((s) => `${shortPath(s.path)}: ${s.reason}`), ...warnings].join("\n");
228
+ setNote(result.skipped.length
229
+ ? {
230
+ tone: "warn",
231
+ text: `Partly moved: ${bytes(result.moved_bytes)} (${items(result.moved_items)}) went to the ${TRASH}, ${result.skipped.length} skipped.`,
232
+ detail,
233
+ }
234
+ : warnings.length
235
+ ? {
236
+ tone: "warn",
237
+ text: `Moved to the ${TRASH}: ${bytes(result.moved_bytes)} (${items(result.moved_items)}), with a warning.`,
238
+ detail,
239
+ }
240
+ : { tone: "ok", text: `Moved to the ${TRASH}: ${bytes(result.moved_bytes)} (${items(result.moved_items)}).`, detail: (result.free_space ?? []).length ? `${(result.free_space ?? []).map(freeSpaceLine).join(" ")} ${FREE_SPACE_CAVEAT}` : undefined });
241
+ // What moved leaves the list at once; the rescan behind it confirms.
242
+ const left = new Set(result.skipped.map((s) => s.path));
243
+ const gone = new Set(sent.filter((f) => !left.has(f.path)).map((f) => f.id));
244
+ setReport((prev) => (prev ? { ...prev, findings: prev.findings.filter((f) => !gone.has(f.id)) } : prev));
245
+ setSelected((prev) => new Set([...prev].filter((id) => !gone.has(id))));
246
+ await loadHistory();
247
+ void refresh();
248
+ });
249
+ };
250
+ const restore = (id) => run(async () => {
251
+ setRestoreIssue((prev) => {
252
+ const next = { ...prev };
253
+ delete next[id];
254
+ return next;
255
+ });
256
+ const result = await cleanup.restore(id);
257
+ const detail = result.skipped.map((s) => `${shortPath(s.path)}: ${s.reason}`).join("\n");
258
+ const warnings = result.warnings ?? [];
259
+ if (result.skipped.length)
260
+ setRestoreIssue((prev) => ({ ...prev, [id]: detail }));
261
+ setNote(result.skipped.length
262
+ ? { tone: "warn", text: `Partly restored: ${bytes(result.restored_bytes)} (${items(result.restored_items)}), ${result.skipped.length} stayed in the ${TRASH}.`, detail: warnings.join("\n") }
263
+ : warnings.length
264
+ ? { tone: "warn", text: `Restored: ${bytes(result.restored_bytes)} (${items(result.restored_items)}), with a warning.`, detail: warnings.join("\n") }
265
+ : { tone: "ok", text: `Restored: ${bytes(result.restored_bytes)} (${items(result.restored_items)}).` });
266
+ await loadHistory();
267
+ void refresh();
268
+ });
269
+ // A plain function, not a component: a component declared here is a new type on every
270
+ // render, which remounts its rows and drops focus and open details on each selection.
271
+ const group = (title, xs) => xs.length === 0 ? null : (_jsxs("section", { className: "ck-group", children: [_jsx(GroupHead, { title: title, xs: xs, selected: selected, onToggle: (on) => toggleGroup(xs, on), disabled: busy }), xs.map((f) => (_jsxs("div", { className: "ck-row", children: [_jsx("span", { className: "ck-check", children: _jsx(Checkbox, { checked: selected.has(f.id), onChange: () => toggle(f.id), disabled: busy, label: _jsxs("span", { className: "ov-vh", children: ["Select ", f.name] }) }) }), _jsxs("details", { className: "ck-detail", children: [_jsxs("summary", { children: [_jsx(ChevronRight, { size: 14, strokeWidth: 1.75, "aria-hidden": true }), _jsxs("span", { children: [_jsxs("span", { className: "ck-title", children: [_jsx("strong", { children: f.name }), _jsx(Badge, { tone: f.risk === "safe" ? "ok" : "warn", children: f.risk === "safe" ? "Safe" : "Review" })] }), _jsx("span", { className: "muted ck-reason", children: f.reason }), _jsx("span", { className: "muted ck-reason", children: facts(f) })] })] }), _jsx("p", { className: "muted ck-path", children: shortPath(f.path) }), _jsx("p", { className: "muted ck-path", children: f.category })] }), _jsxs("span", { className: "ck-num ck-size", title: f.upper_bound ? ESTIMATE : undefined, children: [f.partial ? "≥ " : f.upper_bound ? "up to " : "", bytes(f.bytes)] })] }, f.id)))] }, title));
272
+ const historyPane = (_jsxs("div", { className: "ck-card", children: [_jsx("h2", { children: "Moves and restore" }), historyWarnings.length > 0 && (_jsx(Notice, { tone: "warn", children: historyWarnings.map((w, n) => (_jsx("span", { className: "ck-lines", children: w }, n))) })), history.length === 0 && _jsx("p", { className: "muted", children: "Nothing moved yet. Confirmed moves appear here." }), history.map((a) => {
273
+ const left = a.items.filter((i) => !i.restored);
274
+ // An item whose state is unknown (interrupted) is never offered for Undo.
275
+ const canRestore = left.some((i) => i.trash_path && !i.interrupted);
276
+ const issue = restoreIssue[a.id];
277
+ const warning = runWarning(a);
278
+ return (_jsxs("div", { className: "ck-history", children: [_jsxs("div", { className: "ck-history-main", children: [_jsx("strong", { children: new Date(a.at * 1000).toLocaleString() }), _jsxs("span", { className: "muted", children: [a.items.length === 0
279
+ ? "Nothing was moved"
280
+ : `${items(a.items.length)} moved to the ${TRASH} · ${bounds[a.id] === "lower" ? "≥ " : bounds[a.id] === "upper" ? "up to " : ""}${bytes(a.bytes)}`, left.length < a.items.length ? ` · ${a.items.length - left.length} restored` : ""] }), warning && (_jsx(Notice, { tone: "warn", children: _jsx("span", { className: "ck-lines", children: warning }) })), (a.free_space ?? []).length > 0 && (_jsxs("p", { className: "muted ck-path", children: [(a.free_space ?? []).map(freeSpaceLine).join(" "), " ", FREE_SPACE_CAVEAT] })), a.items.length > 0 && (_jsxs("details", { className: "ck-detail ck-paths", children: [_jsxs("summary", { children: [_jsx(ChevronRight, { size: 14, strokeWidth: 1.75, "aria-hidden": true }), _jsx("span", { children: "Paths" })] }), a.items.map((i) => (_jsxs("p", { className: "muted ck-path", children: [shortPath(i.path), " \u00B7 ", i.restored ? "Restored" : i.interrupted ? "State unknown" : i.trash_path ? `In the ${TRASH}` : "Not restorable"] }, i.path)))] })), a.items.filter((i) => !i.restored && i.interrupted).map((i) => (_jsxs("p", { className: "muted ck-path", children: [shortPath(i.path), ": ", i.interrupted] }, i.path))), issue && (_jsxs("p", { className: "ck-issue", role: "alert", children: ["Some items stayed in the ", TRASH, ".", _jsx("span", { className: "ck-lines", children: issue })] }))] }), a.items.length > 0 && (_jsx(Button, { size: "sm", variant: "secondary", onClick: () => restore(a.id), disabled: busy || !canRestore, children: issue ? "Retry restore" : left.length === 0 ? "Restored" : "Restore" }))] }, a.id));
281
+ })] }));
282
+ const heading = !report
283
+ ? "Looking for what can go…"
284
+ : eligible.length === 0
285
+ ? "Nothing to clean up"
286
+ : `${total(eligible)} can go`;
287
+ return (_jsxs("div", { className: "view ck-cleanup", children: [_jsxs("div", { className: "ck-head", children: [_jsxs("div", { children: [_jsx("h2", { children: heading }), report && (_jsxs("p", { className: "muted", children: [eligible.length > 0 ? `${items(eligible.length)} · ` : "", "Updated ", ago(report.scanned_at), refreshing ? " · Refreshing…" : ""] }))] }), _jsx(Button, { size: "sm", variant: "secondary", onClick: refresh, disabled: busy || refreshing, children: refreshing ? "Refreshing…" : "Rescan" })] }), _jsx(SegmentedControl, { label: "Cleanup workspace", value: pane, options: [
288
+ { value: "findings", label: "Findings" },
289
+ { value: "history", label: `History · ${history.length}` },
290
+ ], onChange: (v) => setPane(v) }), note && (_jsxs(Notice, { tone: note.tone === "error" ? "bad" : note.tone, action: pane === "findings" && history.length > 0 ? (_jsx(Button, { size: "sm", variant: "secondary", onClick: () => setPane("history"), children: "History and restore" })) : undefined, children: [note.text, note.detail && _jsx("span", { className: "ck-lines", children: note.detail })] })), error && (_jsx(Notice, { tone: "bad", children: _jsx("span", { className: "ck-lines", children: error }) })), historyError && (_jsx(Notice, { tone: "bad", children: _jsx("span", { className: "ck-lines", children: historyError }) })), _jsx("div", { className: "ck-scroll", tabIndex: 0, "aria-label": "Cleanup content", children: pane === "history" ? (historyPane) : (_jsxs(_Fragment, { children: [report && report.findings.some((f) => f.partial) && (_jsx(Notice, { tone: "warn", children: "Some sizes are partial; \u2265 marks a lower bound." })), _jsx(ChromeSnapshotsLine, { info: report?.chrome_snapshots }), byCategory.length > 0 && (_jsxs("div", { className: "ck-card", "aria-label": "Where the space is", children: [_jsx("strong", { children: "Where the space is" }), byCategory.map((g) => (_jsxs("div", { className: "ck-held-row", children: [_jsxs("span", { children: [_jsx("strong", { children: g.category }), _jsxs("span", { className: "muted ck-reason", children: [items(g.xs.length), g.safety ? ` · ${g.safety}` : ""] })] }), _jsx("span", { className: "ck-num ck-size", children: bytes(g.bytes) })] }, g.category)))] })), report && eligible.length === 0 && _jsx("p", { className: "muted ck-empty", children: "Nothing to clean up right now. Rescan after apps close." }), eligible.length > 0 && (_jsxs("p", { className: "muted", children: [_jsx(Button, { size: "sm", variant: "secondary", onClick: freeUp, disabled: busy || freeable.length === 0, children: "Free this up" }), " ", "Selects the safe items and Chrome's signing copies for review. Nothing moves until you confirm."] })), eligible.length > 0 && (_jsxs("div", { className: "ck-card", children: [group("Safe to regenerate", safe), group("Review", review)] })), held.length > 0 && (_jsxs("details", { className: "ck-held", children: [_jsxs("summary", { children: [_jsx(ChevronRight, { size: 14, strokeWidth: 1.75, "aria-hidden": true }), _jsxs("span", { children: [_jsxs("strong", { children: [held.length, " not offered"] }), _jsx("span", { className: "muted", children: " \u00B7 informational, running or unverified" })] })] }), held.map((f) => (_jsxs("div", { className: "ck-held-row", children: [_jsxs("span", { children: [_jsx("strong", { children: f.name }), _jsx("span", { className: "muted ck-reason", children: f.reason }), f.kind && _jsx("span", { className: "muted ck-reason", children: facts(f) })] }), _jsx("span", { className: "ck-num ck-size", children: bytes(f.bytes) })] }, f.id)))] })), _jsxs("details", { className: "ck-about", children: [_jsxs("summary", { children: [_jsx(ChevronRight, { size: 14, strokeWidth: 1.75, "aria-hidden": true }), _jsx("span", { children: "About these numbers" })] }), _jsxs("p", { className: "muted", children: ["Nothing is deleted. Items go to the ", TRASH, ", where you can put them back or empty it yourself. Sizes are what the scan measured; \u2265 marks a lower bound when part of a folder could not be read. Items whose owner could not be verified, or whose app is running, are never offered. RustRight build targets are judged by RustRight's own state: a target a build holds or pins is never offered, and the current target is offered only after 14 days unused. Virtual machines are listed but not moved here; remove them with tart delete."] })] })] })) }), pane === "findings" && eligible.length > 0 && (_jsxs("div", { className: "ck-selection", children: [_jsxs("div", { children: [_jsxs("strong", { className: "ck-num", children: [chosen.length, " selected \u00B7 ", total(chosen)] }), _jsxs("span", { className: "muted", children: [" to move to the ", TRASH] })] }), _jsxs(Button, { onClick: () => setPending(chosen), disabled: busy || chosen.length === 0, children: ["Move ", chosen.length, " to ", TRASH] })] })), pending && (_jsx(ConfirmDialog, { title: `Move ${items(pending.length)} to the ${TRASH}?`, description: `${items(pending.length)}, ${total(pending)}, will move to the ${TRASH}${pending.some((f) => f.upper_bound || f.partial) ? ` (size is an estimate from allocated size, an upper bound: hard links and clones can make the real gain smaller)` : ""}. Nothing is deleted, and nothing is freed until the ${TRASH} is emptied: you can put them back from History here or from the ${TRASH}.`, confirmLabel: `Move to ${TRASH}`, onConfirm: () => move(pending), onCancel: () => setPending(null) }))] }));
291
+ }
@@ -0,0 +1,14 @@
1
+ import { type DriveCard, type HealthAlert, type HealthReading } from "../api.js";
2
+ /** Warning as the notch counts it: failed self-assessment, a critical warning, or media errors. */
3
+ export declare function isWarning(reading: HealthReading): boolean;
4
+ /** One drive's health on its volume card. Never blocks the card's scan action. */
5
+ export declare function DriveHealthLine({ card, toolAvailable }: {
6
+ card: DriveCard | undefined;
7
+ toolAvailable: boolean;
8
+ }): import("react").JSX.Element | null;
9
+ /** The detail panel that opens from a drive's card. */
10
+ export declare function DriveHealthPanel({ card, alerts, toolAvailable, }: {
11
+ card: DriveCard | undefined;
12
+ alerts: HealthAlert[];
13
+ toolAvailable: boolean;
14
+ }): import("react").JSX.Element | null;