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

The repository head shows who can see it as a sentence beside the Private/Public badge #2590

Merged opened this

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

Merged

scout/forge:scout/2567-the-repository-head-shows-who-can-see-it → main 2 commits fork

  • Merged. scout/2567-the-repository-head-shows-who-can-see-it was merged into main as 91bb593.

Review

Sign in to review this change.
3 files changed +112 −0
src/web/chrome.ts +16
@@ -12,6 +12,7 @@ import { sshCommand } from "../ssh/command.ts";
1212 import { mayDirectCrew } from "../crew/direct.ts";
1313 import { mayReadRuns } from "../actions/runs.ts";
1414 import { getAppByRepo } from "../core/apps.ts";
15 import { audienceFacts, audienceOf } from "../core/audience.ts";
1516 import { edgeLoginUrl } from "../http/edge.ts";
1617 import {
1718 canAdmin,
@@ -310,6 +311,7 @@ export function scopeHeader(
310311 ${facts.visibility === "private"
311312 ? badge("Private", { icon: "lock", variant: "secondary" })
312313 : badge("Public", { variant: "secondary" })}
314 ${repo === undefined ? null : audienceLine(ctx, repo, admin, base)}
313315 ${actions.text.trim() !== ""
314316 ? html`<span class="repo-actions">${actions}</span>`
315317 : null}`;
@@ -347,6 +349,20 @@ export function scopeHeader(
347349 </header>`;
348350 }
349351
352 /**
353 * Who can see this repository, in the one sentence `audienceOf` gives, beside
354 * the badge. A link to the settings for whoever can open them, plain text for
355 * everyone else. One line: CSS truncates it, `title` keeps all of it.
356 */
357 function audienceLine(ctx: Ctx, repo: Repo, admin: boolean, base: string): Html {
358 const { sentence } = audienceOf(audienceFacts(ctx.app.db, repo, ctx.auth.user));
359 return admin
360 ? html`<a class="repo-audience" href="${base}/settings" title="${sentence}"
361 >${sentence}</a
362 >`
363 : html`<span class="repo-audience" title="${sentence}">${sentence}</span>`;
364 }
365
350366 export { hostOf };
351367
352368 /** This repo's own rows for the command palette — the studio's nouns for a
src/web/style/content.ts +10
@@ -25,6 +25,16 @@ export const content = /* css */ `
2525 }
2626 .repo-title .owner { font-weight: 400; color: var(--muted-fg); }
2727 .repo-title .slash { color: var(--faint); font-weight: 400; }
28 .repo-audience {
29 flex: 1 1 16ch; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
30 font-size: 13px; font-weight: 400; letter-spacing: 0; color: var(--muted-fg);
31 }
32 a.repo-audience:hover { color: var(--fg); }
33 /* A phone has no hover for the title: the whole sentence, on its own line. */
34 @media (max-width: 640px) {
35 .repo-audience { flex-basis: 100%; white-space: normal; text-overflow: clip; }
36 a.repo-audience { display: block; padding: 12px 0; min-height: 44px; box-sizing: border-box; }
37 }
2838 .repo-desc { margin-top: 4px; color: var(--muted-fg); font-size: 13.5px; text-wrap: pretty; }
2939 .repo-lineage { margin-top: 3px; font-size: 12.5px; color: var(--faint); }
3040 .repo-head .tabs { margin-top: 12px; }
test/repo-audience.test.ts added +86
@@ -0,0 +1,86 @@
1 /**
2 * The repository head says who can see the repository, beside the badge: a
3 * link to the settings for who can open them, plain text for everyone else,
4 * and the public sentence for somebody not signed in.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { setRole } from "../src/actions/roles.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22
23 const signIn = async (username: string): Promise<string> => {
24 const r = await fetch(`${servers.url}/signin`, {
25 method: "POST",
26 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
27 redirect: "manual",
28 });
29 return (r.headers.get("set-cookie") ?? "").split(";")[0]!;
30 };
31
32 const head = async (path: string, cookie = ""): Promise<string> => {
33 const r = await fetch(`${servers.url}${path}`, {
34 headers: cookie ? { cookie } : {},
35 });
36 expect(r.status).toBe(200);
37 const text = await r.text();
38 return text.match(/<header class="repo-head">[\s\S]*?<\\?\/header>/)?.[0] ?? "";
39 };
40
41 beforeAll(async () => {
42 dataDir = await mkdtemp(join(tmpdir(), "forge-repo-audience-"));
43 app = createApp({ dataDir, port: 0, host: "forge.test" });
44 servers = await serve(app);
45 const users = [];
46 for (const [username, first] of [["hank", true], ["olive", false], ["ravi", false]] as const) {
47 const made = await createUser(app.db, {
48 origin: "seed",
49 trusted: true,
50 username,
51 email: `${username}@forge.local`,
52 password: "a-good-passphrase",
53 adminIfFirst: first,
54 });
55 if (!made.ok) throw new Error(made.error);
56 users.push(made.value);
57 }
58 const promoted = setRole(app, users[0]!, "olive", "member");
59 if (!promoted.ok) throw new Error(promoted.error);
60 const r = await createRepo(app, users[1]!, { name: "yard", visibility: "public" });
61 if (!r.ok) throw new Error(String(r.error));
62 });
63
64 afterAll(async () => {
65 await servers.stop();
66 await rm(dataDir, { recursive: true, force: true });
67 });
68
69 test("a writer sees the sentence as a link to the settings", async () => {
70 const h = await head("/olive/yard", await signIn("olive"));
71 expect(h).toMatch(
72 /<a class="repo-audience" href="\/olive\/yard\/settings" title="Public: anyone can read this; 1 person can change it\."\s*>Public: anyone can read this; 1 person can change it\.<\/a\s*>/,
73 );
74 });
75
76 test("a reader sees the same sentence as plain text", async () => {
77 const h = await head("/olive/yard", await signIn("ravi"));
78 expect(h).toContain('<span class="repo-audience" title="Public: anyone');
79 expect(h).not.toContain('href="/olive/yard/settings"');
80 });
81
82 test("somebody not signed in sees the public sentence", async () => {
83 const h = await head("/olive/yard");
84 expect(h).toContain("Public: anyone can read this; 1 person can change it.");
85 expect(h).toContain('<span class="repo-audience"');
86 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs