Public: anyone can read this; the crew can change it.

`forge map` prints the plan of the folder's repository: ready now, being built, and what waits on what #7608

Merged opened this

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

Merged

torque/forge:torque/7379-forge-map-prints-the-plan-of-the-folder → main 14 commits fork

  • Merged. torque/7379-forge-map-prints-the-plan-of-the-folder was merged into main as ee0124a.

Review

Sign in to review this change.
8 files changed +485 −0
README.md +13
@@ -330,6 +330,7 @@ forge asks --repo owner/name # only that repository's; --json for the API'
330330 forge asks --ship 1 3 # decide by the line numbers just printed: planned, then Apply? [y/N]
331331 forge asks --close 2 --reason "words" # also --dismiss 4; --yes skips the question (needed without a TTY)
332332 forge asks --undo <id> # take back a decision by the receipt id it printed
333 forge map # the plan of this clone: working, ready, waiting
333334 forge search retry budget # code, issues, pulls and repos you can read; exits 6 on "not now"
334335 forge search x --kind pulls --limit 5 # one kind, five hits; --json marks truncated.issues when more matched
335336 ```
@@ -352,6 +353,18 @@ shell command on purpose: the terminal UI of the forge (`ssh`, `src/tty.ts`)
352353 already has its own pulls screen, and the local UI is about working trees, not
353354 the workbench's conversation; the pulls API and MCP are the other doors.
354355
356 `forge map` reads the plan of the same repository (`--repo` or `origin`, as
357 for `pulls`) from `GET /api/v1/repos/:owner/:name/map` and prints three
358 groups, each headed with its count: Working (`#N title — assignee`), Ready —
359 the map's `startable` subset of its ready issues, unassigned and not held, so
360 its count matches the map page's ready count (`#N title — pillar`) — and
361 Waiting (`#N title — waits on #A, #B`, open blockers only). Titles are cut to
362 the terminal, and a group past 15 lines ends `… N more`. `--json` prints the
363 API's answer unchanged; an unknown repository prints the forge's words and
364 exits 1 (3 for a refused token, 5 for a forbidden one).
365 The same plan is a screen in the forge's terminal UI: press `m` on a
366 repository's issues list (`src/tui/screens/plan.ts`).
367
355368 `sync` never merges, rebases, resets or checks anything out. `--ff` is the one
356369 exception and it fast-forwards a repo only when the tree is clean, nothing is
357370 ahead, no rebase or merge is half-finished, and `merge-base --is-ancestor` has
docs/CLI.md +37
@@ -289,6 +289,43 @@ look alice/shop Pull #3 is green — look and merge https://forge.example.
289289 parked alice/notes Issue #9 waits on a decision https://forge.example.com/alice/notes/issues/9
290290 ```
291291
292 ## forge map
293
294 The plan of the repository: three groups that do not overlap, each headed
295 with its count. **Working** is what the crew is on (`#N title — assignee`),
296 **Ready** is the map's `startable` issues: nothing open stands in front of
297 them and nobody has started or held them, so the count matches the map
298 page's (`#N title — pillar`), and **Waiting** is every open issue with an open
299 blocker (`#N title — waits on #A, #B`). Titles are cut to the terminal and a
300 group stops at 15 lines with `… N more`; an empty one says so (`nothing
301 ready`). The repository comes from `--repo`
302 or from the folder's `origin` remote on the configured workbench.
303
304 Flags:
305
306 - `--repo owner/name` — the repository to read (exit 2 if it is not
307 `owner/name`, or if it is missing and the folder is not a clone).
308 - `--json` — the forge's `/api/v1/repos/:owner/:name/map` answer as it came.
309
310 ```console
311 $ forge map --repo alice/shop
312 Working (1)
313 #12 Checkout keeps the cart — torque
314
315 Ready (1)
316 #14 Receipts by mail — Payments
317
318 Waiting (1)
319 #15 Refunds — waits on #14
320 ```
321
322 The same plan is open to agents through MCP, as the `map` tool, and to
323 people in the terminal UI (`m` on a repository's issues list):
324
325 ```json
326 { "name": "map", "arguments": { "app": "alice/shop" } }
327 ```
328
292329 ## forge search
293330
294331 Code, issues, pulls and repositories the token can read, from
src/cli/api.ts +96
@@ -91,6 +91,28 @@ export interface Bulk {
9191 const show = (value: unknown): string =>
9292 value === null || value === undefined ? "" : String(value);
9393
94 /** One issue on the plan, as `forge map` prints it. */
95 export interface PlanIssue {
96 readonly number: number;
97 readonly title: string;
98 readonly assignee: string | null;
99 readonly pillar: string | null;
100 /** Open issues this one waits on, lowest number first. */
101 readonly waitsOn: readonly number[];
102 }
103
104 /** Where the plan stands: the three groups `forge map` prints. */
105 export interface Plan {
106 /** The answer exactly as the forge gave it, for `--json`. */
107 readonly raw: unknown;
108 /** What the crew is on right now. */
109 readonly working: PlanIssue[];
110 /** Ready now and nobody on it, in the forge's order. */
111 readonly ready: PlanIssue[];
112 /** Open, and waiting on at least one open issue. */
113 readonly waiting: PlanIssue[];
114 }
115
94116 export class ForgeApi {
95117 /** True once `pins()` has had to fall back, so the CLI can say it once. */
96118 pinsUnavailable = false;
@@ -430,6 +452,20 @@ export class ForgeApi {
430452 return ok(this.toBulk(out.value));
431453 }
432454
455 /**
456 * The repository's plan (`GET /api/v1/repos/:owner/:name/map`), split into
457 * what is being built, what is ready and what waits on what. The groups do
458 * not overlap: an issue the crew is on is Working even when it is ready or
459 * still has an open blocker.
460 */
461 async map(owner: string, name: string): Promise<ApiResult<Plan>> {
462 const out = await this.call<unknown>(
463 `/api/v1/repos/${encode(owner)}/${encode(name)}/map`,
464 );
465 if (!out.ok) return out;
466 return ok(toPlan(out.value));
467 }
468
433469 /** A workbench path as a full address; anything not a path is the home. */
434470 private absolute(link: string): string {
435471 return link.startsWith("/") && !link.startsWith("//")
@@ -492,6 +528,66 @@ function titleOf(line: string, repo: string): string {
492528 return line;
493529 }
494530
531 /** The map's JSON → `Plan`; rows without a number are dropped, not guessed. */
532 export function toPlan(raw: unknown): Plan {
533 const body = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
534 const list = (v: unknown): Record<string, unknown>[] =>
535 Array.isArray(v)
536 ? v.filter((x): x is Record<string, unknown> => typeof x === "object" && x !== null)
537 : [];
538 const num = (v: unknown): number | undefined =>
539 typeof v === "number" && Number.isSafeInteger(v) ? v : undefined;
540 const issues = new Map<number, Record<string, unknown>>();
541 for (const i of list(body["issues"])) {
542 const n = num(i["number"]);
543 if (n !== undefined && i["state"] !== "closed") issues.set(n, i);
544 }
545 const waitsOn = new Map<number, number[]>();
546 for (const e of list(body["edges"])) {
547 const a = num(e["issue"]);
548 const b = num(e["blockedBy"]);
549 if (a === undefined || b === undefined || !issues.has(a) || !issues.has(b)) continue;
550 const on = waitsOn.get(a) ?? [];
551 if (!on.includes(b)) on.push(b);
552 waitsOn.set(a, on);
553 }
554 // Every group reads its fields from `issues`: the ready rows agree with it.
555 const issue = (n: number): PlanIssue => {
556 const r = issues.get(n) ?? {};
557 return {
558 number: n,
559 title: str(r["title"]) ?? "",
560 assignee: str(r["assignee"]) ?? null,
561 pillar: str(r["pillar"]) ?? null,
562 waitsOn: [...(waitsOn.get(n) ?? [])].sort((x, y) => x - y),
563 };
564 };
565 const workingSet = new Set<number>();
566 const working: PlanIssue[] = [];
567 for (const v of Array.isArray(body["working"]) ? body["working"] : []) {
568 const n = num(v);
569 // Only an open issue the map lists is being built; a closed one is done.
570 if (n === undefined || workingSet.has(n) || !issues.has(n)) continue;
571 workingSet.add(n);
572 working.push(issue(n));
573 }
574 const startable = Array.isArray(body["startable"])
575 ? new Set(body["startable"].map(num).filter((n) => n !== undefined))
576 : undefined;
577 const ready: PlanIssue[] = [];
578 for (const r of list(body["ready"])) {
579 const n = num(r["number"]);
580 if (n === undefined || workingSet.has(n)) continue;
581 if (startable !== undefined ? !startable.has(n) : r["assignee"] != null) continue;
582 ready.push(issue(n));
583 }
584 const waiting = [...waitsOn.keys()]
585 .filter((n) => !workingSet.has(n))
586 .sort((x, y) => x - y)
587 .map((n) => issue(n));
588 return { raw, working, ready, waiting };
589 }
590
495591 function str(value: unknown): string | undefined {
496592 return typeof value === "string" && value !== "" ? value : undefined;
497593 }
src/cli/commands/index.ts +2
@@ -14,6 +14,7 @@ import { command as unpin } from "./unpin.ts";
1414 import { command as open } from "./open.ts";
1515 import { command as pulls } from "./pulls.ts";
1616 import { command as asks } from "./asks.ts";
17 import { command as map } from "./map.ts";
1718 import { command as search } from "./search.ts";
1819 import { command as ship } from "./ship.ts";
1920 import type { Command } from "./types.ts";
@@ -31,6 +32,7 @@ export const commands: readonly Command[] = [
3132 open,
3233 pulls,
3334 asks,
35 map,
3436 search,
3537 ship,
3638 ];
src/cli/commands/map.ts added +49
@@ -0,0 +1,49 @@
1 import { ForgeApi } from "../api.ts";
2 import { parseRepoRef, type Args } from "../args.ts";
3 import { requireConfig } from "../context.ts";
4 import { EXIT } from "../exit.ts";
5 import { folderRepo } from "../folder-repo.ts";
6 import { inert, type Out } from "../out.ts";
7 import { mapLines } from "../plan.ts";
8 import type { Command } from "./types.ts";
9
10 /**
11 * `forge map`: where the repository's plan stands, in a screenful — what
12 * the crew is building, what is ready, and what waits on what.
13 */
14 async function map(out: Out, args: Args): Promise<number> {
15 const loaded = await requireConfig(out);
16 if (typeof loaded === "number") return loaded;
17 const flagged = args.value("repo");
18 const ref =
19 flagged !== undefined
20 ? parseRepoRef(flagged)
21 : await folderRepo(process.cwd(), loaded.url);
22 if (ref === undefined) {
23 out.err(
24 flagged !== undefined
25 ? "forge map: --repo expects owner/name"
26 : `forge map: this folder is not a clone of ${loaded.url} — run it in a clone, or pass --repo owner/name`,
27 );
28 return EXIT.usage;
29 }
30 const got = await new ForgeApi(loaded.url, loaded.token).map(ref.owner, ref.name);
31 if (!got.ok) {
32 out.err(`forge map: ${inert(got.error.message)}`);
33 if (got.error.status === 401) return EXIT.notConfigured;
34 if (got.error.status === 403) return EXIT.refused;
35 return EXIT.failed;
36 }
37 if (args.flag("json")) {
38 out.line(JSON.stringify(got.value.raw));
39 return EXIT.ok;
40 }
41 for (const line of mapLines(got.value, out.width, out)) out.line(line);
42 return EXIT.ok;
43 }
44
45 export const command: Command = {
46 name: "map",
47 usage: { synopsis: "forge map", flags: "[--repo owner/name] [--json]" },
48 run: map,
49 };
src/cli/main.ts +1
@@ -10,6 +10,7 @@
1010 * forge search <words> code, issues, pulls and repos you can read
1111 * forge asks --ship 1 3 decide asks by their numbers: planned, then a y/N
1212 * forge asks --undo <id> take back what that decided
13 * forge map the plan: being built, ready now, what waits
1314 * forge ship <number> land a pull after a y/N, as its Merge button does
1415 *
1516 * Everything here is a thin shell around three modules: `api.ts` talks to the
src/cli/plan.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * `forge map`'s screenful: the plan's three groups as lines for a terminal.
3 * Plain data in (a `Plan` and a width), lines out; the command reads the
4 * plan and prints, this module only lays it out.
5 */
6
7 import type { Plan, PlanIssue } from "./api.ts";
8 import { inert, type Out } from "./out.ts";
9 import { displayWidth, pad, truncate } from "../tui/term/width.ts";
10
11 /** Lines a group prints before it says how many more it holds. */
12 export const MAP_GROUP_LINES = 15;
13
14 /**
15 * The plan as lines no wider than `width - 1`: three groups, each headed
16 * with its count, at most `MAP_GROUP_LINES` rows each and `… N more` after.
17 * Coloured only when `out` is given.
18 */
19 export function mapLines(
20 plan: Pick<Plan, "working" | "ready" | "waiting">,
21 width: number,
22 out?: Pick<Out, "paint" | "c">,
23 ): string[] {
24 const paint = (text: string, tone: "accent" | "faint"): string =>
25 out === undefined ? text : out.paint(text, out.c[tone]);
26 const groups: [string, PlanIssue[], string, (i: PlanIssue) => string][] = [
27 ["Working", plan.working, "nothing being built", (i) => i.assignee ?? "the crew"],
28 ["Ready", plan.ready, "nothing ready", (i) => i.pillar ?? "no pillar"],
29 [
30 "Waiting",
31 plan.waiting,
32 "nothing waiting",
33 (i) => `waits on ${i.waitsOn.map((n) => `#${n}`).join(", ")}`,
34 ],
35 ];
36 const lines: string[] = [];
37 for (const [head, rows, none, tail] of groups) {
38 if (lines.length > 0) lines.push("");
39 lines.push(paint(`${head} (${rows.length})`, "accent"));
40 if (rows.length === 0) lines.push(paint(` ${none}`, "faint"));
41 const shown = rows.slice(0, MAP_GROUP_LINES);
42 const nW = Math.max(0, ...shown.map((i) => `#${i.number}`.length));
43 for (const i of shown) {
44 const end = ` — ${inert(tail(i))}`;
45 // Two of indent, the number, a space; the title gets what is left.
46 const room = Math.max(8, width - 1 - 2 - nW - 1 - displayWidth(end));
47 lines.push(
48 ` ${pad(`#${i.number}`, nW, "right")} ${truncate(inert(i.title), room)}${paint(end, "faint")}`,
49 );
50 }
51 if (rows.length > shown.length)
52 lines.push(paint(` … ${rows.length - shown.length} more`, "faint"));
53 }
54 return lines;
55 }
test/cli-map.test.ts added +232
@@ -0,0 +1,232 @@
1 import { afterEach, expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { toPlan } from "../src/cli/api.ts";
6 import { EXIT, main } from "../src/cli/main.ts";
7 import { MAP_GROUP_LINES, mapLines } from "../src/cli/plan.ts";
8
9 let server: ReturnType<typeof Bun.serve> | undefined;
10 const env = { url: process.env["FORGE_URL"], token: process.env["FORGE_TOKEN"] };
11 afterEach(() => {
12 server?.stop(true);
13 for (const [k, v] of [["FORGE_URL", env.url], ["FORGE_TOKEN", env.token]] as const)
14 if (v === undefined) delete process.env[k];
15 else process.env[k] = v;
16 });
17
18 const seen: string[] = [];
19 function fake(body: unknown): void {
20 seen.length = 0;
21 server = Bun.serve({
22 port: 0,
23 fetch(req) {
24 const url = new URL(req.url);
25 seen.push(url.pathname);
26 if (url.pathname === "/api/v1/repos/acme/app/map") return Response.json(body);
27 if (url.pathname === "/api/v1/repos/acme/secret/map")
28 return Response.json({ error: "forbidden", message: "you cannot read acme/secret" }, { status: 403 });
29 return Response.json({ error: "not_found", message: "no repository acme/gone" }, { status: 404 });
30 },
31 });
32 process.env["FORGE_URL"] = `http://127.0.0.1:${server.port}`;
33 process.env["FORGE_TOKEN"] = "t";
34 }
35
36 async function run(argv: string[], cwd?: string) {
37 const lines: string[] = [];
38 const errs: string[] = [];
39 const write = process.stdout.write.bind(process.stdout);
40 const werr = process.stderr.write.bind(process.stderr);
41 const prev = process.cwd();
42 if (cwd) process.chdir(cwd);
43 process.stdout.write = ((s: string) => (lines.push(String(s)), true)) as never;
44 process.stderr.write = ((s: string) => (errs.push(String(s)), true)) as never;
45 try {
46 const code = await main(argv);
47 return { code, out: lines.join("").split("\n"), err: errs.join("") };
48 } finally {
49 process.stdout.write = write;
50 process.stderr.write = werr;
51 process.chdir(prev);
52 }
53 }
54
55 const issue = (number: number, title: string, extra: Record<string, unknown> = {}) => ({
56 number,
57 title,
58 state: "open",
59 assignee: null,
60 pillar: null,
61 ...extra,
62 });
63
64 /**
65 * #1 is built by scout; #2 is ready under Reach; #3 is ready but taken, so
66 * not startable; #4 waits on #1 and #2; #5 waits on #4; #6 is closed and
67 * its edge to #5 does not count.
68 */
69 const map = {
70 repo: { owner: "acme", name: "app" },
71 ready: [
72 { ...issue(1, "Build the gauge", { assignee: "scout", pillar: "Reach" }), holdsUp: 1 },
73 { ...issue(2, "Draw the dial", { pillar: "Reach" }), holdsUp: 1 },
74 { ...issue(3, "Taken already", { assignee: "mason" }), holdsUp: 0 },
75 { ...issue(7, "No pillar here"), holdsUp: 0 },
76 ],
77 startable: [2, 7],
78 working: [1],
79 issues: [
80 issue(1, "Build the gauge", { assignee: "scout", pillar: "Reach" }),
81 issue(2, "Draw the dial", { pillar: "Reach" }),
82 issue(3, "Taken already", { assignee: "mason" }),
83 issue(4, "Wire the dial to the gauge"),
84 issue(5, "Ship it"),
85 { ...issue(6, "Old"), state: "closed" },
86 issue(7, "No pillar here"),
87 ],
88 edges: [
89 { issue: 4, blockedBy: 2 },
90 { issue: 4, blockedBy: 1 },
91 { issue: 5, blockedBy: 4 },
92 { issue: 5, blockedBy: 6 },
93 ],
94 };
95
96 test("prints Working, Ready and Waiting, each headed with its count", async () => {
97 fake(map);
98 const r = await run(["map", "--repo", "acme/app"]);
99 expect(r.code).toBe(EXIT.ok);
100 expect(seen).toEqual(["/api/v1/repos/acme/app/map"]);
101 expect(r.out.filter(Boolean)).toEqual([
102 "Working (1)",
103 " #1 Build the gauge — scout",
104 "Ready (2)",
105 " #2 Draw the dial — Reach",
106 " #7 No pillar here — no pillar",
107 "Waiting (2)",
108 " #4 Wire the dial to the gauge — waits on #1, #2",
109 " #5 Ship it — waits on #4",
110 ]);
111 });
112
113 test("each group stops at 15 lines and says how many more", () => {
114 const many = Array.from({ length: 20 }, (_, i) => ({
115 number: 100 + i,
116 title: `Issue ${i}`,
117 assignee: null,
118 pillar: null,
119 waitsOn: [1],
120 }));
121 const lines = mapLines({ working: [], ready: many, waiting: many.slice(0, 15) }, 80);
122 const ready = lines.slice(lines.indexOf("Ready (20)") + 1, lines.indexOf("Waiting (15)") - 1);
123 expect(ready).toHaveLength(MAP_GROUP_LINES + 1);
124 expect(MAP_GROUP_LINES).toBe(15);
125 expect(ready.at(-1)).toBe(" … 5 more");
126 expect(ready[14]).toBe(" #114 Issue 14 — no pillar");
127 // Exactly fifteen: no "more" line.
128 expect(lines.slice(lines.indexOf("Waiting (15)") + 1)).toHaveLength(15);
129 expect(lines.slice(0, 4)).toEqual(["Working (0)", " nothing being built", "", "Ready (20)"]);
130 });
131
132 test("titles are cut so no line is wider than the terminal", () => {
133 const long = { number: 9, title: "x".repeat(200), assignee: "scout", pillar: null, waitsOn: [] };
134 const [, line] = mapLines({ working: [long], ready: [], waiting: [] }, 40);
135 expect(line!.length).toBeLessThanOrEqual(39);
136 expect(line).toEndWith("… — scout");
137 });
138
139 test("--json prints the answer unchanged", async () => {
140 fake(map);
141 const r = await run(["map", "--repo", "acme/app", "--json"]);
142 expect(r.code).toBe(EXIT.ok);
143 expect(JSON.parse(r.out[0]!)).toEqual(map);
144 });
145
146 test("an unknown repository prints the API's words and the matching exit", async () => {
147 fake(map);
148 const gone = await run(["map", "--repo", "acme/gone"]);
149 expect(gone.code).toBe(EXIT.failed);
150 expect(gone.err).toContain("no repository acme/gone");
151 const secret = await run(["map", "--repo", "acme/secret"]);
152 expect(secret.code).toBe(EXIT.refused);
153 expect(secret.err).toContain("you cannot read acme/secret");
154 });
155
156 test("a bad --repo, or a folder that is no clone, exits 2 before any request", async () => {
157 fake(map);
158 expect((await run(["map", "--repo", "nope"])).code).toBe(EXIT.usage);
159 const dir = mkdtempSync(join(process.env["TMPDIR"] ?? tmpdir(), "map-"));
160 expect((await run(["map"], dir)).code).toBe(EXIT.usage);
161 expect(seen).toEqual([]);
162 });
163
164 test("a map with no startable list falls back to the unassigned ready", () => {
165 const { startable: _, ...older } = map;
166 expect(toPlan(older).ready.map((i) => i.number)).toEqual([2, 7]);
167 expect(toPlan(null)).toEqual({ raw: null, working: [], ready: [], waiting: [] });
168 });
169
170 test("a blocker that is closed, or only in edges, holds nothing; Working wins over Waiting", () => {
171 const plan = toPlan({
172 issues: [
173 { number: 1, title: "Build it", state: "open", assignee: "torque" },
174 { number: 2, title: "Old blocker", state: "closed" },
175 { number: 3, title: "Waits on a gone one", state: "open" },
176 { number: 4, title: "Waits for real", state: "open", assignee: "torque" },
177 { number: 5, title: "Still open", state: "open" },
178 ],
179 edges: [
180 { issue: 1, blockedBy: 2 },
181 { issue: 3, blockedBy: 99 },
182 { issue: 4, blockedBy: 5 },
183 ],
184 working: [1, 4],
185 ready: [],
186 startable: [],
187 });
188 expect(plan.working.map((i) => [i.number, i.waitsOn])).toEqual([[1, []], [4, [5]]]);
189 expect(plan.waiting).toEqual([]);
190 });
191
192 test("a title, assignee or pillar with terminal escapes prints inert", () => {
193 const esc = "\x1b]0;owned\x07\x1b[2J";
194 const row = { number: 1, title: `Fix ${esc}it`, assignee: `eve${esc}`, pillar: `P${esc}`, waitsOn: [2] };
195 const lines = mapLines({ working: [row], ready: [row], waiting: [row] }, 200);
196 const text = lines.join("\n");
197 expect(text).not.toMatch(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/);
198 expect(text).toContain("Fix �]0;owned��[2Jit");
199 expect(text).toContain("— eve�");
200 expect(text).toContain("— P�");
201 });
202
203 test("a closed issue, or one the map does not list, is not Working", () => {
204 const plan = toPlan({
205 issues: [
206 { number: 1, title: "Built", state: "closed", assignee: "torque" },
207 { number: 2, title: "Building", state: "open", assignee: "torque" },
208 ],
209 edges: [],
210 working: [1, 2, 3],
211 ready: [],
212 startable: [],
213 });
214 expect(plan.working.map((i) => i.number)).toEqual([2]);
215 });
216
217 test("an empty group says so under its heading", () => {
218 expect(mapLines({ working: [], ready: [], waiting: [] }, 80)).toEqual([
219 "Working (0)",
220 " nothing being built",
221 "",
222 "Ready (0)",
223 " nothing ready",
224 "",
225 "Waiting (0)",
226 " nothing waiting",
227 ]);
228 });
229
230 test("help lists map", async () => {
231 expect((await run(["help"])).out.join("\n")).toContain("forge map");
232 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs