@@ -1,14 +1,14 @@
1import { stripTitle } from "../shared/label";
1import { stripTitle } from "../shared/label";
2−import { applyEvent, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
2+import { applyEvent, bury, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
3−import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
3+import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, REQUEST_ERRORS, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
4import { GLOBAL_PAUSE_AFTER, GLOBAL_PAUSE_MS, Naming, RATE_LIMIT_PAUSES_MS, type Member, type NamePayload, type NameResult } from "./naming";
4import { GLOBAL_PAUSE_AFTER, GLOBAL_PAUSE_MS, Naming, RATE_LIMIT_PAUSES_MS, type Member, type NamePayload, type NameResult } from "./naming";
5import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
5import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
6import { FitChecker } from "./fit";
6import { FitChecker } from "./fit";
7import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
7import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
8import { withDefaults, type Settings } from "./settings";
8import { withDefaults, type Settings } from "./settings";
9−import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
9+import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type RemovedGroup, type State, type TabRecord } from "./state";
10import { readDisk, shouldReload, TRIED_KEY, UPDATE_CHECK_MINUTES } from "./selfupdate";
10import { readDisk, shouldReload, TRIED_KEY, UPDATE_CHECK_MINUTES } from "./selfupdate";
11−import { forgetArchived, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
11+import { adoptParked, forgetArchived, looksParked, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
13/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
13/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
@@ -20,6 +20,8 @@ const ALARM_HOST = "host-retry";
20const ALARM_AUTO = "auto-organize";
20const ALARM_AUTO = "auto-organize";
21const ALARM_UPDATE = "self-update";
21const ALARM_UPDATE = "self-update";
22const HOST_RETRY_MS = 600_000;
22const HOST_RETRY_MS = 600_000;
23+/** Records made after this were made by this worker, so after a restart they describe the new session. */
24+const WORKER_STARTED_AT = Date.now();
24// ----- state cache ------------------------------------------------------------------------------
26// ----- state cache ------------------------------------------------------------------------------
@@ -91,7 +93,11 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
91 h.lastOkAt = Date.now();
93 h.lastOkAt = Date.now();
92 h.consecutiveFailures = 0;
94 h.consecutiveFailures = 0;
93 h.consecutiveTimeouts = 0;
95 h.consecutiveTimeouts = 0;
94− h.rateLimitStep = 0;
96+ if (op !== "ping") {
97+ h.rateLimitStep = 0; // a ping doesn't use the model, so it says nothing about its limit
98+ // A model answer (a manual Organize or Name now during a pause) proves the helper works again.
99+ if (h.pausedUntil) recovered();
95 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
101 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
97 recordHostError(reply.error);
103 recordHostError(reply.error);
@@ -105,6 +111,8 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
107function recordHostError(e: HostError): void {
113function recordHostError(e: HostError): void {
114+ // About one request's tabs, not the host's health: the caller retries or sets those tabs aside.
115+ if (REQUEST_ERRORS.has(e.code)) return;
108 const h = state.host;
116 const h = state.host;
109 const now = Date.now();
117 const now = Date.now();
110 h.lastError = { ...e, at: now };
118 h.lastError = { ...e, at: now };
@@ -136,11 +144,7 @@ async function ping(): Promise<HostReply<PingResult>> {
136 } else if (!reply.result.schemasOk) {
144 } else if (!reply.result.schemasOk) {
137 recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
145 recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
139− h.pausedUntil = undefined;
147+ recovered();
140− h.lastError = undefined;
141− chrome.alarms.clear(ALARM_HOST);
142− void naming.sweepDirty();
143− void autoOrganizer.sweep();
146 commit();
150 commit();
@@ -148,6 +152,16 @@ async function ping(): Promise<HostReply<PingResult>> {
148 return reply;
152 return reply;
155+/** The helper answers again: lift the pause and pick up what waited for it. */
156+function recovered(): void {
157+ const h = state.host;
158+ h.pausedUntil = undefined;
159+ h.lastError = undefined;
160+ chrome.alarms.clear(ALARM_HOST);
161+ void naming.sweepDirty();
162+ void autoOrganizer.sweep();
151// ----- badge -----------------------------------------------------------------------------------
165// ----- badge -----------------------------------------------------------------------------------
153function busy(on: boolean): void {
167function busy(on: boolean): void {
@@ -159,7 +173,6 @@ function hostUnhealthy(): boolean {
159 const h = state.host;
173 const h = state.host;
160 const now = Date.now();
174 const now = Date.now();
161 if (h.pausedUntil && h.pausedUntil > now) return true;
175 if (h.pausedUntil && h.pausedUntil > now) return true;
162− if (h.consecutiveFailures >= 3) return true;
163 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
176 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
@@ -346,16 +359,24 @@ async function tabById(id: number): Promise<chrome.tabs.Tab | undefined> {
346 * across restarts, so records are matched by id first and then by URL (tabs) or title and colour
359 * across restarts, so records are matched by id first and then by URL (tabs) or title and colour
347 * (groups), which keeps managed groups managed after the browser restarts.
360 * (groups), which keeps managed groups managed after the browser restarts.
349−async function reconcile(): Promise<void> {
362+async function reconcile(reason: "startup" | "installed"): Promise<void> {
350 const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
363 const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
351 const now = Date.now();
364 const now = Date.now();
352− const oldTabs = Object.values(state.tabs);
365+ const liveTabIds = new Set(tabs.map((t) => t.id));
366+ // Records of tabs that are gone, by address: after a restart every tab has a new id.
353− const byUrl = new Map<string, TabRecord>(oldTabs.map((t) => [t.url, t]));
367+ const byUrl = new Map<string, TabRecord>(Object.values(state.tabs).filter((t) => !liveTabIds.has(t.id)).map((t) => [t.url, t]));
354 const nextTabs: Record<number, TabRecord> = {};
368 const nextTabs: Record<number, TabRecord> = {};
355 for (const t of tabs) {
369 for (const t of tabs) {
356 if (t.incognito || t.id === undefined) continue;
370 if (t.incognito || t.id === undefined) continue;
357 const url = t.url || t.pendingUrl || "";
371 const url = t.url || t.pendingUrl || "";
358− const prev = state.tabs[t.id]?.url === url ? state.tabs[t.id] : byUrl.get(url);
372+ const same = state.tabs[t.id]?.url === url ? state.tabs[t.id] : undefined;
373+ // An event handled before this ran may have recorded a restored tab as new: its old record knows more.
374+ const old = byUrl.get(url);
375+ const prev = old && (!same || same.createdAt >= WORKER_STARTED_AT) ? old : same;
376+ // Chromium stamps a restored tab it has not shown yet with the restore time, so after a restart the
377+ // stored time is the truer one for every tab but the one on screen.
379+ reason === "startup" && prev && !t.active ? prev.lastActivatedAt : Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0);
359 nextTabs[t.id] = {
380 nextTabs[t.id] = {
360 id: t.id,
381 id: t.id,
361 windowId: t.windowId,
382 windowId: t.windowId,
@@ -368,25 +389,51 @@ async function reconcile(): Promise<void> {
368 openerTabId: t.openerTabId,
389 openerTabId: t.openerTabId,
369 description: prev?.description,
390 description: prev?.description,
370 parkedFrom: prev?.parkedFrom,
391 parkedFrom: prev?.parkedFrom,
392+ parkedAt: prev?.parkedAt,
371 keepLoose: prev?.keepLoose,
393 keepLoose: prev?.keepLoose,
372 organizedKey: prev?.organizedKey,
394 organizedKey: prev?.organizedKey,
395+ organizeRefused: prev?.organizeRefused,
373 handPlaced: prev?.handPlaced,
396 handPlaced: prev?.handPlaced,
397+ fitPending: prev?.fitPending,
374 createdAt: prev?.createdAt ?? now,
398 createdAt: prev?.createdAt ?? now,
375− lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
399+ lastActivatedAt: lastSeen || now,
402+ const liveGroupIds = new Set(groups.map((g) => g.id));
378 const unmatched = new Map(Object.values(state.groups).map((g) => [g.id, g]));
403 const unmatched = new Map(Object.values(state.groups).map((g) => [g.id, g]));
404+ // Made by this worker for a group it did not know: after a restart, that is a restored group whose
405+ // events were handled before this ran, and the record from before the restart is the one to keep.
406+ const fresh = (g: GroupRecord) => g.origin === "user" && (g.registeredAt ?? 0) >= WORKER_STARTED_AT;
407+ const lookalike = (title: string, color: string): GroupRecord | RemovedGroup | undefined => {
408+ if (title === "") return undefined;
409+ const alike = (o: GroupRecord) => (o.stripTitle ?? "") === title && o.color === color;
411+ [...unmatched.values()].find((o) => alike(o) && !liveGroupIds.has(o.id) && !fresh(o)) ??
412+ Object.values(state.removedGroups)
414+ .sort((a, b) => b.removedAt - a.removedAt)[0]
379 const nextGroups: Record<number, GroupRecord> = {};
417 const nextGroups: Record<number, GroupRecord> = {};
380 for (const g of groups) {
418 for (const g of groups) {
381 const title = g.title ?? "";
419 const title = g.title ?? "";
382− let prev = unmatched.get(g.id);
420+ let prev: GroupRecord | undefined = unmatched.get(g.id);
383− if (!prev || (prev.stripTitle ?? "") !== title) {
421+ if (prev && (prev.stripTitle ?? "") !== title) prev = undefined;
384− prev = [...unmatched.values()].find((o) => (o.stripTitle ?? "") === title && title !== "" && o.color === g.color);
422+ const before = !prev || fresh(prev) ? lookalike(title, g.color) : undefined;
423+ if (before) prev = before;
424+ let rec: GroupRecord;
426+ unmatched.delete(prev.id);
427+ delete state.removedGroups[prev.id];
428+ const { removedAt: _, ...kept } = prev as RemovedGroup;
429+ rec = { ...kept, id: g.id, windowId: g.windowId, color: g.color, stripTitle: title };
430+ if (before) rec.registeredAt = undefined;
432+ // Untitled so far may still mean restored: its title can arrive next and match (engine.ts groupUpdated).
433+ rec = newGroupRecord(g.id, g.windowId, "user", g.color, { managed: settings.nameUserGroups, userNamed: !!title, stripTitle: title, registeredAt: now });
386− if (prev) unmatched.delete(prev.id);
435+ // Earlier versions turned Parked into a plain group after a restart: give it back its job.
387− const rec: GroupRecord = prev
436+ if (rec.origin === "user" && looksParked(g, settings)) adoptParked(rec, settings);
388− ? { ...prev, id: g.id, windowId: g.windowId, color: g.color, stripTitle: title }
389− : newGroupRecord(g.id, g.windowId, "user", g.color, { managed: settings.nameUserGroups, userNamed: !!title, stripTitle: title });
390 if (rec.managed) {
437 if (rec.managed) {
391 rec.dirty = true;
438 rec.dirty = true;
392 rec.dirtyAt = 0;
439 rec.dirtyAt = 0;
@@ -394,14 +441,17 @@ async function reconcile(): Promise<void> {
395 nextGroups[g.id] = rec;
442 nextGroups[g.id] = rec;
444+ // Groups not seen now (a closed window, a quit that did not save) may still come back: keep them a day.
445+ for (const old of unmatched.values()) if (!liveGroupIds.has(old.id) && !fresh(old)) bury(state, old, now);
397 state.tabs = nextTabs;
446 state.tabs = nextTabs;
398 state.groups = nextGroups;
447 state.groups = nextGroups;
399 state.pendingCreates = [];
448 state.pendingCreates = [];
400 state.ownWrites = {};
449 state.ownWrites = {};
401 state.ownUngroups = {};
450 state.ownUngroups = {};
402 state.ownAdds = {};
451 state.ownAdds = {};
452+ // Undo lists name tabs by id, and after a restart none of those ids exist.
403− if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
453+ if (reason === "startup" || (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS)) state.lastSweep = undefined;
404− if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
454+ if (reason === "startup" || (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS)) state.lastOrganize = undefined;
405 commit();
455 commit();
@@ -414,9 +464,13 @@ async function injectBacklog(): Promise<void> {
467+let reconciled: Promise<void> | undefined;
417async function boot(reason: "startup" | "installed"): Promise<void> {
469async function boot(reason: "startup" | "installed"): Promise<void> {
418− await ready;
470+ // Queued before anything else this wake handles (serial waits for `ready` itself): after a restart
471+ // Chromium replays the restored groups right after onStartup, and they must meet the reconciled state.
419− await serial("reconcile", reconcile);
472+ reconciled = serial("reconcile", () => reconcile(reason));
420 if (!(await chrome.alarms.get(ALARM_TIDY))) chrome.alarms.create(ALARM_TIDY, { periodInMinutes: 30, delayInMinutes: 1 });
474 if (!(await chrome.alarms.get(ALARM_TIDY))) chrome.alarms.create(ALARM_TIDY, { periodInMinutes: 30, delayInMinutes: 1 });
421 if (reason === "installed") void injectBacklog();
475 if (reason === "installed") void injectBacklog();
422 await ping();
476 await ping();
@@ -458,20 +512,29 @@ if (GROUPS_SUPPORTED) {
459 chrome.tabs.onReplaced.addListener((added, removed) =>
513 chrome.tabs.onReplaced.addListener((added, removed) =>
460 void serial("tabs.onReplaced", async () => {
514 void serial("tabs.onReplaced", async () => {
515+ // Chromium swapped in a new tab (a prerendered page, a discarded tab coming back): it is the same
516+ // tab to the user, so it keeps what Diagonal knew about it, including choices the user made.
461 const rec = state.tabs[removed];
517 const rec = state.tabs[removed];
462 delete state.tabs[removed];
518 delete state.tabs[removed];
463 const t = await tabById(added);
519 const t = await tabById(added);
521+ if (rec && !state.tabs[added]) state.tabs[added] = { ...rec, id: added };
465 await feed({ type: "tabUpdated", tab: snapTab(t) });
522 await feed({ type: "tabUpdated", tab: snapTab(t) });
466− if (rec?.description && state.tabs[added]) state.tabs[added].description = rec.description;
471 chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
527 chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
473− chrome.tabGroups.onCreated.addListener((g) => void serial("tabGroups.onCreated", () => feed({ type: "groupCreated", group: snapGroup(g) })));
529+ // A group is read when its turn comes, not as Chromium sent it: a restored group reports itself
474− chrome.tabGroups.onUpdated.addListener((g) => void serial("tabGroups.onUpdated", () => feed({ type: "groupUpdated", group: snapGroup(g) })));
530+ // untitled and then titled, and replaying the stale "untitled" would read as you clearing the title.
531+ const refetchGroup = (where: string, type: "groupCreated" | "groupUpdated") => (g: chrome.tabGroups.TabGroup) =>
532+ void serial(where, async () => {
533+ const now = await chrome.tabGroups.get(g.id).catch(() => undefined);
534+ if (now) await feed({ type, group: snapGroup(now) });
536+ chrome.tabGroups.onCreated.addListener(refetchGroup("tabGroups.onCreated", "groupCreated"));
537+ chrome.tabGroups.onUpdated.addListener(refetchGroup("tabGroups.onUpdated", "groupUpdated"));
475 chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
538 chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
@@ -500,7 +563,10 @@ chrome.alarms.onAlarm.addListener((alarm) => {
500 await ready;
563 await ready;
501 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
564 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
502 else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
565 else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
566+ else if (alarm.name === ALARM_TIDY) {
567+ await reconciled; // an overdue sweep right after a restart waits for the new tab and group ids
503− else if (alarm.name === ALARM_TIDY) await runSweep(rt).catch((e) => logError("tidy", e));
568+ await runSweep(rt).catch((e) => logError("tidy", e));
504 else if (alarm.name === ALARM_HOST) {
570 else if (alarm.name === ALARM_HOST) {
505 const r = await ping();
571 const r = await ping();
506 if (!r.ok || hostUnhealthy()) chrome.alarms.create(ALARM_HOST, { when: Date.now() + HOST_RETRY_MS });
572 if (!r.ok || hostUnhealthy()) chrome.alarms.create(ALARM_HOST, { when: Date.now() + HOST_RETRY_MS });
@@ -514,27 +580,46 @@ chrome.storage.onChanged.addListener((changes, area) => {
514 await ready;
580 await ready;
515 const before = settings;
581 const before = settings;
516 settings = withDefaults(changes.settings.newValue);
582 settings = withDefaults(changes.settings.newValue);
517− if (before.emoji !== settings.emoji) await rewriteTitles();
583+ if (before.emoji !== settings.emoji) await rewriteTitles(before);
584+ if (before.nameUserGroups !== settings.nameUserGroups) applyNameUserGroups();
518 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
585 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
519 if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
586 if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
520 refreshBadge();
587 refreshBadge();
591+/** "Name groups I make too" applies to the groups you already made, not only to new ones. */
592+function applyNameUserGroups(): void {
593+ for (const g of Object.values(state.groups)) {
594+ if (g.origin !== "user" || g.userNamed) continue;
595+ g.managed = settings.nameUserGroups;
597+ markDirty(g, Date.now());
524/** Emoji switched on or off: rewrite the titles the extension wrote. */
604/** Emoji switched on or off: rewrite the titles the extension wrote. */
525−async function rewriteTitles(): Promise<void> {
605+async function rewriteTitles(before: Settings): Promise<void> {
526 for (const g of Object.values(state.groups)) {
606 for (const g of Object.values(state.groups)) {
607+ // Parked is always "user named", so its title is compared instead: one you typed stays.
608+ if (g.origin === "tidy") {
527− if (g.origin === "tidy") await updateGroup(rt, g.id, { title: parkedTitle(settings) });
609+ if ((g.stripTitle ?? "") === parkedTitle(before)) await updateGroup(rt, g.id, { title: parkedTitle(settings) });
528− else if (g.managed && !g.userNamed && g.title) await updateGroup(rt, g.id, { title: stripTitle(g.title, g.emoji, settings.emoji) });
610+ } else if (g.managed && !g.userNamed && g.title) await updateGroup(rt, g.id, { title: stripTitle(g.title, g.emoji, settings.emoji) });
532async function nameGroupNow(groupId: number): Promise<void> {
614async function nameGroupNow(groupId: number): Promise<void> {
533 const g = state.groups[groupId];
615 const g = state.groups[groupId];
534 if (!g) return;
616 if (!g) return;
535− // An explicit request overrides "don't name" and a hand-typed title for this group.
617+ // An explicit request overrides "don't name" and a hand-typed title for this group, and the model's
618+ // answer goes on the strip even when it is close to the label it gave before.
536 g.managed = true;
619 g.managed = true;
537 g.userNamed = false;
620 g.userNamed = false;
621+ g.title = undefined;
622+ g.lastNamedAt = undefined;
538 commit();
623 commit();
539 await naming.nameNow(groupId);
624 await naming.nameNow(groupId);