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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10945-the-palette-opens-on-the-pages-you-were → main 7 commits fork 6 threads · 6 unresolved
docs/features/palette-recent.md +7 −6
| @@ -1,11 +1,12 @@ | ||
| 1 | 1 | # The empty palette lists what you opened last |
| 2 | 2 | |
| 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`). | |
| 4 | 4 | |
| 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. | |
| 8 | 9 | - **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. | |
| 10 | 11 | |
| 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 */ ` | ||
| 1714 | 1714 | var RECENT_KEY = 'forge.palette.recent'; |
| 1715 | 1715 | var RECENT_MAX = 50; // entries kept |
| 1716 | 1716 | var RECENT_SHOWN = 8; // entries listed while the box is empty |
| 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 (_) {} | |
| 1717 | 1727 | var index = null; |
| 1718 | 1728 | function loadIndex() { |
| 1719 | 1729 | if (index) return index; |
| @@ -1960,8 +1970,11 @@ const shellCore = /* js */ ` | ||
| 1960 | 1970 | return r; |
| 1961 | 1971 | }); |
| 1962 | 1972 | 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; }); | |
| 1965 | 1978 | var served = []; // rows the server found, for the query servedFor |
| 1966 | 1979 | var servedFor = ''; |
| 1967 | 1980 | var pending = false; // a server answer is on its way |
| @@ -2028,7 +2041,7 @@ const shellCore = /* js */ ` | ||
| 2028 | 2041 | function render(keep) { |
| 2029 | 2042 | armed = null; |
| 2030 | 2043 | 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); | |
| 2032 | 2045 | // "#123" on a repository's page: that number, built here from the page's own path (the server read no issue for it). |
| 2033 | 2046 | var hereBase = loadIndex().here; |
| 2034 | 2047 | var num = /^#(\\d+)$/.exec(q); |
src/web/palette.ts +79 −1
| @@ -224,9 +224,87 @@ export function recordUse( | ||
| 224 | 224 | return frecencyOrder([entry].concat(uses.filter((x) => x.h !== row.h)), now).slice(0, max); |
|
||
| 225 | 225 | } |
| 226 | 226 | |
| 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 | ||
| 227 | 305 | /** |
| 228 | 306 | * The functions above as the JavaScript the page runs: Bun transpiles |
| 229 | 307 | * this module before it ever parses it, so `toString` is already the |
| 230 | 308 | * browser's dialect. A test proves the string and the module agree. |
| 231 | 309 | */ |
| 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 { | ||
| 7 | 7 | rankPalette, |
| 8 | 8 | readUses, |
| 9 | 9 | recordUse, |
| 10 | recordVisit, | |
| 11 | visitRows, | |
| 12 | visitLoad, | |
| 13 | palettePool, | |
| 10 | 14 | type PaletteUse, |
| 11 | 15 | type PaletteGroup, |
| 12 | 16 | type PaletteRow, |
| @@ -246,8 +250,132 @@ describe("recording uses", () => { | ||
| 246 | 250 | |
| 247 | 251 | test("the shell script remembers through the helpers, and lists 8", () => { |
| 248 | 252 | 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)"); | |
| 250 | 254 | expect(shellJs).toContain("RECENT_MAX = 50"); |
| 251 | 255 | expect(shellJs).toContain("RECENT_SHOWN = 8"); |
The block added after this line duplicates the existing |
||
| 252 | 256 | }); |
| 253 | 257 | }); |
| 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 | }); |
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.