Public: anyone can read this; the crew can change it.
small: every list that asks for a decision answers j/k, enter, esc and `?` #10717
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10710-small-every-list-that-asks-for-a-decisio → main 3 commits fork 3 threads · 3 unresolved
6 files
changed +204
−10
docs/features/decision-keys.md added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | # Lists that ask for a decision answer the same keys (#10710) | |
| 2 | ||
| 3 | The asks inbox (`/asks`), a repository's issues and its pulls are `data-nav` lists on the web, and list screens in the terminal. | |
| 4 | ||
| 5 | | Key | Web | Terminal | | |
| 6 | | --- | --- | --- | | |
| 7 | | `j` / `k` | focus the next / previous row | move the cursor (`ListState`, `src/tui/screens/common.ts`) | | |
| 8 | | `Enter` or `o` | open the focused row | open the row | | |
| 9 | | `Esc` | on the opened page, return to the list and focus the row you opened | `esc` / `q` is back (`src/tui/app/app.ts`) | | |
| 10 | | `?` | opens the one help sheet on the first press | the screen's key legend | | |
| 11 | ||
| 12 | - The terminal already has these: the dashboard's asks pane (`dashboard.ts`) and the issues and pulls list (`issues.ts`, one screen for both kinds) are `ListState` lists, so this change adds no new screen. | |
| 13 | - The place is kept in `sessionStorage` (`nav-from`, this tab only). A malformed or off-site value is ignored. | |
| 14 | - Esc closes the thing on top first: hints, an open menu, a dialog or the help sheet, and a field's own Esc. Only then does it leave. | |
| 15 | - Tests: `test/keys-decide.test.ts` drives the real script in a DOM. |
docs/features/hotkey-hints.md +1 −1
| @@ -5,7 +5,7 @@ Every action with a key shows it without taking room. Hints are never a box in t | ||
| 5 | 5 | **See them** |
| 6 | 6 | - **Hover or keyboard focus** on a control: its key appears as a small tooltip. This is CSS alone and works with JavaScript off. |
| 7 | 7 | - **Hold Option (Alt)**: every hinted control shows its key at once; release to hide. |
| 8 | - **`?`**: the same overlay, kept until you press Escape or `?` again (the second `?` clears them and opens the full help). | |
| 8 | - **`?`**: opens the one help sheet on the first press (#10710). Escape clears hints first and does nothing else. | |
| 9 | 9 | - Touch screens never draw hints. Menu and palette rows keep their key in a column of their own. |
| 10 | 10 | |
| 11 | 11 | **For builders** |
src/web/client.ts +46 −3
| @@ -1483,6 +1483,40 @@ const shellCore = /* js */ ` | ||
| 1483 | 1483 | next.focus(); |
| 1484 | 1484 | next.scrollIntoView({ block: 'nearest' }); |
| 1485 | 1485 | } |
| 1486 | // Esc returns to the list a row was opened from, and the list focuses that | |
| 1487 | // row again: the place is kept in sessionStorage for this tab only. | |
| 1488 | function place(k, v) { try { if (v == null) sessionStorage.removeItem(k); else sessionStorage.setItem(k, v); } catch (_) {} } | |
| 1489 | function placed(k) { try { return sessionStorage.getItem(k); } catch (_) { return null; } } | |
| 1490 | doc.addEventListener('click', function (e) { | |
| 1491 | var a = e.target.closest ? e.target.closest('[data-nav]') : null; | |
| 1492 | if (a && !a.matches('a')) a = a.querySelector('a'); | |
| 1493 | if (a && a.pathname) place('nav-from', JSON.stringify({ list: location.pathname + location.search, to: a.pathname, href: a.getAttribute('href') })); | |
| 1494 | }); | |
| 1495 | // A stored place is only ever read back through this: a malformed or | |
| 1496 | // foreign value is no place, and never throws inside a key handler. | |
| 1497 | function fromPlace() { | |
| 1498 | try { | |
| 1499 | var v = JSON.parse(placed('nav-from') || 'null'); | |
| 1500 | return v && typeof v.list === 'string' && typeof v.to === 'string' && v.list.charAt(0) === '/' && v.list.charAt(1) !== '/' ? v : null; | |
| 1501 | } catch (_) { return null; } | |
| 1502 | } | |
| 1503 | (function () { | |
| 1504 | var from = fromPlace(); | |
| 1505 | if (!from) return; | |
| 1506 | if (location.pathname + location.search === from.list) { | |
| 1507 | place('nav-from', null); | |
| 1508 | var back = rows().filter(function (r) { var a = r.matches('a') ? r : r.querySelector('a'); return a && a.getAttribute('href') === from.href; })[0]; | |
| 1509 | if (back) { back.focus(); back.scrollIntoView({ block: 'nearest' }); } | |
| 1510 | } | |
| 1511 | })(); | |
| 1512 | function leaveToList() { | |
| 1513 | var from = fromPlace(); | |
| 1514 | // The thing on top closes first: an open dialog or menu keeps its Escape. | |
| 1515 | if (doc.querySelector('dialog[open], details.menu[open]')) return false; | |
| 1516 | if (!from || location.pathname !== from.to || doc.querySelector('[data-nav]')) return false; | |
| 1517 | location.assign(from.list); | |
| 1518 | return true; | |
| 1519 | } | |
| 1486 | 1520 | function openFocused() { |
| 1487 | 1521 | var el = doc.activeElement.closest ? doc.activeElement.closest('[data-nav]') : null; |
| 1488 | 1522 | var link = el && (el.matches('a') ? el : el.querySelector('a')); |
| @@ -1517,7 +1551,13 @@ const shellCore = /* js */ ` | ||
| 1517 | 1551 | function hints(on) { if (on) doc.documentElement.setAttribute('data-hints', ''); else doc.documentElement.removeAttribute('data-hints'); } |
| 1518 | 1552 | doc.addEventListener('keydown', function (e) { if (e.key === 'Alt') hints(true); }); |
| 1519 | 1553 | doc.addEventListener('keyup', function (e) { if (e.key === 'Alt') hints(false); }); |
| 1520 | doc.addEventListener('keydown', function (e) { if (e.key === 'Escape') hints(false); }); | |
| 1554 | // Escape clears the hints and is then spent: it leaves nothing else. | |
| 1555 | var escClosedHints = false; | |
| 1556 | doc.addEventListener('keydown', function (e) { | |
| 1557 | if (e.key !== 'Escape') return; | |
| 1558 | escClosedHints = doc.documentElement.hasAttribute('data-hints'); | |
| 1559 | hints(false); | |
| 1560 | }); | |
| 1521 | 1561 | window.addEventListener('blur', function () { hints(false); }); |
| 1522 | 1562 | doc.addEventListener('keydown', function (e) { |
| 1523 | 1563 | var t = e.target; |
| @@ -1541,6 +1581,9 @@ const shellCore = /* js */ ` | ||
| 1541 | 1581 | return; |
| 1542 | 1582 | } |
| 1543 | 1583 | if (e.metaKey || e.ctrlKey || e.altKey) return; |
| 1584 | if (e.key === 'Escape' && e.defaultPrevented) return; | |
| 1585 | if (e.key === 'Escape' && escClosedHints) { escClosedHints = false; e.preventDefault(); return; } | |
| 1586 | if (e.key === 'Escape' && leaveToList()) { e.preventDefault(); return; } | |
| 1544 | 1587 | if (Date.now() - last > 1200) seq.length = 0; |
| 1545 | 1588 | last = Date.now(); |
| 1546 | 1589 | seq.push(e.key); |
| @@ -1566,8 +1609,8 @@ const shellCore = /* js */ ` | ||
| 1566 | 1609 | if (box) { seq.length = 0; e.preventDefault(); box.focus(); box.select(); } |
| 1567 | 1610 | } else if (e.key === '?') { |
| 1568 | 1611 | seq.length = 0; e.preventDefault(); |
| 1569 | // First press lays the keys over the page; the second clears them and opens the help. | |
| 1570 | if (doc.documentElement.hasAttribute('data-hints')) { hints(false); openHelp(); } else hints(true); | |
| 1612 | // One press, one sheet: the help opens at once (Option still lays the keys over the page). | |
| 1613 | hints(false); openHelp(); | |
| 1571 | 1614 | } |
| 1572 | 1615 | else if (e.key === ':') { seq.length = 0; e.preventDefault(); openGoto(); } |
| 1573 | 1616 | }); |
src/web/studio/asks_page.ts +3 −3
| @@ -211,9 +211,9 @@ function proposalBody(ctx: Ctx, d: Decision): Html | null { | ||
| 211 | 211 | function decisionCard(ctx: Ctx, d: Decision, picked: ReadonlySet<string>): Html { |
| 212 | 212 | const base = repoBase(d.repo); |
| 213 | 213 | const title = d.pull |
| 214 | ? html`<a href="${changeHref(base, d.pull.number)}">${d.pull.title}</a>` | |
| 214 | ? html`<a href="${changeHref(base, d.pull.number)}" data-nav>${d.pull.title}</a>` | |
| 215 | 215 | : d.issue |
| 216 | ? html`<a href="${base}/issues/${d.issue.number}">${d.issue.title}</a>` | |
| 216 | ? html`<a href="${base}/issues/${d.issue.number}" data-nav>${d.issue.title}</a>` | |
| 217 | 217 | : d.proposal |
| 218 | 218 | ? html`${d.proposal.title}` |
| 219 | 219 | : html`<a href="${base}/settings/autonomy">Paused</a>`; |
| @@ -320,7 +320,7 @@ function rowCard(ctx: Ctx, r: AskRow<Decision>, picked: ReadonlySet<string>): Ht | ||
| 320 | 320 | const box = pick(d, picked); |
| 321 | 321 | return row({ |
| 322 | 322 | ...(box === null ? {} : { lead: box }), |
| 323 | title: html`<a href="${href}">${d.pull?.title ?? d.issue?.title ?? ""}</a>`, | |
| 323 | title: html`<a href="${href}" data-nav>${d.pull?.title ?? d.issue?.title ?? ""}</a>`, | |
| 324 | 324 | meta: acts(ctx, d), |
| 325 | 325 | }); |
| 326 | 326 | }); |
test/hotkey-hints.test.ts +3 −3
| @@ -121,10 +121,10 @@ test("an element does not carry the key twice: no title beside its data-hint", a | ||
| 121 | 121 | } |
| 122 | 122 | }); |
| 123 | 123 | |
| 124 | test("? lays the hints over the page and a second press clears them and opens the help; Escape clears", () => { | |
| 124 | test("? opens the help at once, Alt lays the hints; Escape clears them", () => { | |
| 125 | 125 | const client = readFileSync("src/web/client.ts", "utf8"); |
| 126 | expect(client).toContain("hasAttribute('data-hints')) { hints(false); openHelp(); } else hints(true)"); | |
| 127 | expect(client).toContain("e.key === 'Escape') hints(false)"); | |
| 126 | expect(client).toContain("hints(false); openHelp();"); | |
| 127 | expect(client).toContain("escClosedHints = doc.documentElement.hasAttribute('data-hints');"); | |
| 128 | 128 | }); |
| 129 | 129 | |
| 130 | 130 | test("holding Option reveals hints; releasing or leaving hides them", () => { |
test/keys-decide.test.ts added +136
| @@ -0,0 +1,136 @@ | ||
| 1 | /** The lists that ask for a decision answer j/k, enter, esc and `?` (#10710): driven through a real DOM. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { readFileSync } from "node:fs"; | |
| 4 | import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts"; | |
| 5 | import { shellJs } from "../src/web/client.ts"; | |
| 6 | ||
| 7 | type Win = InstanceType<Awaited<typeof import("happy-dom")>["Window"]>; | |
| 8 | ||
| 9 | const LIST = `<ul><li><a class="row" href="/a/b/issues/1" data-nav tabindex="-1">One</a></li> | |
| 10 | <li><a class="row" href="/a/b/issues/2" data-nav tabindex="-1">Two</a></li></ul>`; | |
| 11 | ||
| 12 | /** A page with the shell script running; `store` is the tab's sessionStorage. */ | |
| 13 | async function page(path: string, body: string, store: Record<string, string> = {}) { | |
| 14 | const { Window } = await import("happy-dom"); | |
| 15 | const win = new Window({ url: `http://forge.test${path}` }); | |
| 16 | for (const [k, v] of Object.entries(store)) win.sessionStorage.setItem(k, v); | |
| 17 | win.document.body.innerHTML = body; | |
outdated
This reads source text; it passes with a broken handler. Drive a real DOM and assert focus after j, Enter, Esc. |
||
| 18 | (win.HTMLElement.prototype as any).scrollIntoView ??= () => {}; | |
| 19 | const errors: unknown[] = []; | |
| 20 | win.addEventListener('error', (e: any) => errors.push(e.message ?? e.error)); | |
| 21 | const assigned: string[] = []; | |
| 22 | const loc = new Proxy(win.location, { | |
| 23 | get: (t, k) => (k === "assign" ? (u: string) => void assigned.push(u) : (t as any)[k]), | |
| 24 | }); | |
| 25 | new Function("document", "window", "location", "sessionStorage", "CSS", "fetch", "EventSource", shellJs)( | |
| 26 | win.document, win, loc, win.sessionStorage, { escape: (x: string) => x.replace(/([^\w-])/g, "\\$1") }, async () => ({ ok: false, status: 500, text: async () => "", headers: { get: () => null } }), undefined, | |
| 27 | ); | |
| 28 | const key = (k: string, target: unknown = win.document) => { | |
| 29 | const e = new win.KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }); | |
| 30 | (target as any).dispatchEvent(e); | |
| 31 | return e; | |
| 32 | }; | |
| 33 | return { win, doc: win.document, key, assigned, errors }; | |
| 34 | } | |
| 35 | ||
| 36 | const focusedHref = (w: Win) => (w.document.activeElement as any)?.getAttribute?.("href"); | |
| 37 | ||
| 38 | test("j moves down the rows, k up, and Enter opens the focused one, remembering the list", async () => { | |
| 39 | const p = await page("/a/b/issues", LIST); | |
| 40 | p.key("j"); | |
| 41 | expect(focusedHref(p.win)).toBe("/a/b/issues/1"); | |
| 42 | p.key("j"); | |
| 43 | expect(focusedHref(p.win)).toBe("/a/b/issues/2"); | |
| 44 | p.key("k"); | |
| 45 | expect(focusedHref(p.win)).toBe("/a/b/issues/1"); | |
| 46 | p.key("j"); | |
| 47 | let clicked = ""; | |
| 48 | p.doc.addEventListener("click", (e) => { clicked = (e.target as any).getAttribute("href"); e.preventDefault(); }, true); | |
| 49 | p.key("Enter"); | |
| 50 | expect(clicked).toBe("/a/b/issues/2"); | |
| 51 | expect(JSON.parse(p.win.sessionStorage.getItem("nav-from")!)).toMatchObject({ list: "/a/b/issues", to: "/a/b/issues/2", href: "/a/b/issues/2" }); | |
| 52 | }); | |
| 53 | ||
| 54 | const place = JSON.stringify({ list: "/a/b/issues", to: "/a/b/issues/2", href: "/a/b/issues/2" }); | |
| 55 | ||
| 56 | test("Esc on the opened page goes back to its list", async () => { | |
| 57 | const p = await page("/a/b/issues/2", "<main>thread</main>", { "nav-from": place }); | |
| 58 | const e = p.key("Escape"); | |
| 59 | expect(p.assigned).toEqual(["/a/b/issues"]); | |
| 60 | expect(e.defaultPrevented).toBe(true); | |
| 61 | }); | |
| 62 | ||
| 63 | test("the list refocuses the row that was opened, once", async () => { | |
| 64 | const p = await page("/a/b/issues", LIST, { "nav-from": place }); | |
| 65 | expect(focusedHref(p.win)).toBe("/a/b/issues/2"); | |
| 66 | expect(p.win.sessionStorage.getItem("nav-from")).toBeNull(); | |
| 67 | }); | |
| 68 | ||
| 69 | test("Esc elsewhere than the opened page does nothing", async () => { | |
| 70 | const p = await page("/a/b/pulls", "<main>x</main>", { "nav-from": place }); | |
| 71 | p.key("Escape"); | |
| 72 | expect(p.assigned).toEqual([]); | |
| 73 | }); | |
| 74 | ||
| 75 | test("Esc with a dialog, an open menu or the hints on closes that first and does not leave", async () => { | |
| 76 | const dlg = await page("/a/b/issues/2", "<main>t</main><dialog open>sheet</dialog>", { "nav-from": place }); | |
| 77 | dlg.key("Escape"); | |
| 78 | expect(dlg.assigned).toEqual([]); | |
| 79 | ||
| 80 | const menu = await page("/a/b/issues/2", '<main>t</main><details class="menu" open><summary>m</summary></details>', { "nav-from": place }); | |
| 81 | menu.key("Escape"); | |
| 82 | expect(menu.doc.querySelector("details.menu")!.hasAttribute("open")).toBe(false); | |
| 83 | expect(menu.assigned).toEqual([]); | |
| 84 | ||
| 85 | const hints = await page("/a/b/issues/2", "<main>t</main>", { "nav-from": place }); | |
| 86 | hints.key("Alt"); | |
| 87 | expect(hints.doc.documentElement.hasAttribute("data-hints")).toBe(true); | |
| 88 | hints.key("Escape"); | |
| 89 | expect(hints.doc.documentElement.hasAttribute("data-hints")).toBe(false); | |
| 90 | expect(hints.assigned).toEqual([]); | |
| 91 | hints.key("Escape"); | |
| 92 | expect(hints.assigned).toEqual(["/a/b/issues"]); | |
| 93 | }); | |
| 94 | ||
| 95 | test("Esc in a field leaves the field, not the page", async () => { | |
| 96 | const p = await page("/a/b/issues/2", "<main><textarea id=t></textarea></main>", { "nav-from": place }); | |
| 97 | const t = p.doc.getElementById("t") as any; | |
| 98 | t.focus(); | |
| 99 | p.key("Escape", t); | |
| 100 | expect(p.assigned).toEqual([]); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a malformed or foreign stored place never throws and never navigates", async () => { | |
| 104 | for (const bad of ["{not json", "42", '{"list":"//evil.test/","to":"/a/b/issues/2"}', '{"list":"https://evil.test","to":"/a/b/issues/2"}']) { | |
| 105 | const p = await page("/a/b/issues/2", "<main>t</main>", { "nav-from": bad }); | |
| 106 | p.key("j"); | |
| 107 | p.key("Escape"); | |
| 108 | expect(p.assigned).toEqual([]); | |
| 109 | } | |
| 110 | const l = await page("/a/b/issues", LIST, { "nav-from": "{oops" }); | |
| 111 | l.key("j"); | |
| 112 | expect(focusedHref(l.win)).toBe("/a/b/issues/1"); | |
| 113 | }); | |
| 114 | ||
| 115 | test("? opens the one help sheet on the first press", async () => { | |
| 116 | const p = await page("/a/b/issues", `${LIST}<dialog id="help-dialog"><div data-help-body></div></dialog>`); | |
| 117 | let shown = 0; | |
| 118 | (p.doc.getElementById("help-dialog") as any).showModal = () => void shown++; | |
| 119 | p.key("?"); | |
| 120 | expect(shown).toBe(1); | |
| 121 | expect(p.doc.documentElement.hasAttribute("data-hints")).toBe(false); | |
| 122 | }); | |
| 123 | ||
| 124 | let f: SnappyFixture; | |
| 125 | beforeAll(async () => { f = await snappyFixture(); }, 120_000); | |
| 126 | afterAll(async () => { await f.close(); }); | |
| 127 | ||
| 128 | test("the issues list the server draws carries data-nav rows, and the pulls list's rows are declared nav", async () => { | |
| 129 | const repo = f.repos[0]!; | |
| 130 | const get = async (path: string) => (await fetch(`${f.base}${path}`, { headers: { cookie: f.ownerCookie } })).text(); | |
| 131 | expect(await get(`/${repo.ownerName}/${repo.name}/issues`)).toMatch(/<[^>]*\bdata-nav\b/); | |
| 132 | // The fixture has no open pull to draw; the row the pulls list builds is the one declared here. | |
| 133 | const lists = readFileSync(new URL("../src/web/studio/lists.ts", import.meta.url), "utf8"); | |
| 134 | const pulls = lists.slice(lists.indexOf("export async function changesList"), lists.indexOf("export function isWorkingNow")); | |
| 135 | expect(pulls).toMatch(/row\(\{\s*href: changeHref\(base, pull\.number\),\s*nav: true,/); | |
| 136 | }); |
Unguarded JSON.parse on a storage value: malformed data throws on page load and stops the shell. Wrap in try/catch (same in leaveToList).