Public: anyone can read this; the crew can change it.
The studio's lists answer at the studio's nouns: /tells and /changes #139
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/119-the-studio-s-lists-answer-at-the-studio → main 1 commit fork
18 files
changed +255
−56
docs/STUDIO.md +14 −5
| @@ -156,11 +156,20 @@ issue numbers, labels, milestones and branch names replaced by the same | ||
| 156 | 156 | five words the rest of the studio speaks. Each is a page of rows, newest |
| 157 | 157 | first: who has it (the assignee, the author, or who ran it), when it last |
| 158 | 158 | moved, its state as `wordBadge` draws it (`src/web/studio/words.ts`), and |
| 159 | one link — the row itself — to the tell, the change or the transcript. The | |
| 160 | app page's own footer (`the map · everything told · every change`, and | |
| 161 | *Every run* above **Now**) and the repository header (`Told`, `Look`, in | |
| 162 | place of dev's `Issues`, `Pulls`) both land here; the header's own tabs | |
| 163 | keep dev's names, and dev's `Commits`/`Branches` stay dev's alone. | |
| 159 | one link — the row itself — to the tell, the change or the transcript. | |
| 160 | `/:owner/:name/tells` and `/:owner/:name/changes` are the studio's own | |
| 161 | nouns for the first two lists — the same handler, the same authorization, | |
| 162 | the same `?word=` filter and paging, not a second copy of the page; a | |
| 163 | viewer the studio list does not apply to (dev mode, `?mode=dev`, or a | |
| 164 | repository that is not an app) is sent on to `/issues` or `/pulls`, dev's | |
| 165 | own address, rather than the two ever growing a second address of their | |
| 166 | own. The app page's own footer (`the map · everything told · every | |
| 167 | change`, and *Every run* above **Now**) and the repository header's tabs | |
| 168 | (`Told`, `Look`, in place of dev's `Issues`, `Pulls`) link to `/tells` and | |
| 169 | `/changes` in studio mode; a tell or a change keeps its own | |
| 170 | `/issues/:number` / `/pulls/:number` address either way, and dev's | |
| 171 | `Commits`/`Branches` stay dev's alone. `/runs` has no second noun — the | |
| 172 | studio and dev share the one address there. | |
| 164 | 173 | |
| 165 | 174 | A row's word comes from `tellWord`, `pullWord` or `runWord` — the one place |
| 166 | 175 | each is decided, never re-derived here. `?word=told|working|look|shipped| |
src/http/server.ts +8
| @@ -84,6 +84,7 @@ import { | ||
| 84 | 84 | tellSubmit, |
| 85 | 85 | } from "../web/studio/actions.ts"; |
| 86 | 86 | import { appStateFragment } from "../web/studio/previews.ts"; |
| 87 | import { changesList, tellsList } from "../web/studio/lists.ts"; | |
| 87 | 88 | import { DEFAULT_MODE, isMode, modeCookie, type Mode } from "../web/mode.ts"; |
| 88 | 89 | import { dependencySubmit } from "../web/pages/thread.ts"; |
| 89 | 90 | import { |
| @@ -529,6 +530,11 @@ export function buildRouter(): Router<Handler> { | ||
| 529 | 530 | // Issues (web). The literal /new is registered before /:number so it can |
| 530 | 531 | // never be captured as a number. |
| 531 | 532 | router.add("GET", "/:owner/:name/issues", issuesList); |
| 533 | // The studio's own noun for the same list — everything told. A tell or a | |
| 534 | // change keeps its `/issues/:number` / `/pulls/:number` address; only the | |
| 535 | // list itself gains a second door (`tellsList`/`changesList` redirect to | |
| 536 | // dev's own address when the studio's list does not apply). | |
| 537 | router.add("GET", "/:owner/:name/tells", tellsList); | |
| 532 | 538 | router.add("GET", "/:owner/:name/issues/new", newIssuePage); |
| 533 | 539 | router.add("POST", "/:owner/:name/issues/new", newIssueSubmit); |
| 534 | 540 | router.add("GET", "/:owner/:name/issues/:number", issuePage); |
| @@ -562,6 +568,8 @@ export function buildRouter(): Router<Handler> { | ||
| 562 | 568 | |
| 563 | 569 | // Pull requests. Same /new-before-/:number ordering discipline. |
| 564 | 570 | router.add("GET", "/:owner/:name/pulls", pullsList); |
| 571 | // The studio's own noun for the same list — every change. | |
| 572 | router.add("GET", "/:owner/:name/changes", changesList); | |
| 565 | 573 | router.add("GET", "/:owner/:name/pulls/new", newPullPage); |
| 566 | 574 | router.add("POST", "/:owner/:name/pulls/new", newPullSubmit); |
| 567 | 575 | router.add("GET", "/:owner/:name/pulls/:number", pullPage); |
src/web/chrome.ts +18 −6
| @@ -165,7 +165,7 @@ export function scopeHeader( | ||
| 165 | 165 | // Studio names its own lists — told, not issues; dev's tabs are |
| 166 | 166 | // unchanged in dev mode and on a repository that is not an app. |
| 167 | 167 | label: studio ? "Told" : "Issues", |
| 168 | href: `${base}/issues`, | |
| 168 | href: studio ? `${base}/tells` : `${base}/issues`, | |
| 169 | 169 | icon: studio ? "clock" : "issue", |
| 170 | 170 | ...(facts.openIssues === null ? {} : { count: facts.openIssues }), |
| 171 | 171 | current: active === "issues", |
| @@ -173,7 +173,7 @@ export function scopeHeader( | ||
| 173 | 173 | }, |
| 174 | 174 | { |
| 175 | 175 | label: studio ? "Look" : "Pulls", |
| 176 | href: `${base}/pulls`, | |
| 176 | href: studio ? `${base}/changes` : `${base}/pulls`, | |
| 177 | 177 | icon: studio ? "eye" : "pull", |
| 178 | 178 | ...(facts.openPulls === null ? {} : { count: facts.openPulls }), |
| 179 | 179 | current: active === "pulls", |
| @@ -283,14 +283,26 @@ export function scopeHeader( | ||
| 283 | 283 | |
| 284 | 284 | export { hostOf }; |
| 285 | 285 | |
| 286 | /** This repo's own rows for the command palette. */ | |
| 287 | export function repoPalette(repo: Repo): PaletteItem[] { | |
| 286 | /** This repo's own rows for the command palette — the studio's nouns for a | |
| 287 | * studio-mode viewer of an app, so the palette never sends someone to a | |
| 288 | * list under a name the address bar would not use. */ | |
| 289 | export function repoPalette(ctx: Ctx, repo: Repo): PaletteItem[] { | |
| 288 | 290 | const base = repoBase(repo); |
| 289 | 291 | const name = `${repo.ownerName}/${repo.name}`; |
| 292 | const studio = | |
| 293 | ctx.mode === "studio" && getAppByRepo(ctx.app.db, repo.id) !== undefined; | |
| 290 | 294 | return [ |
| 291 | 295 | { t: `${name} — Code`, h: base, c: "This repo" }, |
| 292 | { t: `${name} — Issues`, h: `${base}/issues`, c: "This repo" }, | |
| 293 | { t: `${name} — Pull requests`, h: `${base}/pulls`, c: "This repo" }, | |
| 296 | { | |
| 297 | t: `${name} — ${studio ? "Told" : "Issues"}`, | |
| 298 | h: studio ? `${base}/tells` : `${base}/issues`, | |
| 299 | c: "This repo", | |
| 300 | }, | |
| 301 | { | |
| 302 | t: `${name} — ${studio ? "Changes" : "Pull requests"}`, | |
| 303 | h: studio ? `${base}/changes` : `${base}/pulls`, | |
| 304 | c: "This repo", | |
| 305 | }, | |
| 294 | 306 | { t: `${name} — Commits`, h: `${base}/commits`, c: "This repo" }, |
| 295 | 307 | { t: `${name} — New issue`, h: `${base}/issues/new`, c: "This repo" }, |
| 296 | 308 | { t: `${name} — Runs`, h: `${base}/runs`, c: "This repo" }, |
src/web/pages/apps.ts +1 −1
| @@ -761,7 +761,7 @@ export function appPage(ctx: Ctx): Response { | ||
| 761 | 761 | ctx, |
| 762 | 762 | title: `App · ${repo.name}`, |
| 763 | 763 | crumbs: crumbsFor(repo), |
| 764 | palette: repoPalette(repo), | |
| 764 | palette: repoPalette(ctx, repo), | |
| 765 | 765 | body, |
| 766 | 766 | scriptJs: liveClientJs, |
| 767 | 767 | }); |
src/web/pages/issues.ts +5 −9
| @@ -63,7 +63,7 @@ import { | ||
| 63 | 63 | threadFragment, |
| 64 | 64 | } from "./thread.ts"; |
| 65 | 65 | import { crewActivityCard } from "./runs.ts"; |
| 66 | import { studioTells } from "../studio/lists.ts"; | |
| 66 | import { studioListRecord, studioTells } from "../studio/lists.ts"; | |
| 67 | 67 | import { studioTell } from "../studio/tell.ts"; |
| 68 | 68 | |
| 69 | 69 | const PER_PAGE = 25; |
| @@ -295,12 +295,8 @@ export async function issuesList(ctx: Ctx): Promise<Response> { | ||
| 295 | 295 | // In the studio "everything told" is the issue list in the studio's own |
| 296 | 296 | // words — the same rule `issuePage` follows for one tell. `?mode=dev` is |
| 297 | 297 | // the one-off way to dev's list without switching the browser. |
| 298 | const studio = | |
| 299 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 300 | if (studio) { | |
| 301 | const record = getAppByRepo(ctx.app.db, repo.id); | |
| 302 | if (record !== undefined) return await studioTells(ctx, repo, record); | |
| 303 | } | |
| 298 | const record = studioListRecord(ctx, repo); | |
| 299 | if (record !== undefined) return await studioTells(ctx, repo, record); | |
| 304 | 300 | const scope = localScope(ctx, repo); |
| 305 | 301 | const base = scope.base; |
| 306 | 302 | const query = readQuery(ctx); |
| @@ -420,7 +416,7 @@ export function newIssuePage(ctx: Ctx): Response { | ||
| 420 | 416 | ctx, |
| 421 | 417 | title: `New issue · ${repo.name}`, |
| 422 | 418 | crumbs: crumbsFor(repo), |
| 423 | palette: repoPalette(repo), | |
| 419 | palette: repoPalette(ctx, repo), | |
| 424 | 420 | body: html`${repoHeader(ctx, repo, "issues", { |
| 425 | 421 | compact: true, |
| 426 | 422 | ownTitle: true, |
| @@ -545,7 +541,7 @@ export async function issuePage(ctx: Ctx): Promise<Response> { | ||
| 545 | 541 | ctx, |
| 546 | 542 | title: `${issue.title} · ${repo.name}`, |
| 547 | 543 | crumbs: crumbsFor(repo), |
| 548 | palette: repoPalette(repo), | |
| 544 | palette: repoPalette(ctx, repo), | |
| 549 | 545 | body, |
| 550 | 546 | styleCss: labelCss([...all, ...applied]), |
| 551 | 547 | scriptJs: liveClientJs, |
src/web/pages/map.ts +1 −1
| @@ -1239,7 +1239,7 @@ export function mapPage(ctx: Ctx): Response { | ||
| 1239 | 1239 | ctx, |
| 1240 | 1240 | title: `Map · ${repo.name}`, |
| 1241 | 1241 | crumbs: crumbsFor(repo), |
| 1242 | palette: repoPalette(repo), | |
| 1242 | palette: repoPalette(ctx, repo), | |
| 1243 | 1243 | body, |
| 1244 | 1244 | styleCss: mapCss, |
| 1245 | 1245 | scriptJs: `${liveRegionsJs}\n${mapViewJs}`, |
src/web/pages/pull.ts +1 −1
| @@ -834,7 +834,7 @@ export async function pullPage(ctx: Ctx): Promise<Response> { | ||
| 834 | 834 | ctx, |
| 835 | 835 | title: `${pull.title} · ${repo.name}`, |
| 836 | 836 | crumbs: crumbsFor(repo), |
| 837 | palette: repoPalette(repo), | |
| 837 | palette: repoPalette(ctx, repo), | |
| 838 | 838 | body, |
| 839 | 839 | styleCss: labelCss([...allLabels, ...applied]), |
| 840 | 840 | scriptJs: liveClientJs, |
src/web/pages/pulls.ts +5 −10
| @@ -6,7 +6,6 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { openPull } from "../../actions/pulls.ts"; |
| 9 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 10 | 9 | import { |
| 11 | 10 | commentCounts, |
| 12 | 11 | issueCounts, |
| @@ -53,7 +52,7 @@ import { | ||
| 53 | 52 | } from "../ui.ts"; |
| 54 | 53 | import { refSet } from "./refs.ts"; |
| 55 | 54 | import { threadRow } from "./issues.ts"; |
| 56 | import { studioChanges } from "../studio/lists.ts"; | |
| 55 | import { studioChanges, studioListRecord } from "../studio/lists.ts"; | |
| 57 | 56 | import { originGlyph } from "./thread.ts"; |
| 58 | 57 | |
| 59 | 58 | const PER_PAGE = 25; |
| @@ -128,12 +127,8 @@ export async function pullsList(ctx: Ctx): Promise<Response> { | ||
| 128 | 127 | // In the studio "every change" is the pull list in the studio's own |
| 129 | 128 | // words — the same rule `pullPage` follows for one change. `?mode=dev` is |
| 130 | 129 | // the one-off way to dev's list without switching the browser. |
| 131 | const studio = | |
| 132 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 133 | if (studio) { | |
| 134 | const record = getAppByRepo(ctx.app.db, repo.id); | |
| 135 | if (record !== undefined) return await studioChanges(ctx, repo, record); | |
| 136 | } | |
| 130 | const record = studioListRecord(ctx, repo); | |
| 131 | if (record !== undefined) return await studioChanges(ctx, repo, record); | |
| 137 | 132 | const base = repoBase(repo); |
| 138 | 133 | const db = ctx.app.db; |
| 139 | 134 | const params = ctx.url.searchParams; |
| @@ -282,7 +277,7 @@ export async function pullsList(ctx: Ctx): Promise<Response> { | ||
| 282 | 277 | ctx, |
| 283 | 278 | title: `Pull requests · ${repo.name}`, |
| 284 | 279 | crumbs: crumbsFor(repo), |
| 285 | palette: repoPalette(repo), | |
| 280 | palette: repoPalette(ctx, repo), | |
| 286 | 281 | body, |
| 287 | 282 | styleCss: labelCss([ |
| 288 | 283 | ...listLabels(db, repo.id), |
| @@ -449,7 +444,7 @@ export async function newPullPage(ctx: Ctx): Promise<Response> { | ||
| 449 | 444 | ctx, |
| 450 | 445 | title: `New pull request · ${repo.name}`, |
| 451 | 446 | crumbs: crumbsFor(repo), |
| 452 | palette: repoPalette(repo), | |
| 447 | palette: repoPalette(ctx, repo), | |
| 453 | 448 | body, |
| 454 | 449 | }); |
| 455 | 450 | } |
src/web/pages/runs.ts +2 −2
| @@ -334,7 +334,7 @@ export async function runsList(ctx: Ctx): Promise<Response> { | ||
| 334 | 334 | ctx, |
| 335 | 335 | title: `Runs · ${repo.name}`, |
| 336 | 336 | crumbs: crumbsFor(repo), |
| 337 | palette: repoPalette(repo), | |
| 337 | palette: repoPalette(ctx, repo), | |
| 338 | 338 | body, |
| 339 | 339 | scriptJs: liveClientJs, |
| 340 | 340 | }); |
| @@ -536,7 +536,7 @@ export async function runPage(ctx: Ctx): Promise<Response> { | ||
| 536 | 536 | ctx, |
| 537 | 537 | title: `${title} · ${repo.name}`, |
| 538 | 538 | crumbs: crumbsFor(repo), |
| 539 | palette: repoPalette(repo), | |
| 539 | palette: repoPalette(ctx, repo), | |
| 540 | 540 | body, |
| 541 | 541 | scriptJs: runLogJs, |
| 542 | 542 | // The no-script half of a live log: while the run is going, the page |
src/web/pages/settings.ts +1 −1
| @@ -170,7 +170,7 @@ function settingsPageResponse( | ||
| 170 | 170 | ctx, |
| 171 | 171 | title: `${title} · ${repo.name}`, |
| 172 | 172 | crumbs: crumbsFor(repo), |
| 173 | palette: repoPalette(repo), | |
| 173 | palette: repoPalette(ctx, repo), | |
| 174 | 174 | ...(styleCss !== undefined ? { styleCss } : {}), |
| 175 | 175 | body: html`${repoHeader(ctx, repo, "settings", { compact: true })} |
| 176 | 176 | ${settingsLayout(sections(repoBase(repo), active), body)}`, |
src/web/studio/app.ts +3 −3
| @@ -404,8 +404,8 @@ export async function studioApp( | ||
| 404 | 404 | ); |
| 405 | 405 | const more = html`<p class="muted small"> |
| 406 | 406 | <a href="${base}/map">The map</a> · |
| 407 | <a href="${base}/issues">Everything told</a> · | |
| 408 | <a href="${base}/pulls">Every change</a> | |
| 407 | <a href="${base}/tells">Everything told</a> · | |
| 408 | <a href="${base}/changes">Every change</a> | |
| 409 | 409 | </p>`; |
| 410 | 410 | |
| 411 | 411 | const body = html`${repoHeader(ctx, repo, "code", { |
| @@ -441,7 +441,7 @@ export async function studioApp( | ||
| 441 | 441 | ctx, |
| 442 | 442 | title: `${repo.name}`, |
| 443 | 443 | crumbs: crumbsFor(repo), |
| 444 | palette: repoPalette(repo), | |
| 444 | palette: repoPalette(ctx, repo), | |
| 445 | 445 | body, |
| 446 | 446 | styleCss: studioCss, |
| 447 | 447 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}`, |
src/web/studio/change.ts +1 −1
| @@ -197,7 +197,7 @@ export async function studioPull( | ||
| 197 | 197 | ctx, |
| 198 | 198 | title: `${pull.title} · ${repo.name}`, |
| 199 | 199 | crumbs: crumbsFor(repo), |
| 200 | palette: repoPalette(repo), | |
| 200 | palette: repoPalette(ctx, repo), | |
| 201 | 201 | body, |
| 202 | 202 | styleCss: studioCss, |
| 203 | 203 | scriptJs: liveClientJs, |
src/web/studio/crew.ts +1 −1
| @@ -253,7 +253,7 @@ export function studioCrewSettings( | ||
| 253 | 253 | ctx, |
| 254 | 254 | title: `Settings · ${repo.name}`, |
| 255 | 255 | crumbs: crumbsFor(repo), |
| 256 | palette: repoPalette(repo), | |
| 256 | palette: repoPalette(ctx, repo), | |
| 257 | 257 | body, |
| 258 | 258 | }); |
| 259 | 259 | } |
src/web/studio/lists.ts +66 −10
| @@ -8,16 +8,23 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 11 | import type { AppRecord } from "../../core/apps.ts"; | |
| 11 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; | |
| 12 | 12 | import { listIssues, type Issue } from "../../core/issues.ts"; |
| 13 | 13 | import { listPulls, openPullsClosing } from "../../core/pulls.ts"; |
| 14 | 14 | import type { Repo } from "../../core/repos.ts"; |
| 15 | 15 | import { listRuns } from "../../core/runs.ts"; |
| 16 | 16 | import { repoDir } from "../../git/repos.ts"; |
| 17 | 17 | import type { Ctx } from "../../http/context.ts"; |
| 18 | import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts"; | |
| 18 | import { | |
| 19 | crumbsFor, | |
| 20 | loadRepo, | |
| 21 | notFoundPage, | |
| 22 | repoBase, | |
| 23 | repoHeader, | |
| 24 | repoPalette, | |
| 25 | } from "../chrome.ts"; | |
| 19 | 26 | import { html, type Html } from "../html.ts"; |
| 20 | import { page } from "../layout.ts"; | |
| 27 | import { page, redirect } from "../layout.ts"; | |
| 21 | 28 | import { liveClientJs, liveMount } from "../live.ts"; |
| 22 | 29 | import { |
| 23 | 30 | avatar, |
| @@ -98,6 +105,18 @@ function pagedHref(base: string, filter: WordFilter | undefined, cursor: string) | ||
| 98 | 105 | return `${base}?${filter ? `word=${filter}&` : ""}cursor=${cursor}`; |
| 99 | 106 | } |
| 100 | 107 | |
| 108 | /** | |
| 109 | * The one rule for whether a viewer sees the studio's own list on this repo: | |
| 110 | * studio mode, no `?mode=dev` escape hatch, and an app to have a studio word | |
| 111 | * for — the same check `issuesList`/`pullsList` make before falling back to | |
| 112 | * dev's own list. | |
| 113 | */ | |
| 114 | export function studioListRecord(ctx: Ctx, repo: Repo): AppRecord | undefined { | |
| 115 | const studio = | |
| 116 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 117 | return studio ? getAppByRepo(ctx.app.db, repo.id) : undefined; | |
| 118 | } | |
| 119 | ||
| 101 | 120 | /** |
| 102 | 121 | * GET /:owner/:name/issues in the studio: everything told, one row per |
| 103 | 122 | * issue in its studio word — who has it, when it last moved, and the tell |
| @@ -166,13 +185,17 @@ export async function studioTells( | ||
| 166 | 185 | ), |
| 167 | 186 | ); |
| 168 | 187 | const lastNumber = fetched[fetched.length - 1]?.number; |
| 188 | // Self-links follow whichever noun reached this page — `/tells` from the | |
| 189 | // studio's own door, `/issues` when a dev URL is asked for in studio mode | |
| 190 | // — so paging never jumps a reader to the other address mid-list. | |
| 191 | const listBase = ctx.url.pathname; | |
| 169 | 192 | |
| 170 | 193 | const body = html`${repoHeader(ctx, repo, "issues")} |
| 171 | 194 | <div class="mt"> |
| 172 | ${wordFilterBar(`${base}/issues`, filter)} ${list} | |
| 195 | ${wordFilterBar(listBase, filter)} ${list} | |
| 173 | 196 | ${pager( |
| 174 | 197 | fetched.length === LIST_PAGE && lastNumber !== undefined |
| 175 | ? { next: pagedHref(`${base}/issues`, filter, String(lastNumber)) } | |
| 198 | ? { next: pagedHref(listBase, filter, String(lastNumber)) } | |
| 176 | 199 | : {}, |
| 177 | 200 | )} |
| 178 | 201 | </div> |
| @@ -186,7 +209,7 @@ export async function studioTells( | ||
| 186 | 209 | ctx, |
| 187 | 210 | title: `Everything told · ${repo.name}`, |
| 188 | 211 | crumbs: crumbsFor(repo), |
| 189 | palette: repoPalette(repo), | |
| 212 | palette: repoPalette(ctx, repo), | |
| 190 | 213 | body, |
| 191 | 214 | styleCss: studioCss, |
| 192 | 215 | scriptJs: liveClientJs, |
| @@ -251,13 +274,17 @@ export async function studioChanges( | ||
| 251 | 274 | ), |
| 252 | 275 | ); |
| 253 | 276 | const lastNumber = fetched[fetched.length - 1]?.number; |
| 277 | // Self-links follow whichever noun reached this page — `/changes` from the | |
| 278 | // studio's own door, `/pulls` when a dev URL is asked for in studio mode | |
| 279 | // — so paging never jumps a reader to the other address mid-list. | |
| 280 | const listBase = ctx.url.pathname; | |
| 254 | 281 | |
| 255 | 282 | const body = html`${repoHeader(ctx, repo, "pulls")} |
| 256 | 283 | <div class="mt"> |
| 257 | ${wordFilterBar(`${base}/pulls`, filter)} ${list} | |
| 284 | ${wordFilterBar(listBase, filter)} ${list} | |
| 258 | 285 | ${pager( |
| 259 | 286 | fetched.length === LIST_PAGE && lastNumber !== undefined |
| 260 | ? { next: pagedHref(`${base}/pulls`, filter, String(lastNumber)) } | |
| 287 | ? { next: pagedHref(listBase, filter, String(lastNumber)) } | |
| 261 | 288 | : {}, |
| 262 | 289 | )} |
| 263 | 290 | </div> |
| @@ -271,13 +298,42 @@ export async function studioChanges( | ||
| 271 | 298 | ctx, |
| 272 | 299 | title: `Every change · ${repo.name}`, |
| 273 | 300 | crumbs: crumbsFor(repo), |
| 274 | palette: repoPalette(repo), | |
| 301 | palette: repoPalette(ctx, repo), | |
| 275 | 302 | body, |
| 276 | 303 | styleCss: studioCss, |
| 277 | 304 | scriptJs: liveClientJs, |
| 278 | 305 | }); |
| 279 | 306 | } |
| 280 | 307 | |
| 308 | /** | |
| 309 | * GET /:owner/:name/tells — the studio's own door onto the same list | |
| 310 | * `/issues` reaches: `issuesList` and this route both resolve through | |
| 311 | * `studioListRecord`/`studioTells`, so there is exactly one page, never a | |
| 312 | * second copy of it. A viewer for whom the studio's own list does not apply | |
| 313 | * (dev mode, `?mode=dev`, or a repository that is not an app) is sent to | |
| 314 | * `/issues` — dev's own address is the one canonical URL for dev's own list, | |
| 315 | * so this noun never grows a second, dev-flavoured address of its own. | |
| 316 | */ | |
| 317 | export async function tellsList(ctx: Ctx): Promise<Response> { | |
| 318 | const repo = loadRepo(ctx); | |
| 319 | if (!repo) return notFoundPage(ctx); | |
| 320 | const record = studioListRecord(ctx, repo); | |
| 321 | if (record === undefined) | |
| 322 | return redirect(`${repoBase(repo)}/issues${ctx.url.search}`); | |
| 323 | return await studioTells(ctx, repo, record); | |
| 324 | } | |
| 325 | ||
| 326 | /** GET /:owner/:name/changes — the studio's own door onto the same list | |
| 327 | * `/pulls` reaches. See `tellsList` for the rule. */ | |
| 328 | export async function changesList(ctx: Ctx): Promise<Response> { | |
| 329 | const repo = loadRepo(ctx); | |
| 330 | if (!repo) return notFoundPage(ctx); | |
| 331 | const record = studioListRecord(ctx, repo); | |
| 332 | if (record === undefined) | |
| 333 | return redirect(`${repoBase(repo)}/pulls${ctx.url.search}`); | |
| 334 | return await studioChanges(ctx, repo, record); | |
| 335 | } | |
| 336 | ||
| 281 | 337 | /** |
| 282 | 338 | * GET /:owner/:name/runs in the studio: every run, one row per run in its |
| 283 | 339 | * studio word — who ran it, when it last moved, and the transcript as the |
| @@ -357,7 +413,7 @@ export async function studioRuns( | ||
| 357 | 413 | ctx, |
| 358 | 414 | title: `Every run · ${repo.name}`, |
| 359 | 415 | crumbs: crumbsFor(repo), |
| 360 | palette: repoPalette(repo), | |
| 416 | palette: repoPalette(ctx, repo), | |
| 361 | 417 | body, |
| 362 | 418 | styleCss: studioCss, |
| 363 | 419 | scriptJs: liveClientJs, |
src/web/studio/run.ts +1 −1
| @@ -283,7 +283,7 @@ export async function studioRun( | ||
| 283 | 283 | ctx, |
| 284 | 284 | title: `${title} · ${repo.name}`, |
| 285 | 285 | crumbs: crumbsFor(repo), |
| 286 | palette: repoPalette(repo), | |
| 286 | palette: repoPalette(ctx, repo), | |
| 287 | 287 | body, |
| 288 | 288 | styleCss: studioCss, |
| 289 | 289 | scriptJs: studioRunJs, |
src/web/studio/tell.ts +1 −1
| @@ -299,7 +299,7 @@ export async function studioTell( | ||
| 299 | 299 | ctx, |
| 300 | 300 | title: `${issue.title} · ${repo.name}`, |
| 301 | 301 | crumbs: crumbsFor(repo), |
| 302 | palette: repoPalette(repo), | |
| 302 | palette: repoPalette(ctx, repo), | |
| 303 | 303 | body, |
| 304 | 304 | styleCss: studioCss, |
| 305 | 305 | scriptJs: liveClientJs, |
test/studio.test.ts +123
| @@ -714,6 +714,129 @@ describe("the studio", () => { | ||
| 714 | 714 | expect(runs).not.toContain("See dev's list"); |
| 715 | 715 | }); |
| 716 | 716 | |
| 717 | test("/tells and /changes are the studio's own doors onto the same lists /issues and /pulls reach — same words, same filter, same paging", async () => { | |
| 718 | const told = openIssue(app, alice, repo, { | |
| 719 | title: "Door test: a tell", | |
| 720 | body: "reached through /tells too", | |
| 721 | }); | |
| 722 | if (!told.ok) throw new Error(String(told.error)); | |
| 723 | ||
| 724 | const viaIssues = await (await get("/alice/notes/issues")).text(); | |
| 725 | const viaTells = await (await get("/alice/notes/tells")).text(); | |
| 726 | expect(viaTells).toContain("Door test: a tell"); | |
| 727 | expect(viaTells).toContain('data-word="told"'); | |
| 728 | // Self-links (the word filter, paging) follow whichever door was used, | |
| 729 | // so a dev URL asked for in studio mode still answers exactly as it | |
| 730 | // does today. | |
| 731 | expect(viaTells).toContain(`href="/alice/notes/tells?word=told"`); | |
| 732 | expect(viaIssues).toContain(`href="/alice/notes/issues?word=told"`); | |
| 733 | ||
| 734 | const filtered = await ( | |
| 735 | await get("/alice/notes/tells?word=told") | |
| 736 | ).text(); | |
| 737 | expect(filtered).toContain("Door test: a tell"); | |
| 738 | const other = await ( | |
| 739 | await get("/alice/notes/tells?word=shipped") | |
| 740 | ).text(); | |
| 741 | expect(other).not.toContain("Door test: a tell"); | |
| 742 | ||
| 743 | const branch = await commitToBranch(app, alice, repo, { | |
| 744 | branch: "feature/door-test", | |
| 745 | base: "main", | |
| 746 | message: "Door test change", | |
| 747 | files: [{ path: "DOOR_TEST.md", content: "a change\n" }], | |
| 748 | }); | |
| 749 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 750 | const opened = await openPull(app, alice, repo, { | |
| 751 | title: "Door test: a change", | |
| 752 | body: "", | |
| 753 | baseBranch: "main", | |
| 754 | headBranch: "feature/door-test", | |
| 755 | }); | |
| 756 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 757 | const doorPull = opened.value; | |
| 758 | const doorCheck = createRun(app.db, { | |
| 759 | repoId: repo.id, | |
| 760 | kind: "check", | |
| 761 | issueId: doorPull.id, | |
| 762 | env: `pull-${doorPull.number}`, | |
| 763 | commitOid: doorPull.headOid, | |
| 764 | reason: "pull", | |
| 765 | }); | |
| 766 | claimRun(app.db, doorCheck.id); | |
| 767 | finishRun(app.db, doorCheck.id, { state: "ok", summary: "check passed" }); | |
| 768 | ||
| 769 | const viaPulls = await (await get("/alice/notes/pulls")).text(); | |
| 770 | const viaChanges = await (await get("/alice/notes/changes")).text(); | |
| 771 | expect(viaChanges).toContain("Door test: a change"); | |
| 772 | expect(viaChanges).toContain('data-word="look"'); | |
| 773 | expect(viaChanges).toContain(`href="/alice/notes/changes?word=look"`); | |
| 774 | expect(viaPulls).toContain(`href="/alice/notes/pulls?word=look"`); | |
| 775 | ||
| 776 | const changeFiltered = await ( | |
| 777 | await get("/alice/notes/changes?word=look") | |
| 778 | ).text(); | |
| 779 | expect(changeFiltered).toContain("Door test: a change"); | |
| 780 | const changeOther = await ( | |
| 781 | await get("/alice/notes/changes?word=shipped") | |
| 782 | ).text(); | |
| 783 | expect(changeOther).not.toContain("Door test: a change"); | |
| 784 | }); | |
| 785 | ||
| 786 | test("/tells and /changes send a dev-mode viewer, or ?mode=dev, on to /issues and /pulls — the one canonical dev address", async () => { | |
| 787 | const devTells = await get("/alice/notes/tells", { | |
| 788 | cookie: `${cookie}; forge_mode=dev`, | |
| 789 | }); | |
| 790 | expect(devTells.status).toBe(303); | |
| 791 | expect(devTells.headers.get("location")).toBe("/alice/notes/issues"); | |
| 792 | ||
| 793 | const devChanges = await get("/alice/notes/changes", { | |
| 794 | cookie: `${cookie}; forge_mode=dev`, | |
| 795 | }); | |
| 796 | expect(devChanges.status).toBe(303); | |
| 797 | expect(devChanges.headers.get("location")).toBe("/alice/notes/pulls"); | |
| 798 | ||
| 799 | const escapeTells = await fetch(`${base}/alice/notes/tells?mode=dev`, { | |
| 800 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 801 | redirect: "manual", | |
| 802 | }); | |
| 803 | expect(escapeTells.status).toBe(303); | |
| 804 | expect(escapeTells.headers.get("location")).toBe( | |
| 805 | "/alice/notes/issues?mode=dev", | |
| 806 | ); | |
| 807 | }); | |
| 808 | ||
| 809 | test("/tells and /changes on a repository that is not an app send a studio viewer on to /issues and /pulls too", async () => { | |
| 810 | const plain = await createRepo(app, alice, { | |
| 811 | name: "doorlists", | |
| 812 | visibility: "private", | |
| 813 | description: "no app here", | |
| 814 | }); | |
| 815 | if (!plain.ok) throw new Error(String(plain.error)); | |
| 816 | ||
| 817 | const tells = await get("/alice/doorlists/tells"); | |
| 818 | expect(tells.status).toBe(303); | |
| 819 | expect(tells.headers.get("location")).toBe("/alice/doorlists/issues"); | |
| 820 | ||
| 821 | const changes = await get("/alice/doorlists/changes"); | |
| 822 | expect(changes.status).toBe(303); | |
| 823 | expect(changes.headers.get("location")).toBe("/alice/doorlists/pulls"); | |
| 824 | }); | |
| 825 | ||
| 826 | test("a reader without access gets the forge's not-found on /tells and /changes, exactly as /issues and /pulls", async () => { | |
| 827 | const anonTells = await fetch(`${base}/alice/notes/tells`, { | |
| 828 | headers: { cookie: "forge_mode=studio" }, | |
| 829 | redirect: "manual", | |
| 830 | }); | |
| 831 | expect(anonTells.status).toBe(404); | |
| 832 | ||
| 833 | const anonChanges = await fetch(`${base}/alice/notes/changes`, { | |
| 834 | headers: { cookie: "forge_mode=studio" }, | |
| 835 | redirect: "manual", | |
| 836 | }); | |
| 837 | expect(anonChanges.status).toBe(404); | |
| 838 | }); | |
| 839 | ||
| 717 | 840 | test("a reader without access gets the forge's not-found on a tell, exactly as dev", async () => { |
| 718 | 841 | const told = openIssue(app, alice, repo, { title: "Private work" }); |
| 719 | 842 | if (!told.ok) throw new Error(String(told.error)); |
test/web.test.ts +3 −3
| @@ -304,7 +304,7 @@ describe("web pages end to end", () => { | ||
| 304 | 304 | }); |
| 305 | 305 | |
| 306 | 306 | test("a path under no route at all gets the forge's page for a browser, and no_route for anyone else", async () => { |
| 307 | const browser = await fetch(`${base}/ada/engine/tells`, { | |
| 307 | const browser = await fetch(`${base}/ada/engine/frobnicate`, { | |
| 308 | 308 | headers: { |
| 309 | 309 | accept: |
| 310 | 310 | "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", |
| @@ -323,12 +323,12 @@ describe("web pages end to end", () => { | ||
| 323 | 323 | |
| 324 | 324 | // No Accept header at all — a plain script or `curl` — keeps the JSON |
| 325 | 325 | // contract, same as before this page existed. |
| 326 | const plain = await fetch(`${base}/ada/engine/tells`); | |
| 326 | const plain = await fetch(`${base}/ada/engine/frobnicate`); | |
| 327 | 327 | expect(plain.status).toBe(404); |
| 328 | 328 | expect(plain.headers.get("content-type")).toContain("application/json"); |
| 329 | 329 | const body = (await plain.json()) as { error: string; message: string }; |
| 330 | 330 | expect(body.error).toBe("no_route"); |
| 331 | expect(body.message).toContain("/ada/engine/tells"); | |
| 331 | expect(body.message).toContain("/ada/engine/frobnicate"); | |
| 332 | 332 | |
| 333 | 333 | // An API path is unchanged even for a browser's own Accept header. |
| 334 | 334 | const api = await fetch(`${base}/api/v1/nonsense`, { |
new above
offline