@@ -1,14 +1,14 @@
1import { stripTitle } from "../shared/label";
1import { stripTitle } from "../shared/label";
2−import { applyEvent, bury, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
2+import { applyEvent, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
3−import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, REQUEST_ERRORS, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
3+import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, 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 RemovedGroup, type State, type TabRecord } from "./state";
9+import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, 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 { adoptParked, forgetArchived, looksParked, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
11+import { forgetArchived, 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,8 +20,6 @@ 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();
26// ----- state cache ------------------------------------------------------------------------------
24// ----- state cache ------------------------------------------------------------------------------
@@ -93,11 +91,7 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
93 h.lastOkAt = Date.now();
91 h.lastOkAt = Date.now();
94 h.consecutiveFailures = 0;
92 h.consecutiveFailures = 0;
95 h.consecutiveTimeouts = 0;
93 h.consecutiveTimeouts = 0;
96− if (op !== "ping") {
94+ h.rateLimitStep = 0;
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();
101 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
95 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
103 recordHostError(reply.error);
97 recordHostError(reply.error);
@@ -111,8 +105,6 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
113function recordHostError(e: HostError): void {
107function 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;
116 const h = state.host;
108 const h = state.host;
117 const now = Date.now();
109 const now = Date.now();
118 h.lastError = { ...e, at: now };
110 h.lastError = { ...e, at: now };
@@ -144,7 +136,11 @@ async function ping(): Promise<HostReply<PingResult>> {
144 } else if (!reply.result.schemasOk) {
136 } else if (!reply.result.schemasOk) {
145 recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
137 recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
147− recovered();
139+ h.pausedUntil = undefined;
140+ h.lastError = undefined;
141+ chrome.alarms.clear(ALARM_HOST);
142+ void naming.sweepDirty();
143+ void autoOrganizer.sweep();
150 commit();
146 commit();
@@ -152,16 +148,6 @@ async function ping(): Promise<HostReply<PingResult>> {
152 return reply;
148 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();
165// ----- badge -----------------------------------------------------------------------------------
151// ----- badge -----------------------------------------------------------------------------------
167function busy(on: boolean): void {
153function busy(on: boolean): void {
@@ -173,6 +159,7 @@ function hostUnhealthy(): boolean {
173 const h = state.host;
159 const h = state.host;
174 const now = Date.now();
160 const now = Date.now();
175 if (h.pausedUntil && h.pausedUntil > now) return true;
161 if (h.pausedUntil && h.pausedUntil > now) return true;
162+ if (h.consecutiveFailures >= 3) return true;
176 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
163 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
@@ -359,24 +346,16 @@ async function tabById(id: number): Promise<chrome.tabs.Tab | undefined> {
359 * across restarts, so records are matched by id first and then by URL (tabs) or title and colour
346 * across restarts, so records are matched by id first and then by URL (tabs) or title and colour
360 * (groups), which keeps managed groups managed after the browser restarts.
347 * (groups), which keeps managed groups managed after the browser restarts.
362−async function reconcile(reason: "startup" | "installed"): Promise<void> {
349+async function reconcile(): Promise<void> {
363 const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
350 const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
364 const now = Date.now();
351 const now = Date.now();
365− const liveTabIds = new Set(tabs.map((t) => t.id));
352+ const oldTabs = Object.values(state.tabs);
366− // Records of tabs that are gone, by address: after a restart every tab has a new id.
367− const byUrl = new Map<string, TabRecord>(Object.values(state.tabs).filter((t) => !liveTabIds.has(t.id)).map((t) => [t.url, t]));
353+ const byUrl = new Map<string, TabRecord>(oldTabs.map((t) => [t.url, t]));
368 const nextTabs: Record<number, TabRecord> = {};
354 const nextTabs: Record<number, TabRecord> = {};
369 for (const t of tabs) {
355 for (const t of tabs) {
370 if (t.incognito || t.id === undefined) continue;
356 if (t.incognito || t.id === undefined) continue;
371 const url = t.url || t.pendingUrl || "";
357 const url = t.url || t.pendingUrl || "";
372− const same = state.tabs[t.id]?.url === url ? state.tabs[t.id] : undefined;
358+ const prev = state.tabs[t.id]?.url === url ? state.tabs[t.id] : byUrl.get(url);
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);
380 nextTabs[t.id] = {
359 nextTabs[t.id] = {
381 id: t.id,
360 id: t.id,
382 windowId: t.windowId,
361 windowId: t.windowId,
@@ -389,51 +368,25 @@ async function reconcile(reason: "startup" | "installed"): Promise<void> {
389 openerTabId: t.openerTabId,
368 openerTabId: t.openerTabId,
390 description: prev?.description,
369 description: prev?.description,
391 parkedFrom: prev?.parkedFrom,
370 parkedFrom: prev?.parkedFrom,
392− parkedAt: prev?.parkedAt,
393 keepLoose: prev?.keepLoose,
371 keepLoose: prev?.keepLoose,
394 organizedKey: prev?.organizedKey,
372 organizedKey: prev?.organizedKey,
395− organizeRefused: prev?.organizeRefused,
396 handPlaced: prev?.handPlaced,
373 handPlaced: prev?.handPlaced,
397− fitPending: prev?.fitPending,
398 createdAt: prev?.createdAt ?? now,
374 createdAt: prev?.createdAt ?? now,
399− lastActivatedAt: lastSeen || now,
375+ lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
402− const liveGroupIds = new Set(groups.map((g) => g.id));
403 const unmatched = new Map(Object.values(state.groups).map((g) => [g.id, g]));
378 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]
417 const nextGroups: Record<number, GroupRecord> = {};
379 const nextGroups: Record<number, GroupRecord> = {};
418 for (const g of groups) {
380 for (const g of groups) {
419 const title = g.title ?? "";
381 const title = g.title ?? "";
420− let prev: GroupRecord | undefined = unmatched.get(g.id);
382+ let prev = unmatched.get(g.id);
421− if (prev && (prev.stripTitle ?? "") !== title) prev = undefined;
383+ if (!prev || (prev.stripTitle ?? "") !== title) {
422− const before = !prev || fresh(prev) ? lookalike(title, g.color) : undefined;
384+ prev = [...unmatched.values()].find((o) => (o.stripTitle ?? "") === title && title !== "" && o.color === g.color);
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 });
435− // Earlier versions turned Parked into a plain group after a restart: give it back its job.
386+ if (prev) unmatched.delete(prev.id);
436− if (rec.origin === "user" && looksParked(g, settings)) adoptParked(rec, settings);
387+ const rec: GroupRecord = prev
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 });
437 if (rec.managed) {
390 if (rec.managed) {
438 rec.dirty = true;
391 rec.dirty = true;
439 rec.dirtyAt = 0;
392 rec.dirtyAt = 0;
@@ -441,17 +394,14 @@ async function reconcile(reason: "startup" | "installed"): Promise<void> {
442 nextGroups[g.id] = rec;
395 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);
446 state.tabs = nextTabs;
397 state.tabs = nextTabs;
447 state.groups = nextGroups;
398 state.groups = nextGroups;
448 state.pendingCreates = [];
399 state.pendingCreates = [];
449 state.ownWrites = {};
400 state.ownWrites = {};
450 state.ownUngroups = {};
401 state.ownUngroups = {};
451 state.ownAdds = {};
402 state.ownAdds = {};
452− // Undo lists name tabs by id, and after a restart none of those ids exist.
453− if (reason === "startup" || (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS)) state.lastSweep = undefined;
403+ if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
454− if (reason === "startup" || (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS)) state.lastOrganize = undefined;
404+ if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
455 commit();
405 commit();
@@ -464,13 +414,9 @@ async function injectBacklog(): Promise<void> {
467−let reconciled: Promise<void> | undefined;
469async function boot(reason: "startup" | "installed"): Promise<void> {
417async function boot(reason: "startup" | "installed"): Promise<void> {
470− // Queued before anything else this wake handles (serial waits for `ready` itself): after a restart
418+ await ready;
471− // Chromium replays the restored groups right after onStartup, and they must meet the reconciled state.
472− reconciled = serial("reconcile", () => reconcile(reason));
419+ await serial("reconcile", reconcile);
474 if (!(await chrome.alarms.get(ALARM_TIDY))) chrome.alarms.create(ALARM_TIDY, { periodInMinutes: 30, delayInMinutes: 1 });
420 if (!(await chrome.alarms.get(ALARM_TIDY))) chrome.alarms.create(ALARM_TIDY, { periodInMinutes: 30, delayInMinutes: 1 });
475 if (reason === "installed") void injectBacklog();
421 if (reason === "installed") void injectBacklog();
476 await ping();
422 await ping();
@@ -512,29 +458,20 @@ if (GROUPS_SUPPORTED) {
513 chrome.tabs.onReplaced.addListener((added, removed) =>
459 chrome.tabs.onReplaced.addListener((added, removed) =>
514 void serial("tabs.onReplaced", async () => {
460 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.
517 const rec = state.tabs[removed];
461 const rec = state.tabs[removed];
518 delete state.tabs[removed];
462 delete state.tabs[removed];
519 const t = await tabById(added);
463 const t = await tabById(added);
521− if (rec && !state.tabs[added]) state.tabs[added] = { ...rec, id: added };
522 await feed({ type: "tabUpdated", tab: snapTab(t) });
465 await feed({ type: "tabUpdated", tab: snapTab(t) });
466+ if (rec?.description && state.tabs[added]) state.tabs[added].description = rec.description;
527 chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
471 chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
529− // A group is read when its turn comes, not as Chromium sent it: a restored group reports itself
473+ chrome.tabGroups.onCreated.addListener((g) => void serial("tabGroups.onCreated", () => feed({ type: "groupCreated", group: snapGroup(g) })));
530− // untitled and then titled, and replaying the stale "untitled" would read as you clearing the title.
474+ chrome.tabGroups.onUpdated.addListener((g) => void serial("tabGroups.onUpdated", () => feed({ type: "groupUpdated", group: snapGroup(g) })));
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"));
538 chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
475 chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
@@ -563,10 +500,7 @@ chrome.alarms.onAlarm.addListener((alarm) => {
563 await ready;
500 await ready;
564 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
501 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
565 else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
502 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
568− await runSweep(rt).catch((e) => logError("tidy", e));
503+ else if (alarm.name === ALARM_TIDY) await runSweep(rt).catch((e) => logError("tidy", e));
570 else if (alarm.name === ALARM_HOST) {
504 else if (alarm.name === ALARM_HOST) {
571 const r = await ping();
505 const r = await ping();
572 if (!r.ok || hostUnhealthy()) chrome.alarms.create(ALARM_HOST, { when: Date.now() + HOST_RETRY_MS });
506 if (!r.ok || hostUnhealthy()) chrome.alarms.create(ALARM_HOST, { when: Date.now() + HOST_RETRY_MS });
@@ -580,46 +514,27 @@ chrome.storage.onChanged.addListener((changes, area) => {
580 await ready;
514 await ready;
581 const before = settings;
515 const before = settings;
582 settings = withDefaults(changes.settings.newValue);
516 settings = withDefaults(changes.settings.newValue);
583− if (before.emoji !== settings.emoji) await rewriteTitles(before);
517+ if (before.emoji !== settings.emoji) await rewriteTitles();
584− if (before.nameUserGroups !== settings.nameUserGroups) applyNameUserGroups();
585 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
518 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
586 if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
519 if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
587 refreshBadge();
520 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());
604/** Emoji switched on or off: rewrite the titles the extension wrote. */
524/** Emoji switched on or off: rewrite the titles the extension wrote. */
605−async function rewriteTitles(before: Settings): Promise<void> {
525+async function rewriteTitles(): Promise<void> {
606 for (const g of Object.values(state.groups)) {
526 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") {
609− if ((g.stripTitle ?? "") === parkedTitle(before)) await updateGroup(rt, g.id, { title: parkedTitle(settings) });
527+ if (g.origin === "tidy") await updateGroup(rt, g.id, { title: parkedTitle(settings) });
610− } else if (g.managed && !g.userNamed && g.title) await updateGroup(rt, g.id, { title: stripTitle(g.title, g.emoji, settings.emoji) });
528+ else if (g.managed && !g.userNamed && g.title) await updateGroup(rt, g.id, { title: stripTitle(g.title, g.emoji, settings.emoji) });
614async function nameGroupNow(groupId: number): Promise<void> {
532async function nameGroupNow(groupId: number): Promise<void> {
615 const g = state.groups[groupId];
533 const g = state.groups[groupId];
616 if (!g) return;
534 if (!g) return;
617− // An explicit request overrides "don't name" and a hand-typed title for this group, and the model's
535+ // An explicit request overrides "don't name" and a hand-typed title for this group.
618− // answer goes on the strip even when it is close to the label it gave before.
619 g.managed = true;
536 g.managed = true;
620 g.userNamed = false;
537 g.userNamed = false;
621− g.title = undefined;
622− g.lastNamedAt = undefined;
623 commit();
538 commit();
624 await naming.nameNow(groupId);
539 await naming.nameNow(groupId);