@@ -1,107 +1,164 @@
11import { DEFAULT_SETTINGS, withDefaults, type Settings } from "../background/settings";
2+import { notices, renderNotice, renderPill, type Health } from "../shared/health";
23import { el, send } from "../shared/messaging";
34
45const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
56
6−type Field =
7− | { key: keyof Settings; label: string; help?: string; kind: "bool" }
8− | { key: keyof Settings; label: string; help?: string; kind: "number"; min: number; max: number; step?: number; unit?: string; scale?: number }
9− | { key: keyof Settings; label: string; help?: string; kind: "select"; options: [string, string][] }
10− | { key: keyof Settings; label: string; help?: string; kind: "list" };
11−
12−const SECTIONS: [string, Field[]][] = [
13− ["Grouping", [
14− { key: "autoOrganize", label: "Organize loose tabs into groups automatically", help: "Tabs you take out of a group stay out until they go to another page.", kind: "bool" },
15− { key: "autoOrganizeDelayMs", label: "Wait after the last tab change", kind: "number", min: 3, max: 120, step: 1, unit: "s", scale: 1000 },
16− { key: "openerGrouping", label: "Group a tab with the tab it was opened from", kind: "bool" },
17− { key: "dissolveSingletons", label: "Ungroup Diagonal's groups that drop to one tab", kind: "bool" },
18− ]],
19− ["Naming", [
20− { key: "naming", label: "Name groups with the on-device model", kind: "bool" },
21− { key: "nameUserGroups", label: "Name my own groups too", help: "Stops for good once you type a title.", kind: "bool" },
22− { key: "emoji", label: "Prefix titles with an emoji", kind: "bool" },
23− { key: "namingDebounceMs", label: "Quiet period before naming", kind: "number", min: 1, max: 30, step: 1, unit: "s", scale: 1000 },
24− { key: "sendDescription", label: "Send page descriptions to the model", help: "Off: title and address only.", kind: "bool" },
25− { key: "sendFullUrl", label: "Send full addresses to the model", help: "Off: hostname only.", kind: "bool" },
26− { key: "model", label: "Model", kind: "select", options: [["system", "On-device (system)"], ["pcc", "Private Cloud Compute"]] },
27− { key: "timeoutMs", label: "Timeout per model call", kind: "number", min: 5, max: 120, step: 1, unit: "s", scale: 1000 },
28− { key: "organizeMinGroupSize", label: "Smallest new group", kind: "number", min: 2, max: 5, unit: "tabs" },
29− ]],
30− ["Tidy", [
31− { key: "tidyMode", label: "Tidy sweep", kind: "select", options: [["auto", "Park automatically"], ["ask", "Ask before parking"], ["off", "Off"]] },
32− { key: "parkAfterHours", label: "Park tabs untouched for", kind: "number", min: 1, max: 720, unit: "h" },
33− { key: "archiveAfterHours", label: "Archive parked tabs after", help: "0 = never archive.", kind: "number", min: 0, max: 2160, unit: "h" },
34− { key: "tidyThreshold", label: "Ask mode: offer a sweep at", kind: "number", min: 1, max: 100, unit: "tabs" },
35− { key: "discardParked", label: "Discard parked tabs from memory", kind: "bool" },
36− { key: "tidyUserGroups", label: "Tidy inside my own groups", kind: "bool" },
37− { key: "tidyExclusions", label: "Never tidy addresses containing", help: "One per line. Write /…/ for a regular expression.", kind: "list" },
38− { key: "archiveCap", label: "Keep at most", kind: "number", min: 50, max: 5000, unit: "archived tabs" },
39− ]],
40− ["Diagnostics", [
41− { key: "debugLog", label: "Debug logging", help: "Worker logs to the console; the host logs to ~/Library/Logs/Diagonal/host.log when ~/Library/Application Support/Diagonal/debug exists.", kind: "bool" },
42− ]],
7+type Field = { key: keyof Settings; label: string; help?: string; when?: (s: Settings) => boolean } & (
8+ | { kind: "bool" }
9+ | { kind: "number"; min: number; max: number; step?: number; unit?: string; scale?: number }
10+ | { kind: "select"; options: [string, string][] }
11+ | { kind: "list" }
12+);
13+
14+interface Section { title: string; intro: string; fields: Field[]; extra?: () => HTMLElement }
15+
16+const SECTIONS: Section[] = [
17+ {
18+ title: "Grouping",
19+ intro: "Tabs join a group on their own. Nothing to press.",
20+ fields: [
21+ { key: "openerGrouping", label: "Keep a tab with the one it was opened from", kind: "bool" },
22+ { key: "autoOrganize", label: "Sort loose tabs into groups", help: "A tab you pull out of a group stays out until it goes to another page.", kind: "bool" },
23+ { key: "autoOrganizeDelayMs", label: "Wait after the last tab change", kind: "number", min: 3, max: 120, unit: "sec", scale: 1000, when: (s) => s.autoOrganize },
24+ { key: "organizeMinGroupSize", label: "Smallest new group", kind: "number", min: 2, max: 5, unit: "tabs", when: (s) => s.autoOrganize },
25+ { key: "dissolveSingletons", label: "Ungroup a group that's down to one tab", help: "Only groups Diagonal made.", kind: "bool" },
26+ ],
27+ },
28+ {
29+ title: "Naming",
30+ intro: "Groups get a short title from the model on this Mac.",
31+ fields: [
32+ { key: "naming", label: "Name groups automatically", kind: "bool" },
33+ { key: "emoji", label: "Start titles with an emoji", kind: "bool", when: (s) => s.naming },
34+ { key: "nameUserGroups", label: "Name groups I make too", help: "Stops for good once you type a title yourself.", kind: "bool", when: (s) => s.naming },
35+ { key: "namingDebounceMs", label: "Wait before naming", kind: "number", min: 1, max: 30, unit: "sec", scale: 1000, when: (s) => s.naming },
36+ { key: "timeoutMs", label: "Give the model up to", kind: "number", min: 5, max: 120, unit: "sec", scale: 1000, when: (s) => s.naming },
37+ ],
38+ },
39+ {
40+ title: "Privacy",
41+ intro: "What the model sees when it names a group.",
42+ fields: [
43+ { key: "model", label: "Model", kind: "select", options: [["system", "On this Mac"], ["pcc", "Private Cloud Compute"]] },
44+ { key: "sendDescription", label: "Include page descriptions", help: "Off: titles and addresses only.", kind: "bool" },
45+ { key: "sendFullUrl", label: "Include full addresses", help: "Off: just the site name.", kind: "bool" },
46+ ],
47+ },
48+ {
49+ title: "Tidy",
50+ intro: "Tabs you haven't touched in a while move to a parked group, then to the archive.",
51+ fields: [
52+ { key: "tidyMode", label: "Tidy old tabs", kind: "select", options: [["auto", "Automatically"], ["ask", "Ask me first"], ["off", "Never"]] },
53+ { key: "parkAfterHours", label: "Park tabs untouched for", kind: "number", min: 1, max: 720, unit: "hours", when: (s) => s.tidyMode !== "off" },
54+ { key: "archiveAfterHours", label: "Archive parked tabs after", help: "0 keeps them parked.", kind: "number", min: 0, max: 2160, unit: "hours", when: (s) => s.tidyMode !== "off" },
55+ { key: "tidyThreshold", label: "Ask once this many are waiting", kind: "number", min: 1, max: 100, unit: "tabs", when: (s) => s.tidyMode === "ask" },
56+ { key: "discardParked", label: "Free memory used by parked tabs", kind: "bool", when: (s) => s.tidyMode !== "off" },
57+ { key: "tidyUserGroups", label: "Tidy inside groups I made", kind: "bool", when: (s) => s.tidyMode !== "off" },
58+ { key: "tidyExclusions", label: "Never tidy addresses containing", help: "One per line. Wrap in /…/ for a regular expression.", kind: "list", when: (s) => s.tidyMode !== "off" },
59+ ],
60+ },
61+ {
62+ title: "Archive",
63+ intro: "Archived tabs can be restored from the toolbar popup.",
64+ fields: [{ key: "archiveCap", label: "Keep up to", kind: "number", min: 50, max: 5000, unit: "tabs" }],
65+ extra: () =>
66+ el("div", { className: "field" },
67+ el("span", { className: "label", textContent: "Export the archive" }),
68+ el("button", { textContent: "Export JSON", onclick: exportArchive }),
69+ el("span", { className: "help", textContent: "Every archived tab with its title, address and group." })),
70+ },
4371];
4472
73+const DEBUG_FIELD: Field = {
74+ key: "debugLog", label: "Debug logging", kind: "bool",
75+ help: "The extension logs to its console. The helper logs to ~/Library/Logs/Diagonal/host.log once ~/Library/Application Support/Diagonal/debug exists.",
76+};
77+
4578let settings: Settings = DEFAULT_SETTINGS;
79+const rows: { field: Field; row: HTMLElement }[] = [];
80+let toastTimer: ReturnType<typeof setTimeout> | undefined;
4681
4782async function save(patch: Partial<Settings>): Promise<void> {
4883 settings = withDefaults({ ...settings, ...patch });
4984 await chrome.storage.local.set({ settings });
50− const s = $("saved");
51− s.style.opacity = "1";
52− setTimeout(() => (s.style.opacity = "0"), 1200);
53− renderWarnings();
85+ const t = $("saved");
86+ t.classList.add("show");
87+ clearTimeout(toastTimer);
88+ toastTimer = setTimeout(() => t.classList.remove("show"), 1200);
89+ applyDependencies();
90+ $("debug-section").hidden = !settings.debugLog;
5491}
5592
56−function renderWarnings(): void {
93+/** Dim and disable settings that don't apply with the current choices. */
94+function applyDependencies(): void {
95+ for (const { field, row } of rows) {
96+ const on = field.when ? field.when(settings) : true;
97+ row.classList.toggle("off", !on);
98+ row.querySelectorAll<HTMLInputElement>("input, select, textarea").forEach((c) => (c.disabled = !on));
99+ }
57100 const w = document.getElementById("pcc-warning");
58101 if (w) w.hidden = settings.model !== "pcc";
59102}
60103
104+function fieldRow(f: Field): HTMLElement {
105+ const value = settings[f.key] as unknown;
106+ const id = `f-${f.key}`;
107+ let control: HTMLElement;
108+ if (f.kind === "bool") {
109+ control = el("input", { id, type: "checkbox", className: "switch", checked: !!value, onchange: (e: Event) => save({ [f.key]: (e.target as HTMLInputElement).checked }) });
110+ } else if (f.kind === "number") {
111+ const scale = f.scale ?? 1;
112+ control = el("span", { className: "unit" },
113+ el("input", {
114+ id, type: "number", min: String(f.min), max: String(f.max), step: String(f.step ?? 1), value: String((value as number) / scale),
115+ onchange: (e: Event) => {
116+ const input = e.target as HTMLInputElement;
117+ const n = Math.min(f.max, Math.max(f.min, Number(input.value) || f.min));
118+ input.value = String(n);
119+ void save({ [f.key]: n * scale });
120+ },
121+ }),
122+ el("span", { textContent: f.unit ?? "" }));
123+ } else if (f.kind === "select") {
124+ const sel = el("select", { id, onchange: (e: Event) => save({ [f.key]: (e.target as HTMLSelectElement).value }) });
125+ for (const [v, label] of f.options) sel.append(el("option", { value: v, textContent: label, selected: v === value }));
126+ control = sel;
127+ } else {
128+ control = el("span");
129+ }
130+ const row = el("div", { className: "field" }, el("label", { htmlFor: id, textContent: f.label }), control);
131+ if (f.help) row.append(el("div", { className: "help", textContent: f.help }));
132+ if (f.kind === "list") {
133+ row.append(el("textarea", {
134+ id, className: "wide", spellcheck: false,
135+ value: (value as string[]).join("\n"),
136+ onchange: (e: Event) => save({ [f.key]: (e.target as HTMLTextAreaElement).value.split("\n") }),
137+ }));
138+ }
139+ if (f.key === "model") {
140+ row.append(el("div", {
141+ className: "warn-text", id: "pcc-warning",
142+ textContent: "Private Cloud Compute sends tab titles, addresses and descriptions to Apple's servers. On this Mac, nothing leaves the computer.",
143+ }));
144+ }
145+ rows.push({ field: f, row });
146+ return row;
147+}
148+
61149function renderSettings(): void {
150+ rows.length = 0;
62151 const root = $("settings");
63152 root.replaceChildren();
64− for (const [title, fields] of SECTIONS) {
65− root.append(el("h2", { textContent: title }));
66− for (const f of fields) {
67− const value = settings[f.key] as unknown;
68− let control: HTMLElement;
69− if (f.kind === "bool") {
70− control = el("input", { type: "checkbox", checked: !!value, onchange: (e: Event) => save({ [f.key]: (e.target as HTMLInputElement).checked }) });
71− } else if (f.kind === "number") {
72− const scale = f.scale ?? 1;
73− control = el("span", {},
74− el("input", {
75− type: "number", min: String(f.min), max: String(f.max), step: String(f.step ?? 1), value: String((value as number) / scale),
76− onchange: (e: Event) => save({ [f.key]: Number((e.target as HTMLInputElement).value) * scale }),
77− }),
78− f.unit ? ` ${f.unit}` : "");
79− } else if (f.kind === "select") {
80− const sel = el("select", { onchange: (e: Event) => save({ [f.key]: (e.target as HTMLSelectElement).value }) });
81− for (const [v, label] of f.options) sel.append(el("option", { value: v, textContent: label, selected: v === value }));
82− control = sel;
83− } else {
84− control = el("span");
85− }
86− const row = el("div", { className: "field" }, el("label", { textContent: f.label }), control);
87− if (f.kind === "list") {
88− row.append(el("textarea", {
89− className: "help",
90− value: (value as string[]).join("\n"),
91− onchange: (e: Event) => save({ [f.key]: (e.target as HTMLTextAreaElement).value.split("\n") }),
92− }));
93− }
94− if (f.help) row.append(el("div", { className: "help", textContent: f.help }));
95− if (f.key === "model") {
96− row.append(el("div", {
97− className: "help warn-text", id: "pcc-warning",
98− textContent: "Private Cloud Compute sends tab titles, addresses and descriptions to Apple's servers. The on-device model keeps them on this Mac.",
99− }));
100− }
101− root.append(row);
102− }
153+ for (const s of SECTIONS) {
154+ const card = el("section", { className: "card" },
155+ el("div", { className: "head" }, el("h2", { textContent: s.title }), el("p", { textContent: s.intro })));
156+ for (const f of s.fields) card.append(fieldRow(f));
157+ if (s.extra) card.append(s.extra());
158+ root.append(card);
103159 }
104− renderWarnings();
160+ $("debug-fields").replaceChildren(fieldRow(DEBUG_FIELD));
161+ applyDependencies();
105162}
106163
107164function percentile(values: number[], p: number): number {
@@ -109,18 +166,53 @@ function percentile(values: number[], p: number): number {
109166 return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
110167}
111168
169+async function renderHealth(): Promise<void> {
170+ try {
171+ const s = await send<Health>("status");
172+ renderPill($("status"), s);
173+ const list = notices(s);
174+ $("notices").replaceChildren(...list.map(renderNotice));
175+ $("notices").hidden = list.length === 0;
176+ } catch {
177+ /* worker asleep or reloading */
178+ }
179+}
180+
112181async function renderDiagnostics(): Promise<void> {
113182 try {
114183 const d = await send<{ latency: number[]; debugLog: { at: number; msg: string }[]; host: { lastError?: unknown } }>("diagnostics");
115− if (d.latency.length) $("latency").textContent = `p50 ${percentile(d.latency, 50)} ms · p95 ${percentile(d.latency, 95)} ms · n=${d.latency.length}`;
116− $("last-error").textContent = d.host.lastError ? JSON.stringify(d.host.lastError, null, 2) : "none";
184+ if (d.latency.length) $("latency").textContent = `${percentile(d.latency, 50)} ms · ${percentile(d.latency, 95)} ms · ${d.latency.length} calls`;
185+ $("last-error").textContent = d.host.lastError ? JSON.stringify(d.host.lastError, null, 2) : "None";
117186 $("debug-section").hidden = !settings.debugLog;
118− $("debug-log").textContent = d.debugLog.map((e) => `${new Date(e.at).toLocaleTimeString()} ${e.msg}`).join("\n") || "empty";
187+ $("debug-log").textContent = d.debugLog.map((e) => `${new Date(e.at).toLocaleTimeString()} ${e.msg}`).join("\n") || "Empty";
119188 } catch {
120189 /* worker asleep or reloading */
121190 }
122191}
123192
193+async function exportArchive(): Promise<void> {
194+ const archive = await send("exportArchive");
195+ const blob = new Blob([JSON.stringify(archive, null, 2)], { type: "application/json" });
196+ const a = el("a", { href: URL.createObjectURL(blob), download: `diagonal-archive-${new Date().toISOString().slice(0, 10)}.json` });
197+ a.click();
198+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
199+}
200+
201+type SelfTest = {
202+ ping?: { ok: boolean; result?: { fmAvailable?: boolean }; error?: { message?: string } };
203+ pingMs?: number;
204+ name?: { ok: boolean; result?: { title?: string; emoji?: string }; error?: { message?: string } };
205+ nameMs?: number;
206+};
207+
208+function summarize(r: SelfTest): string {
209+ if (!r.ping?.ok) return `The helper didn't answer: ${r.ping?.error?.message ?? "no reply"}`;
210+ if (!r.name) return `The helper answered in ${r.pingMs} ms.`;
211+ if (!r.name.ok) return `The helper answered, but naming failed: ${r.name.error?.message ?? "no reply"}`;
212+ const title = [r.name.result?.emoji, r.name.result?.title].filter(Boolean).join(" ");
213+ return `Working. A sample group was named “${title}” in ${r.nameMs} ms.`;
214+}
215+
124216async function main(): Promise<void> {
125217 settings = withDefaults((await chrome.storage.local.get("settings")).settings);
126218 renderSettings();
@@ -140,30 +232,30 @@ async function main(): Promise<void> {
140232 $("copy-path").onclick = () => navigator.clipboard.writeText(path);
141233 $("copy-manifest").onclick = () => navigator.clipboard.writeText(manifest);
142234 $("selftest").onclick = async () => {
235+ const b = $<HTMLButtonElement>("selftest");
143236 const out = $("selftest-out");
144− out.hidden = false;
145− out.textContent = "Running…";
237+ const summary = $("selftest-summary");
238+ b.disabled = true;
239+ b.textContent = "Checking…";
240+ summary.textContent = "Asking the helper to name a sample group…";
146241 try {
147− const r = await send("selftest");
242+ const r = await send<SelfTest>("selftest");
148243 out.textContent = JSON.stringify(r, null, 2);
244+ summary.textContent = summarize(r);
149245 } catch (e) {
150246 out.textContent = String(e);
247+ summary.textContent = `The check couldn't run: ${e instanceof Error ? e.message : String(e)}`;
151248 }
152− await renderDiagnostics();
153− };
154− $("export").onclick = async () => {
155− const archive = await send("exportArchive");
156− const blob = new Blob([JSON.stringify(archive, null, 2)], { type: "application/json" });
157− const a = el("a", { href: URL.createObjectURL(blob), download: `diagonal-archive-${new Date().toISOString().slice(0, 10)}.json` });
158− a.click();
159− setTimeout(() => URL.revokeObjectURL(a.href), 1000);
249+ b.disabled = false;
250+ b.textContent = "Run check";
251+ await Promise.all([renderHealth(), renderDiagnostics()]);
160252 };
161253 chrome.storage.onChanged.addListener((changes, area) => {
162254 if (area === "local" && changes.settings) {
163255 settings = withDefaults(changes.settings.newValue);
164256 }
165257 });
166− await renderDiagnostics();
258+ await Promise.all([renderHealth(), renderDiagnostics()]);
167259}
168260
169261void main();