Public: anyone can read this; the crew can change it.
`g` then a letter jumps between the forge's main pages from any page, as in k9s and lazygit #10963
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10948-g-then-a-letter-jumps-between-the-forge → main 6 commits fork 3 threads · 3 unresolved
5 files
changed +151
−11
docs/features/go-jumps.md added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | # `g` then a letter: jump between pages (#10948) | |
| 2 | ||
| 3 | Press `g`, then a letter within one second, from any page outside a field. Modifier keys (Ctrl, Cmd, Alt) cancel it, and in a text field `g` is just a letter. | |
| 4 | ||
| 5 | | Keys | Page | Who | | |
| 6 | |---|---|---| | |
| 7 | | `g h` | Home | everyone | | |
| 8 | | `g e` | Explore | everyone | | |
| 9 | | `g f` | Flow | signed in | | |
| 10 | | `g q` | Asks | signed in | | |
| 11 | | `g t` | Talk | signed in | | |
| 12 | | `g s` | System | admins | | |
| 13 | ||
| 14 | - A page the viewer cannot open does nothing (no error, no redirect to sign-in): the script only knows the pages for the viewer's level. | |
| 15 | - The table is `GO_PAGES` in `src/web/go_pages.ts`, the same list as the `:` prompt and Cmd/Ctrl K. A page gets a jump by giving it a `key` there; a test refuses a letter used twice. `?` lists the jumps the viewer has, read from that table. | |
| 16 | - `g h`, `g q` and `g t` are the chords the top bar already wore (its hover hints show them), so they now work from anywhere and the sheet names them like the rest. | |
| 17 | - Test: `test/keys-goto.test.ts` runs the real keys script against a fake document: stranger and user, the 1 s timeout, Ctrl/Meta, and a field. | |
| 18 | ||
| 19 | **Terminal:** none added. Terminal screens are reached by their own menus and print their keys in their footer line; `g` there already means "top" in lists or an action on a screen, so a global `g` chord would collide. |
src/web/client.ts +8 −1
| @@ -1584,7 +1584,7 @@ const shellCore = /* js */ ` | ||
| 1584 | 1584 | if (e.key === 'Escape' && e.defaultPrevented) return; |
| 1585 | 1585 | if (e.key === 'Escape' && escClosedHints) { escClosedHints = false; e.preventDefault(); return; } |
| 1586 | 1586 | if (e.key === 'Escape' && leaveToList()) { e.preventDefault(); return; } |
| 1587 | if (Date.now() - last > 1200) seq.length = 0; | |
| 1587 | if (Date.now() - last > 1000) seq.length = 0; | |
| 1588 | 1588 | last = Date.now(); |
| 1589 | 1589 | seq.push(e.key); |
| 1590 | 1590 | if (seq.length > 2) seq.splice(0, seq.length - 2); |
| @@ -1598,6 +1598,11 @@ const shellCore = /* js */ ` | ||
| 1598 | 1598 | if (after) after.focus(); |
| 1599 | 1599 | return; |
| 1600 | 1600 | } |
| 1601 | // \`g\` then a letter: a page from the one list (go_pages.ts), only if this viewer may open it. | |
| 1602 | if (seq.length === 2 && seq[0] === 'g') { | |
| 1603 | var gp = gotoPages().filter(function (p) { return p[3] !== '' && p[3] === e.key; })[0]; | |
| 1604 | if (gp) { seq.length = 0; e.preventDefault(); location.assign(gp[1]); return; } | |
| 1605 | } | |
| 1601 | 1606 | if (e.key === 'j') { seq.length = 0; e.preventDefault(); move(1); } |
| 1602 | 1607 | else if (e.key === 'k') { seq.length = 0; e.preventDefault(); move(-1); } |
| 1603 | 1608 | else if (e.key === 'o' || e.key === 'Enter') { seq.length = 0; openFocused(); } |
| @@ -1675,6 +1680,8 @@ const shellCore = /* js */ ` | ||
| 1675 | 1680 | var pl = doc.createElement('td'); pl.textContent = 'Pages: ' + names.join(', '); |
| 1676 | 1681 | pr.appendChild(pk); pr.appendChild(pl); tbody.appendChild(pr); |
| 1677 | 1682 | } |
| 1683 | // The \`g\` jumps, read from the same list: one row per page this viewer may open by key. | |
| 1684 | gotoPages().forEach(function (p) { if (p[3] !== '') tbody.appendChild(keyRow('g ' + p[3], 'Go to ' + p[0])); }); | |
| 1678 | 1685 | table.appendChild(tbody); |
| 1679 | 1686 | if (body) { body.textContent = ''; body.appendChild(table); } |
| 1680 | 1687 | openModal(dlg); |
src/web/go_pages.ts +11 −9
| @@ -13,27 +13,29 @@ export interface GoPage { | ||
| 13 | 13 | readonly who: "all" | "user" | "admin"; |
| 14 | 14 | /** Present when the Cmd/Ctrl K palette also lists it as a "Go to" row, in its longer wording. */ |
| 15 | 15 | readonly palette?: string; |
| 16 | /** The letter that follows `g` to jump here from any page (`g e` Explore). One letter, once. */ | |
| 17 | readonly key?: string; | |
| 16 | 18 | } |
| 17 | 19 | |
| 18 | 20 | export const GO_PAGES: readonly GoPage[] = [ |
| 19 | { name: "Home", href: "/", who: "all", palette: "Home" }, | |
| 21 | { name: "Home", href: "/", who: "all", key: "h", palette: "Home" }, | |
| 20 | 22 | { name: "Apps", href: "/apps", who: "all", palette: "Apps" }, |
| 21 | { name: "Explore", href: "/explore", who: "all", palette: "Explore" }, | |
| 23 | { name: "Explore", href: "/explore", who: "all", key: "e", palette: "Explore" }, | |
| 22 | 24 | { name: "Packs", href: "/packs", who: "all", palette: "Forge packs" }, |
| 23 | 25 | { name: "Front page", href: "/front", who: "all", palette: "Front page" }, |
| 24 | 26 | { name: "Search", href: "/search", who: "all", palette: "Search" }, |
| 25 | 27 | { name: "Feed", href: "/feed", who: "user" }, |
| 26 | 28 | { name: "Board", href: "/board", who: "user" }, |
| 27 | 29 | { name: "Boards", href: "/boards", who: "user" }, |
| 28 | { name: "Flow", href: "/flow", who: "user" }, | |
| 29 | { name: "Asks", href: "/asks", who: "user" }, | |
| 30 | { name: "Flow", href: "/flow", who: "user", key: "f" }, | |
| 31 | { name: "Asks", href: "/asks", who: "user", key: "q" }, | |
| 30 | 32 | { name: "Asked", href: "/asked", who: "user", palette: "What you asked for" }, |
| 31 | 33 | { name: "Notes", href: "/notes", who: "user", palette: "Notes" }, |
| 32 | { name: "Talk", href: "/talk", who: "user", palette: "Talk to the forge" }, | |
| 34 | { name: "Talk", href: "/talk", who: "user", key: "t", palette: "Talk to the forge" }, | |
| 33 | 35 | { name: "Map", href: "/map", who: "user", palette: "Map" }, |
| 34 | 36 | { name: "Displays", href: "/displays", who: "user", palette: "Displays" }, |
| 35 | 37 | { name: "Settings", href: "/settings", who: "user" }, |
| 36 | { name: "System", href: "/system", who: "admin" }, | |
| 38 | { name: "System", href: "/system", who: "admin", key: "s" }, | |
| 37 | 39 | { name: "Releases", href: "/releases", who: "admin" }, |
| 38 | 40 | { name: "Forges", href: "/forges", who: "admin" }, |
| 39 | 41 | { name: "Family", href: "/family", who: "admin" }, |
| @@ -52,9 +54,9 @@ export function goLevel(viewer: { signedIn: boolean; admin: boolean }): 0 | 1 | | ||
| 52 | 54 | return !viewer.signedIn ? 0 : viewer.admin ? 2 : 1; |
| 53 | 55 | } |
| 54 | 56 | |
| 55 | /** The list as the keys script carries it: [name, href, level needed]. */ | |
| 56 | export function goPagesData(): [string, string, number][] { | |
| 57 | return GO_PAGES.map((p) => [p.name, p.href, p.who === "all" ? 0 : p.who === "user" ? 1 : 2]); | |
| 57 | /** The list as the keys script carries it: [name, href, level needed, the letter after `g` or ""]. */ | |
| 58 | export function goPagesData(): [string, string, number, string][] { | |
| 59 | return GO_PAGES.map((p) => [p.name, p.href, p.who === "all" ? 0 : p.who === "user" ? 1 : 2, p.key ?? ""]); | |
| 58 | 60 | } |
| 59 | 61 | |
| 60 | 62 | /** The Cmd/Ctrl K palette's "Go to" rows, in list order, and which of them a stranger may see. */ |
test/keys-goto.test.ts added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { shellJs } from "../src/web/client.ts"; | |
| 3 | import { GO_PAGES, goPagesFor } from "../src/web/go_pages.ts"; | |
| 4 | ||
| 5 | const keyed = GO_PAGES.filter((p) => p.key !== undefined); | |
| 6 | ||
| 7 | test("every g-letter is one lowercase letter, bound once", () => { | |
| 8 | const letters = keyed.map((p) => p.key as string); | |
| 9 | for (const l of letters) expect(l).toMatch(/^[a-z]$/); | |
| 10 | expect(new Set(letters).size).toBe(letters.length); | |
| 11 | }); | |
| 12 | ||
| 13 | test("the main pages are bound: g h, g f, g e, g s", () => { | |
| 14 | const at = (k: string) => keyed.find((p) => p.key === k)?.href; | |
| 15 | expect(at("h")).toBe("/"); | |
| 16 | expect(at("f")).toBe("/flow"); | |
| 17 | expect(at("e")).toBe("/explore"); | |
| 18 | expect(at("s")).toBe("/system"); | |
| 19 | }); | |
| 20 | ||
| 21 | test("a page has one binding; every target is a page of the list", () => { | |
| 22 | expect(new Set(keyed.map((p) => p.href)).size).toBe(keyed.length); | |
| 23 | expect(keyed.length).toBeGreaterThanOrEqual(4); | |
| 24 | }); | |
| 25 | ||
| 26 | test("a viewer is offered only the targets they can open", () => { | |
| 27 | const keys = (v: { signedIn: boolean; admin: boolean }) => goPagesFor(v).filter((p) => p.key).map((p) => p.key); | |
| 28 | expect(keys({ signedIn: false, admin: false })).toEqual(["h", "e"]); | |
| 29 | expect(keys({ signedIn: true, admin: false })).not.toContain("s"); | |
| 30 | expect(keys({ signedIn: true, admin: true })).toContain("s"); | |
| 31 | }); | |
| 32 | ||
| 33 | // The real keys script, run against a minimal fake document: it records the listeners | |
| 34 | // and the pages it was sent to, and finds nothing on the page. | |
outdated
Greps source strings; it would pass with a broken jump. Execute the script (fake document/location) and assert: stranger g f does nothing, user g f goes to /flow, timeout over 1 s resets, input and ctrl ignored. |
||
| 35 | function keys(level: number) { | |
| 36 | const handlers: Array<(e: any) => void> = []; | |
| 37 | const assigned: string[] = []; | |
| 38 | const el: any = new Proxy(function () {}, { get: (_t, k) => (k === "length" ? 0 : el), apply: () => el }); | |
| 39 | const doc: any = { | |
| 40 | body: { getAttribute: (n: string) => (n === "data-go" ? String(level) : null) }, | |
| 41 | documentElement: { classList: { add() {} }, setAttribute() {}, removeAttribute() {}, hasAttribute: () => false }, | |
| 42 | activeElement: null, | |
| 43 | currentScript: null, | |
| 44 | addEventListener: (t: string, f: any) => t === "keydown" && handlers.push(f), | |
| 45 | querySelector: () => null, | |
| 46 | querySelectorAll: () => [], | |
| 47 | getElementById: () => null, | |
| 48 | createElement: () => el, | |
| 49 | }; | |
| 50 | const win: any = { addEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {} }) }; | |
| 51 | const loc: any = { pathname: "/", search: "", assign: (h: string) => assigned.push(h) }; | |
| 52 | new Function("document", "window", "location", "sessionStorage", "CSS", shellJs)(doc, win, loc, undefined, undefined); | |
| 53 | const press = (key: string, extra: any = {}) => { | |
| 54 | const e = { key, target: null, metaKey: false, ctrlKey: false, altKey: false, defaultPrevented: false, preventDefault() {}, ...extra }; | |
| 55 | for (const h of handlers) h(e); | |
| 56 | }; | |
| 57 | return { press, assigned }; | |
| 58 | } | |
| 59 | const realNow = Date.now; | |
| 60 | const clock = (t: number) => (Date.now = () => t); | |
| 61 | ||
| 62 | test("the script: a stranger's g f does nothing, a user's goes to /flow, g h and g e work for all", () => { | |
| 63 | const stranger = keys(0); | |
| 64 | clock(10_000); | |
| 65 | stranger.press("g"); | |
| 66 | stranger.press("f"); | |
| 67 | expect(stranger.assigned).toEqual([]); | |
| 68 | stranger.press("g"); | |
| 69 | stranger.press("e"); | |
| 70 | expect(stranger.assigned).toEqual(["/explore"]); | |
| 71 | const user = keys(1); | |
| 72 | user.press("g"); | |
| 73 | user.press("f"); | |
| 74 | expect(user.assigned).toEqual(["/flow"]); | |
| 75 | user.press("g"); | |
| 76 | user.press("s"); | |
| 77 | expect(user.assigned).toEqual(["/flow"]); | |
| 78 | const admin = keys(2); | |
| 79 | admin.press("g"); | |
| 80 | admin.press("s"); | |
| 81 | expect(admin.assigned).toEqual(["/system"]); | |
| 82 | Date.now = realNow; | |
| 83 | }); | |
| 84 | ||
| 85 | test("the script: a pause over 1 s forgets the g", () => { | |
| 86 | const u = keys(1); | |
| 87 | clock(10_000); | |
| 88 | u.press("g"); | |
| 89 | clock(11_500); | |
| 90 | u.press("f"); | |
| 91 | expect(u.assigned).toEqual([]); | |
| 92 | clock(20_000); | |
| 93 | u.press("g"); | |
| 94 | clock(20_900); | |
| 95 | u.press("f"); | |
| 96 | expect(u.assigned).toEqual(["/flow"]); | |
| 97 | Date.now = realNow; | |
| 98 | }); | |
| 99 | ||
| 100 | test("the script: ctrl or meta with g, and g typed in a field, jump nowhere", () => { | |
| 101 | const u = keys(1); | |
| 102 | clock(10_000); | |
| 103 | u.press("g", { ctrlKey: true }); | |
| 104 | u.press("f"); | |
| 105 | u.press("g", { metaKey: true }); | |
| 106 | u.press("f"); | |
| 107 | const input = { matches: (s: string) => s.includes("input"), closest: () => null }; | |
| 108 | u.press("g", { target: input }); | |
| 109 | u.press("f", { target: input }); | |
| 110 | expect(u.assigned).toEqual([]); | |
| 111 | Date.now = realNow; | |
| 112 | }); |
test/keys-palette.test.ts +1 −1
| @@ -12,7 +12,7 @@ afterAll(async () => { | ||
| 12 | 12 | await f.close(); |
| 13 | 13 | }); |
| 14 | 14 | |
| 15 | type Row = [string, string, number]; | |
| 15 | type Row = [string, string, number, string]; | |
| 16 | 16 | // The very source the browser receives, run here as a function. |
| 17 | 17 | const goMatch = new Function(`${GO_MATCH_JS}; return goMatch;`)() as (pages: Row[], q: string) => Row[]; |
| 18 | 18 | const all = goPagesData(); |
No docs/features how-to for the g jumps, and no statement of how the terminal reaches the same pages (both doors). Add the doc.