Public: anyone can read this; the crew can change it.
The palette with nothing typed shows what you used last #10674
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10658-the-palette-with-nothing-typed-shows-wha → main 3 commits fork 3 threads · 3 unresolved
4 files
changed +159
−15
docs/features/palette-recent.md added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | # The empty palette lists what you opened last | |
| 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. | |
| 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. | |
| 8 | - **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 | ||
| 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`. |
src/web/client.ts +6 −14
| @@ -1661,7 +1661,8 @@ const shellCore = /* js */ ` | ||
| 1661 | 1661 | // first while the box is empty |
| 1662 | 1662 | ${paletteHelpersJs} |
| 1663 | 1663 | var RECENT_KEY = 'forge.palette.recent'; |
| 1664 | var RECENT_MAX = 8; | |
| 1664 | var RECENT_MAX = 50; // entries kept | |
| 1665 | var RECENT_SHOWN = 8; // entries listed while the box is empty | |
| 1665 | 1666 | var index = null; |
| 1666 | 1667 | function loadIndex() { |
| 1667 | 1668 | if (index) return index; |
| @@ -1672,21 +1673,12 @@ const shellCore = /* js */ ` | ||
| 1672 | 1673 | // Storage may be absent, full or refused (a private window): a palette |
| 1673 | 1674 | // with no memory is still a palette. |
| 1674 | 1675 | function readRecent() { |
| 1675 | try { | |
| 1676 | var raw = JSON.parse(window.localStorage.getItem(RECENT_KEY) || '[]'); | |
| 1677 | if (!Array.isArray(raw)) return []; | |
| 1678 | return raw.filter(function (r) { | |
| 1679 | return r && typeof r.t === 'string' && typeof r.h === 'string' && r.h.charAt(0) === '/'; | |
| 1680 | }).slice(0, RECENT_MAX).map(function (r) { | |
| 1681 | return { t: r.t, h: r.h, g: typeof r.g === 'string' ? r.g : '' }; | |
| 1682 | }); | |
| 1683 | } catch (_) { return []; } | |
| 1676 | try { return readUses(JSON.parse(window.localStorage.getItem(RECENT_KEY) || '[]'), RECENT_MAX); } catch (_) { return []; } | |
| 1684 | 1677 | } |
| 1685 | 1678 | function remember(r) { |
| 1686 | 1679 | try { |
| 1687 | var rest = readRecent().filter(function (x) { return x.h !== r.h; }); | |
| 1688 | var next = [{ t: r.t, h: r.h, g: r.g === 'Recent' ? (r.d || '') : r.g }].concat(rest); | |
| 1689 | window.localStorage.setItem(RECENT_KEY, JSON.stringify(next.slice(0, RECENT_MAX))); | |
| 1680 | var next = recordUse(readRecent(), { t: r.t, h: r.h, g: r.g === 'Recent' ? (r.d || '') : r.g }, Date.now(), RECENT_MAX); | |
| 1681 | window.localStorage.setItem(RECENT_KEY, JSON.stringify(next)); | |
| 1690 | 1682 | } catch (_) {} |
| 1691 | 1683 | } |
| 1692 | 1684 | // What the server found, as rows. The links are built here from the |
| @@ -1914,7 +1906,7 @@ const shellCore = /* js */ ` | ||
| 1914 | 1906 | return r; |
| 1915 | 1907 | }); |
| 1916 | 1908 | var tellApp = (loadIndex().tell || {}).app; |
| 1917 | var recent = readRecent().map(function (r) { return { t: r.t, h: r.h, g: 'Recent', d: r.g }; }); | |
| 1909 | 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 }; }); | |
| 1918 | 1910 | var recentHrefs = recent.map(function (r) { return r.h; }); |
| 1919 | 1911 | var served = []; // rows the server found, for the query servedFor |
| 1920 | 1912 | var servedFor = ''; |
src/web/palette.ts +66 −1
| @@ -143,9 +143,74 @@ export function opensAsSheet(marked: boolean, width: number): boolean { | ||
| 143 | 143 | return marked && width > 0 && width < 720; |
| 144 | 144 | } |
| 145 | 145 | |
| 146 | /** A row opened before: where, how it was labelled, how often, and when last. */ | |
| 147 | export interface PaletteUse { | |
| 148 | readonly t: string; | |
| 149 | readonly h: string; | |
| 150 | readonly g: string; | |
| 151 | /** Times opened. */ | |
| 152 | readonly c: number; | |
| 153 | /** Milliseconds since the epoch of the last opening. */ | |
| 154 | readonly l: number; | |
| 155 | } | |
| 156 | ||
| 157 | /** | |
| 158 | * Remembered rows in frecency order, best first: each opening is worth one, | |
| 159 | * halved for every week since the last, so a row opened often and a row | |
| 160 | * opened just now both lead a row opened once long ago. (Mozilla-style | |
| 161 | * frecency, reduced to count × 2^(-weeks); the decay is applied to the whole | |
| 162 | * count, an approximation that needs no per-visit history.) Ties go to the | |
| 163 | * later opening. Returns a new array; `now` is passed in so the function is | |
| 164 | * pure. Self-contained, shipped in the script. | |
| 165 | */ | |
| 166 | export function frecencyOrder<T extends { c: number; l: number }>(uses: readonly T[], now: number): T[] { | |
| 167 | const week = 7 * 24 * 3600 * 1000; | |
| 168 | const score = (u: T) => u.c * Math.pow(0.5, Math.max(0, now - u.l) / week); | |
| 169 | return uses.slice().sort((a, b) => score(b) - score(a) || b.l - a.l); | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * What the browser's stored list means: anything that is not an array of | |
| 174 | * `{t, h}` with a same-origin `h` is dropped; a missing or bad count is one | |
| 175 | * opening, a missing time is the epoch (oldest). Cut to `max`. Self-contained. | |
| 176 | */ | |
| 177 | export function readUses(raw: unknown, max: number): PaletteUse[] { | |
| 178 | if (!Array.isArray(raw)) return []; | |
| 179 | const out: PaletteUse[] = []; | |
| 180 | for (const r of raw) { | |
| 181 | if (out.length >= max) break; | |
| 182 | if (!r || typeof r.t !== "string" || typeof r.h !== "string" || r.h.charAt(0) !== "/") continue; | |
| 183 | out.push({ | |
| 184 | t: r.t, | |
| 185 | h: r.h, | |
| 186 | g: typeof r.g === "string" ? r.g : "", | |
| 187 | c: typeof r.c === "number" && r.c >= 1 ? Math.min(r.c, 1e6) : 1, | |
| 188 | l: typeof r.l === "number" && r.l > 0 ? r.l : 1, | |
| 189 | }); | |
| 190 | } | |
| 191 | return out; | |
| 192 | } | |
| 193 | ||
| 194 | /** | |
| 195 | * The list after opening `row` at `now`: its count is one more than before | |
| 196 | * (one if new), its time is now, and the whole list is re-ordered by | |
| 197 | * frecency and cut to `max` — so the least useful entry is the one that | |
| 198 | * falls off. Pure; self-contained apart from `frecencyOrder`. | |
| 199 | */ | |
| 200 | export function recordUse( | |
| 201 | uses: readonly PaletteUse[], | |
| 202 | row: { t: string; h: string; g: string }, | |
| 203 | now: number, | |
| 204 | max: number, | |
| 205 | ): PaletteUse[] { | |
| 206 | const prev = uses.find((x) => x.h === row.h); | |
| 207 | const entry: PaletteUse = { t: row.t, h: row.h, g: row.g, c: (prev ? prev.c : 0) + 1, l: now }; | |
| 208 | return frecencyOrder([entry].concat(uses.filter((x) => x.h !== row.h)), now).slice(0, max); | |
| 209 | } | |
| 210 | ||
| 146 | 211 | /** |
| 147 | 212 | * The functions above as the JavaScript the page runs: Bun transpiles |
| 148 | 213 | * this module before it ever parses it, so `toString` is already the |
| 149 | 214 | * browser's dialect. A test proves the string and the module agree. |
| 150 | 215 | */ |
| 151 | export const paletteHelpersJs = `${fuzzyScore.toString()}\n${rankPalette.toString()}\n${opensAsSheet.toString()}`; | |
| 216 | export const paletteHelpersJs = `${fuzzyScore.toString()}\n${rankPalette.toString()}\n${opensAsSheet.toString()}\n${frecencyOrder.toString()}\n${readUses.toString()}\n${recordUse.toString()}`; |
test/palette.test.ts +76
| @@ -1,9 +1,13 @@ | ||
| 1 | 1 | import { describe, expect, test } from "bun:test"; |
| 2 | 2 | import { shellJs } from "../src/web/client.ts"; |
| 3 | 3 | import { |
| 4 | frecencyOrder, | |
| 4 | 5 | fuzzyScore, |
| 5 | 6 | paletteHelpersJs, |
| 6 | 7 | rankPalette, |
| 8 | readUses, | |
| 9 | recordUse, | |
| 10 | type PaletteUse, | |
| 7 | 11 | type PaletteGroup, |
| 8 | 12 | type PaletteRow, |
| 9 | 13 | } from "../src/web/palette.ts"; |
| @@ -175,3 +179,75 @@ describe("the page runs the same functions", () => { | ||
| 175 | 179 | expect(shellJs).not.toContain("${"); |
| 176 | 180 | }); |
| 177 | 181 | }); |
| 182 | ||
| 183 | describe("frecency", () => { | |
| 184 | const now = Date.UTC(2026, 10, 10); | |
| 185 | const day = 24 * 3600 * 1000; | |
| 186 | const u = (h: string, c: number, ago: number) => ({ t: h, h, g: "", c, l: now - ago }); | |
| 187 | ||
| 188 | test("recent and frequent both beat old and rare", () => { | |
| 189 | const order = frecencyOrder([u("/old", 1, 60 * day), u("/often", 6, 7 * day), u("/now", 1, 0)], now).map((x) => x.h); | |
| 190 | // /often: 6 × 0.5 = 3; /now: 1; /old: ~0.003 | |
| 191 | expect(order).toEqual(["/often", "/now", "/old"]); | |
| 192 | }); | |
| 193 | ||
| 194 | test("a hand calculation: 4 openings two weeks ago equal one opening now", () => { | |
| 195 | const order = frecencyOrder([u("/a", 4, 14 * day), u("/b", 1, 1 * day)], now).map((x) => x.h); | |
| 196 | // /a: 4 × 0.25 = 1; /b: 2^(-1/7) ≈ 0.906 | |
| 197 | expect(order).toEqual(["/a", "/b"]); | |
| 198 | }); | |
| 199 | ||
| 200 | test("ties go to the later opening; the input is not mutated", () => { | |
| 201 | const input = [u("/x", 1, 0), u("/y", 1, 0)].map((e, i) => ({ ...e, l: now - i })); | |
| 202 | const out = frecencyOrder(input, now); | |
| 203 | expect(out.map((x) => x.h)).toEqual(["/x", "/y"]); | |
| 204 | expect(out).not.toBe(input); | |
| 205 | }); | |
outdated
This string-contains check passes without any working behaviour. Replace it with a test that runs remember() and the empty-query list. |
||
| 206 | ||
| 207 | test("the helpers ride in the shell script", () => { | |
| 208 | for (const f of ["frecencyOrder", "readUses", "recordUse"]) expect(paletteHelpersJs).toContain(`function ${f}`); | |
| 209 | }); | |
| 210 | }); | |
| 211 | ||
| 212 | describe("recording uses", () => { | |
| 213 | const row = (h: string) => ({ t: h, h, g: "Go to" }); | |
| 214 | ||
| 215 | test("opening a row again raises its count and moves its time", () => { | |
| 216 | const a = recordUse([], row("/a"), 1000, 50); | |
| 217 | const b = recordUse(a, row("/a"), 2000, 50); | |
| 218 | expect(b).toEqual([{ t: "/a", h: "/a", g: "Go to", c: 2, l: 2000 }]); | |
| 219 | }); | |
| 220 | ||
| 221 | test("at most 50 are kept, and the least useful one falls off", () => { | |
| 222 | let uses: PaletteUse[] = []; | |
| 223 | for (let i = 0; i < 60; i++) uses = recordUse(uses, row("/r" + i), 1000 + i, 50); | |
| 224 | expect(uses.length).toBe(50); | |
| 225 | expect(uses[0]!.h).toBe("/r59"); | |
| 226 | expect(uses.some((u) => u.h === "/r0")).toBe(false); | |
| 227 | }); | |
| 228 | ||
| 229 | test("a often-opened old row survives the cap over once-opened newer ones", () => { | |
| 230 | let uses = recordUse([], row("/keep"), 1000, 3); | |
| 231 | uses = recordUse(uses, row("/keep"), 1001, 3); | |
| 232 | uses = recordUse(uses, row("/keep"), 1002, 3); | |
| 233 | for (let i = 0; i < 5; i++) uses = recordUse(uses, row("/n" + i), 2000 + i, 3); | |
| 234 | expect(uses.map((u) => u.h)).toContain("/keep"); | |
| 235 | }); | |
| 236 | ||
| 237 | test("legacy entries (no count or time) read as one opening; junk and off-origin links drop", () => { | |
| 238 | const raw = [{ t: "A", h: "/a", g: "x" }, { t: "B", h: "https://evil.example/" }, { h: "/c" }, null, "s", { t: "D", h: "/d", c: -4, l: "no" }]; | |
| 239 | expect(readUses(raw, 50)).toEqual([ | |
| 240 | { t: "A", h: "/a", g: "x", c: 1, l: 1 }, | |
| 241 | { t: "D", h: "/d", g: "", c: 1, l: 1 }, | |
| 242 | ]); | |
| 243 | expect(readUses("nope", 50)).toEqual([]); | |
| 244 | expect(readUses(raw, 1).length).toBe(1); | |
| 245 | }); | |
| 246 | ||
| 247 | test("the shell script remembers through the helpers, and lists 8", () => { | |
| 248 | expect(shellJs).toContain("recordUse(readRecent()"); | |
| 249 | expect(shellJs).toContain("frecencyOrder(readRecent(), Date.now()).slice(0, RECENT_SHOWN)"); | |
| 250 | expect(shellJs).toContain("RECENT_MAX = 50"); | |
| 251 | expect(shellJs).toContain("RECENT_SHOWN = 8"); | |
| 252 | }); | |
| 253 | }); |
Unused
iparameter. Drop it.