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

One tell, one change: the studio's nouns go all the way down #174

Merged opened this

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

Merged

mason/forge:mason/158-one-tell-one-change-the-studio-s-nouns-g → main 1 commit fork

  • Merged. mason/158-one-tell-one-change-the-studio-s-nouns-g was merged into main as e5ddbc3.

Review

Sign in to review this change.
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
179179 moved, its state as `wordBadge` draws it (`src/web/studio/words.ts`), and
180180 one link — the row itself — to the tell, the change or the transcript.
181181 `/: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.
194206
195207 A row's word comes from `tellWord`, `pullWord` or `runWord` — the one place
196208 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.
208220
209221 ## A tell, on its own page
210222
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
212225 sentence someone said to the crew — not dev's labels, milestones and
213226 dependency form. It answers three questions: who has it (or that nobody
214227 does yet), what it is waiting on and what waits on it (`dependenciesOf`,
@@ -246,7 +259,8 @@ mode, renders exactly what it always has.
246259
247260 ## The look, on its own page
248261
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 —
250264 never its diff. The title and the opening post in the author's own words,
251265 who made it and when, the check in the studio's five words (`pullWord`,
252266 `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 {
7474 issueThreadFragment,
7575 newIssuePage,
7676 newIssueSubmit,
77 tellPage,
7778 } from "../web/pages/issues.ts";
7879 import { globalMapPage, mapPage } from "../web/pages/map.ts";
7980 import {
@@ -120,6 +121,7 @@ import {
120121 } from "../web/pages/crew.ts";
121122 import { newPullPage, newPullSubmit, pullsList } from "../web/pages/pulls.ts";
122123 import {
124 changePage,
123125 pullCommentSubmit,
124126 pullMergeSubmit,
125127 pullPage,
@@ -537,11 +539,15 @@ export function buildRouter(): Router<Handler> {
537539 // Issues (web). The literal /new is registered before /:number so it can
538540 // never be captured as a number.
539541 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.
544549 router.add("GET", "/:owner/:name/tells", tellsList);
550 router.add("GET", "/:owner/:name/tells/:number", tellPage);
545551 router.add("GET", "/:owner/:name/issues/new", newIssuePage);
546552 router.add("POST", "/:owner/:name/issues/new", newIssueSubmit);
547553 router.add("GET", "/:owner/:name/issues/:number", issuePage);
@@ -575,8 +581,11 @@ export function buildRouter(): Router<Handler> {
575581
576582 // Pull requests. Same /new-before-/:number ordering discipline.
577583 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.
579587 router.add("GET", "/:owner/:name/changes", changesList);
588 router.add("GET", "/:owner/:name/changes/:number", changePage);
580589 router.add("GET", "/:owner/:name/pulls/new", newPullPage);
581590 router.add("POST", "/:owner/:name/pulls/new", newPullSubmit);
582591 router.add("GET", "/:owner/:name/pulls/:number", pullPage);
src/web/chrome.ts +5 −4
@@ -24,6 +24,7 @@ import {
2424 whereRow,
2525 type RepoScope,
2626 } from "./scope.ts";
27 import { changesHref, tellsHref } from "./studio/nouns.ts";
2728 import { badge, btn, empty, tabs, type TabItem } from "./ui.ts";
2829
2930 // ---- loading ----
@@ -165,7 +166,7 @@ export function scopeHeader(
165166 // Studio names its own lists — told, not issues; dev's tabs are
166167 // unchanged in dev mode and on a repository that is not an app.
167168 label: studio ? "Told" : "Issues",
168 href: studio ? `${base}/tells` : `${base}/issues`,
169 href: studio ? tellsHref(base) : `${base}/issues`,
169170 icon: studio ? "clock" : "issue",
170171 ...(facts.openIssues === null ? {} : { count: facts.openIssues }),
171172 current: active === "issues",
@@ -173,7 +174,7 @@ export function scopeHeader(
173174 },
174175 {
175176 label: studio ? "Look" : "Pulls",
176 href: studio ? `${base}/changes` : `${base}/pulls`,
177 href: studio ? changesHref(base) : `${base}/pulls`,
177178 icon: studio ? "eye" : "pull",
178179 ...(facts.openPulls === null ? {} : { count: facts.openPulls }),
179180 current: active === "pulls",
@@ -295,12 +296,12 @@ export function repoPalette(ctx: Ctx, repo: Repo): PaletteItem[] {
295296 { t: `${name} — Code`, h: base, c: "This repo" },
296297 {
297298 t: `${name} — ${studio ? "Told" : "Issues"}`,
298 h: studio ? `${base}/tells` : `${base}/issues`,
299 h: studio ? tellsHref(base) : `${base}/issues`,
299300 c: "This repo",
300301 },
301302 {
302303 t: `${name} — ${studio ? "Changes" : "Pull requests"}`,
303 h: studio ? `${base}/changes` : `${base}/pulls`,
304 h: studio ? changesHref(base) : `${base}/pulls`,
304305 c: "This repo",
305306 },
306307 { t: `${name} — Commits`, h: `${base}/commits`, c: "This repo" },
src/web/client.ts +9 −2
@@ -1588,12 +1588,19 @@ export const shellJs = /* js */ `
15881588 d: r.originKind ? 'on ' + r.originKind : 'on an origin' }
15891589 : { t: r.owner + '/' + r.name, h: '/' + r.owner + '/' + r.name, g: 'Repositories' });
15901590 });
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;
15911596 (out.issues || []).slice(0, 5).forEach(function (i) {
15921597 if (i.source !== 'local') return;
1598 var tell = i.kind === 'issue';
1599 var noun = tell ? (studio ? 'tells' : 'issues') : (studio ? 'changes' : 'pulls');
15931600 rows.push({
15941601 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',
15971604 d: i.repo.owner + '/' + i.repo.name + ' #' + i.number,
15981605 });
15991606 });
src/web/layout.ts +5 −1
@@ -311,7 +311,11 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html {
311311 })),
312312 ...(studio ? STATIC_PALETTE_STUDIO : STATIC_PALETTE_DEV),
313313 ];
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");
315319 return raw(
316320 `<script type="application/json" id="palette-index">${json}</script>`,
317321 );
src/web/pages/issues.ts +35 −9
@@ -5,7 +5,6 @@
55 */
66
77 import { addCommentThrough, openIssueThrough } from "../../actions/issues.ts";
8 import { getAppByRepo } from "../../core/apps.ts";
98 import {
109 issueCounts,
1110 labelsFor,
@@ -30,7 +29,7 @@ import {
3029 } from "../chrome.ts";
3130 import { field, readForm } from "../form.ts";
3231 import { html, when, type Html } from "../html.ts";
33 import { flashTo, page } from "../layout.ts";
32 import { flashTo, page, redirect } from "../layout.ts";
3433 import { liveClientJs, liveMount } from "../live.ts";
3534 import { localScope, type RepoScope } from "../scope.ts";
3635 import {
@@ -63,7 +62,8 @@ import {
6362 threadFragment,
6463 } from "./thread.ts";
6564 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";
6767 import { studioTell } from "../studio/tell.ts";
6868
6969 const PER_PAGE = 25;
@@ -295,7 +295,7 @@ export async function issuesList(ctx: Ctx): Promise<Response> {
295295 // In the studio "everything told" is the issue list in the studio's own
296296 // words — the same rule `issuePage` follows for one tell. `?mode=dev` is
297297 // the one-off way to dev's list without switching the browser.
298 const record = studioListRecord(ctx, repo);
298 const record = studioRecord(ctx, repo);
299299 if (record !== undefined) return await studioTells(ctx, repo, record);
300300 const scope = localScope(ctx, repo);
301301 const base = scope.base;
@@ -477,11 +477,11 @@ export async function issuePage(ctx: Ctx): Promise<Response> {
477477 if (found instanceof Response) return found;
478478 const { repo, issue } = found;
479479 // 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)
485485 return await studioTell(ctx, repo, issue);
486486 const base = repoBase(repo);
487487 const thread = `${base}/issues/${issue.number}`;
@@ -548,6 +548,32 @@ export async function issuePage(ctx: Ctx): Promise<Response> {
548548 });
549549 }
550550
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
551577 // GET /:owner/:name/issues/:number/thread?after=<id>
552578 export function issueThreadFragment(ctx: Ctx): Response {
553579 return threadFragment(ctx, "issue");
src/web/pages/pull.ts +33 −8
@@ -61,7 +61,7 @@ import { field, readForm } from "../form.ts";
6161 import { html, raw, when, type Html } from "../html.ts";
6262 import { renderMarkdown } from "../markdown.ts";
6363 import type { IconName as Glyph } from "../icons.ts";
64 import { flashTo, page } from "../layout.ts";
64 import { flashTo, page, redirect } from "../layout.ts";
6565 import { liveClientJs, liveMount } from "../live.ts";
6666 import {
6767 avatar,
@@ -97,6 +97,7 @@ import { lookCard } from "./looks.ts";
9797 import { pullState } from "./pulls.ts";
9898 import { crewActivityCard } from "./runs.ts";
9999 import { studioPull } from "../studio/change.ts";
100 import { studioRecord } from "../studio/nouns.ts";
100101
101102 /** The pull and its repo, or the response that refuses to say why not. */
102103 function pullContext(ctx: Ctx): { repo: Repo; pull: Pull } | Response {
@@ -668,13 +669,13 @@ export async function pullPage(ctx: Ctx): Promise<Response> {
668669 if (found instanceof Response) return found;
669670 const { repo, pull } = found;
670671 // 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);
678679 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
679680 const base = repoBase(repo);
680681 const thread = `${base}/pulls/${pull.number}`;
@@ -841,6 +842,30 @@ export async function pullPage(ctx: Ctx): Promise<Response> {
841842 });
842843 }
843844
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
844869 // GET /:owner/:name/pulls/:number/thread?after=<id>
845870 export function pullThreadFragment(ctx: Ctx): Response {
846871 return threadFragment(ctx, "pull");
src/web/pages/pulls.ts +3 −2
@@ -52,7 +52,8 @@ import {
5252 } from "../ui.ts";
5353 import { refSet } from "./refs.ts";
5454 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";
5657 import { originGlyph } from "./thread.ts";
5758
5859 const PER_PAGE = 25;
@@ -127,7 +128,7 @@ export async function pullsList(ctx: Ctx): Promise<Response> {
127128 // In the studio "every change" is the pull list in the studio's own
128129 // words — the same rule `pullPage` follows for one change. `?mode=dev` is
129130 // the one-off way to dev's list without switching the browser.
130 const record = studioListRecord(ctx, repo);
131 const record = studioRecord(ctx, repo);
131132 if (record !== undefined) return await studioChanges(ctx, repo, record);
132133 const base = repoBase(repo);
133134 const db = ctx.app.db;
src/web/pages/runs.ts +4 −9
@@ -17,7 +17,6 @@ import {
1717 runLog,
1818 steerRepoRun,
1919 } from "../../actions/runs.ts";
20 import { getAppByRepo } from "../../core/apps.ts";
2120 import { getIssueById, getIssuesByIds, type Issue } from "../../core/issues.ts";
2221 import { canRead, canWrite, getRepoById, type Repo } from "../../core/repos.ts";
2322 import { getRun, isTerminal, type Run, type RunKind } from "../../core/runs.ts";
@@ -38,6 +37,7 @@ import { html, when, type Html } from "../html.ts";
3837 import { flashTo, page, redirect } from "../layout.ts";
3938 import { liveClientJs, liveMount } from "../live.ts";
4039 import { studioRuns } from "../studio/lists.ts";
40 import { studioRecord } from "../studio/nouns.ts";
4141 import { studioRun } from "../studio/run.ts";
4242 import {
4343 btn,
@@ -263,12 +263,8 @@ export async function runsList(ctx: Ctx): Promise<Response> {
263263 // In the studio "every run" is the run list in the studio's own words —
264264 // the same rule `runPage` follows for one run. `?mode=dev` is the
265265 // 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);
272268 const base = repoBase(repo);
273269 const q = ctx.url.searchParams;
274270 const kindRaw = q.get("kind");
@@ -353,8 +349,7 @@ export async function runPage(ctx: Ctx): Promise<Response> {
353349 // what it has cost, and the box to steer it — the same rule `repoHome`
354350 // and `pullPage` follow. `?mode=dev` on the URL is the escape hatch to
355351 // 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)
358353 return await studioRun(ctx, repo, run, lines, truncated);
359354 const base = repoBase(repo);
360355 const actor =
src/web/studio/app.ts +10 −4
@@ -42,6 +42,12 @@ import {
4242 } from "../ui.ts";
4343 import { asksPanel, decisionsFor } from "./asks.ts";
4444 import { withLikely } from "./leans.ts";
45 import {
46 changeHref,
47 changesHref,
48 tellHref,
49 tellsHref,
50 } from "./nouns.ts";
4551 import { LOG_TAIL_CAP, logBox } from "./log.ts";
4652 import { appPanels } from "./previews.ts";
4753 import { stopForm } from "./run.ts";
@@ -307,7 +313,7 @@ export async function studioApp(
307313 const { word, note } = await pullWord(wordCtx, p);
308314 const who = nameFor(p.authorName);
309315 return row({
310 href: `${base}/pulls/${p.number}`,
316 href: changeHref(base, p.number),
311317 nav: true,
312318 lead: avatar(who),
313319 title: p.title,
@@ -320,7 +326,7 @@ export async function studioApp(
320326 ...waiting.map((i) => {
321327 const who = nameFor(i.assigneeName ?? "crew");
322328 return row({
323 href: `${base}/issues/${i.number}`,
329 href: tellHref(base, i.number),
324330 nav: true,
325331 lead: avatar(who),
326332 title: i.title,
@@ -462,8 +468,8 @@ export async function studioApp(
462468 );
463469 const more = html`<p class="muted small">
464470 <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>
467473 </p>`;
468474
469475 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";
3434 import { btn, card, icon, row } from "../ui.ts";
3535 import { lookThumb, lookUrl } from "../pages/looks.ts";
3636 import { leanNote, leanVariant } from "./leans.ts";
37 import { changeHref } from "./nouns.ts";
3738
3839 /** The apps a person can see, as repositories, with their records — the
3940 * same visibility a signed-out reader gets from `listReposVisibleTo` too. */
@@ -323,7 +324,7 @@ export function decisionActions(ctx: Ctx, d: Decision): Html {
323324 : `${base}/app/pull-${pull.number}/open`;
324325 if (d.kind === "stopped" && pull)
325326 return html`${btn("Tell them", {
326 href: `${base}/pulls/${pull.number}#comment-body`,
327 href: `${changeHref(base, pull.number)}#comment-body`,
327328 size: "sm",
328329 ...leanVariant(d, "say", "primary"),
329330 })}${leanNote(d, "say")}
@@ -333,7 +334,7 @@ export function decisionActions(ctx: Ctx, d: Decision): Html {
333334 ...leanVariant(d, "close", undefined),
334335 })}${leanNote(d, "close")}
335336 ${btn("See", {
336 href: `${base}/pulls/${pull.number}`,
337 href: changeHref(base, pull.number),
337338 size: "sm",
338339 ...leanVariant(d, "look", undefined),
339340 })}${leanNote(d, "look")}`;
@@ -353,7 +354,7 @@ export function decisionActions(ctx: Ctx, d: Decision): Html {
353354 })}${leanNote(d, "preview")}`,
354355 )}
355356 ${btn("Not like this", {
356 href: `${base}/pulls/${pull.number}#comment-body`,
357 href: `${changeHref(base, pull.number)}#comment-body`,
357358 size: "sm",
358359 ...leanVariant(d, "say", undefined),
359360 })}${leanNote(d, "say")}`;
@@ -370,7 +371,7 @@ function decisionRow(ctx: Ctx, d: Decision): Html {
370371 d.kind === "stopped" ? "alert" : d.kind === "look" ? "eye" : "clock";
371372 // The title links; the row itself does not, because it holds controls
372373 // 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;
374375 const title = html`<a href="${href}">${d.pull?.title ?? d.repo.name}</a>`;
375376 return row({
376377 lead: html`<input
src/web/studio/change.ts +4
@@ -60,6 +60,10 @@ export async function studioPull(
6060 ): Promise<Response> {
6161 const db = ctx.app.db;
6262 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).
6367 const thread = `${base}/pulls/${pull.number}`;
6468 const wordCtx: WordCtx = {
6569 db,
src/web/studio/lists.ts +7 −18
@@ -8,7 +8,7 @@
88 */
99
1010 import { appsRuntime } from "../../apps/runtime.ts";
11 import { getAppByRepo, type AppRecord } from "../../core/apps.ts";
11 import type { AppRecord } from "../../core/apps.ts";
1212 import { listIssues, type Issue } from "../../core/issues.ts";
1313 import { listPulls, openPullsClosing } from "../../core/pulls.ts";
1414 import type { Repo } from "../../core/repos.ts";
@@ -37,6 +37,7 @@ import {
3737 time,
3838 } from "../ui.ts";
3939 import { actorsFor, issuesFor, runTitleFrom } from "../pages/runs.ts";
40 import { changeHref, studioRecord, tellHref } from "./nouns.ts";
4041 import { tellWord } from "./tell.ts";
4142 import {
4243 pullWord,
@@ -104,18 +105,6 @@ function pagedHref(base: string, filter: WordFilter | undefined, cursor: string)
104105 return `${base}?${filter ? `word=${filter}&` : ""}cursor=${cursor}`;
105106 }
106107
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
119108 /**
120109 * GET /:owner/:name/issues in the studio: everything told, one row per
121110 * issue in its studio word — who has it, when it last moved, and the tell
@@ -172,7 +161,7 @@ export async function studioTells(
172161 : listCard(
173162 rows.map(({ issue, info }) =>
174163 row({
175 href: `${base}/issues/${issue.number}`,
164 href: tellHref(base, issue.number),
176165 nav: true,
177166 lead: avatar(issue.assigneeName ?? "crew"),
178167 title: issue.title,
@@ -261,7 +250,7 @@ export async function studioChanges(
261250 : listCard(
262251 rows.map(({ pull, info }) =>
263252 row({
264 href: `${base}/pulls/${pull.number}`,
253 href: changeHref(base, pull.number),
265254 nav: true,
266255 lead: avatar(pull.authorName),
267256 title: pull.title,
@@ -305,7 +294,7 @@ export async function studioChanges(
305294 /**
306295 * GET /:owner/:name/tells — the studio's own door onto the same list
307296 * `/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
309298 * second copy of it. A viewer for whom the studio's own list does not apply
310299 * (dev mode, `?mode=dev`, or a repository that is not an app) is sent to
311300 * `/issues` — dev's own address is the one canonical URL for dev's own list,
@@ -314,7 +303,7 @@ export async function studioChanges(
314303 export async function tellsList(ctx: Ctx): Promise<Response> {
315304 const repo = loadRepo(ctx);
316305 if (!repo) return notFoundPage(ctx);
317 const record = studioListRecord(ctx, repo);
306 const record = studioRecord(ctx, repo);
318307 if (record === undefined)
319308 return redirect(`${repoBase(repo)}/issues${ctx.url.search}`);
320309 return await studioTells(ctx, repo, record);
@@ -325,7 +314,7 @@ export async function tellsList(ctx: Ctx): Promise<Response> {
325314 export async function changesList(ctx: Ctx): Promise<Response> {
326315 const repo = loadRepo(ctx);
327316 if (!repo) return notFoundPage(ctx);
328 const record = studioListRecord(ctx, repo);
317 const record = studioRecord(ctx, repo);
329318 if (record === undefined)
330319 return redirect(`${repoBase(repo)}/pulls${ctx.url.search}`);
331320 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";
2929 import { previewCard } from "../pages/apps.ts";
3030 import { costText, durationText, runTitle } from "../pages/runs.ts";
3131 import { logBox, RUN_TRANSCRIPT_CAP } from "./log.ts";
32 import { changeHref, tellHref } from "./nouns.ts";
3233 import { runWord, wordBadge, type WordCtx } from "./words.ts";
3334
3435 /**
@@ -111,7 +112,9 @@ export async function studioRun(
111112 issue,
112113 (i) =>
113114 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)}"
115118 >#${i.number} ${i.title}</a
116119 >`,
117120 )}
src/web/studio/search.ts +9 −2
@@ -33,6 +33,7 @@ import {
3333 type Tone,
3434 } from "../ui.ts";
3535 import { appsOf } from "./asks.ts";
36 import { changeHref, tellHref } from "./nouns.ts";
3637
3738 const APP_LIMIT = 20;
3839 const CHANGE_LIMIT = 20;
@@ -76,9 +77,15 @@ function appRow(ctx: Ctx, repo: Repo, record: AppRecord, query: string): Html {
7677
7778 type LocalIssueHit = IssueHit & { source: "local" };
7879
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. */
7984 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);
8289 }
8390
8491 /** 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 {
3737 import { previewCard } from "../pages/apps.ts";
3838 import { costText, durationText, reasonWords } from "../pages/runs.ts";
3939 import { composer, conversation, originGlyph } from "../pages/thread.ts";
40 import { changeHref, tellHref } from "./nouns.ts";
4041 import {
4142 pullWord,
4243 runWord,
@@ -105,7 +106,7 @@ function tellDependencies(base: string, issue: Issue, db: Ctx["app"]["db"]): Htm
105106 (i) =>
106107 html`<li data-state="${i.state}">
107108 ${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>
109110 <span class="muted small">#${i.number}</span>
110111 </li>`,
111112 )}
@@ -187,7 +188,7 @@ async function tellClosingPull(
187188 const { word, note } = await pullWord(wordCtx, pull);
188189 return html`${card(
189190 html`<p>
190 <a href="${base}/pulls/${pull.number}">${pull.title}</a>
191 <a href="${changeHref(base, pull.number)}">${pull.title}</a>
191192 ${wordBadge(word)} <span class="muted small">${note}</span>
192193 </p>`,
193194 { head: html`${icon("pull")} <h2>Closes with</h2>` },
@@ -210,6 +211,10 @@ export async function studioTell(
210211 ): Promise<Response> {
211212 const db = ctx.app.db;
212213 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.
213218 const thread = `${base}/issues/${issue.number}`;
214219 const direction = repo.directionIssueId === issue.id;
215220 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", () => {
440440 path: `${repoPath}/issues/${tellNumber}`,
441441 dev: ["<h2>Labels</h2>"],
442442 },
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 },
443453 { word: "working", path: `${repoPath}/runs/${runId}` },
444454 {
445455 word: "look",
446456 path: `${repoPath}/pulls/${openPullNumber}`,
447457 dev: ["diff-table", openBranch],
448458 },
459 {
460 word: "look (the studio's noun)",
461 path: `${repoPath}/changes/${openPullNumber}`,
462 shows: [openPullTitle],
463 },
449464 {
450465 word: "shipped",
451466 path: `${repoPath}/pulls/${mergedPullNumber}`,
@@ -478,6 +493,16 @@ describe("dev's vocabulary never reaches a studio page", () => {
478493 hides: [tellTitle],
479494 dev: [`>#${untouchedNumber} opened`],
480495 },
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 },
481506 {
482507 word: "changes",
483508 path: `${repoPath}/pulls`,
test/studio.test.ts +144 −4
@@ -604,7 +604,8 @@ describe("the studio", () => {
604604 expect(studio).toContain("scout");
605605 expect(studio).toContain("has it");
606606 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}"`);
608609 expect(studio).toContain('data-word="working"');
609610 expect(studio).toContain("$0.12");
610611 expect(studio).toContain('id="comment-body"');
@@ -922,6 +923,144 @@ describe("the studio", () => {
922923 expect(anonChanges.status).toBe(404);
923924 });
924925
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
9251064 test("a reader without access gets the forge's not-found on a tell, exactly as dev", async () => {
9261065 const told = openIssue(app, alice, repo, { title: "Private work" });
9271066 if (!told.ok) throw new Error(String(told.error));
@@ -2938,7 +3077,8 @@ describe("working: a run's own page in the studio", () => {
29383077 await get(`/alice/engine/runs/${run.id}`, writerCookie)
29393078 ).text();
29403079 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}"`);
29423082 expect(page).toContain("$0.42 spent");
29433083 expect(page).toContain('data-word="working"');
29443084 expect(page).toContain('id="run-steer"');
@@ -4602,9 +4742,9 @@ describe("finding things in the studio: /search", () => {
46024742 test("a change and a tell are found too, each saying which app they belong to", async () => {
46034743 const html = await (await asAlice("/search?q=chess")).text();
46044744 expect(html).toContain("Add a chess clock timer");
4605 expect(html).toContain(`/alice/widgets/issues/${tellNumber}`);
4745 expect(html).toContain(`/alice/widgets/tells/${tellNumber}`);
46064746 expect(html).toContain("Wire up the chess clock");
4607 expect(html).toContain(`/alice/widgets/pulls/${changeNumber}`);
4747 expect(html).toContain(`/alice/widgets/changes/${changeNumber}`);
46084748 expect(html).toContain("alice/widgets");
46094749 });
46104750

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs