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

Merged opened this

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

Merged

scout/forge:scout/10948-g-then-a-letter-jumps-between-the-forge → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/10948-g-then-a-letter-jumps-between-the-forge was merged into main as 460ba39.

Review

Sign in to review this change.
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 */ `
15841584 if (e.key === 'Escape' && e.defaultPrevented) return;
15851585 if (e.key === 'Escape' && escClosedHints) { escClosedHints = false; e.preventDefault(); return; }
15861586 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;
15881588 last = Date.now();
15891589 seq.push(e.key);
15901590 if (seq.length > 2) seq.splice(0, seq.length - 2);
@@ -1598,6 +1598,11 @@ const shellCore = /* js */ `
15981598 if (after) after.focus();
15991599 return;
16001600 }
1601 // \`g\` then a letter: a page from the one list (go_pages.ts), only if this viewer may open it.

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.

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 }
16011606 if (e.key === 'j') { seq.length = 0; e.preventDefault(); move(1); }
16021607 else if (e.key === 'k') { seq.length = 0; e.preventDefault(); move(-1); }
16031608 else if (e.key === 'o' || e.key === 'Enter') { seq.length = 0; openFocused(); }
@@ -1675,6 +1680,8 @@ const shellCore = /* js */ `
16751680 var pl = doc.createElement('td'); pl.textContent = 'Pages: ' + names.join(', ');
16761681 pr.appendChild(pk); pr.appendChild(pl); tbody.appendChild(pr);
16771682 }
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])); });
16781685 table.appendChild(tbody);
16791686 if (body) { body.textContent = ''; body.appendChild(table); }
16801687 openModal(dlg);
src/web/go_pages.ts +11 −9
@@ -13,27 +13,29 @@ export interface GoPage {
1313 readonly who: "all" | "user" | "admin";
1414 /** Present when the Cmd/Ctrl K palette also lists it as a "Go to" row, in its longer wording. */
1515 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;
1618 }
1719
1820 export const GO_PAGES: readonly GoPage[] = [
19 { name: "Home", href: "/", who: "all", palette: "Home" },
21 { name: "Home", href: "/", who: "all", key: "h", palette: "Home" },
2022 { 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" },
2224 { name: "Packs", href: "/packs", who: "all", palette: "Forge packs" },
2325 { name: "Front page", href: "/front", who: "all", palette: "Front page" },
2426 { name: "Search", href: "/search", who: "all", palette: "Search" },
2527 { name: "Feed", href: "/feed", who: "user" },
2628 { name: "Board", href: "/board", who: "user" },
2729 { 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" },
3032 { name: "Asked", href: "/asked", who: "user", palette: "What you asked for" },
3133 { 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" },
3335 { name: "Map", href: "/map", who: "user", palette: "Map" },
3436 { name: "Displays", href: "/displays", who: "user", palette: "Displays" },
3537 { name: "Settings", href: "/settings", who: "user" },
36 { name: "System", href: "/system", who: "admin" },
38 { name: "System", href: "/system", who: "admin", key: "s" },
3739 { name: "Releases", href: "/releases", who: "admin" },
3840 { name: "Forges", href: "/forges", who: "admin" },
3941 { name: "Family", href: "/family", who: "admin" },
@@ -52,9 +54,9 @@ export function goLevel(viewer: { signedIn: boolean; admin: boolean }): 0 | 1 |
5254 return !viewer.signedIn ? 0 : viewer.admin ? 2 : 1;
5355 }
5456
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 ?? ""]);
5860 }
5961
6062 /** 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 () => {
1212 await f.close();
1313 });
1414
15 type Row = [string, string, number];
15 type Row = [string, string, number, string];
1616 // The very source the browser receives, run here as a function.
1717 const goMatch = new Function(`${GO_MATCH_JS}; return goMatch;`)() as (pages: Row[], q: string) => Row[];
1818 const all = goPagesData();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs