Public: anyone can read this; the crew can change it.
One tell, one change: the studio's nouns go all the way down #174
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/158-one-tell-one-change-the-studio-s-nouns-g → main 1 commit fork
19 files
changed +400
−89
docs/STUDIO.md +28 −14
| @@ -179,18 +179,30 @@ first: who has it (the assignee, the author, or who ran it), when it last | ||
| 179 | 179 | moved, its state as `wordBadge` draws it (`src/web/studio/words.ts`), and |
| 180 | 180 | one link — the row itself — to the tell, the change or the transcript. |
| 181 | 181 | `/:owner/:name/tells` and `/:owner/:name/changes` are the studio's own |
| 182 | nouns for the first two lists — the same handler, the same authorization, | |
| 183 | the same `?word=` filter and paging, not a second copy of the page; a | |
| 184 | viewer the studio list does not apply to (dev mode, `?mode=dev`, or a | |
| 185 | repository that is not an app) is sent on to `/issues` or `/pulls`, dev's | |
| 186 | own address, rather than the two ever growing a second address of their | |
| 187 | own. The app page's own footer (`the map · everything told · every | |
| 188 | change`, and *Every run* above **Now**) and the repository header's tabs | |
| 189 | (`Told`, `Look`, in place of dev's `Issues`, `Pulls`) link to `/tells` and | |
| 190 | `/changes` in studio mode; a tell or a change keeps its own | |
| 191 | `/issues/:number` / `/pulls/:number` address either way, and dev's | |
| 192 | `Commits`/`Branches` stay dev's alone. `/runs` has no second noun — the | |
| 193 | studio and dev share the one address there. | |
| 182 | nouns for the first two lists, and `/:owner/:name/tells/:number` and | |
| 183 | `/:owner/:name/changes/:number` for one tell and one change — the same | |
| 184 | handler each, the same authorization, the same `?word=` filter and paging, | |
| 185 | not a second copy of any page; a viewer the studio does not apply to (dev | |
| 186 | mode, `?mode=dev`, or a repository that is not an app) is sent on to | |
| 187 | `/issues`, `/pulls`, `/issues/:number` or `/pulls/:number`, dev's own | |
| 188 | address, rather than the studio's nouns ever growing a second, | |
| 189 | dev-flavoured address of their own. It goes one way only: a dev URL asked | |
| 190 | for in studio mode answers the studio's page where it stands, never a | |
| 191 | redirect, so every link ever written to `/issues/7` keeps working and | |
| 192 | `?mode=dev` on it is still the one click to dev's own. In studio mode the | |
| 193 | studio's own links use the studio's nouns wherever a tell or a change is | |
| 194 | linked — the three lists' rows, the app page's *what asks you* and *now*, | |
| 195 | a tell's dependencies and the change that closes it, a run's own tell, the | |
| 196 | search box and the command palette — so a person is never handed two | |
| 197 | addresses for one thing. The app page's own footer (`the map · everything | |
| 198 | told · every change`, and *Every run* above **Now**) and the repository | |
| 199 | header's tabs (`Told`, `Look`, in place of dev's `Issues`, `Pulls`) link | |
| 200 | to `/tells` and `/changes`; dev's `Commits`/`Branches` stay dev's alone. | |
| 201 | `/runs` has no second noun — the studio and dev share the one address | |
| 202 | there. What a page *posts* to never moves with the noun: ship, close, | |
| 203 | comment and decide keep posting under `/issues/:number` and | |
| 204 | `/pulls/:number`, which is what `form-action 'self'` and the same-origin | |
| 205 | checks are written against. | |
| 194 | 206 | |
| 195 | 207 | A row's word comes from `tellWord`, `pullWord` or `runWord` — the one place |
| 196 | 208 | each is decided, never re-derived here. `?word=told|working|look|shipped| |
| @@ -208,7 +220,8 @@ of mode, since there is no studio to switch to. | ||
| 208 | 220 | |
| 209 | 221 | ## A tell, on its own page |
| 210 | 222 | |
| 211 | `/:owner/:name/issues/:number` on an app, in the studio, is a tell — one | |
| 223 | `/:owner/:name/issues/:number` — `/:owner/:name/tells/:number` in the | |
| 224 | studio's own noun, the same page — on an app, in the studio, is a tell — one | |
| 212 | 225 | sentence someone said to the crew — not dev's labels, milestones and |
| 213 | 226 | dependency form. It answers three questions: who has it (or that nobody |
| 214 | 227 | does yet), what it is waiting on and what waits on it (`dependenciesOf`, |
| @@ -246,7 +259,8 @@ mode, renders exactly what it always has. | ||
| 246 | 259 | |
| 247 | 260 | ## The look, on its own page |
| 248 | 261 | |
| 249 | `/:owner/:name/pulls/:n` on an app, in the studio, is the change itself — | |
| 262 | `/:owner/:name/pulls/:n` — `/:owner/:name/changes/:n` in the studio's own | |
| 263 | noun, the same page — on an app, in the studio, is the change itself — | |
| 250 | 264 | never its diff. The title and the opening post in the author's own words, |
| 251 | 265 | who made it and when, the check in the studio's five words (`pullWord`, |
| 252 | 266 | `src/web/studio/words.ts`) with its deploy one click away, the preview's |
src/http/server.ts +14 −5
| @@ -74,6 +74,7 @@ import { | ||
| 74 | 74 | issueThreadFragment, |
| 75 | 75 | newIssuePage, |
| 76 | 76 | newIssueSubmit, |
| 77 | tellPage, | |
| 77 | 78 | } from "../web/pages/issues.ts"; |
| 78 | 79 | import { globalMapPage, mapPage } from "../web/pages/map.ts"; |
| 79 | 80 | import { |
| @@ -120,6 +121,7 @@ import { | ||
| 120 | 121 | } from "../web/pages/crew.ts"; |
| 121 | 122 | import { newPullPage, newPullSubmit, pullsList } from "../web/pages/pulls.ts"; |
| 122 | 123 | import { |
| 124 | changePage, | |
| 123 | 125 | pullCommentSubmit, |
| 124 | 126 | pullMergeSubmit, |
| 125 | 127 | pullPage, |
| @@ -537,11 +539,15 @@ export function buildRouter(): Router<Handler> { | ||
| 537 | 539 | // Issues (web). The literal /new is registered before /:number so it can |
| 538 | 540 | // never be captured as a number. |
| 539 | 541 | router.add("GET", "/:owner/:name/issues", issuesList); |
| 540 | // The studio's own noun for the same list — everything told. A tell or a | |
| 541 | // change keeps its `/issues/:number` / `/pulls/:number` address; only the | |
| 542 | // list itself gains a second door (`tellsList`/`changesList` redirect to | |
| 543 | // dev's own address when the studio's list does not apply). | |
| 542 | // The studio's own nouns for the same list and the same page — everything | |
| 543 | // told, and one tell. Each is a second door onto the one handler, never a | |
| 544 | // second copy of the page; `/issues` and `/issues/:number` stay dev's own | |
| 545 | // canonical addresses and answer exactly as they always have, in both | |
| 546 | // modes (`tellsList`/`tellPage` redirect there when the studio does not | |
| 547 | // apply). The literal /tells is registered before /tells/:number for the | |
| 548 | // same reason /issues/new is. | |
| 544 | 549 | router.add("GET", "/:owner/:name/tells", tellsList); |
| 550 | router.add("GET", "/:owner/:name/tells/:number", tellPage); | |
| 545 | 551 | router.add("GET", "/:owner/:name/issues/new", newIssuePage); |
| 546 | 552 | router.add("POST", "/:owner/:name/issues/new", newIssueSubmit); |
| 547 | 553 | router.add("GET", "/:owner/:name/issues/:number", issuePage); |
| @@ -575,8 +581,11 @@ export function buildRouter(): Router<Handler> { | ||
| 575 | 581 | |
| 576 | 582 | // Pull requests. Same /new-before-/:number ordering discipline. |
| 577 | 583 | router.add("GET", "/:owner/:name/pulls", pullsList); |
| 578 | // The studio's own noun for the same list — every change. | |
| 584 | // The studio's own nouns for the same list and the same page — every | |
| 585 | // change, and one change. See the tells above for the rule; the forms | |
| 586 | // that ship, close and comment keep posting under /pulls/:number. | |
| 579 | 587 | router.add("GET", "/:owner/:name/changes", changesList); |
| 588 | router.add("GET", "/:owner/:name/changes/:number", changePage); | |
| 580 | 589 | router.add("GET", "/:owner/:name/pulls/new", newPullPage); |
| 581 | 590 | router.add("POST", "/:owner/:name/pulls/new", newPullSubmit); |
| 582 | 591 | router.add("GET", "/:owner/:name/pulls/:number", pullPage); |
src/web/chrome.ts +5 −4
| @@ -24,6 +24,7 @@ import { | ||
| 24 | 24 | whereRow, |
| 25 | 25 | type RepoScope, |
| 26 | 26 | } from "./scope.ts"; |
| 27 | import { changesHref, tellsHref } from "./studio/nouns.ts"; | |
| 27 | 28 | import { badge, btn, empty, tabs, type TabItem } from "./ui.ts"; |
| 28 | 29 | |
| 29 | 30 | // ---- loading ---- |
| @@ -165,7 +166,7 @@ export function scopeHeader( | ||
| 165 | 166 | // Studio names its own lists — told, not issues; dev's tabs are |
| 166 | 167 | // unchanged in dev mode and on a repository that is not an app. |
| 167 | 168 | label: studio ? "Told" : "Issues", |
| 168 | href: studio ? `${base}/tells` : `${base}/issues`, | |
| 169 | href: studio ? tellsHref(base) : `${base}/issues`, | |
| 169 | 170 | icon: studio ? "clock" : "issue", |
| 170 | 171 | ...(facts.openIssues === null ? {} : { count: facts.openIssues }), |
| 171 | 172 | current: active === "issues", |
| @@ -173,7 +174,7 @@ export function scopeHeader( | ||
| 173 | 174 | }, |
| 174 | 175 | { |
| 175 | 176 | label: studio ? "Look" : "Pulls", |
| 176 | href: studio ? `${base}/changes` : `${base}/pulls`, | |
| 177 | href: studio ? changesHref(base) : `${base}/pulls`, | |
| 177 | 178 | icon: studio ? "eye" : "pull", |
| 178 | 179 | ...(facts.openPulls === null ? {} : { count: facts.openPulls }), |
| 179 | 180 | current: active === "pulls", |
| @@ -295,12 +296,12 @@ export function repoPalette(ctx: Ctx, repo: Repo): PaletteItem[] { | ||
| 295 | 296 | { t: `${name} — Code`, h: base, c: "This repo" }, |
| 296 | 297 | { |
| 297 | 298 | t: `${name} — ${studio ? "Told" : "Issues"}`, |
| 298 | h: studio ? `${base}/tells` : `${base}/issues`, | |
| 299 | h: studio ? tellsHref(base) : `${base}/issues`, | |
| 299 | 300 | c: "This repo", |
| 300 | 301 | }, |
| 301 | 302 | { |
| 302 | 303 | t: `${name} — ${studio ? "Changes" : "Pull requests"}`, |
| 303 | h: studio ? `${base}/changes` : `${base}/pulls`, | |
| 304 | h: studio ? changesHref(base) : `${base}/pulls`, | |
| 304 | 305 | c: "This repo", |
| 305 | 306 | }, |
| 306 | 307 | { t: `${name} — Commits`, h: `${base}/commits`, c: "This repo" }, |
src/web/client.ts +9 −2
| @@ -1588,12 +1588,19 @@ export const shellJs = /* js */ ` | ||
| 1588 | 1588 | d: r.originKind ? 'on ' + r.originKind : 'on an origin' } |
| 1589 | 1589 | : { t: r.owner + '/' + r.name, h: '/' + r.owner + '/' + r.name, g: 'Repositories' }); |
| 1590 | 1590 | }); |
| 1591 | // Which noun a tell or a change lives under is the mode's, and the index | |
| 1592 | // carries it — the studio's /tells/:n and /changes/:n, dev's /issues/:n | |
| 1593 | // and /pulls/:n, the same addresses the server just rendered into the | |
| 1594 | // page behind this box (\`tellHref\`/\`changeHref\`, src/web/studio/nouns.ts). | |
| 1595 | var studio = loadIndex().studio === true; | |
| 1591 | 1596 | (out.issues || []).slice(0, 5).forEach(function (i) { |
| 1592 | 1597 | if (i.source !== 'local') return; |
| 1598 | var tell = i.kind === 'issue'; | |
| 1599 | var noun = tell ? (studio ? 'tells' : 'issues') : (studio ? 'changes' : 'pulls'); | |
| 1593 | 1600 | rows.push({ |
| 1594 | 1601 | t: i.title, |
| 1595 | h: '/' + i.repo.owner + '/' + i.repo.name + '/' + (i.kind === 'issue' ? 'issues' : 'pulls') + '/' + i.number, | |
| 1596 | g: i.kind === 'issue' ? 'Issues' : 'Pull requests', | |
| 1602 | h: '/' + i.repo.owner + '/' + i.repo.name + '/' + noun + '/' + i.number, | |
| 1603 | g: tell ? 'Issues' : 'Pull requests', | |
| 1597 | 1604 | d: i.repo.owner + '/' + i.repo.name + ' #' + i.number, |
| 1598 | 1605 | }); |
| 1599 | 1606 | }); |
src/web/layout.ts +5 −1
| @@ -311,7 +311,11 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html { | ||
| 311 | 311 | })), |
| 312 | 312 | ...(studio ? STATIC_PALETTE_STUDIO : STATIC_PALETTE_DEV), |
| 313 | 313 | ]; |
| 314 | const json = JSON.stringify({ items }).replaceAll("<", "\\u003c"); | |
| 314 | // `studio` rides along so the palette's *remote* rows — what the search | |
| 315 | // API found, folded in the browser — build the same addresses the server | |
| 316 | // just rendered (`tellHref`/`changeHref`, `src/web/studio/nouns.ts`). The | |
| 317 | // palette can never hand out a noun the page behind it would not. | |
| 318 | const json = JSON.stringify({ items, studio }).replaceAll("<", "\\u003c"); | |
| 315 | 319 | return raw( |
| 316 | 320 | `<script type="application/json" id="palette-index">${json}</script>`, |
| 317 | 321 | ); |
src/web/pages/issues.ts +35 −9
| @@ -5,7 +5,6 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { addCommentThrough, openIssueThrough } from "../../actions/issues.ts"; |
| 8 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 9 | 8 | import { |
| 10 | 9 | issueCounts, |
| 11 | 10 | labelsFor, |
| @@ -30,7 +29,7 @@ import { | ||
| 30 | 29 | } from "../chrome.ts"; |
| 31 | 30 | import { field, readForm } from "../form.ts"; |
| 32 | 31 | import { html, when, type Html } from "../html.ts"; |
| 33 | import { flashTo, page } from "../layout.ts"; | |
| 32 | import { flashTo, page, redirect } from "../layout.ts"; | |
| 34 | 33 | import { liveClientJs, liveMount } from "../live.ts"; |
| 35 | 34 | import { localScope, type RepoScope } from "../scope.ts"; |
| 36 | 35 | import { |
| @@ -63,7 +62,8 @@ import { | ||
| 63 | 62 | threadFragment, |
| 64 | 63 | } from "./thread.ts"; |
| 65 | 64 | import { crewActivityCard } from "./runs.ts"; |
| 66 | import { studioListRecord, studioTells } from "../studio/lists.ts"; | |
| 65 | import { studioTells } from "../studio/lists.ts"; | |
| 66 | import { studioRecord } from "../studio/nouns.ts"; | |
| 67 | 67 | import { studioTell } from "../studio/tell.ts"; |
| 68 | 68 | |
| 69 | 69 | const PER_PAGE = 25; |
| @@ -295,7 +295,7 @@ 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 record = studioListRecord(ctx, repo); | |
| 298 | const record = studioRecord(ctx, repo); | |
| 299 | 299 | if (record !== undefined) return await studioTells(ctx, repo, record); |
| 300 | 300 | const scope = localScope(ctx, repo); |
| 301 | 301 | const base = scope.base; |
| @@ -477,11 +477,11 @@ export async function issuePage(ctx: Ctx): Promise<Response> { | ||
| 477 | 477 | if (found instanceof Response) return found; |
| 478 | 478 | const { repo, issue } = found; |
| 479 | 479 | // In the studio a tell is one sentence someone said, not dev's labels and |
| 480 | // dependency form — the same rule `pullPage` follows for a change. | |
| 481 | // `?mode=dev` on the URL is the escape hatch to dev's page, resolved | |
| 482 | // once for `ctx.mode`. | |
| 483 | const studio = ctx.mode === "studio"; | |
| 484 | if (studio && getAppByRepo(ctx.app.db, repo.id) !== undefined) | |
| 480 | // dependency form — the same rule `pullPage` follows for a change, and the | |
| 481 | // same rule `tellPage` below asks before opening this very page under the | |
| 482 | // studio's own noun. `?mode=dev` on the URL is the escape hatch to dev's | |
| 483 | // page, resolved once for `ctx.mode`. | |
| 484 | if (studioRecord(ctx, repo) !== undefined) | |
| 485 | 485 | return await studioTell(ctx, repo, issue); |
| 486 | 486 | const base = repoBase(repo); |
| 487 | 487 | const thread = `${base}/issues/${issue.number}`; |
| @@ -548,6 +548,32 @@ export async function issuePage(ctx: Ctx): Promise<Response> { | ||
| 548 | 548 | }); |
| 549 | 549 | } |
| 550 | 550 | |
| 551 | /** | |
| 552 | * GET /:owner/:name/tells/:number — the studio's own noun for the page | |
| 553 | * `/issues/:number` already reaches. The page is `studioTell`, rendered | |
| 554 | * from here exactly as `issuePage` renders it: one handler each, never a | |
| 555 | * second copy of the page, and the same `threadContext` for who may see it, | |
| 556 | * so a reader who may not gets the forge's not-found at either address. | |
| 557 | * | |
| 558 | * A viewer the studio's tell does not apply to — dev mode, `?mode=dev`, or | |
| 559 | * a repository that is not an app — is sent on to `/issues/:number`, dev's | |
| 560 | * own address, the same rule `tellsList` follows for the list. The other | |
| 561 | * direction never bounces: `/issues/:number` asked for in studio mode | |
| 562 | * answers the studio's page where it stands, so every link ever written to | |
| 563 | * it keeps working and no reader is redirected out of a URL they were | |
| 564 | * handed. | |
| 565 | */ | |
| 566 | export async function tellPage(ctx: Ctx): Promise<Response> { | |
| 567 | const found = threadContext(ctx, "issue"); | |
| 568 | if (found instanceof Response) return found; | |
| 569 | const { repo, issue } = found; | |
| 570 | if (studioRecord(ctx, repo) === undefined) | |
| 571 | return redirect( | |
| 572 | `${repoBase(repo)}/issues/${issue.number}${ctx.url.search}`, | |
| 573 | ); | |
| 574 | return await studioTell(ctx, repo, issue); | |
| 575 | } | |
| 576 | ||
| 551 | 577 | // GET /:owner/:name/issues/:number/thread?after=<id> |
| 552 | 578 | export function issueThreadFragment(ctx: Ctx): Response { |
| 553 | 579 | return threadFragment(ctx, "issue"); |
src/web/pages/pull.ts +33 −8
| @@ -61,7 +61,7 @@ import { field, readForm } from "../form.ts"; | ||
| 61 | 61 | import { html, raw, when, type Html } from "../html.ts"; |
| 62 | 62 | import { renderMarkdown } from "../markdown.ts"; |
| 63 | 63 | import type { IconName as Glyph } from "../icons.ts"; |
| 64 | import { flashTo, page } from "../layout.ts"; | |
| 64 | import { flashTo, page, redirect } from "../layout.ts"; | |
| 65 | 65 | import { liveClientJs, liveMount } from "../live.ts"; |
| 66 | 66 | import { |
| 67 | 67 | avatar, |
| @@ -97,6 +97,7 @@ import { lookCard } from "./looks.ts"; | ||
| 97 | 97 | import { pullState } from "./pulls.ts"; |
| 98 | 98 | import { crewActivityCard } from "./runs.ts"; |
| 99 | 99 | import { studioPull } from "../studio/change.ts"; |
| 100 | import { studioRecord } from "../studio/nouns.ts"; | |
| 100 | 101 | |
| 101 | 102 | /** The pull and its repo, or the response that refuses to say why not. */ |
| 102 | 103 | function pullContext(ctx: Ctx): { repo: Repo; pull: Pull } | Response { |
| @@ -668,13 +669,13 @@ export async function pullPage(ctx: Ctx): Promise<Response> { | ||
| 668 | 669 | if (found instanceof Response) return found; |
| 669 | 670 | const { repo, pull } = found; |
| 670 | 671 | // In the studio a change is judged by what it does, not its diff — the |
| 671 | // same rule `repoHome` follows for an app's own page. `?mode=dev` on the | |
| 672 | // URL is the escape hatch to the diff, resolved once for `ctx.mode`. | |
| 673 | const studio = ctx.mode === "studio"; | |
| 674 | if (studio) { | |
| 675 | const record = getAppByRepo(ctx.app.db, repo.id); | |
| 676 | if (record !== undefined) return await studioPull(ctx, repo, pull, record); | |
| 677 | } | |
| 672 | // same rule `repoHome` follows for an app's own page, and the same rule | |
| 673 | // `changePage` below asks before opening this very page under the | |
| 674 | // studio's own noun. `?mode=dev` on the URL is the escape hatch to the | |
| 675 | // diff, resolved once for `ctx.mode`. | |
| 676 | const studioApp = studioRecord(ctx, repo); | |
| 677 | if (studioApp !== undefined) | |
| 678 | return await studioPull(ctx, repo, pull, studioApp); | |
| 678 | 679 | const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); |
| 679 | 680 | const base = repoBase(repo); |
| 680 | 681 | const thread = `${base}/pulls/${pull.number}`; |
| @@ -841,6 +842,30 @@ export async function pullPage(ctx: Ctx): Promise<Response> { | ||
| 841 | 842 | }); |
| 842 | 843 | } |
| 843 | 844 | |
| 845 | /** | |
| 846 | * GET /:owner/:name/changes/:number — the studio's own noun for the page | |
| 847 | * `/pulls/:number` already reaches. The page is `studioPull`, rendered from | |
| 848 | * here exactly as `pullPage` renders it: one handler each, never a second | |
| 849 | * copy of the page, and the same `pullContext` for who may see it, so a | |
| 850 | * reader who may not gets the forge's not-found at either address. | |
| 851 | * | |
| 852 | * A viewer the studio's change does not apply to — dev mode, `?mode=dev`, | |
| 853 | * or a repository that is not an app — is sent on to `/pulls/:number`, | |
| 854 | * dev's own address, the same rule `changesList` follows for the list. | |
| 855 | * `/pulls/:number` asked for in studio mode answers where it stands, as it | |
| 856 | * always has; this door adds an address, it never closes one. The forms on | |
| 857 | * the page post where they have always posted. | |
| 858 | */ | |
| 859 | export async function changePage(ctx: Ctx): Promise<Response> { | |
| 860 | const found = pullContext(ctx); | |
| 861 | if (found instanceof Response) return found; | |
| 862 | const { repo, pull } = found; | |
| 863 | const record = studioRecord(ctx, repo); | |
| 864 | if (record === undefined) | |
| 865 | return redirect(`${repoBase(repo)}/pulls/${pull.number}${ctx.url.search}`); | |
| 866 | return await studioPull(ctx, repo, pull, record); | |
| 867 | } | |
| 868 | ||
| 844 | 869 | // GET /:owner/:name/pulls/:number/thread?after=<id> |
| 845 | 870 | export function pullThreadFragment(ctx: Ctx): Response { |
| 846 | 871 | return threadFragment(ctx, "pull"); |
src/web/pages/pulls.ts +3 −2
| @@ -52,7 +52,8 @@ import { | ||
| 52 | 52 | } from "../ui.ts"; |
| 53 | 53 | import { refSet } from "./refs.ts"; |
| 54 | 54 | import { threadRow } from "./issues.ts"; |
| 55 | import { studioChanges, studioListRecord } from "../studio/lists.ts"; | |
| 55 | import { studioChanges } from "../studio/lists.ts"; | |
| 56 | import { studioRecord } from "../studio/nouns.ts"; | |
| 56 | 57 | import { originGlyph } from "./thread.ts"; |
| 57 | 58 | |
| 58 | 59 | const PER_PAGE = 25; |
| @@ -127,7 +128,7 @@ export async function pullsList(ctx: Ctx): Promise<Response> { | ||
| 127 | 128 | // In the studio "every change" is the pull list in the studio's own |
| 128 | 129 | // words — the same rule `pullPage` follows for one change. `?mode=dev` is |
| 129 | 130 | // the one-off way to dev's list without switching the browser. |
| 130 | const record = studioListRecord(ctx, repo); | |
| 131 | const record = studioRecord(ctx, repo); | |
| 131 | 132 | if (record !== undefined) return await studioChanges(ctx, repo, record); |
| 132 | 133 | const base = repoBase(repo); |
| 133 | 134 | const db = ctx.app.db; |
src/web/pages/runs.ts +4 −9
| @@ -17,7 +17,6 @@ import { | ||
| 17 | 17 | runLog, |
| 18 | 18 | steerRepoRun, |
| 19 | 19 | } from "../../actions/runs.ts"; |
| 20 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 21 | 20 | import { getIssueById, getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| 22 | 21 | import { canRead, canWrite, getRepoById, type Repo } from "../../core/repos.ts"; |
| 23 | 22 | import { getRun, isTerminal, type Run, type RunKind } from "../../core/runs.ts"; |
| @@ -38,6 +37,7 @@ import { html, when, type Html } from "../html.ts"; | ||
| 38 | 37 | import { flashTo, page, redirect } from "../layout.ts"; |
| 39 | 38 | import { liveClientJs, liveMount } from "../live.ts"; |
| 40 | 39 | import { studioRuns } from "../studio/lists.ts"; |
| 40 | import { studioRecord } from "../studio/nouns.ts"; | |
| 41 | 41 | import { studioRun } from "../studio/run.ts"; |
| 42 | 42 | import { |
| 43 | 43 | btn, |
| @@ -263,12 +263,8 @@ export async function runsList(ctx: Ctx): Promise<Response> { | ||
| 263 | 263 | // In the studio "every run" is the run list in the studio's own words — |
| 264 | 264 | // the same rule `runPage` follows for one run. `?mode=dev` is the |
| 265 | 265 | // one-off way to dev's list without switching the browser. |
| 266 | const studio = | |
| 267 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 268 | if (studio) { | |
| 269 | const record = getAppByRepo(ctx.app.db, repo.id); | |
| 270 | if (record !== undefined) return await studioRuns(ctx, repo, record); | |
| 271 | } | |
| 266 | const record = studioRecord(ctx, repo); | |
| 267 | if (record !== undefined) return await studioRuns(ctx, repo, record); | |
| 272 | 268 | const base = repoBase(repo); |
| 273 | 269 | const q = ctx.url.searchParams; |
| 274 | 270 | const kindRaw = q.get("kind"); |
| @@ -353,8 +349,7 @@ export async function runPage(ctx: Ctx): Promise<Response> { | ||
| 353 | 349 | // what it has cost, and the box to steer it — the same rule `repoHome` |
| 354 | 350 | // and `pullPage` follow. `?mode=dev` on the URL is the escape hatch to |
| 355 | 351 | // the transcript dev already renders, resolved once for `ctx.mode`. |
| 356 | const studio = ctx.mode === "studio"; | |
| 357 | if (studio && getAppByRepo(ctx.app.db, repo.id) !== undefined) | |
| 352 | if (studioRecord(ctx, repo) !== undefined) | |
| 358 | 353 | return await studioRun(ctx, repo, run, lines, truncated); |
| 359 | 354 | const base = repoBase(repo); |
| 360 | 355 | const actor = |
src/web/studio/app.ts +10 −4
| @@ -42,6 +42,12 @@ import { | ||
| 42 | 42 | } from "../ui.ts"; |
| 43 | 43 | import { asksPanel, decisionsFor } from "./asks.ts"; |
| 44 | 44 | import { withLikely } from "./leans.ts"; |
| 45 | import { | |
| 46 | changeHref, | |
| 47 | changesHref, | |
| 48 | tellHref, | |
| 49 | tellsHref, | |
| 50 | } from "./nouns.ts"; | |
| 45 | 51 | import { LOG_TAIL_CAP, logBox } from "./log.ts"; |
| 46 | 52 | import { appPanels } from "./previews.ts"; |
| 47 | 53 | import { stopForm } from "./run.ts"; |
| @@ -307,7 +313,7 @@ export async function studioApp( | ||
| 307 | 313 | const { word, note } = await pullWord(wordCtx, p); |
| 308 | 314 | const who = nameFor(p.authorName); |
| 309 | 315 | return row({ |
| 310 | href: `${base}/pulls/${p.number}`, | |
| 316 | href: changeHref(base, p.number), | |
| 311 | 317 | nav: true, |
| 312 | 318 | lead: avatar(who), |
| 313 | 319 | title: p.title, |
| @@ -320,7 +326,7 @@ export async function studioApp( | ||
| 320 | 326 | ...waiting.map((i) => { |
| 321 | 327 | const who = nameFor(i.assigneeName ?? "crew"); |
| 322 | 328 | return row({ |
| 323 | href: `${base}/issues/${i.number}`, | |
| 329 | href: tellHref(base, i.number), | |
| 324 | 330 | nav: true, |
| 325 | 331 | lead: avatar(who), |
| 326 | 332 | title: i.title, |
| @@ -462,8 +468,8 @@ export async function studioApp( | ||
| 462 | 468 | ); |
| 463 | 469 | const more = html`<p class="muted small"> |
| 464 | 470 | <a href="${base}/map">The map</a> · |
| 465 | <a href="${base}/tells">Everything told</a> · | |
| 466 | <a href="${base}/changes">Every change</a> | |
| 471 | <a href="${tellsHref(base)}">Everything told</a> · | |
| 472 | <a href="${changesHref(base)}">Every change</a> | |
| 467 | 473 | </p>`; |
| 468 | 474 | |
| 469 | 475 | const body = html`${repoHeader(ctx, repo, "code", { |
src/web/studio/asks.ts +5 −4
| @@ -34,6 +34,7 @@ import { html, when, type Html } from "../html.ts"; | ||
| 34 | 34 | import { btn, card, icon, row } from "../ui.ts"; |
| 35 | 35 | import { lookThumb, lookUrl } from "../pages/looks.ts"; |
| 36 | 36 | import { leanNote, leanVariant } from "./leans.ts"; |
| 37 | import { changeHref } from "./nouns.ts"; | |
| 37 | 38 | |
| 38 | 39 | /** The apps a person can see, as repositories, with their records — the |
| 39 | 40 | * same visibility a signed-out reader gets from `listReposVisibleTo` too. */ |
| @@ -323,7 +324,7 @@ export function decisionActions(ctx: Ctx, d: Decision): Html { | ||
| 323 | 324 | : `${base}/app/pull-${pull.number}/open`; |
| 324 | 325 | if (d.kind === "stopped" && pull) |
| 325 | 326 | return html`${btn("Tell them", { |
| 326 | href: `${base}/pulls/${pull.number}-body`, | |
| 327 | href: `${changeHref(base, pull.number)}-body`, | |
| 327 | 328 | size: "sm", |
| 328 | 329 | ...leanVariant(d, "say", "primary"), |
| 329 | 330 | })}${leanNote(d, "say")} |
| @@ -333,7 +334,7 @@ export function decisionActions(ctx: Ctx, d: Decision): Html { | ||
| 333 | 334 | ...leanVariant(d, "close", undefined), |
| 334 | 335 | })}${leanNote(d, "close")} |
| 335 | 336 | ${btn("See", { |
| 336 | href: `${base}/pulls/${pull.number}`, | |
| 337 | href: changeHref(base, pull.number), | |
| 337 | 338 | size: "sm", |
| 338 | 339 | ...leanVariant(d, "look", undefined), |
| 339 | 340 | })}${leanNote(d, "look")}`; |
| @@ -353,7 +354,7 @@ export function decisionActions(ctx: Ctx, d: Decision): Html { | ||
| 353 | 354 | })}${leanNote(d, "preview")}`, |
| 354 | 355 | )} |
| 355 | 356 | ${btn("Not like this", { |
| 356 | href: `${base}/pulls/${pull.number}-body`, | |
| 357 | href: `${changeHref(base, pull.number)}-body`, | |
| 357 | 358 | size: "sm", |
| 358 | 359 | ...leanVariant(d, "say", undefined), |
| 359 | 360 | })}${leanNote(d, "say")}`; |
| @@ -370,7 +371,7 @@ function decisionRow(ctx: Ctx, d: Decision): Html { | ||
| 370 | 371 | d.kind === "stopped" ? "alert" : d.kind === "look" ? "eye" : "clock"; |
| 371 | 372 | // The title links; the row itself does not, because it holds controls |
| 372 | 373 | // and a control inside an anchor is not HTML a browser will keep together. |
| 373 | const href = d.pull ? `${base}/pulls/${d.pull.number}` : base; | |
| 374 | const href = d.pull ? changeHref(base, d.pull.number) : base; | |
| 374 | 375 | const title = html`<a href="${href}">${d.pull?.title ?? d.repo.name}</a>`; |
| 375 | 376 | return row({ |
| 376 | 377 | lead: html`<input |
src/web/studio/change.ts +4
| @@ -60,6 +60,10 @@ export async function studioPull( | ||
| 60 | 60 | ): Promise<Response> { |
| 61 | 61 | const db = ctx.app.db; |
| 62 | 62 | const base = repoBase(repo); |
| 63 | // Dev's own address for this change, and the only one the page *posts* | |
| 64 | // to: ship, say and the escape to the diff all live under | |
| 65 | // `/pulls/:number`, whether a reader arrived there or at | |
| 66 | // `/changes/:number` (`changeHref`, nouns.ts). | |
| 63 | 67 | const thread = `${base}/pulls/${pull.number}`; |
| 64 | 68 | const wordCtx: WordCtx = { |
| 65 | 69 | db, |
src/web/studio/lists.ts +7 −18
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 11 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; | |
| 11 | import 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"; |
| @@ -37,6 +37,7 @@ import { | ||
| 37 | 37 | time, |
| 38 | 38 | } from "../ui.ts"; |
| 39 | 39 | import { actorsFor, issuesFor, runTitleFrom } from "../pages/runs.ts"; |
| 40 | import { changeHref, studioRecord, tellHref } from "./nouns.ts"; | |
| 40 | 41 | import { tellWord } from "./tell.ts"; |
| 41 | 42 | import { |
| 42 | 43 | pullWord, |
| @@ -104,18 +105,6 @@ function pagedHref(base: string, filter: WordFilter | undefined, cursor: string) | ||
| 104 | 105 | return `${base}?${filter ? `word=${filter}&` : ""}cursor=${cursor}`; |
| 105 | 106 | } |
| 106 | 107 | |
| 107 | /** | |
| 108 | * The one rule for whether a viewer sees the studio's own list on this repo: | |
| 109 | * studio mode, no `?mode=dev` escape hatch, and an app to have a studio word | |
| 110 | * for — the same check `issuesList`/`pullsList` make before falling back to | |
| 111 | * dev's own list. | |
| 112 | */ | |
| 113 | export function studioListRecord(ctx: Ctx, repo: Repo): AppRecord | undefined { | |
| 114 | const studio = | |
| 115 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 116 | return studio ? getAppByRepo(ctx.app.db, repo.id) : undefined; | |
| 117 | } | |
| 118 | ||
| 119 | 108 | /** |
| 120 | 109 | * GET /:owner/:name/issues in the studio: everything told, one row per |
| 121 | 110 | * issue in its studio word — who has it, when it last moved, and the tell |
| @@ -172,7 +161,7 @@ export async function studioTells( | ||
| 172 | 161 | : listCard( |
| 173 | 162 | rows.map(({ issue, info }) => |
| 174 | 163 | row({ |
| 175 | href: `${base}/issues/${issue.number}`, | |
| 164 | href: tellHref(base, issue.number), | |
| 176 | 165 | nav: true, |
| 177 | 166 | lead: avatar(issue.assigneeName ?? "crew"), |
| 178 | 167 | title: issue.title, |
| @@ -261,7 +250,7 @@ export async function studioChanges( | ||
| 261 | 250 | : listCard( |
| 262 | 251 | rows.map(({ pull, info }) => |
| 263 | 252 | row({ |
| 264 | href: `${base}/pulls/${pull.number}`, | |
| 253 | href: changeHref(base, pull.number), | |
| 265 | 254 | nav: true, |
| 266 | 255 | lead: avatar(pull.authorName), |
| 267 | 256 | title: pull.title, |
| @@ -305,7 +294,7 @@ export async function studioChanges( | ||
| 305 | 294 | /** |
| 306 | 295 | * GET /:owner/:name/tells — the studio's own door onto the same list |
| 307 | 296 | * `/issues` reaches: `issuesList` and this route both resolve through |
| 308 | * `studioListRecord`/`studioTells`, so there is exactly one page, never a | |
| 297 | * `studioRecord`/`studioTells`, so there is exactly one page, never a | |
| 309 | 298 | * second copy of it. A viewer for whom the studio's own list does not apply |
| 310 | 299 | * (dev mode, `?mode=dev`, or a repository that is not an app) is sent to |
| 311 | 300 | * `/issues` — dev's own address is the one canonical URL for dev's own list, |
| @@ -314,7 +303,7 @@ export async function studioChanges( | ||
| 314 | 303 | export async function tellsList(ctx: Ctx): Promise<Response> { |
| 315 | 304 | const repo = loadRepo(ctx); |
| 316 | 305 | if (!repo) return notFoundPage(ctx); |
| 317 | const record = studioListRecord(ctx, repo); | |
| 306 | const record = studioRecord(ctx, repo); | |
| 318 | 307 | if (record === undefined) |
| 319 | 308 | return redirect(`${repoBase(repo)}/issues${ctx.url.search}`); |
| 320 | 309 | return await studioTells(ctx, repo, record); |
| @@ -325,7 +314,7 @@ export async function tellsList(ctx: Ctx): Promise<Response> { | ||
| 325 | 314 | export async function changesList(ctx: Ctx): Promise<Response> { |
| 326 | 315 | const repo = loadRepo(ctx); |
| 327 | 316 | if (!repo) return notFoundPage(ctx); |
| 328 | const record = studioListRecord(ctx, repo); | |
| 317 | const record = studioRecord(ctx, repo); | |
| 329 | 318 | if (record === undefined) |
| 330 | 319 | return redirect(`${repoBase(repo)}/pulls${ctx.url.search}`); |
| 331 | 320 | return await studioChanges(ctx, repo, record); |
src/web/studio/nouns.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** | |
| 2 | * The studio's nouns: where one tell and one change live, and the one rule | |
| 3 | * for whether a viewer gets the studio at all on a repository. Every studio | |
| 4 | * surface — the app page, the asks, the lists, a tell, a change, the search | |
| 5 | * box and the palette — builds its links through here, so the page a reader | |
| 6 | * came from and the address bar they land in can never disagree about where | |
| 7 | * a tell lives. | |
| 8 | * | |
| 9 | * Dev's own addresses are never closed: `/issues/:number` and | |
| 10 | * `/pulls/:number` answer exactly as they always have, in both modes, so | |
| 11 | * every link already written to one keeps working forever. This is a second | |
| 12 | * door, not a replacement — and the doors that *change* something are not | |
| 13 | * here at all. A form still posts where it has always posted | |
| 14 | * (`/pulls/:number/ship`, `/issues/:number/comments`), which is what the | |
| 15 | * same-origin checks and `form-action 'self'` are written against. | |
| 16 | */ | |
| 17 | ||
| 18 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; | |
| 19 | import type { Repo } from "../../core/repos.ts"; | |
| 20 | import type { Ctx } from "../../http/context.ts"; | |
| 21 | ||
| 22 | /** Everything told — the studio's own door onto `/issues`. */ | |
| 23 | export const tellsHref = (base: string): string => `${base}/tells`; | |
| 24 | ||
| 25 | /** Every change — the studio's own door onto `/pulls`. */ | |
| 26 | export const changesHref = (base: string): string => `${base}/changes`; | |
| 27 | ||
| 28 | /** One tell: the same page `/issues/:number` reaches, under the studio's | |
| 29 | * own noun (`tellPage`, `src/web/pages/issues.ts`). */ | |
| 30 | export const tellHref = (base: string, number: number): string => | |
| 31 | `${base}/tells/${number}`; | |
| 32 | ||
| 33 | /** One change: the same page `/pulls/:number` reaches, under the studio's | |
| 34 | * own noun (`changePage`, `src/web/pages/pull.ts`). */ | |
| 35 | export const changeHref = (base: string, number: number): string => | |
| 36 | `${base}/changes/${number}`; | |
| 37 | ||
| 38 | /** | |
| 39 | * The one rule for whether a viewer sees the studio's own page on this | |
| 40 | * repo: studio mode, no `?mode=dev` escape hatch, and an app to have a | |
| 41 | * studio word for — the same check `issuesList`/`pullsList` make before | |
| 42 | * falling back to dev's own list, and `issuePage`/`pullPage` before falling | |
| 43 | * back to dev's own page. | |
| 44 | */ | |
| 45 | export function studioRecord(ctx: Ctx, repo: Repo): AppRecord | undefined { | |
| 46 | const studio = | |
| 47 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 48 | return studio ? getAppByRepo(ctx.app.db, repo.id) : undefined; | |
| 49 | } |
src/web/studio/run.ts +4 −1
| @@ -29,6 +29,7 @@ import { btn, icon } from "../ui.ts"; | ||
| 29 | 29 | import { previewCard } from "../pages/apps.ts"; |
| 30 | 30 | import { costText, durationText, runTitle } from "../pages/runs.ts"; |
| 31 | 31 | import { logBox, RUN_TRANSCRIPT_CAP } from "./log.ts"; |
| 32 | import { changeHref, tellHref } from "./nouns.ts"; | |
| 32 | 33 | import { runWord, wordBadge, type WordCtx } from "./words.ts"; |
| 33 | 34 | |
| 34 | 35 | /** |
| @@ -111,7 +112,9 @@ export async function studioRun( | ||
| 111 | 112 | issue, |
| 112 | 113 | (i) => |
| 113 | 114 | html`<a |
| 114 | href="${base}/${i.kind === "pull" ? "pulls" : "issues"}/${i.number}" | |
| 115 | href="${i.kind === "pull" | |
| 116 | ? changeHref(base, i.number) | |
| 117 | : tellHref(base, i.number)}" | |
| 115 | 118 | >#${i.number} ${i.title}</a |
| 116 | 119 | >`, |
| 117 | 120 | )} |
src/web/studio/search.ts +9 −2
| @@ -33,6 +33,7 @@ import { | ||
| 33 | 33 | type Tone, |
| 34 | 34 | } from "../ui.ts"; |
| 35 | 35 | import { appsOf } from "./asks.ts"; |
| 36 | import { changeHref, tellHref } from "./nouns.ts"; | |
| 36 | 37 | |
| 37 | 38 | const APP_LIMIT = 20; |
| 38 | 39 | const CHANGE_LIMIT = 20; |
| @@ -76,9 +77,15 @@ function appRow(ctx: Ctx, repo: Repo, record: AppRecord, query: string): Html { | ||
| 76 | 77 | |
| 77 | 78 | type LocalIssueHit = IssueHit & { source: "local" }; |
| 78 | 79 | |
| 80 | /** Where the hit lives, in the studio's own nouns — every hit here is on | |
| 81 | * an app (`appsOf` is what was searched), so the studio's address is the | |
| 82 | * one this page can hand out without ever sending a reader somewhere the | |
| 83 | * page behind it would not. */ | |
| 79 | 84 | function issueHitHref(hit: LocalIssueHit): string { |
| 80 | const segment = hit.kind === "issue" ? "issues" : "pulls"; | |
| 81 | return `/${hit.repo.owner}/${hit.repo.name}/${segment}/${hit.number}`; | |
| 85 | const base = `/${hit.repo.owner}/${hit.repo.name}`; | |
| 86 | return hit.kind === "issue" | |
| 87 | ? tellHref(base, hit.number) | |
| 88 | : changeHref(base, hit.number); | |
| 82 | 89 | } |
| 83 | 90 | |
| 84 | 91 | /** A change or a tell: which app it belongs to, and its own studio link — |
src/web/studio/tell.ts +7 −2
| @@ -37,6 +37,7 @@ import { | ||
| 37 | 37 | import { previewCard } from "../pages/apps.ts"; |
| 38 | 38 | import { costText, durationText, reasonWords } from "../pages/runs.ts"; |
| 39 | 39 | import { composer, conversation, originGlyph } from "../pages/thread.ts"; |
| 40 | import { changeHref, tellHref } from "./nouns.ts"; | |
| 40 | 41 | import { |
| 41 | 42 | pullWord, |
| 42 | 43 | runWord, |
| @@ -105,7 +106,7 @@ function tellDependencies(base: string, issue: Issue, db: Ctx["app"]["db"]): Htm | ||
| 105 | 106 | (i) => |
| 106 | 107 | html`<li data-state="${i.state}"> |
| 107 | 108 | ${icon(i.state === "closed" ? "issueClosed" : "issue")} |
| 108 | <a href="${base}/issues/${i.number}">${i.title}</a> | |
| 109 | <a href="${tellHref(base, i.number)}">${i.title}</a> | |
| 109 | 110 | <span class="muted small">#${i.number}</span> |
| 110 | 111 | </li>`, |
| 111 | 112 | )} |
| @@ -187,7 +188,7 @@ async function tellClosingPull( | ||
| 187 | 188 | const { word, note } = await pullWord(wordCtx, pull); |
| 188 | 189 | return html`${card( |
| 189 | 190 | html`<p> |
| 190 | <a href="${base}/pulls/${pull.number}">${pull.title}</a> | |
| 191 | <a href="${changeHref(base, pull.number)}">${pull.title}</a> | |
| 191 | 192 | ${wordBadge(word)} <span class="muted small">${note}</span> |
| 192 | 193 | </p>`, |
| 193 | 194 | { head: html`${icon("pull")} <h2>Closes with</h2>` }, |
| @@ -210,6 +211,10 @@ export async function studioTell( | ||
| 210 | 211 | ): Promise<Response> { |
| 211 | 212 | const db = ctx.app.db; |
| 212 | 213 | const base = repoBase(repo); |
| 214 | // Dev's own address for this tell, and the only one the page *posts* to: | |
| 215 | // the reply, the close and the escape to dev all live under | |
| 216 | // `/issues/:number`, whether a reader arrived there or at `/tells/:number` | |
| 217 | // (`tellHref`, nouns.ts). The write surface does not move with the noun. | |
| 213 | 218 | const thread = `${base}/issues/${issue.number}`; |
| 214 | 219 | const direction = repo.directionIssueId === issue.id; |
| 215 | 220 | const closingPull = openPullsClosing(db, repo.id).get(issue.number); |
test/studio-words.test.ts +25
| @@ -440,12 +440,27 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 440 | 440 | path: `${repoPath}/issues/${tellNumber}`, |
| 441 | 441 | dev: ["<h2>Labels</h2>"], |
| 442 | 442 | }, |
| 443 | // The studio's own nouns for the same two pages (#158): one handler | |
| 444 | // each, so what is walked here is the very page above under the | |
| 445 | // address the studio's own links hand out. No `dev` half — `?mode=dev` | |
| 446 | // at these addresses is a redirect to dev's own (`/issues/:number`, | |
| 447 | // `/pulls/:number`), which the row above already proves. | |
| 448 | { | |
| 449 | word: "tell (the studio's noun)", | |
| 450 | path: `${repoPath}/tells/${tellNumber}`, | |
| 451 | shows: [tellTitle], | |
| 452 | }, | |
| 443 | 453 | { word: "working", path: `${repoPath}/runs/${runId}` }, |
| 444 | 454 | { |
| 445 | 455 | word: "look", |
| 446 | 456 | path: `${repoPath}/pulls/${openPullNumber}`, |
| 447 | 457 | dev: ["diff-table", openBranch], |
| 448 | 458 | }, |
| 459 | { | |
| 460 | word: "look (the studio's noun)", | |
| 461 | path: `${repoPath}/changes/${openPullNumber}`, | |
| 462 | shows: [openPullTitle], | |
| 463 | }, | |
| 449 | 464 | { |
| 450 | 465 | word: "shipped", |
| 451 | 466 | path: `${repoPath}/pulls/${mergedPullNumber}`, |
| @@ -478,6 +493,16 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 478 | 493 | hides: [tellTitle], |
| 479 | 494 | dev: [`>#${untouchedNumber} opened`], |
| 480 | 495 | }, |
| 496 | { | |
| 497 | word: "tells (the studio's noun)", | |
| 498 | path: `${repoPath}/tells`, | |
| 499 | shows: [untouchedTitle, tellTitle], | |
| 500 | }, | |
| 501 | { | |
| 502 | word: "changes (the studio's noun)", | |
| 503 | path: `${repoPath}/changes`, | |
| 504 | shows: [openPullTitle, secondChangeTitle], | |
| 505 | }, | |
| 481 | 506 | { |
| 482 | 507 | word: "changes", |
| 483 | 508 | path: `${repoPath}/pulls`, |
test/studio.test.ts +144 −4
| @@ -604,7 +604,8 @@ describe("the studio", () => { | ||
| 604 | 604 | expect(studio).toContain("scout"); |
| 605 | 605 | expect(studio).toContain("has it"); |
| 606 | 606 | expect(studio).toContain("Ground work"); |
| 607 | expect(studio).toContain(`href="/alice/notes/issues/${blocker.value.number}"`); | |
| 607 | // What it waits on, in the studio's own noun (#158). | |
| 608 | expect(studio).toContain(`href="/alice/notes/tells/${blocker.value.number}"`); | |
| 608 | 609 | expect(studio).toContain('data-word="working"'); |
| 609 | 610 | expect(studio).toContain("$0.12"); |
| 610 | 611 | expect(studio).toContain('id="comment-body"'); |
| @@ -922,6 +923,144 @@ describe("the studio", () => { | ||
| 922 | 923 | expect(anonChanges.status).toBe(404); |
| 923 | 924 | }); |
| 924 | 925 | |
| 926 | test("/tells/:number and /changes/:number reach the same pages /issues/:number and /pulls/:number reach — one page, the same anchors, and forms that still post to dev's own address", async () => { | |
| 927 | const told = openIssue(app, alice, repo, { | |
| 928 | title: "Door test: one tell", | |
| 929 | body: "reached through /tells/:number too", | |
| 930 | }); | |
| 931 | if (!told.ok) throw new Error(String(told.error)); | |
| 932 | const n = told.value.number; | |
| 933 | ||
| 934 | const viaTell = await get(`/alice/notes/tells/${n}`); | |
| 935 | expect(viaTell.status).toBe(200); | |
| 936 | const tellHtml = await viaTell.text(); | |
| 937 | const viaIssue = await (await get(`/alice/notes/issues/${n}`)).text(); | |
| 938 | // The same page: the studio's tell, never dev's labels or dependency | |
| 939 | // form, and never a second copy of the markup. | |
| 940 | expect(tellHtml).toContain("Door test: one tell"); | |
| 941 | expect(tellHtml).toContain('data-word="told"'); | |
| 942 | expect(tellHtml).not.toContain("<h2>Labels</h2>"); | |
| 943 | expect(viaIssue).toContain('data-word="told"'); | |
| 944 | // The write surface does not move with the noun: the reply, the close | |
| 945 | // and the escape to dev all still point at /issues/:number. | |
| 946 | expect(tellHtml).toContain(`action="/alice/notes/issues/${n}/comments"`); | |
| 947 | expect(tellHtml).toContain(`href="/alice/notes/issues/${n}?mode=dev"`); | |
| 948 | // An anchor into the conversation still lands — the composer is on the | |
| 949 | // page either door was used. | |
| 950 | expect(tellHtml).toContain('id="comment-body"'); | |
| 951 | ||
| 952 | const branch = await commitToBranch(app, alice, repo, { | |
| 953 | branch: "feature/one-change-door", | |
| 954 | base: "main", | |
| 955 | message: "One change door", | |
| 956 | files: [{ path: "ONE_DOOR.md", content: "a change\n" }], | |
| 957 | }); | |
| 958 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 959 | const opened = await openPull(app, alice, repo, { | |
| 960 | title: "Door test: one change", | |
| 961 | body: "", | |
| 962 | baseBranch: "main", | |
| 963 | headBranch: "feature/one-change-door", | |
| 964 | }); | |
| 965 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 966 | const p = opened.value.number; | |
| 967 | ||
| 968 | const viaChange = await get(`/alice/notes/changes/${p}`); | |
| 969 | expect(viaChange.status).toBe(200); | |
| 970 | const changeHtml = await viaChange.text(); | |
| 971 | expect(changeHtml).toContain("Door test: one change"); | |
| 972 | // The studio's change, not dev's diff. | |
| 973 | expect(changeHtml).not.toContain("diff-table"); | |
| 974 | expect(changeHtml).toContain(`action="/alice/notes/pulls/${p}/comments"`); | |
| 975 | expect(changeHtml).toContain(`href="/alice/notes/pulls/${p}?mode=dev"`); | |
| 976 | expect(changeHtml).toContain('id="comment-body"'); | |
| 977 | ||
| 978 | // Dev's own addresses answer the studio's pages where they stand — a | |
| 979 | // link already written to one is never bounced anywhere. | |
| 980 | const stillIssue = await get(`/alice/notes/issues/${n}`); | |
| 981 | expect(stillIssue.status).toBe(200); | |
| 982 | const stillPull = await get(`/alice/notes/pulls/${p}`); | |
| 983 | expect(stillPull.status).toBe(200); | |
| 984 | ||
| 985 | // The three lists, the app page and the search box all hand out the | |
| 986 | // studio's noun, so a person is never given two addresses for one thing. | |
| 987 | const tells = await (await get("/alice/notes/tells")).text(); | |
| 988 | expect(tells).toContain(`href="/alice/notes/tells/${n}"`); | |
| 989 | const changes = await (await get("/alice/notes/changes")).text(); | |
| 990 | expect(changes).toContain(`href="/alice/notes/changes/${p}"`); | |
| 991 | const appPage = await (await get("/alice/notes")).text(); | |
| 992 | expect(appPage).toContain(`href="/alice/notes/changes/${p}"`); | |
| 993 | const search = await (await get("/search?q=Door+test")).text(); | |
| 994 | expect(search).toContain(`/alice/notes/tells/${n}`); | |
| 995 | expect(search).toContain(`/alice/notes/changes/${p}`); | |
| 996 | // The palette's remote rows are folded in the browser; the index tells | |
| 997 | // the script which mode it is in, so it folds the same nouns. | |
| 998 | expect(appPage).toContain('"studio":true'); | |
| 999 | }); | |
| 1000 | ||
| 1001 | test("/tells/:number and /changes/:number send a dev-mode viewer, ?mode=dev, and a repository that is not an app on to /issues/:number and /pulls/:number", async () => { | |
| 1002 | const told = openIssue(app, alice, repo, { title: "Door test: dev's way" }); | |
| 1003 | if (!told.ok) throw new Error(String(told.error)); | |
| 1004 | const n = told.value.number; | |
| 1005 | ||
| 1006 | const devTell = await get(`/alice/notes/tells/${n}`, { | |
| 1007 | cookie: `${cookie}; forge_mode=dev`, | |
| 1008 | }); | |
| 1009 | expect(devTell.status).toBe(303); | |
| 1010 | expect(devTell.headers.get("location")).toBe(`/alice/notes/issues/${n}`); | |
| 1011 | ||
| 1012 | const escape = await get(`/alice/notes/tells/${n}?mode=dev`); | |
| 1013 | expect(escape.status).toBe(303); | |
| 1014 | expect(escape.headers.get("location")).toBe( | |
| 1015 | `/alice/notes/issues/${n}?mode=dev`, | |
| 1016 | ); | |
| 1017 | ||
| 1018 | const plain = await createRepo(app, alice, { | |
| 1019 | name: "doorpages", | |
| 1020 | visibility: "private", | |
| 1021 | description: "no app here", | |
| 1022 | }); | |
| 1023 | if (!plain.ok) throw new Error(String(plain.error)); | |
| 1024 | const plainTold = openIssue(app, alice, plain.value, { | |
| 1025 | title: "Not an app", | |
| 1026 | }); | |
| 1027 | if (!plainTold.ok) throw new Error(String(plainTold.error)); | |
| 1028 | const plainTell = await get( | |
| 1029 | `/alice/doorpages/tells/${plainTold.value.number}`, | |
| 1030 | ); | |
| 1031 | expect(plainTell.status).toBe(303); | |
| 1032 | expect(plainTell.headers.get("location")).toBe( | |
| 1033 | `/alice/doorpages/issues/${plainTold.value.number}`, | |
| 1034 | ); | |
| 1035 | }); | |
| 1036 | ||
| 1037 | test("a reader without access gets the forge's not-found on /tells/:number and /changes/:number, exactly as /issues/:number and /pulls/:number", async () => { | |
| 1038 | const told = openIssue(app, alice, repo, { title: "Door test: private" }); | |
| 1039 | if (!told.ok) throw new Error(String(told.error)); | |
| 1040 | const n = told.value.number; | |
| 1041 | ||
| 1042 | const anonTell = await fetch(`${base}/alice/notes/tells/${n}`, { | |
| 1043 | headers: { cookie: "forge_mode=studio" }, | |
| 1044 | redirect: "manual", | |
| 1045 | }); | |
| 1046 | expect(anonTell.status).toBe(404); | |
| 1047 | const anonIssue = await fetch(`${base}/alice/notes/issues/${n}`, { | |
| 1048 | headers: { cookie: "forge_mode=studio" }, | |
| 1049 | redirect: "manual", | |
| 1050 | }); | |
| 1051 | expect(anonIssue.status).toBe(anonTell.status); | |
| 1052 | ||
| 1053 | // A number that is not a tell is not a tell at either address, and a | |
| 1054 | // change's number is not one under the tell's own noun. | |
| 1055 | const missing = await get("/alice/notes/tells/99999"); | |
| 1056 | expect(missing.status).toBe(404); | |
| 1057 | const anonChange = await fetch(`${base}/alice/notes/changes/1`, { | |
| 1058 | headers: { cookie: "forge_mode=studio" }, | |
| 1059 | redirect: "manual", | |
| 1060 | }); | |
| 1061 | expect(anonChange.status).toBe(404); | |
| 1062 | }); | |
| 1063 | ||
| 925 | 1064 | test("a reader without access gets the forge's not-found on a tell, exactly as dev", async () => { |
| 926 | 1065 | const told = openIssue(app, alice, repo, { title: "Private work" }); |
| 927 | 1066 | if (!told.ok) throw new Error(String(told.error)); |
| @@ -2938,7 +3077,8 @@ describe("working: a run's own page in the studio", () => { | ||
| 2938 | 3077 | await get(`/alice/engine/runs/${run.id}`, writerCookie) |
| 2939 | 3078 | ).text(); |
| 2940 | 3079 | expect(page).toContain("Fix the flicker on load"); |
| 2941 | expect(page).toContain(`href="/alice/engine/issues/${tell.value.number}"`); | |
| 3080 | // The tell it came from, in the studio's own noun (#158). | |
| 3081 | expect(page).toContain(`href="/alice/engine/tells/${tell.value.number}"`); | |
| 2942 | 3082 | expect(page).toContain("$0.42 spent"); |
| 2943 | 3083 | expect(page).toContain('data-word="working"'); |
| 2944 | 3084 | expect(page).toContain('id="run-steer"'); |
| @@ -4602,9 +4742,9 @@ describe("finding things in the studio: /search", () => { | ||
| 4602 | 4742 | test("a change and a tell are found too, each saying which app they belong to", async () => { |
| 4603 | 4743 | const html = await (await asAlice("/search?q=chess")).text(); |
| 4604 | 4744 | expect(html).toContain("Add a chess clock timer"); |
| 4605 | expect(html).toContain(`/alice/widgets/issues/${tellNumber}`); | |
| 4745 | expect(html).toContain(`/alice/widgets/tells/${tellNumber}`); | |
| 4606 | 4746 | expect(html).toContain("Wire up the chess clock"); |
| 4607 | expect(html).toContain(`/alice/widgets/pulls/${changeNumber}`); | |
| 4747 | expect(html).toContain(`/alice/widgets/changes/${changeNumber}`); | |
| 4608 | 4748 | expect(html).toContain("alice/widgets"); |
| 4609 | 4749 | }); |
| 4610 | 4750 |
new above
offline