Public: anyone can read this; the crew can change it.
In the studio, a repository that is not an app drops you into the git tree with nothing to say #188
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/181-in-the-studio-a-repository-that-is-not-a → main 1 commit fork
3 files
changed +90
−7
docs/STUDIO.md +12
| @@ -58,6 +58,18 @@ page: the studio only offers principled moves. | ||
| 58 | 58 | |
| 59 | 59 | ## The app |
| 60 | 60 | |
| 61 | `/:owner/:name` in the studio, on a repository with no app record, keeps | |
| 62 | the studio's own framing rather than silently becoming dev's code page: one | |
| 63 | card above the code says in one line that this repository has no app yet, | |
| 64 | with a door to make it one — `/settings/app`, the same "Run as an app" form | |
| 65 | Settings already carries — open only to whoever could submit it (`canAdmin`); | |
| 66 | a reader sees the sentence with no button that would just come back | |
| 67 | refused. `?mode=dev` on the same URL is unaffected: the card is the studio's | |
| 68 | own, never dev's. `/tells` and `/changes` on such a repository still send a | |
| 69 | studio viewer on to `/issues` and `/pulls`, as they do for any repository | |
| 70 | the studio does not apply to — the card names the one thing to do here, | |
| 71 | not a second front door onto lists that would be empty anyway. | |
| 72 | ||
| 61 | 73 | An app's home in the studio (`/:owner/:name`, when the repository is an |
| 62 | 74 | app) shows, top to bottom: what it serves and where; the box; what asks |
| 63 | 75 | you; what is happening now. Beside it, who is on the crew and what each is |
src/web/pages/repo.ts +33 −7
| @@ -628,6 +628,31 @@ function aboutCard( | ||
| 628 | 628 | |
| 629 | 629 | // ---- pages ---- |
| 630 | 630 | |
| 631 | /** | |
| 632 | * Studio's own word for a repository it otherwise has nothing to say about: | |
| 633 | * no app record, so no head card, no box, no Now — just the code underneath, | |
| 634 | * same as it always rendered. One line says so, and the one door out — | |
| 635 | * `/settings/app`, the same "Run as an app" form Settings already carries — | |
| 636 | * opens only for whoever could actually submit it, so a reader never sees a | |
| 637 | * button that would just come back refused. | |
| 638 | */ | |
| 639 | function noAppCard(ctx: Ctx, repo: Repo): Html { | |
| 640 | const admin = canAdmin(ctx.app.db, repo, ctx.auth.user); | |
| 641 | return empty({ | |
| 642 | icon: "rocket", | |
| 643 | title: "This repository has no app yet.", | |
| 644 | ...(admin | |
| 645 | ? { | |
| 646 | action: btn("Run as an app", { | |
| 647 | href: `${repoBase(repo)}/settings/app`, | |
| 648 | variant: "primary", | |
| 649 | icon: "rocket", | |
| 650 | }), | |
| 651 | } | |
| 652 | : {}), | |
| 653 | }); | |
| 654 | } | |
| 655 | ||
| 631 | 656 | /** The tree, the README under it, and the ref switcher above both. */ |
| 632 | 657 | export async function renderCode( |
| 633 | 658 | scope: RepoScope, |
| @@ -843,17 +868,18 @@ export async function repoHome(ctx: Ctx): Promise<Response> { | ||
| 843 | 868 | const repo = loadRepo(ctx); |
| 844 | 869 | if (!repo) return notFoundPage(ctx); |
| 845 | 870 | // In the studio an app's home is the app: what it serves, who is on it, |
| 846 | // what it asks. Dev mode is the code, as ever. | |
| 847 | if (ctx.mode === "studio") { | |
| 848 | const record = getAppByRepo(ctx.app.db, repo.id); | |
| 849 | if (record !== undefined) return await studioApp(ctx, repo, record); | |
| 850 | } | |
| 871 | // what it asks. Dev mode is the code, as ever. A repository with no app | |
| 872 | // record has nothing of that to show — read once, used either way below. | |
| 873 | const studioMode = ctx.mode === "studio"; | |
| 874 | const record = studioMode ? getAppByRepo(ctx.app.db, repo.id) : undefined; | |
| 875 | if (record !== undefined) return await studioApp(ctx, repo, record); | |
| 876 | const noApp = studioMode ? noAppCard(ctx, repo) : null; | |
| 851 | 877 | const scope = localScope(ctx, repo); |
| 852 | 878 | const cloneUrl = `${ctx.url.origin}/${repo.ownerName}/${repo.name}.git`; |
| 853 | 879 | const refs = await refSetFrom(scope.source); |
| 854 | 880 | |
| 855 | 881 | if (refs.branches.length === 0) { |
| 856 | const body = html`${scopeHeader(ctx, scope, "code")} | |
| 882 | const body = html`${scopeHeader(ctx, scope, "code")}${noApp} | |
| 857 | 883 | <div class="mt"> |
| 858 | 884 | ${empty({ |
| 859 | 885 | icon: "repo", |
| @@ -906,7 +932,7 @@ export async function repoHome(ctx: Ctx): Promise<Response> { | ||
| 906 | 932 | |
| 907 | 933 | const body = html`${scopeHeader(ctx, scope, "code", { |
| 908 | 934 | branchCount: refs.branches.length, |
| 909 | })} | |
| 935 | })}${noApp} | |
| 910 | 936 | ${appStrip(ctx, repo)} ${forkBar(ctx, repo)} |
| 911 | 937 | ${await renderCode(scope, { |
| 912 | 938 | refs, |
test/studio.test.ts +45
| @@ -222,6 +222,51 @@ describe("the studio", () => { | ||
| 222 | 222 | expect(dev).toContain(">Commits<"); |
| 223 | 223 | }); |
| 224 | 224 | |
| 225 | test("a repository with no app record keeps the studio's framing: one card, one door to make it one; ?mode=dev is unchanged, and an app itself never shows the card", async () => { | |
| 226 | const plain = await createRepo(app, alice, { | |
| 227 | name: "plainhome", | |
| 228 | visibility: "public", | |
| 229 | description: "no app here", | |
| 230 | }); | |
| 231 | if (!plain.ok) throw new Error(String(plain.error)); | |
| 232 | const seeded = await commitToBranch(app, alice, plain.value, { | |
| 233 | message: "Start", | |
| 234 | files: [{ path: "README.md", content: "hi\n" }], | |
| 235 | }); | |
| 236 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 237 | ||
| 238 | const studio = await (await get("/alice/plainhome")).text(); | |
| 239 | expect(studio).toContain("This repository has no app yet."); | |
| 240 | expect(studio).toContain(`href="/alice/plainhome/settings/app"`); | |
| 241 | expect(studio).toContain("Run as an app"); | |
| 242 | ||
| 243 | // `?mode=dev` on the very same URL renders exactly what it always has: | |
| 244 | // the code, no card above it at all. | |
| 245 | const dev = await ( | |
| 246 | await get("/alice/plainhome", { cookie: `${cookie}; forge_mode=dev` }) | |
| 247 | ).text(); | |
| 248 | expect(dev).toContain("README.md"); | |
| 249 | expect(dev).toContain(">Commits<"); | |
| 250 | expect(dev).not.toContain("This repository has no app yet."); | |
| 251 | expect(dev).not.toContain("Run as an app"); | |
| 252 | ||
| 253 | // A signed-out reader of the same public repository sees the same | |
| 254 | // sentence but no door it could not walk through. | |
| 255 | const stranger = await ( | |
| 256 | await fetch(`${base}/alice/plainhome`, { | |
| 257 | headers: { cookie: "forge_mode=studio" }, | |
| 258 | redirect: "manual", | |
| 259 | }) | |
| 260 | ).text(); | |
| 261 | expect(stranger).toContain("This repository has no app yet."); | |
| 262 | expect(stranger).not.toContain("Run as an app"); | |
| 263 | expect(stranger).not.toContain(`href="/alice/plainhome/settings/app"`); | |
| 264 | ||
| 265 | // An app itself never carries this card. | |
| 266 | const app_ = await (await get("/alice/notes")).text(); | |
| 267 | expect(app_).not.toContain("This repository has no app yet."); | |
| 268 | }); | |
| 269 | ||
| 225 | 270 | test("the studio's design is in the stylesheet, not in every page", async () => { |
| 226 | 271 | // The rules are static, so they belong in the file the browser caches. |
| 227 | 272 | // A studio page carries no <style> at all — only a page that computes CSS |
new above
offline