Public: anyone can read this; the crew can change it.
The map opens for its owner, signed in, in Safari, and shows the next few things first #8702
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8693-the-map-opens-for-its-owner-signed-in-in → main 3 commits fork 4 threads · 4 unresolved
5 files
changed +150
−22
docs/features/map.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # The map, lightly (#8693) | |
| 2 | ||
| 3 | `/map` is every app you can see, one lane each. The first screen is light: | |
| 4 | ||
| 5 | - **Next**: the top 5 ready issues across your lanes, crew lanes first, each with its lane, its number and how many are ready there. | |
| 6 | - **Lanes**: one folded row per app with its counts. The row carries a link, `Draw N issues`, that opens `/map?lane=owner/name`: that lane's graph, drawn at the same scale as all lanes, with `Show fewer` to go back. Each lane also links its own map, `/owner/name/map`. | |
| 7 | ||
| 8 | `?lane=` is `owner/name` made of letters, digits, `.`, `_` and `-`, at most 200 characters; anything else, an unknown lane, or one you may not see draws nothing and the page is the light one. The test in `test/map.test.ts` holds the first screen under 50 KB. | |
| 9 | ||
| 10 | The other doors keep the whole graph: `/api/v1/map`, the MCP map tools and the terminal map return every lane, since a program asks for data, not a first paint. |
src/web/pages/global-map.ts +62 −5
| @@ -208,15 +208,23 @@ export function globalLanes( | ||
| 208 | 208 | |
| 209 | 209 | /** A lane: one folding list row — the caret, the repository's name and its |
| 210 | 210 | * autonomy mode, who is on it, its counts — over its map. */ |
| 211 | export function laneHtml(lane: Lane, width: number): Html { | |
| 211 | export function laneHtml(lane: Lane, width: number, drawn = false): Html { | |
| 212 | 212 | const { repo } = lane; |
| 213 | const name = `${repo.ownerName}/${repo.name}`; | |
| 213 | 214 | return row({ |
| 214 | 215 | lead: icon("chevronRight"), |
| 215 | 216 | title: html`<a href="${repoBase(repo)}/map" |
| 216 | 217 | ><span class="muted">${repo.ownerName}/</span>${repo.name}</a |
| 217 | 218 | > |
| 218 | 219 | ${badge(repo.autonomy, { variant: "outline" })}`, |
| 219 | meta: countsLine(lane.counts), | |
| 220 | // The way to the drawing is in the row itself, so a folded lane is one | |
| 221 | // tap from its graph; a drawn lane says so and offers the way back. | |
| 222 | meta: html`${countsLine(lane.counts)} · | |
| 223 | ${drawn | |
| 224 | ? html`<a href="/map#lane-${repo.ownerName}-${repo.name}">Show fewer</a>` | |
| 225 | : html`<a href="/map?lane=${name}#lane-${repo.ownerName}-${repo.name}" | |
| 226 | >Draw ${lane.graph.issues.length} issues</a | |
| 227 | >`}`, | |
| 220 | 228 | trail: |
| 221 | 229 | lane.workers.length === 0 |
| 222 | 230 | ? html`` |
| @@ -233,8 +241,16 @@ export function laneHtml(lane: Lane, width: number): Html { | ||
| 233 | 241 | : { autonomy: repo.autonomy }, |
| 234 | 242 | fold: { |
| 235 | 243 | id: `lane-${repo.ownerName}-${repo.name}`, |
| 236 | open: !lane.idle, | |
| 237 | body: drawMap( | |
| 244 | open: drawn || !lane.idle, | |
| 245 | // The drawing is the heavy part (megabytes across every lane): the | |
| 246 | // first screen carries the headers and the next things, and a lane's | |
| 247 | // graph comes when asked for, at `?lane=owner/name`. | |
| 248 | body: !drawn | |
| 249 | ? html`<p class="muted small"> | |
| 250 | Drawn on request: <a href="/map?lane=${name}#lane-${repo.ownerName}-${repo.name}">draw it here</a> | |
| 251 | or <a href="${repoBase(repo)}/map">open its own map</a>. | |
| 252 | </p>` | |
| 253 | : drawMap( | |
| 238 | 254 | repo, |
| 239 | 255 | lane.graph, |
| 240 | 256 | lane.placed, |
| @@ -249,6 +265,37 @@ export function laneHtml(lane: Lane, width: number): Html { | ||
| 249 | 265 | }); |
| 250 | 266 | } |
| 251 | 267 | |
| 268 | /** `owner/name` as the `?lane=` of `/map` names it, or undefined: capped, and | |
| 269 | * made of the characters a repository path is made of, nothing else. */ | |
| 270 | export function parseLane(raw: string | null): string | undefined { | |
| 271 | if (raw === null || raw.length > 200) return undefined; | |
| 272 | return /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/.test(raw) ? raw : undefined; | |
| 273 | } | |
| 274 | ||
| 275 | /** How many next things the first screen names. */ | |
| 276 | export const NEXT_COUNT = 5; | |
| 277 | ||
outdated
Hand-rolled <h2>; use the DESIGN.md section heading component. Rows carry only a title; add state/count meta. |
||
| 278 | /** The next things across the lanes, the crew's lanes first (lane order). */ | |
| 279 | export function nextThings(lanes: readonly Lane[], n = NEXT_COUNT): Html { | |
| 280 | const picked = lanes | |
| 281 | .flatMap((lane) => lane.ready.map((r) => ({ lane, r }))) | |
| 282 | .slice(0, n); | |
| 283 | if (picked.length === 0) return html``; | |
| 284 | return html`<h2>Next</h2> | |
| 285 | ${listCard( | |
| 286 | picked.map(({ lane, r }) => | |
| 287 | row({ | |
| 288 | href: `${repoBase(lane.repo)}/issues/${r.issue.number}`, | |
| 289 | title: r.issue.title, | |
| 290 | meta: html`${lane.repo.ownerName}/${lane.repo.name} #${r.issue.number} | |
| 291 | · ${lane.counts.ready} ready there${lane.inFlight > 0 | |
| 292 | ? html` · crew working` | |
| 293 | : null}`, | |
| 294 | }), | |
| 295 | ), | |
| 296 | )}`; | |
| 297 | } | |
| 298 | ||
outdated
Validate |
||
| 252 | 299 | /** GET /map — every app the viewer can see, one lane each. */ |
| 253 | 300 | export function globalMapPage(ctx: Ctx): Response { |
| 254 | 301 | const viewer = ctx.auth.user; |
| @@ -263,16 +310,26 @@ export function globalMapPage(ctx: Ctx): Response { | ||
| 263 | 310 | notePhase(request, "layout", spent.layoutMs); |
| 264 | 311 | const renderStart = performance.now(); |
| 265 | 312 | const width = Math.max(0, ...lanes.map((l) => drawingWidth(l.placed))); |
| 313 | const want = parseLane(ctx.url.searchParams.get("lane")); | |
| 266 | 314 | const body = html`${pageHead("Map", { |
| 267 | 315 | sub: "Every app you can see, laid out by what waits for what. The top edge of each lane is ready now; a ringed card is where the crew is.", |
| 268 | 316 | })} |
| 317 | ${nextThings(lanes)} | |
| 269 | 318 | <div id="lanes" data-live="${LIVE_KINDS}"> |
| 270 | 319 | ${lanes.length === 0 |
| 271 | 320 | ? html`<p class="muted"> |
| 272 | 321 | Nothing to map: no open issues anywhere you can see, and nobody |
| 273 | 322 | working. |
| 274 | 323 | </p>` |
| 275 | : listCard(lanes.map((lane) => laneHtml(lane, width)))} | |
| 324 | : listCard( | |
| 325 | lanes.map((lane) => | |
| 326 | laneHtml( | |
| 327 | lane, | |
| 328 | width, | |
| 329 | want === `${lane.repo.ownerName}/${lane.repo.name}`, | |
| 330 | ), | |
| 331 | ), | |
| 332 | )} | |
| 276 | 333 | </div>`; |
| 277 | 334 | const response = page({ |
| 278 | 335 | ctx, |
test/map-cache.test.ts +8 −5
| @@ -210,21 +210,24 @@ describe("/map after a write", () => { | ||
| 210 | 210 | const privateName = privateRepo!.split("/")[1]!; |
| 211 | 211 | const privateTitle = "Plan 2 step 0"; |
| 212 | 212 | |
| 213 | const warmed = await get(servers, "/map", f.ownerCookie); | |
| 213 | // A lane is drawn when asked for (`?lane=`), so the drawings are asked for. | |
| 214 | const pubLane = `/map?lane=${pub.ownerName}/${pub.name}`; | |
| 215 | const privLane = `/map?lane=${privateRepo}`; | |
| 216 | const warmed = await get(servers, pubLane, f.ownerCookie); | |
| 214 | 217 | expect(warmed).toContain("Hidden hole in login"); |
| 215 | expect(warmed).toContain(privateTitle); | |
| 218 | expect(await get(servers, privLane, f.ownerCookie)).toContain(privateTitle); | |
| 216 | 219 | |
| 217 | for (const path of ["/map", "/api/v1/map"]) { | |
| 220 | for (const path of ["/map", pubLane, privLane, "/api/v1/map"]) { | |
| 218 | 221 | const seen = await get(servers, path, f.strangerCookie); |
| 219 | 222 | expect(seen).not.toContain("Hidden hole in login"); |
| 220 | 223 | expect(seen).not.toContain(privateTitle); |
| 221 | 224 | expect(seen).not.toContain(privateName); |
| 222 | 225 | } |
| 223 | 226 | // And the other way: the stranger warming first does not hide it from the owner. |
| 224 | expect(await get(servers, "/map", f.ownerCookie)).toContain("Hidden hole in login"); | |
| 227 | expect(await get(servers, pubLane, f.ownerCookie)).toContain("Hidden hole in login"); | |
| 225 | 228 | // Unsealing is a write the doc names: the stranger sees it next. |
| 226 | 229 | if (sealed.ok) setIssueSealed(f.app, owner, pub, sealed.value, false); |
| 227 | expect(await get(servers, "/map", f.strangerCookie)).toContain("Hidden hole in login"); | |
| 230 | expect(await get(servers, pubLane, f.strangerCookie)).toContain("Hidden hole in login"); | |
| 228 | 231 | expect(stranger.id).not.toBe(owner.id); |
| 229 | 232 | }); |
| 230 | 233 | }); |
test/map.test.ts +61 −6
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * idle lanes folded. A lane is exactly the repository's own map, so what |
| 7 | 7 | * the per-repo page says and what `/map` says never disagree. |
| 8 | 8 | */ |
| 9 | import { parseLane } from "../src/web/pages/global-map.ts"; | |
| 9 | 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 10 | 11 | import { mkdtemp, rm } from "node:fs/promises"; |
| 11 | 12 | import { tmpdir } from "node:os"; |
| @@ -484,15 +485,27 @@ describe("the map, per app and across the forge", () => { | ||
| 484 | 485 | expect(page).toMatch(/<details[^>]*id="lane-alice-engine"[^>]*>\s*<summary\s+class="row"/); |
| 485 | 486 | for (const gone of ["map-lane", "map-lane-caret", "map-lane-crew", "map-lane-head", "map-lane-name", "map-lane-word", "map-lanes"]) |
| 486 | 487 | expect(page).not.toContain(gone); |
| 487 | // Every lane is drawn at the widest lane's width: one scale, one grid. | |
| 488 | const widths = [ | |
| 489 | ...page.matchAll(/class="map-canvas"[^>]*data-w="(\d+)"/g), | |
| 490 | ].map((m) => m[1]); | |
| 488 | // The first screen draws no graph; a lane's comes when asked for, at one | |
| 489 | // scale: every lane drawn is the widest lane's width. | |
| 490 | expect(page).not.toContain('class="map-canvas"'); | |
| 491 | expect(page).toContain('href="/map?lane=alice/engine#lane-alice-engine"'); | |
| 492 | const widths = [] as (string | undefined)[]; | |
| 493 | for (const lane of ["alice/engine", "alice/dormant", "bob/secret"]) { | |
| 494 | const drawn = await ( | |
| 495 | await fetch(`${base}/map?lane=${lane}`, { headers: { cookie } }) | |
| 496 | ).text(); | |
| 497 | widths.push( | |
| 498 | ...[...drawn.matchAll(/class="map-canvas"[^>]*data-w="(\d+)"/g)].map((m) => m[1]), | |
| 499 | ); | |
| 500 | } | |
| 491 | 501 | expect(widths.length).toBeGreaterThanOrEqual(3); |
| 492 | 502 | expect(new Set(widths).size).toBe(1); |
| 493 | 503 | // The same card words as the repository's own map. |
| 494 | expect(page).toContain('data-state="working"'); | |
| 495 | expect(page).toContain('data-run="running"'); | |
| 504 | const engine = await ( | |
| 505 | await fetch(`${base}/map?lane=alice/engine`, { headers: { cookie } }) | |
| 506 | ).text(); | |
| 507 | expect(engine).toContain('data-state="working"'); | |
| 508 | expect(engine).toContain('data-run="running"'); | |
| 496 | 509 | // A repository with nothing to draw is not a lane. |
| 497 | 510 | expect(page).not.toContain('href="/alice/bare/map"'); |
| 498 | 511 | // The forge's admin reads every repository, so bob's is a lane here, |
| @@ -512,6 +525,48 @@ describe("the map, per app and across the forge", () => { | ||
| 512 | 525 | expect(bobs).not.toContain("Extract renderer"); |
| 513 | 526 | }); |
| 514 | 527 | |
| 528 | test("the first screen is small and fast, names the next things, and leaves the graphs to ask for (#8693)", async () => { | |
| 529 | const cookie = await signIn("alice"); | |
| 530 | const res = await fetch(`${base}/map`, { headers: { cookie } }); | |
| 531 | const page = await res.text(); | |
| 532 | expect(new TextEncoder().encode(page).length).toBeLessThan(50_000); | |
| 533 | expect(page).not.toContain('class="map-canvas"'); | |
| 534 | const next = page.slice(page.indexOf("<h2>Next</h2>"), page.indexOf('id="lanes"')); | |
| 535 | expect(next).toContain("Extract renderer"); | |
| 536 | expect((next.match(/<li/g) ?? []).length).toBeLessThanOrEqual(5); | |
| 537 | // Asking for a lane draws it, and only it. | |
| 538 | const one = await ( | |
outdated
Wall-clock assertion will flake on a loaded shard; keep the byte cap only. |
||
| 539 | await fetch(`${base}/map?lane=alice/engine`, { headers: { cookie } }) | |
| 540 | ).text(); | |
| 541 | expect((one.match(/class="map-canvas"/g) ?? []).length).toBe(1); | |
| 542 | // The row says how to draw it, or how to go back; the lane's own map is linked. | |
| 543 | expect(page).toContain(">Draw "); | |
| 544 | expect(one).toContain(">Show fewer</a>"); | |
| 545 | expect(page).toContain('href="/alice/engine/map"'); | |
| 546 | // The lane names are checked at the boundary: unknown, odd and long ones draw nothing. | |
| 547 | for (const bad of ["nobody/nothing", "alice", "../etc/passwd", "a/b/c", `${"x".repeat(300)}/y`, "alice/engine%00"]) { | |
| 548 | const res = await fetch(`${base}/map?lane=${encodeURIComponent(bad)}`, { headers: { cookie } }); | |
| 549 | expect(res.status).toBe(200); | |
| 550 | expect(await res.text()).not.toContain('class="map-canvas"'); | |
| 551 | } | |
| 552 | expect(parseLane("alice/engine")).toBe("alice/engine"); | |
| 553 | expect(parseLane("alice")).toBeUndefined(); | |
| 554 | expect(parseLane(null)).toBeUndefined(); | |
| 555 | }); | |
| 556 | ||
| 557 | test("a stranger's first screen and lane asks never name or draw a private lane (#8693)", async () => { | |
| 558 | const carol = await signIn("carol"); | |
| 559 | for (const path of ["/map", "/map?lane=bob/secret", "/map?lane=alice/engine"]) { | |
| 560 | const text = await (await fetch(`${base}${path}`, { headers: { cookie: carol } })).text(); | |
| 561 | expect(text).not.toContain("Rotate the keys"); | |
| 562 | expect(text).not.toContain("bob/secret"); | |
| 563 | expect(text).not.toContain('class="map-canvas"'); | |
| 564 | } | |
| 565 | const bob = await signIn("bob"); | |
| 566 | const text = await (await fetch(`${base}/map?lane=alice/engine`, { headers: { cookie: bob } })).text(); | |
| 567 | expect(text).not.toContain("Extract renderer"); | |
| 568 | }); | |
| 569 | ||
| 515 | 570 | test("an empty map is §5's empty state, with the words and the way on", async () => { |
| 516 | 571 | const { drawMap, layout } = await import("../src/web/pages/map.ts"); |
| 517 | 572 | const graph = { issues: [], edges: [] } as unknown as Graph; |
test/snappy.test.ts +9 −6
| @@ -76,10 +76,12 @@ describe("the caches are exact", () => { | ||
| 76 | 76 | // (docs/CACHING.md, "Lane writes"). |
| 77 | 77 | test("a write is on the very next map: a new issue, a new edge, a closed one", async () => { |
| 78 | 78 | const repo = f.repos[1]!; |
| 79 | // A lane's drawing comes when asked for (#8693). | |
| 80 | const lanePage = (cookie: string) => page(`/map?lane=${repo.ownerName}/${repo.name}`, cookie); | |
| 79 | 81 | await page("/map", f.ownerCookie); |
| 80 | 82 | const opened = openIssue(f.app, f.owner, repo, { title: "Zeppelin moorings" }); |
| 81 | 83 | if (!opened.ok) throw new Error(String(opened.error)); |
| 82 | expect(await page("/map", f.ownerCookie)).toContain("Zeppelin moorings"); | |
| 84 | expect(await lanePage(f.ownerCookie)).toContain("Zeppelin moorings"); | |
| 83 | 85 | |
| 84 | 86 | const second = openIssue(f.app, f.owner, repo, { title: "Quokka census" }); |
| 85 | 87 | if (!second.ok) throw new Error(String(second.error)); |
| @@ -95,22 +97,23 @@ describe("the caches are exact", () => { | ||
| 95 | 97 | // An issue on no edge leaves the map when it closes. |
| 96 | 98 | const lone = openIssue(f.app, f.owner, repo, { title: "Narwhal tally" }); |
| 97 | 99 | if (!lone.ok) throw new Error(String(lone.error)); |
| 98 | expect(await page("/map", f.ownerCookie)).toContain("Narwhal tally"); | |
| 100 | expect(await lanePage(f.ownerCookie)).toContain("Narwhal tally"); | |
| 99 | 101 | expect(closeIssue(f.app, f.owner, repo, lone.value, "closed").ok).toBe(true); |
| 100 | expect(await page("/map", f.ownerCookie)).not.toContain("Narwhal tally"); | |
| 102 | expect(await lanePage(f.ownerCookie)).not.toContain("Narwhal tally"); | |
| 101 | 103 | }); |
| 102 | 104 | |
| 103 | 105 | test("a sealed issue the owner's map drew is not drawn for a member who may not see it", async () => { |
| 104 | 106 | // app-0 is public: the member reads it, but not what is sealed in it. |
| 105 | 107 | const repo = f.repos[0]!; |
| 108 | const lane = `/map?lane=${repo.ownerName}/${repo.name}`; | |
| 106 | 109 | const sealed = openIssue(f.app, f.owner, repo, { title: "Sealed vault combination", sealed: true }); |
| 107 | 110 | if (!sealed.ok) throw new Error(String(sealed.error)); |
| 108 | 111 | // Warm in both orders: the owner first, then the member, then the owner. |
| 109 | expect(await page("/map", f.ownerCookie)).toContain("Sealed vault combination"); | |
| 110 | const member = await page("/map", f.peerCookie); | |
| 112 | expect(await page(lane, f.ownerCookie)).toContain("Sealed vault combination"); | |
| 113 | const member = await page(lane, f.peerCookie); | |
| 111 | 114 | expect(member).toContain(`lane-olive-${repo.name}`); |
| 112 | 115 | expect(member).not.toContain("Sealed vault combination"); |
| 113 | expect(await page("/map", f.ownerCookie)).toContain("Sealed vault combination"); | |
| 116 | expect(await page(lane, f.ownerCookie)).toContain("Sealed vault combination"); | |
| 114 | 117 | // And the member's own private repository is never on the owner's |
| 115 | 118 | // stranger-scoped lanes either way: it is listed by visibility per request. |
| 116 | 119 | expect(await page("/map", f.peerCookie)).toContain("lane-pablo-notes"); |
new above
offline
The link is inside a fold that is closed for idle lanes: two taps, and invisible to a skimmer. Surface it in the row meta or open the fold.