on master help sign in
Sort and name tabs the browser discarded to save memory
fae43a2Claude
src/background/fit.ts+2−2
@@ -1,6 +1,6 @@
1import { isInternalUrl } from "../shared/url";
1import { isInternalUrl } from "../shared/url";
2import { fitCheckable } from "./engine";
2import { fitCheckable } from "./engine";
3−import { toItems, type Member } from "./naming";
3+import { settled, toItems, type Member } from "./naming";
4import type { OrganizePayload, OrganizeResult } from "./organize";
4import type { OrganizePayload, OrganizeResult } from "./organize";
5import { ungroup, type Runtime } from "./runtime";
5import { ungroup, type Runtime } from "./runtime";
6
6
@@ -75,7 +75,7 @@ export class FitChecker {
75 if (pausedUntil && pausedUntil > rt.now()) return;
75 if (pausedUntil && pausedUntil > rt.now()) return;
76
76
77 const others = (await chrome.tabs.query({ groupId: rec.groupId }).catch(() => []))
77 const others = (await chrome.tabs.query({ groupId: rec.groupId }).catch(() => []))
78− .filter((t) => t.id !== tabId && t.status === "complete" && !isInternalUrl(t.url))
78+ .filter((t) => t.id !== tabId && settled(t) && !isInternalUrl(t.url))
79 .sort((a, b) => (b.lastAccessed ?? 0) - (a.lastAccessed ?? 0))
79 .sort((a, b) => (b.lastAccessed ?? 0) - (a.lastAccessed ?? 0))
80 .slice(0, FIT_SAMPLE);
80 .slice(0, FIT_SAMPLE);
81 if (others.length < MIN_OTHERS) return done();
81 if (others.length < MIN_OTHERS) return done();
src/background/naming.ts+9−3
@@ -49,9 +49,15 @@ export interface NameResult {
49 emoji: string;
49 emoji: string;
50}
50}
51
51
52−/** Tabs that can describe a group: loaded web pages, not the new-tab page or internal pages. */
52+/** Tabs that can describe a group: web pages, not the new-tab page or internal pages. */
53export const nameable = (m: Member): boolean => !isInternalUrl(m.url);
53export const nameable = (m: Member): boolean => !isInternalUrl(m.url);
54
54
55+/**
56+ * Done loading, or not loaded at all: a tab the browser discarded to save memory (status "unloaded") keeps
57+ * its title and address, so it can describe its group without being loaded again.
58+ */
59+export const settled = (t: { status?: string }): boolean => t.status !== "loading";
60+
55/** sha-1 of the sorted `url|title` lines: reorder-proof, changes when a member or its title does. */
61/** sha-1 of the sorted `url|title` lines: reorder-proof, changes when a member or its title does. */
56export const membersHash = (members: Pick<Member, "url" | "title">[]): string =>
62export const membersHash = (members: Pick<Member, "url" | "title">[]): string =>
57 sha1(members.map((m) => `${m.url}|${m.title}`).sort().join("\n"));
63 sha1(members.map((m) => `${m.url}|${m.title}`).sort().join("\n"));
@@ -214,7 +220,7 @@ export class Naming {
214 }
220 }
215 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
221 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
216 if (!members) return;
222 if (!members) return;
217− if (members.length < 2 || members.some((m) => m.status !== "complete")) return; // stay dirty; next event or alarm
223+ if (members.length < 2 || !members.every(settled)) return; // stay dirty; the load finishing touches it again
218 if (!force && membersHash(members) === g.membersHash) {
224 if (!force && membersHash(members) === g.membersHash) {
219 g.dirty = false;
225 g.dirty = false;
220 this.d.commit();
226 this.d.commit();
@@ -252,7 +258,7 @@ export class Naming {
252 if (!g || !eligible(g, settings)) return;
258 if (!g || !eligible(g, settings)) return;
253 // Built lazily: a group that changed three times while queued is described once.
259 // Built lazily: a group that changed three times while queued is described once.
254 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
260 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
255− if (!members || members.length < 2 || members.some((m) => m.status !== "complete")) return;
261+ if (!members || members.length < 2 || !members.every(settled)) return;
256 const hash = membersHash(members);
262 const hash = membersHash(members);
257 if (!force && hash === g.membersHash) {
263 if (!force && hash === g.membersHash) {
258 g.dirty = false;
264 g.dirty = false;
src/background/organize.ts+4−3
@@ -2,7 +2,7 @@ import { COLORS, isColor, type GroupColor } from "../shared/colors";
2import { labelOf, repairLabel, safeEmoji, stripTitle } from "../shared/label";
2import { labelOf, repairLabel, safeEmoji, stripTitle } from "../shared/label";
3import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
3import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
4import { pathKey } from "./engine";
4import { pathKey } from "./engine";
5−import { membersHash, memberUrls, toItems, type Member, type NameItem } from "./naming";
5+import { membersHash, memberUrls, settled, toItems, type Member, type NameItem } from "./naming";
6import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
6import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
7import { markDirty, type State } from "./state";
7import { markDirty, type State } from "./state";
8
8
@@ -377,8 +377,9 @@ export class AutoOrganizer {
377 if (pausedUntil && pausedUntil > rt.now()) return; // the host-retry ping sweeps again when it recovers
377 if (pausedUntil && pausedUntil > rt.now()) return; // the host-retry ping sweeps again when it recovers
378 const tabs = (await chrome.tabs.query({ windowId }).catch(() => [])).sort((a, b) => a.index - b.index);
378 const tabs = (await chrome.tabs.query({ windowId }).catch(() => [])).sort((a, b) => a.index - b.index);
379 const s = rt.state();
379 const s = rt.state();
380− // Still-loading tabs are skipped here; finishing the load touches the window again.
380+ // Still-loading tabs are skipped here; finishing the load touches the window again. Discarded tabs keep
381+ // their title and address, so they're sorted like loaded ones.
381− const loose = tabs.filter((t) => organizable(t) && t.status === "complete" && !s.tabs[t.id!]?.keepLoose);
382+ const loose = tabs.filter((t) => organizable(t) && settled(t) && !s.tabs[t.id!]?.keepLoose);
382 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
383 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
383 if (!fresh.length) return;
384 if (!fresh.length) return;
384 const { ids: existingIds, list: existingGroups } = existingGroupsFor(s, windowId);
385 const { ids: existingIds, list: existingGroups } = existingGroupsFor(s, windowId);
test/auto.test.ts+6
@@ -109,6 +109,12 @@ describe("auto-organize", () => {
109 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 5", "Tab 6"]);
109 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 5", "Tab 6"]);
110 });
110 });
111
111
112+ it("sorts tabs the browser discarded, which keep their title and address", async () => {
113+ const auto = setup([liveTab(1, { status: "unloaded" }), liveTab(2)]);
114+ await auto.run(1);
115+ expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 1", "Tab 2"]);
116+ });
117+
112 it("one loose tab with nothing to join waits for company", async () => {
118 it("one loose tab with nothing to join waits for company", async () => {
113 const auto = setup([liveTab(1)]);
119 const auto = setup([liveTab(1)]);
114 await auto.run(1);
120 await auto.run(1);
test/naming.test.ts+8
@@ -162,6 +162,14 @@ describe("naming queue", () => {
162 expect(state.groups[7].dirty).toBe(true);
162 expect(state.groups[7].dirty).toBe(true);
163 });
163 });
164
164
165+ it("names a group whose member the browser discarded to save memory", async () => {
166+ members = [member(1), member(2, { status: "unloaded" })];
167+ naming.touch(7);
168+ await settle();
169+ expect(calls).toHaveLength(1);
170+ expect(state.groups[7].dirty).toBe(false);
171+ });
172+
165 it("new-tab pages do not count as members", async () => {
173 it("new-tab pages do not count as members", async () => {
166 members = [member(1), member(2, { url: "brave://newtab/" })];
174 members = [member(1), member(2, { url: "brave://newtab/" })];
167 naming.touch(7);
175 naming.touch(7);