@@ -3,7 +3,7 @@ import { labelOf, stripTitle } from "../shared/label";
33import { isInternalUrl } from "../shared/url";
44import { addToGroup, createManagedGroup, groupExists, ungroup, updateGroup, type Runtime } from "./runtime";
55import type { Settings } from "./settings";
6−import type { ArchivedTab, State, SweepMove } from "./state";
6+import { newGroupRecord, type ArchivedTab, type GroupRecord, type State, type SweepMove } from "./state";
77
88/** Section 7: park stale tabs in a collapsed group, then archive and close them. No model calls. */
99
@@ -23,11 +23,22 @@ export interface TidyTab {
2323 audible?: boolean;
2424 incognito?: boolean;
2525 discarded?: boolean;
26+ autoDiscardable?: boolean;
2627 lastAccessed?: number;
2728}
2829
30+/**
31+ * The engine's record wins over Chromium's lastAccessed: after a restart Chromium stamps restored tabs
32+ * nobody has looked at yet with the restore time, which would keep them from ever going stale.
33+ */
2934export const idleMs = (tab: TidyTab, state: State, now: number): number =>
30− now - Math.max(tab.lastAccessed ?? 0, state.tabs[tab.id]?.lastActivatedAt ?? 0);
35+ now - (state.tabs[tab.id]?.lastActivatedAt ?? tab.lastAccessed ?? now);
36+
37+/** How long a tab has sat in Parked. Tabs found there with no parking time start their clock now. */
38+export const parkedMs = (tab: TidyTab, state: State, now: number): number => {
39+ const at = state.tabs[tab.id]?.parkedAt;
40+ return at === undefined ? 0 : now - at;
41+};
3142
3243/** Exclusions are substrings of the URL; a pattern written as /…/ is a regular expression. */
3344export function excluded(url: string, patterns: string[]): boolean {
@@ -55,7 +66,8 @@ export function isParkCandidate(tab: TidyTab, state: State, settings: Settings,
5566 if (tab.groupId !== -1) {
5667 const g = state.groups[tab.groupId];
5768 if (g?.keep) return false;
58− if ((!g || g.origin === "user") && !settings.tidyUserGroups) return false;
69+ // Groups you made or renamed are yours, like Diagonal's other rules treat them.
70+ if ((!g || g.origin === "user" || g.userNamed) && !settings.tidyUserGroups) return false;
5971 }
6072 if (excluded(tab.url, settings.tidyExclusions)) return false;
6173 return idleMs(tab, state, now) >= settings.parkAfterHours * HOUR;
@@ -66,10 +78,18 @@ export function parkCandidates(tabs: TidyTab[], state: State, settings: Settings
6678 return tabs.filter((t) => isParkCandidate(t, state, settings, now, parking));
6779}
6880
81+/** Tabs that have sat in Parked for the archive time, counted from when they were parked. */
6982export function archiveCandidates(tabs: TidyTab[], state: State, settings: Settings, now: number): TidyTab[] {
7083 if (!settings.archiveAfterHours) return [];
7184 const parking = parkingGroupIds(state);
72− return tabs.filter((t) => parking.has(t.groupId) && !t.active && !t.audible && idleMs(t, state, now) >= settings.archiveAfterHours * HOUR);
85+ return tabs.filter(
86+ (t) =>
87+ parking.has(t.groupId) &&
88+ !t.active &&
89+ !t.audible &&
90+ !(t.url && excluded(t.url, settings.tidyExclusions)) &&
91+ parkedMs(t, state, now) >= settings.archiveAfterHours * HOUR,
92+ );
7393}
7494
7595/** Prepend entries newest first and drop the oldest past the cap. */
@@ -79,6 +99,15 @@ export function addToArchive(archive: ArchivedTab[], entries: ArchivedTab[], cap
7999
80100export const parkedTitle = (settings: Settings): string => stripTitle("Parked", PARKED_EMOJI, settings.emoji);
81101
102+/** A grey group titled like Parked: Diagonal's Parked group, even where an older version lost track of it. */
103+export const looksParked = (g: { title?: string; color: string }, settings: Settings): boolean =>
104+ g.color === "grey" && (g.title ?? "") === parkedTitle(settings);
105+
106+/** Make a group Diagonal's Parked group again. */
107+export function adoptParked(g: GroupRecord, settings: Settings): void {
108+ Object.assign(g, { origin: "tidy", managed: true, userNamed: true, stripTitle: parkedTitle(settings) });
109+}
110+
82111// ---------------------------------------------------------------------------------------------
83112
84113const liveTabs = async (): Promise<TidyTab[]> =>
@@ -95,6 +124,7 @@ const liveTabs = async (): Promise<TidyTab[]> =>
95124 audible: t.audible,
96125 incognito: t.incognito,
97126 discarded: t.discarded,
127+ autoDiscardable: t.autoDiscardable,
98128 lastAccessed: t.lastAccessed,
99129 }));
100130
@@ -104,11 +134,20 @@ export interface SweepReport {
104134 archived: number;
105135}
106136
137+let sweeps: Promise<unknown> = Promise.resolve();
138+
107139/**
108140 * The 30-minute sweep (manual = "Tidy now" / "Park N tabs": ignores the threshold and the Ask
109− * consent, still respects eligibility).
141+ * consent, still respects eligibility). One at a time: two at once would park, and archive, the
142+ * same tabs twice.
110143 */
111−export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport> {
144+export function runSweep(rt: Runtime, manual = false): Promise<SweepReport> {
145+ const next = sweeps.then(() => sweep(rt, manual));
146+ sweeps = next.catch(() => undefined);
147+ return next;
148+}
149+
150+async function sweep(rt: Runtime, manual: boolean): Promise<SweepReport> {
112151 const settings = rt.settings();
113152 const report: SweepReport = { candidates: 0, parked: 0, archived: 0 };
114153 if (settings.tidyMode === "off" && !manual) {
@@ -118,6 +157,7 @@ export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport
118157 }
119158 const now = rt.now();
120159 let tabs = await liveTabs();
160+ startParkedClocks(rt.state(), tabs, now);
121161 const candidates = parkCandidates(tabs, rt.state(), settings, now);
122162 report.candidates = candidates.length;
123163 // Auto parks whatever is stale; the threshold only decides when Ask mode puts a count on the badge.
@@ -133,8 +173,52 @@ export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport
133173 return report;
134174}
135175
176+/** Tabs already in Parked with no parking time (from before it was recorded) start their archive clock now. */
177+function startParkedClocks(s: State, tabs: TidyTab[], now: number): void {
178+ const parking = parkingGroupIds(s);
179+ for (const t of tabs) {
180+ const rec = s.tabs[t.id];
181+ if (rec && parking.has(t.groupId) && rec.parkedAt === undefined) rec.parkedAt = now;
182+ }
183+}
184+
185+/** What undo needs to make a group again if parking emptied it, named as it was. */
186+const groupShape = (g: GroupRecord): SweepMove["group"] => ({
187+ origin: g.origin,
188+ managed: g.managed,
189+ userNamed: g.userNamed,
190+ title: g.title,
191+ emoji: g.emoji,
192+ stripTitle: g.stripTitle,
193+ color: g.color,
194+ colorLocked: g.colorLocked,
195+ keep: g.keep,
196+ membersHash: g.membersHash,
197+ memberUrls: g.memberUrls,
198+ lastNamedAt: g.lastNamedAt,
199+});
200+
201+/**
202+ * The window's Parked group: the one on record, or a group already showing the Parked title (one an
203+ * older version lost track of after a restart), which becomes Parked again.
204+ */
205+async function parkingGroupIn(rt: Runtime, windowId: number): Promise<number | undefined> {
206+ const s = rt.state();
207+ const known = Object.values(s.groups).find((g) => g.origin === "tidy" && g.windowId === windowId)?.id;
208+ if (known !== undefined && (await groupExists(known))) return known;
209+ const settings = rt.settings();
210+ const shown = (await chrome.tabGroups.query({ windowId }).catch(() => [])).filter((g) => looksParked(g, settings));
211+ for (const g of shown) {
212+ const rec = s.groups[g.id] ?? newGroupRecord(g.id, windowId, "tidy", "grey");
213+ adoptParked(rec, settings);
214+ s.groups[g.id] = rec;
215+ }
216+ return shown[0]?.id;
217+}
218+
136219async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
137220 const settings = rt.settings();
221+ const now = rt.now();
138222 const moves: SweepMove[] = [];
139223 const byWindow = new Map<number, TidyTab[]>();
140224 for (const t of candidates) byWindow.set(t.windowId, [...(byWindow.get(t.windowId) ?? []), t]);
@@ -142,13 +226,15 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
142226 const ids = tabs.map((t) => t.id);
143227 const s = rt.state();
144228 for (const t of tabs) {
145− moves.push({ tabId: t.id, groupId: t.groupId, index: t.index, windowId });
146− const rec = s.tabs[t.id];
147229 const from = t.groupId !== -1 ? s.groups[t.groupId] : undefined;
148− if (rec) rec.parkedFrom = from ? labelOf(from.stripTitle || from.title) || undefined : undefined;
230+ moves.push({ tabId: t.id, groupId: t.groupId, index: t.index, windowId, group: from && groupShape(from) });
231+ const rec = s.tabs[t.id];
232+ if (rec) {
233+ rec.parkedFrom = from ? labelOf(from.stripTitle || from.title) || undefined : undefined;
234+ rec.parkedAt = now;
235+ }
149236 }
150− let parking = Object.values(s.groups).find((g) => g.origin === "tidy" && g.windowId === windowId)?.id;
151− if (parking !== undefined && !(await groupExists(parking))) parking = undefined;
237+ let parking = await parkingGroupIn(rt, windowId);
152238 if (parking === undefined) {
153239 parking = await createManagedGroup(rt, ids, windowId, "tidy", { title: parkedTitle(settings), color: "grey", collapsed: true }, { userNamed: true });
154240 } else {
@@ -162,12 +248,16 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
162248 rt.log("could not move parking group to the end", e);
163249 }
164250 if (settings.discardParked) {
251+ // An extension's discard skips Chromium's own checks, so honour a tab marked "never discard".
165252 for (const t of tabs) {
166− if (!t.discarded) await chrome.tabs.discard(t.id).catch(() => undefined);
253+ if (!t.discarded && t.autoDiscardable !== false) await chrome.tabs.discard(t.id).catch(() => undefined);
167254 }
168255 }
169256 }
170− rt.state().lastSweep = { at: rt.now(), moves };
257+ // Sweeps inside the undo window add up, so Undo puts back everything parked in that hour.
258+ const last = rt.state().lastSweep;
259+ const kept = last && now - last.at < UNDO_WINDOW_MS ? last.moves.filter((m) => !moves.some((n) => n.tabId === m.tabId)) : [];
260+ rt.state().lastSweep = { at: now, moves: [...kept, ...moves] };
171261 rt.commit();
172262 return moves.length;
173263}
@@ -197,18 +287,40 @@ export async function undoSweep(rt: Runtime): Promise<number> {
197287 const last = rt.state().lastSweep;
198288 if (!last || rt.now() - last.at > UNDO_WINDOW_MS) return 0;
199289 const live = new Map((await chrome.tabs.query({})).map((t) => [t.id!, t]));
290+ // Parking every tab of a group removes it: undo makes it again, once, with its title and colour.
291+ const remade = new Map<number, number>();
292+ const target = async (m: SweepMove): Promise<number | undefined> => {
293+ if (m.groupId === -1) return undefined;
294+ const again = remade.get(m.groupId);
295+ if (again !== undefined) return again;
296+ return (await groupExists(m.groupId)) ? m.groupId : undefined;
297+ };
200298 let restored = 0;
201299 for (const m of [...last.moves].sort((a, b) => a.index - b.index)) {
202300 const t = live.get(m.tabId);
203301 if (!t) continue;
204− if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
205− else await ungroup(rt, [m.tabId]);
302+ let groupId = await target(m);
303+ if (groupId !== undefined) await addToGroup(rt, [m.tabId], groupId);
304+ else if (m.groupId !== -1 && m.group) {
305+ const g = m.group;
306+ groupId = await createManagedGroup(rt, [m.tabId], m.windowId, g.origin, { title: g.stripTitle ?? "", color: g.color }, { ...g });
307+ if (groupId !== undefined) remade.set(m.groupId, groupId);
308+ } else {
309+ rt.state().ownUngroups[m.tabId] = rt.now(); // back to loose is the worker's move, not the user pulling it out
310+ await ungroup(rt, [m.tabId]);
311+ }
206312 await chrome.tabs.move(m.tabId, { windowId: m.windowId, index: m.index }).catch(() => undefined);
207313 // Moving a tab next to its old neighbours may pull it into a group they share; re-assert.
208− if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
314+ groupId = await target(m);
315+ if (groupId !== undefined) await addToGroup(rt, [m.tabId], groupId);
316+ else if ((await chrome.tabs.get(m.tabId).catch(() => undefined))?.groupId !== -1) {
317+ rt.state().ownUngroups[m.tabId] = rt.now();
318+ await ungroup(rt, [m.tabId]);
319+ }
209320 const rec = rt.state().tabs[m.tabId];
210321 if (rec) {
211322 rec.parkedFrom = undefined;
323+ rec.parkedAt = undefined;
212324 rec.lastActivatedAt = rt.now(); // un-parking is a touch: do not re-park on the next sweep
213325 }
214326 restored++;