Public: anyone can read this; the crew can change it.

The palette opens on the pages you were just on, so a returning owner picks up where they left off #10980

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10945-the-palette-opens-on-the-pages-you-were → main 7 commits fork 6 threads · 6 unresolved

  • Merged. scout/10945-the-palette-opens-on-the-pages-you-were was merged into main as d05b37d.

Review

Sign in to review this change.
4 files changed +231 −11
docs/features/palette-recent.md +7 −6
@@ -1,11 +1,12 @@
11 # The empty palette lists what you opened last
22
3 Open the command palette (Cmd/Ctrl K, or the phone search sheet) and type nothing: the first group, "Recent", lists the rows you opened before, ordered by frecency, then the usual rows follow.
3 Open the command palette (Cmd/Ctrl K, or the phone search sheet) and type nothing: the first group, "Recent", holds at most five rows, then the usual rows follow. The pages you visited come first, newest first; when fewer than five, rows you picked before fill the rest, ordered by frecency. Each page is listed once (a page both visited and picked sits where its visit puts it), and never the page you are on (`palettePool`, `src/web/palette.ts`).
44
5 - **Frecency:** each opening counts one, halved for every week since the last. A row opened often and a row opened just now both lead a row opened once long ago (`frecencyOrder`, `src/web/palette.ts`).
6 - **Local:** the list lives in this browser's `localStorage` (`forge.palette.recent`). Nothing is sent to the server, and clearing site data clears it.
7 - **Limits:** 50 rows are kept (the least useful falls off when a 51st is opened); 8 are shown while the box is empty. Typing searches everything as before.
5 - **Visits:** paths only, kept in `localStorage` (`forge.palette.visits`), never titles: a title is looked up from the palette's own rows. Only a page the palette has a row for is recorded, so error and login pages never are (`recordVisit`, `visitLoad`, `visitRows`). Logging out does not clear the list: it lives in the browser, so on a shared machine clear site data.
6 - **Frecency:** each opening counts one, halved for every week since the last. A row opened often and a row opened just now both lead a row opened once long ago (`frecencyOrder`).
7 - **Local:** the picked rows live in this browser's `localStorage` (`forge.palette.recent`). Nothing is sent to the server, and clearing site data clears both lists. Storage is read once each time the palette opens, not per keystroke.
8 - **Limits:** 50 picked rows are kept (the least useful falls off when a 51st is opened); the Recent group shows at most five. Typing hides it and searches everything as before.
89 - **Older lists:** entries saved before counts existed read as one opening.
9 - **Terminal:** the terminal's palette (`src/tui/`) has its own ranking and no browser storage; it is out of scope here.
10 - **Terminal:** the terminal's palette (`src/tui/`) has its own ranking and no browser storage; it is out of scope here, because both lists are browser storage.
1011
11 Recording and reading are the pure `recordUse` and `readUses`; they ride in the shell script with the other palette helpers. Tests: `test/palette.test.ts`.
12 Recording and reading are the pure `recordUse` and `readUses`; they ride in the shell script with the other palette helpers. Tests: `test/palette.test.ts`, including the shell itself run in happy-dom (a load records its page, the next page's palette opens on it, typing reads no storage).
src/web/client.ts +16 −3
@@ -1714,6 +1714,16 @@ const shellCore = /* js */ `
17141714 var RECENT_KEY = 'forge.palette.recent';
17151715 var RECENT_MAX = 50; // entries kept
17161716 var RECENT_SHOWN = 8; // entries listed while the box is empty
outdated

Wiring with no test that would fail without it (box 4 / tests rule). Extract the load-record and pool-building into the pure module and test them, not only that the function names appear in the script.

1717 var VISITS_KEY = 'forge.palette.visits';
1718 var VISITS_MAX = 5; // rows in the Recent group while the box is empty (visits, then picked)
1719 // Pages visited: paths only, never titles; each load records its page once.
1720 function readVisits() {
1721 try { var v = JSON.parse(window.localStorage.getItem(VISITS_KEY) || '[]'); return Array.isArray(v) ? v : []; } catch (_) { return []; }
1722 }
1723 try {
1724 var seenRows = (function () { try { return JSON.parse(doc.getElementById('palette-index').textContent).items || []; } catch (_) { return []; } })();
1725 window.localStorage.setItem(VISITS_KEY, JSON.stringify(visitLoad(readVisits(), window.location.pathname, seenRows, VISITS_MAX + 1)));
1726 } catch (_) {}
17171727 var index = null;
17181728 function loadIndex() {
17191729 if (index) return index;
@@ -1960,8 +1970,11 @@ const shellCore = /* js */ `
19601970 return r;
19611971 });
19621972 var tellApp = (loadIndex().tell || {}).app;
1963 var recent = frecencyOrder(readRecent(), Date.now()).slice(0, RECENT_SHOWN).map(function (r) { return { t: r.t, h: r.h, g: 'Recent', d: r.g }; });
1964 var recentHrefs = recent.map(function (r) { return r.h; });
1973 // Storage is read once per opening, not per keystroke.
1974 var openedAt = Date.now();
1975 var visits = readVisits();
1976 var uses = readRecent();
1977 var recentHrefs = frecencyOrder(uses, openedAt).slice(0, RECENT_SHOWN).map(function (r) { return r.h; });
19651978 var served = []; // rows the server found, for the query servedFor
19661979 var servedFor = '';
19671980 var pending = false; // a server answer is on its way
@@ -2028,7 +2041,7 @@ const shellCore = /* js */ `
20282041 function render(keep) {
20292042 armed = null;
20302043 var q = input.value.trim();
2031 var pool = (q === '' ? recent : []).concat(items, q.length >= 2 ? served : []);
2044 var pool = palettePool(q, items, served, visits, uses, window.location.pathname, openedAt, VISITS_MAX);
20322045 // "#123" on a repository's page: that number, built here from the page's own path (the server read no issue for it).
20332046 var hereBase = loadIndex().here;
20342047 var num = /^#(\\d+)$/.exec(q);
src/web/palette.ts +79 −1
@@ -224,9 +224,87 @@ export function recordUse(
224224 return frecencyOrder([entry].concat(uses.filter((x) => x.h !== row.h)), now).slice(0, max);

palettePool concatenates up to shown visits and up to picked uses under one 'Recent' title: up to 13 rows, against 'up to 5' in the issue. Cap the group and dedupe visits against uses.

225225 }
226226
227 /**
228 * The visited pages after one more at `href`: newest first, the page moved
229 * to the front when seen before (so a page is listed once), cut to `max`.
230 * Only same-origin paths are kept, and only the path: no title is stored,
231 * so a shared machine learns no private name. Self-contained.
232 */
233 export function recordVisit(visits: readonly string[], href: string, max: number): string[] {
234 if (typeof href !== "string" || href.charAt(0) !== "/" || href.charAt(1) === "/") return visits.slice(0, max);
235 return [href].concat(visits.filter((v) => v !== href)).slice(0, max);
236 }
237
238 /**
239 * The "Recent" rows for an empty box: the stored visits, newest first, each
240 * titled from the row the palette already has for that href (a visit with no
241 * row is skipped), never the page `here`, at most `limit`. Self-contained.
242 */
243 export function visitRows(
244 visits: readonly unknown[],
245 rows: readonly PaletteRow[],
246 here: string,
247 limit: number,
248 ): PaletteRow[] {
249 const out: PaletteRow[] = [];
250 const seen = new Set<string>();
251 for (const v of visits) {
252 if (out.length >= limit) break;
253 if (typeof v !== "string" || v === here || seen.has(v)) continue;
254 const r = rows.find((x) => x.h === v);
255 if (r === undefined) continue;
256 seen.add(v);
257 out.push({ t: r.t, h: r.h, g: "Recent", d: r.g });
258 }
259 return out;
260 }
261
262 /**
263 * What a page load stores: the visit list with `href` added, but only when the
264 * palette has a row for that page (error pages, login and the like are not
265 * visits worth listing). Self-contained apart from `recordVisit`.
266 */
267 export function visitLoad(
268 visits: readonly string[],
269 href: string,
270 rows: readonly { h: string }[],
271 max: number,
272 ): string[] {
273 return rows.some((r) => r.h === href) ? recordVisit(visits, href, max) : visits.slice(0, max);
274 }
275
276 /**
277 * The rows `rankPalette` is given. With nothing typed: one "Recent" group of
278 * at most `shown` rows, the visited pages first (newest first), then the rows
279 * picked before (by frecency) to fill it, each href once and never the page
280 * `here`; then the rest. Typed: no Recent rows, the rest and, from two
281 * letters, what the server `served`. Self-contained apart from `visitRows`
282 * and `frecencyOrder`.
283 */
284 export function palettePool(
285 query: string,
286 rows: readonly PaletteRow[],
287 served: readonly PaletteRow[],
288 visits: readonly unknown[],
289 uses: readonly PaletteUse[],
290 here: string,
291 now: number,
292 shown: number,
293 ): PaletteRow[] {
294 const q = query.trim();
295 if (q !== "") return rows.concat(q.length >= 2 ? served : []);
296 const head = visitRows(visits, rows, here, shown);
297 for (const u of frecencyOrder(uses, now)) {
298 if (head.length >= shown) break;
299 if (u.h === here || head.some((r) => r.h === u.h)) continue;
300 head.push({ t: u.t, h: u.h, g: "Recent", d: u.g });
301 }
302 return head.concat(rows);
303 }
304
227305 /**
228306 * The functions above as the JavaScript the page runs: Bun transpiles
229307 * this module before it ever parses it, so `toString` is already the
230308 * browser's dialect. A test proves the string and the module agree.
231309 */
232 export const paletteHelpersJs = `${fuzzyScore.toString()}\n${rankPalette.toString()}\n${opensAsSheet.toString()}\n${frecencyOrder.toString()}\n${readUses.toString()}\n${recordUse.toString()}`;
310 export const paletteHelpersJs = `${fuzzyScore.toString()}\n${rankPalette.toString()}\n${opensAsSheet.toString()}\n${frecencyOrder.toString()}\n${readUses.toString()}\n${recordUse.toString()}\n${recordVisit.toString()}\n${visitRows.toString()}\n${visitLoad.toString()}\n${palettePool.toString()}`;
test/palette.test.ts +129 −1
@@ -7,6 +7,10 @@ import {
77 rankPalette,
88 readUses,
99 recordUse,
10 recordVisit,
11 visitRows,
12 visitLoad,
13 palettePool,
1014 type PaletteUse,
1115 type PaletteGroup,
1216 type PaletteRow,
@@ -246,8 +250,132 @@ describe("recording uses", () => {
246250
247251 test("the shell script remembers through the helpers, and lists 8", () => {
248252 expect(shellJs).toContain("recordUse(readRecent()");
249 expect(shellJs).toContain("frecencyOrder(readRecent(), Date.now()).slice(0, RECENT_SHOWN)");
253 expect(shellJs).toContain("frecencyOrder(uses, openedAt).slice(0, RECENT_SHOWN)");
250254 expect(shellJs).toContain("RECENT_MAX = 50");
251255 expect(shellJs).toContain("RECENT_SHOWN = 8");

The block added after this line duplicates the existing recording uses describe and a second recent visits describe. Remove the copy and the weak duplicate; keep the palettePool tests.

252256 });
253257 });
258
259 describe("recent visits", () => {
260 const rows: PaletteRow[] = ["a", "b", "c", "d", "e", "f", "g"].map((n) => ({ t: `Page ${n}`, h: `/${n}`, g: "Go to" }));
261 test("a visit is recorded once, newest first, at most max, paths only", () => {
262 let v: string[] = [];
263 for (const n of ["a", "b", "a", "c"]) v = recordVisit(v, `/${n}`, 3);
264 expect(v).toEqual(["/c", "/a", "/b"]);
265 expect(recordVisit(v, "/d", 3)).toEqual(["/d", "/c", "/a"]);
266 expect(recordVisit(v, "//evil.example", 3)).toEqual(v);
267 expect(recordVisit(v, "https://x.example/", 3)).toEqual(v);
268 });
269 test("the visits list up to five, titled from rows, never the current page", () => {
270 const v = ["/a", "/b", "/c", "/d", "/e", "/f", "/g"];
271 const got = visitRows(v, rows, "/a", 5);
272 expect(got.map((r) => r.h)).toEqual(["/b", "/c", "/d", "/e", "/f"]);
273 expect(got[0]).toEqual({ t: "Page b", h: "/b", g: "Recent", d: "Go to" });
274 });
275 test("a visit with no row, a repeat or a non-string is skipped", () => {
276 expect(visitRows(["/zzz", "/b", "/b", 7, "/c"], rows, "/", 5).map((r) => r.h)).toEqual(["/b", "/c"]);
277 });
278 const use = (h: string, c = 1, l = 1000): PaletteUse => ({ t: `Page ${h.slice(1)}`, h, g: "Go to", c, l });
279 test("a load records its page only when the palette has a row for it", () => {
280 expect(visitLoad(["/b"], "/a", rows, 6)).toEqual(["/a", "/b"]);
281 expect(visitLoad(["/b"], "/login", rows, 6)).toEqual(["/b"]);
outdated

This 'typing hides it' check passes without the feature: rankPalette is called with rows that never held the group. The hiding happens in the client (q === '' ? recent : []), which is untested.

282 });
283 test("empty query: visited pages first, then picked ones, both Recent, never the page you are on", () => {
284 const pool = palettePool("", rows, [], ["/c", "/a"], [use("/d", 9), use("/a", 99), use("/e", 1)], "/a", 1000, 5);
285 expect(pool.slice(0, 3).map((r) => r.h)).toEqual(["/c", "/d", "/e"]);
286 expect(pool.slice(0, 3).every((r) => r.g === "Recent")).toBe(true);
287 expect(pool.slice(0, 3).map((r) => r.d)).toEqual(["Go to", "Go to", "Go to"]);
288 expect(pool.slice(3)).toEqual(rows);
289 const groups = rankPalette(pool, "", [], 24);
290 expect(groups[0]!.title).toBe("Recent");
291 expect(groups[0]!.rows.map((r) => r.h)).toEqual(["/c", "/d", "/e"]);
292 });
293 test("the whole Recent group is at most five: visits first, picked rows only fill it", () => {
294 const visits = ["/b", "/c", "/d"];
295 const uses = ["/e", "/f", "/g", "/a"].map((h, i) => use(h, 10 - i));
296 const recent = palettePool("", rows, [], visits, uses, "/", 1000, 5).filter((r) => r.g === "Recent");
297 expect(recent.map((r) => r.h)).toEqual(["/b", "/c", "/d", "/e", "/f"]);
298 const full = palettePool("", rows, [], ["/a", "/b", "/c", "/d", "/e", "/f"], uses, "/", 1000, 5).filter((r) => r.g === "Recent");
299 expect(full.map((r) => r.h)).toEqual(["/a", "/b", "/c", "/d", "/e"]);
300 });
301 test("a page both visited and picked is listed once, where its visit puts it", () => {
302 const pool = palettePool("", rows, [], ["/c", "/b"], [use("/b", 50), use("/d", 1)], "/", 1000, 5);
303 const recent = pool.filter((r) => r.g === "Recent");
304 expect(recent.map((r) => r.h)).toEqual(["/c", "/b", "/d"]);
305 const groups = rankPalette(pool, "", [], 24);
306 expect(groups[0]!.rows.filter((r) => r.h === "/b").length).toBe(1);
307 });
308 test("typing hides every Recent row, keeps the rest and adds served rows from two letters", () => {
309 const served: PaletteRow[] = [{ t: "x/y", h: "/x/y", g: "Repositories" }];
310 expect(palettePool("p", rows, served, ["/c"], [use("/d")], "/a", 1000, 5)).toEqual(rows);
311 const typed = palettePool("pa", rows, served, ["/c"], [use("/d")], "/a", 1000, 5);
312 expect(typed).toEqual([...rows, ...served]);
313 expect(rankPalette(typed, "page", [], 24).some((g) => g.title === "Recent")).toBe(false);
314 });
315 });
316
317 describe("recent visits in the running shell", () => {
318 const index = JSON.stringify({ items: ["a", "b", "c"].map((n) => ({ t: `Page ${n}`, h: `/${n}` })) });
319 const body = `<script type="application/json" id="palette-index">${index}</script>
320 <dialog class="dialog palette" id="palette"><input class="input palette-input" type="text" /><div class="palette-list" id="palette-list"></div></dialog>`;
321 /** The shell running on `path` over a localStorage seeded with `stored`, as one page load. */
322 async function shellPageWith(path: string, stored: Record<string, string>) {
323 const { Window } = await import("happy-dom");
324 const win = new Window({ url: `http://forge.test${path}` });
325 for (const [k, v] of Object.entries(stored)) win.localStorage.setItem(k, v);
326 win.document.body.innerHTML = body;
327 (win.HTMLElement.prototype as any).scrollIntoView ??= () => {};
328 new Function("document", "window", "location", "sessionStorage", "CSS", "fetch", "EventSource", shellJs)(
329 win.document, win, win.location, win.sessionStorage, { escape: (x: string) => x },
330 async () => ({ ok: false, status: 500, text: async () => "", headers: { get: () => null } }), undefined,
331 );
332 const visits = () => JSON.parse(win.localStorage.getItem("forge.palette.visits") ?? "[]");
333 const open = () => {
334 win.document.dispatchEvent(new win.KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }));
335 const doc = win.document as any;
336 const groups = [...doc.querySelectorAll("#palette-list .palette-group")].map((g: any) => ({
337 title: g.querySelector(".palette-heading")?.textContent,
338 hrefs: [...g.querySelectorAll("a")].map((a: any) => a.getAttribute("href")),
339 }));
340 return groups;
341 };
342 return { win, visits, open, stored: () => ({ "forge.palette.visits": win.localStorage.getItem("forge.palette.visits") ?? "[]" }) };
343 }
344
345 test("each load records its page, and the next page's palette opens on it", async () => {
346 let p = await shellPageWith("/a", {});
347 expect(p.visits()).toEqual(["/a"]);
348 p = await shellPageWith("/b", p.stored());
349 expect(p.visits()).toEqual(["/b", "/a"]);
350 p = await shellPageWith("/c", p.stored());
351 const groups = p.open();
352 expect(groups[0]).toEqual({ title: "Recent", hrefs: ["/b", "/a"] });
353 });
354
355 test("a page with no palette row is not recorded", async () => {
356 const p = await shellPageWith("/login", { "forge.palette.visits": JSON.stringify(["/a"]) });
357 expect(p.visits()).toEqual(["/a"]);
358 });
359
360 test("storage is read once per opening, not per keystroke", async () => {
361 const p = await shellPageWith("/c", { "forge.palette.visits": JSON.stringify(["/c", "/a"]) });
362 const real = p.win.localStorage;
363 let reads = 0;
364 const counting = {
365 getItem: (k: string) => { reads++; return real.getItem(k); },
366 setItem: (k: string, v: string) => real.setItem(k, v),
367 removeItem: (k: string) => real.removeItem(k),
368 };
369 Object.defineProperty(p.win, "localStorage", { value: counting, configurable: true });
370 const groups = p.open();
371 expect(groups[0]).toEqual({ title: "Recent", hrefs: ["/a"] });
372 const opened = reads;
373 expect(opened).toBeGreaterThan(0);
374 const input = p.win.document.querySelector(".palette-input") as any;
375 for (const ch of ["p", "pa", "pag", ""]) {
376 input.value = ch;
377 input.dispatchEvent(new p.win.Event("input", { bubbles: true }));
378 }
379 expect(reads).toBe(opened);
380 });
381 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs