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

Config files open as a form: JSON edited through a clean, typed UI that commits or proposes #7392

Merged opened this in answer to

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

Merged

mason/forge:mason/7336-config-files-open-as-a-form-json-edited → main 10 commits fork 9 threads · 9 unresolved

  • Merged. mason/7336-config-files-open-as-a-form-json-edited was merged into main as 48f4957.

Review

Sign in to review this change.
17 files changed +2274 −12
README.md +1
@@ -436,6 +436,7 @@ This page is the front door. Everything else is a file of its own under
436436 - [`docs/GIT.md`](docs/GIT.md) — cloning over HTTPS and SSH, and partial clones of big repositories.
437437 - [`docs/features/csv-view.md`](docs/features/csv-view.md) — open a `.csv` or `.tsv` in the code view to read it as a table; `?all=1` draws more rows, `?view=source` shows the text. The terminal has its own table view for them (see the `.csv` paragraph above).
438438 - [`docs/features/file-media.md`](docs/features/file-media.md) — a file in the browser: every file page has **Raw** and **Download** (any size), and `/:owner/:name/media/:ref/*path` serves images, audio, video and PDFs under their real type, sandboxed, with byte ranges.
439 - [`docs/features/config-form.md`](docs/features/config-form.md) — a JSON file opens as a form: typed fields (from its schema when one is found), cards to add and reorder, and a save that commits for a writer or proposes a pull for anyone else. In the terminal, a reader edits in the workspace (`e`) and `c` proposes the change from their fork; any commit there that breaks a JSON file's schema is refused first.
439440 - [`docs/ROLES.md`](docs/ROLES.md) — guest, member and admin, and what each may do.
440441 - [`docs/MCP.md`](docs/MCP.md) — the forge as a connector; [`docs/STANDARDS.md`](docs/STANDARDS.md) is what it answers by default: `.well-known`, feeds, cards, the manifest.
441442
docs/design/areas/config.md added +11
@@ -0,0 +1,11 @@
1 # Config form
2
3 A JSON file edited as a form (`src/web/render/config-form.ts`, `src/web/pages/config.ts`, #7336). Styled in
4 `style/content.ts`.
5
6 - `.config-form` — the card that holds the form, on the blob page (read-only) and on the editor page.
7 - `.config-group` — a nested object or an array, as a `fieldset.field` with a thin rule down its left edge, so depth reads
8 without boxes inside boxes.
9 - `.config-card` — one item of an array of objects: a `.card` with its Up, Down and Remove buttons in a `.bar`.
10 - `.config-chips` — an array of scalars: a wrapping row of small inputs, each with its Remove button.
11 - `.config-diff` — the review step's change, one line per row, `data-op="add|del|same"` tinted with the diff tokens.
docs/design/sections/5-components.md +2 −2
@@ -7,9 +7,9 @@ specification.
77
88 | component | web class | variants (`data-*`) | states | terminal equivalent |
99 | --------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ------ | ------ |
10 | **button** `btn()` | `.btn` | `variant=primary\|secondary\|outline\|ghost\|danger\|link`, `size=sm\|lg` | hover, active, `:focus-visible`, `[disabled]`, `[aria-busy]` | a labelled key or a highlighted action cell |
10 | **button** `btn()` | `.btn` | `variant=primary\|secondary\|outline\|ghost\|danger\|link`, `size=sm\|lg`, `formnovalidate` | hover, active, `:focus-visible`, `[disabled]`, `[aria-busy]` | a labelled key or a highlighted action cell |
1111 | **icon button** `iconBtn()` | `.btn.btn-icon` | same | `aria-label` required | a single glyph with a status-bar hint |
12 | **input / textarea / select** `field()` | `.input` | `size=sm`, `[aria-invalid]` | focus, `:user-invalid`, `[readonly]` | a line editor with an inline error row |
12 | **input / textarea / select** `field()` | `.input` | `size=sm`, `[aria-invalid]`, `type=select\|checkbox\|number` (`min`, `max`, `step`) | focus, `:user-invalid`, `[readonly]` | a line editor with an inline error row |
1313 | **form field** `field()` | `.field` (`label`, `.hint`, `.error`) | `data-invalid` | — | label line, value line, hint line |
1414 | **checkbox** | `.check` | — | native, `accent-color` themed | `[x]` / `[ ]` |
1515 | **badge** `badge()` / `stateBadge()` | `.badge` | `variant=secondary\|outline\|success\|danger\|merged\|attn`; `data-state=open\|closed`, `data-pr=merged` | — | a bracketed word in the state color |
docs/features/config-form.md added +77
@@ -0,0 +1,77 @@
1 # Config files as a form
2
3 A `.json` file in a repository opens as a form (#7336, part of #7329).
4
5 ## Reading
6
7 The blob page's **Rendered** view draws the file's values as read-only
8 fields. **Source**, **Raw** and **Download** are where they always are.
9 A file that is not valid JSON says why, with the line and column, above
10 its text. YAML, TOML and other config files keep their source view.
11
12 ## Editing
13
14 **Edit as form** opens `/:owner/:name/config/:ref/*path`. You need to be
15 signed in. A private repository's file is a 404 to anyone who cannot read it.
16
17 - **Without a schema** each value's type picks its control. A string is a text
18 input, or a textarea when it has more than one line. A number is a number
19 input and a boolean is a checkbox. `null` is shown as `null`. A nested
20 object is a fieldset. An array of scalars is a row of chips, and an array
21 of objects is a list of cards you can add, remove and move up or down.
22 - **With a schema** the fields follow it. An `enum` becomes a select, and
23 `description` becomes help text. `required`, `minimum`, `maximum` and
24 `pattern` are set as the inputs' own attributes, so the browser checks
25 them as you type. The server checks `required`, the bounds, `enum` and
26 `type` again on save. It never runs a pattern the repository wrote.
27 A glob in `.forge/schemas.json` is matched segment by segment in
28 O(glob × path); no regular expression is built from it.
29 Properties the schema names but the file does not have are offered as
30 **+ key** buttons. Local `$ref`s (`#/definitions/…`, `#/$defs/…`) are
31 followed.
32 - **Finding the schema.** The first one found is used:
33 1. `$schema` in the file, if it is a path. A path that starts with `/`
34 is read from the repository root; any other path is read from the
35 file's folder. A URL is ignored.
36 2. A sibling `<name>.schema.json`.
37 3. `.forge/schemas.json`, an object that maps globs to schema paths, for
38 example `{ "config/**/*.json": "schemas/config.json" }`.
39
40 ## Saving
41
42 **Review** shows the change as a diff, under a commit message.
43
44 - **A writer** commits one commit to the branch.
45 - **Anyone else** gets **Propose change**: one commit on a new branch and a
46 pull request into the branch they were reading. That includes a reader,
47 an agent, and a writer facing a protected default branch. A writer's
48 branch goes in the repository itself. A reader's goes on their fork; the
49 forge makes the fork if they have none, and the usual fork rules apply
50 (`actions/propose_edit.ts`).
51 - The file is written back with its own key order, indent (spaces or tabs),
52 line endings and final newline. Numbers keep their spelling.
53 - A save that changes nothing writes nothing.
54 - If the file changed on the branch after you opened it, the save stops
55 and asks you to review again.
56
57 All of this works without JavaScript, as plain form posts: each card button
58 submits the form, and the form comes back with the change applied.
59
60 Files over 256 KB, or with more than 2,000 values, open in the source view
61 and can be edited as text.
62
63 ## In the terminal
64
65 The workspace is the terminal's editor. Open a repository, press `e` on a
66 file, then `c`:
67
68 - A writer gets the commit dialog, as before.
69 - A signed-in reader of a forge repository now gets **Propose a change**:
70 `ctrl+s` puts the commit on a branch of their fork (made if needed) and
71 opens a pull back. This is the same `proposeEdit` the web uses, with the
72 same refusals in the same words. The status line says "proposes from your
73 fork".
74 - Before any commit or proposal, each JSON file in the change is checked
75 against its schema (`schemaRefusal`, found the same three ways). A file
76 that breaks it is refused in the dialog, naming the field and the schema,
77 and nothing is written.
src/actions/propose_edit.ts added +123
@@ -0,0 +1,123 @@
1 /**
2 * Proposing a change to files: one commit on a new branch, and a pull that
3 * asks for it. The shape every person who cannot (or should not) commit to
4 * a branch gets, in one call.
5 *
6 * - A writer — an agent, or a person facing a protected branch — gets the
7 * branch in the repository itself and a pull inside it.
8 * - Anyone else who can read the repository gets it on their fork: the fork
9 * they already have, or a new one (`forkRepo`, with its limits), and a
10 * pull from that fork back to the branch they were reading.
11 *
12 * The commit lands on the fork's copy of `base`, so a fork far behind its
13 * parent proposes against its own older snapshot; the pull's mergeability
14 * says so, as it does for any fork's pull.
15 */
16
17 import type { App } from "../app.ts";
18 import { canWrite, getForkOf, type Repo } from "../core/repos.ts";
19 import type { Pull } from "../core/pulls.ts";
20 import type { User } from "../core/users.ts";
21 import { isSafeRef } from "../git/guard.ts";
22 import { err, ok, type Result } from "../kernel/result.ts";
23 import { requireParticipant } from "./access.ts";
24 import { editFiles, type EditFile } from "./contents.ts";
25 import { openPull } from "./pulls.ts";
26 import { forkRepo } from "./repos.ts";
27
28 export interface ProposeEditInput {
29 /** The branch the pull asks to change. */
30 readonly base: string;
31 readonly files: readonly EditFile[];
32 readonly message: string;
33 readonly title: string;
34 readonly body?: string;
35 /** The new branch's name; one is made up (`proposalBranch`) when absent. */
36 readonly branch?: string;
37 }
38
39 export interface Proposed {
40 readonly pull: Pull;
41 /** The repository the commit landed in: `repo` itself, or the actor's fork. */
42 readonly head: Repo;
43 readonly branch: string;
44 }
45
46 /** A refusal, as the code a page turns into words. */
47 export type ProposeEditError = { readonly step: "fork" | "commit" | "pull"; readonly reason: string };
48
49 /** `alice/edit-settings-lq3k2`: whose, what, and unique enough not to collide. */
50 export function proposalBranch(username: string, path: string, now = Date.now()): string {
51 const name = (path.split("/").pop() ?? "file")
52 .replace(/\.[^.]*$/, "")
53 .toLowerCase()
54 .replace(/[^a-z0-9]+/g, "-")
55 .replace(/^-+|-+$/g, "")
56 .slice(0, 40) || "file";
57 return `${username}/edit-${name}-${now.toString(36)}`;
58 }
59
60 const reasonOf = (e: unknown): string =>
61 typeof e === "string" ? e : typeof e === "object" && e !== null && "kind" in e ? String((e as { kind: unknown }).kind) : "refused";
62
63 export async function proposeEdit(
64 app: App,
65 actor: User | undefined,
66 repo: Repo,
67 input: ProposeEditInput,
68 ): Promise<Result<Proposed, ProposeEditError>> {
69 const user = requireParticipant(app, actor, repo);
70 if (!user.ok) return err({ step: "fork", reason: user.error });
71 if (!isSafeRef(input.base)) return err({ step: "commit", reason: "invalid_branch" });
72
73 let head = repo;
74 if (!canWrite(app.db, repo, user.value)) {
75 const mine = getForkOf(app.db, user.value.id, repo.id);
76 if (mine !== undefined) head = mine;
77 else {
78 const forked = await forkRepo(app, user.value, repo);
79 if (!forked.ok) return err({ step: "fork", reason: forked.error });
80 head = forked.value;
81 }
82 }
83
84 const branch = input.branch?.trim() || proposalBranch(user.value.username, input.files[0]?.path ?? "file");

Non-blocking: a branch name the person picks in the terminal dialog that already exists on their fork ends as branch_failed ("It may already exist."). That is fine. A test of it would keep that wording honest.

85 if (!isSafeRef(branch)) return err({ step: "commit", reason: "invalid_branch" });
86 const written = await editFiles(app, user.value, head, {
87 base: input.base,
88 newBranch: branch,
89 message: input.message,
90 files: input.files,
91 });
92 if (!written.ok) return err({ step: "commit", reason: reasonOf(written.error) });
93
94 const pull = await openPull(app, user.value, repo, {
95 title: input.title,
96 body: input.body ?? "",
97 baseBranch: input.base,
98 headBranch: branch,
99 ...(head.id === repo.id ? {} : { headRepo: head }),
100 });
101 if (!pull.ok) return err({ step: "pull", reason: reasonOf(pull.error) });
102 return ok({ pull: pull.value, head, branch });
103 }
104
105 /** A refused proposal as the sentence the page and the terminal both show. */
106 export function proposeEditText(error: ProposeEditError): string {
107 const { step, reason } = error;
108 if (step === "fork") {
109 if (reason === "unverified")
110 return "Confirm your email first: proposing a change makes a fork, and a fork needs a confirmed address.";
111 if (reason === "not_member")
112 return "Proposing a change makes a fork, and forks are for members of this forge. Ask its admin to make you one.";
113 if (reason === "limit" || reason === "repository_limit" || reason === "storage_limit")
114 return "You cannot make another fork right now, so this could not be proposed. Try again later.";
115 if (reason === "not_found") return "That repository is not one you can read.";
116 return "A fork to carry this change could not be made.";
117 }
118 if (step === "pull")
119 return "The commit landed on a new branch, but the pull request could not be opened. Open it from the branch.";
120 if (reason === "invalid_branch") return "That branch name is not one git will take.";
121 if (reason === "branch_failed") return "That branch could not be created. It may already exist.";
122 return "The change could not be committed, so nothing was proposed.";
123 }
src/http/server.ts +4
@@ -222,6 +222,7 @@ import {
222222 newFilePage,
223223 previewSubmit,
224224 } from "../web/pages/edit.ts";
225 import { configPage, configSubmit } from "../web/pages/config.ts";
225226 import { commitPage, commitsPage } from "../web/pages/commits.ts";
226227 import {
227228 issueCommentSubmit,
@@ -1359,6 +1360,9 @@ export function buildRouter(): Router<Handler> {
13591360 // A mirror of a repository you cannot push to takes its edits as
13601361 // proposals: fork at the origin, commit there, open the pull upstream.
13611362 router.add("POST", "/:owner/:name/contribute", contributeSubmit);
1363 // A JSON config file as a form: a writer commits, anyone else proposes.
1364 router.add("GET", "/:owner/:name/config/:ref/*path", configPage);
1365 router.add("POST", "/:owner/:name/config", configSubmit);
13621366
13631367 router.add("GET", "/:owner/:name/commits", commitsPage);
13641368 router.add("GET", "/:owner/:name/commit/:oid", commitPage);
src/tui/screens/workspace.ts +101 −3
@@ -35,6 +35,8 @@
3535 */
3636
3737 import { editFiles } from "../../actions/contents.ts";
38 import { proposeEdit, proposeEditText } from "../../actions/propose_edit.ts";
39 import { schemaRefusal } from "../../web/render/json-schema.ts";
3840 import { err, ok } from "../../kernel/result.ts";
3941 import { createBranch } from "../../actions/branches.ts";
4042 import { NAME_THE_BRANCH } from "../../actions/branch_words.ts";
@@ -560,8 +562,15 @@ export function workspaceScreen(
560562 /** Set when a change made here has to travel to another forge to land. */
561563 const proposal = (): OriginTarget | undefined =>
562564 proposeTarget(src, canPushUpstream());
565 /**
566 * A forge repository you can read and not write: a change to it goes on
567 * your fork, as a pull back here (`actions/propose_edit.ts`). A mirror
568 * proposes through its origin instead (`proposal`).
569 */
570 const forkable = (): boolean =>
571 !writable() && repo !== undefined && repo.origin == null && f.user !== undefined && src.where.kind === "local";
563572 /** Editing is allowed wherever the change has somewhere to go. */
564 const editable = (): boolean => writable() || proposal() !== undefined;
573 const editable = (): boolean => writable() || proposal() !== undefined || forkable();
565574 const admin = (): boolean =>
566575 repo !== undefined && canAdmin(f.db, repo, f.user);
567576 const readPin = (): void => {
@@ -842,9 +851,28 @@ export function workspaceScreen(
842851
843852 // ---- mutations ----
844853
854 /**
855 * Why the change breaks a schema one of its JSON files names, or
856 * undefined. The same check the web form makes before a save.
857 */
858 const schemaCheck = async (
859 files: readonly { path: string; content: Uint8Array | null }[],
860 ): Promise<string | undefined> => {
861 const read = async (p: string): Promise<string | undefined> => {
862 const blob = await src.blob(ref, p);
863 return blob.ok && blob.value.kind === "text" ? new TextDecoder().decode(blob.value.body) : undefined;
864 };
865 for (const file of files) {
866 if (file.content === null) continue;
867 const refused = await schemaRefusal(file.path, new TextDecoder().decode(file.content), read);
868 if (refused !== undefined) return refused;
869 }
870 return undefined;
871 };
872
845873 const commit = (): void => {
846874 const target = proposal();
847 if (!writable() && target === undefined) {
875 if (!writable() && target === undefined && !forkable()) {
848876 readOnlyToast();
849877 return;
850878 }
@@ -881,6 +909,10 @@ export function workspaceScreen(
881909 proposeChange(target, files, subject, payload);
882910 return;
883911 }
912 if (forkable() && repo !== undefined) {
913 proposeHere(repo, files, subject, payload);
914 return;
915 }
884916 if (repo === undefined) {
885917 readOnlyToast();
886918 return;
@@ -896,6 +928,8 @@ export function workspaceScreen(
896928 files,
897929 defaultBranchName: suggestBranch(f, "edit", slug(first.path)),
898930 commit: async (plan) => {
931 const broken = await schemaCheck(payload(plan.paths));
932 if (broken !== undefined) return err(broken);
899933 const made = await editFiles(f.app, f.user, here, {
900934 branch: ref,
901935 base: ref,
@@ -929,6 +963,68 @@ export function workspaceScreen(
929963 );
930964 };
931965
966 /**
967 * The same dialog, for a forge repository you can only read: the commit
968 * goes on a branch of your fork (made if you have none) and a pull asks
969 * for it here — what the web's Propose change does.
970 */
971 const proposeHere = (
972 here: Repo,
973 files: readonly CommitFile[],
974 subject: string,
975 payload: (paths: readonly string[]) => {
976 path: string;
977 content: Uint8Array | null;
978 }[],
979 ): void => {
980 const first = files[0]!;
981 const base = ref === "" ? here.defaultBranch : ref;
982 const fullName = `${here.ownerName}/${here.name}`;
983 ctx.push(
984 commitScreen(f, ctx, screen, {
985 repo: here,
986 where: fullName,
987 branch: ref,
988 title: "Propose a change",
989 subject,
990 files,
991 mode: "propose",
992 upstream: { fullName, base },
993 defaultBranchName: suggestBranch(f, "edit", slug(first.path)),
994 commit: async (plan) => {
995 const chosen = payload(plan.paths);
996 const broken = await schemaCheck(chosen);
997 if (broken !== undefined) return err(broken);
998 const sent = await proposeEdit(f.app, f.user, here, {
999 base,
1000 files: chosen,
1001 message: plan.message,
1002 title: plan.pull?.title ?? subject,
1003 ...(plan.pull?.body === undefined ? {} : { body: plan.pull.body }),
1004 ...(plan.newBranch === undefined ? {} : { branch: plan.newBranch }),
1005 });
1006 if (!sent.ok) return err(proposeEditText(sent.error));
1007 const pull = sent.value.pull;
1008 return ok({
1009 message: `opened pull #${pull.number} on ${fullName} from ${sent.value.head.ownerName}/${sent.value.head.name}`,
1010 then: () => {
1011 // The work lives on the fork and in the pull: the buffers go
1012 // back to what this repository holds.
1013 for (const t of tabs) {
1014 t.creating = false;
1015 t.editor = null;
1016 t.mode = "view";
1017 t.rebuild(ctx.theme.c);
1018 }
1019 pending.clear();
1020 ctx.invalidate();
1021 },
1022 });
1023 },
1024 }),
1025 );
1026 };
1027
9321028 /**
9331029 * The same dialog, aimed at somebody else's forge.
9341030 *
@@ -2325,7 +2421,9 @@ export function workspaceScreen(
23252421 ? ""
23262422 : ` · ${tableProblem(t.table)}`
23272423 }`
2328 }${writable() || target !== undefined ? "" : " · read only"}`;
2424 }${
2425 writable() || target !== undefined ? "" : forkable() ? " · proposes from your fork" : " · read only"
2426 }`;
23292427 if (tail === "") return;
23302428 const shown = truncate(tail, Math.max(0, r.w - 4));
23312429 s.text(
src/web/pages/config.ts added +321
@@ -0,0 +1,321 @@
1 /**
2 * Editing a JSON config file as a form (#7336).
3 *
4 * GET /:owner/:name/config/:ref/*path — the file's values as fields, typed
5 * by its schema when one is found (`render/json-schema.ts`), else by
6 * the values themselves.
7 * POST /:owner/:name/config — one route, three steps:
8 * `op` add, remove or reorder a card; the form comes back.
9 * step=review the change as a diff, with a commit message.
10 * step=save a writer commits to the branch; anyone else (a
11 * reader, an agent, a writer facing a protected branch)
12 * proposes it: one commit on a new branch and a pull
13 * (`actions/propose_edit.ts`).
14 *
15 * The file is written back in its own key order and indent, and a save
16 * that changes nothing writes nothing. Everything works as a plain form
17 * POST; a browser with HTML validation checks a schema's bounds inline.
18 * A file that is not valid JSON goes back to its source view, with the
19 * error and its line.
20 */
21
22 import { editFiles } from "../../actions/contents.ts";
23 import { proposeEdit, proposeEditText } from "../../actions/propose_edit.ts";
24 import { canAdmin } from "../../core/repos.ts";
25 import { isSafeRef, isSafeTreePath } from "../../git/guard.ts";
26 import type { Ctx } from "../../http/context.ts";
27 import { loadRepo, notFoundPage, pageHead, signInFirst } from "../chrome.ts";
28 import { field as formValue, readForm, type Form } from "../form.ts";
29 import { html, when, type Html } from "../html.ts";
30 import { flashTo, page } from "../layout.ts";
31 import { isFormJson } from "../render/config.ts";
32 import { applyOp, configFields, countFields, MAX_FIELDS, readValues } from "../render/config-form.ts";
33 import {
34 detectStyle,
35 lineDiff,
36 parseJson,
37 sameDoc,
38 stringify,
39 type Json,
40 } from "../render/json-doc.ts";
41 import { findSchema, validate, type FoundSchema } from "../render/json-schema.ts";
42 import { localScope, type RepoScope } from "../scope.ts";
43 import { btn, field, icon } from "../ui.ts";
44 import { editErrorText } from "./edit.ts";
45 import { refSetFrom } from "./refs.ts";
46
47 /** Bigger than this, the form is no help; the source editor is. */
48 export const MAX_FORM_BYTES = 256 * 1024;
49 /** A schema file larger than this is not read. */
50 const MAX_SCHEMA_BYTES = 256 * 1024;
51
52 /** Names the file's text as it was opened, so a save sees whether it moved. */
53 export const digestOf = (text: string): string => Bun.hash(text).toString(16);
54
55 const blobHref = (scope: RepoScope, ref: string, path: string, view = ""): string =>
56 `${scope.base}/blob/${encodeURIComponent(ref)}/${path}${view}`;
57
58 /** Who this is to the file: commits straight on, or proposes. */
59 async function commitsDirectly(ctx: Ctx, scope: RepoScope, ref: string): Promise<boolean> {
60 const repo = scope.repo;
61 const user = ctx.auth.user;
62 if (repo === undefined || user === undefined || !scope.caps.write || user.kind === "agent") return false;

Nothing tests that an agent with write access proposes, or (line 65) that a non-admin writer on a protected default branch proposes on a branch in the repository itself. Add a test for each; deleting either condition today breaks no test.

63 const refs = await refSetFrom(scope.source);
64 if (!refs.branches.includes(ref)) return false;
65 return !(ref === repo.defaultBranch && repo.protectDefault && !canAdmin(ctx.app.db, repo, user));
66 }
67
68 /** The repository, the signed-in person, and a ref and path fit to edit; or the refusal. */
69 function configContext(ctx: Ctx, ref: string, path: string): RepoScope | Response {
70 const repo = loadRepo(ctx);
71 if (!repo) return notFoundPage(ctx);
72 if (!ctx.auth.user) return signInFirst(ctx);
73 if (!isSafeRef(ref) || !isSafeTreePath(path) || !isFormJson(path)) return notFoundPage(ctx);
74 const scope = localScope(ctx, repo);
75 // A mirror you cannot push to proposes through its origin: the source editor's card.
76 if (scope.propose !== undefined)
77 return flashTo(ctx, blobHref(scope, ref, path), {
78 tone: "danger",
79 message: "This file mirrors an origin. Propose a change to it from the editor.",
80 });
81 return scope;
82 }
83
84 /** The file's text at `ref`, when it is text the form can hold. */
85 async function readText(scope: RepoScope, ref: string, path: string, max: number): Promise<string | undefined> {
86 const blob = await scope.source.blob(ref, path);
87 if (!blob.ok || blob.value.kind !== "text" || blob.value.size > max) return undefined;
88 return new TextDecoder().decode(blob.value.body);
89 }
90
91 const schemaFor = (scope: RepoScope, ref: string, path: string, doc: Json): Promise<FoundSchema | undefined> =>
92 findSchema(path, doc, (p) => (isSafeTreePath(p) ? readText(scope, ref, p, MAX_SCHEMA_BYTES) : Promise.resolve(undefined)));
93
94 interface FormState {
95 readonly ref: string;
96 readonly path: string;
97 readonly base: string;
98 readonly doc: Json;
99 readonly found: FoundSchema | undefined;
100 readonly errors?: ReadonlyMap<string, string>;
101 readonly typed?: ReadonlyMap<string, string>;
102 readonly flash?: { tone: "danger" | "success"; message: string };
103 }
104
105 const hidden = (s: FormState, doc: Json): Html => html`<input type="hidden" name="ref" value="${s.ref}" />
106 <input type="hidden" name="path" value="${s.path}" />
107 <input type="hidden" name="base" value="${s.base}" />
108 <input type="hidden" name="doc" value="${stringify(doc)}" />`;
109
110 function head(scope: RepoScope, s: FormState, proposing: boolean, step: string): Html {
111 return pageHead(html`${icon(proposing ? "fork" : "pencil")}${s.path.split("/").pop() ?? s.path}`, {
112 sub: html`${step} · <span class="mono">${s.path}</span> on <span class="mono">${s.ref}</span> in
113 <span class="mono">${scope.fullName}</span>${
114 s.found === undefined
115 ? html` · fields from the values`
116 : html` · fields from <a href="${blobHref(scope, s.ref, s.found.from)}" class="mono">${s.found.from}</a>`
117 }`,
118 });
119 }
120
121 async function formPage(ctx: Ctx, scope: RepoScope, s: FormState): Promise<Response> {
122 const proposing = !(await commitsDirectly(ctx, scope, s.ref));
123 // The first submit button in the form, so Enter in any field reviews
124 // rather than pressing a card's Up or Remove.
125 const review = btn(proposing ? "Review and propose" : "Review changes", { name: "step", value: "review", variant: "primary" });
126 return page({
127 ctx,
128 ...(s.flash === undefined ? {} : { flash: s.flash }),
129 title: `${s.path} · ${scope.facts.name}`,
130 crumbs: scope.crumbs,
131 palette: scope.palette,
outdated

Remove this raw() submit button, which is aria-hidden and sr-only. Make Review the first submit button in the form, so Enter in a field submits it without a hidden control.

132 body: html`${head(scope, s, proposing, "Editing settings")}
133 <form method="post" action="${scope.base}/config" class="card config-form">
134 <div class="bar mb">
135 <span class="muted small">Enter in a field reviews the change.</span><span class="spacer"></span>
136 ${review}
137 </div>
138 ${hidden(s, s.doc)}
139 <input type="hidden" name="from" value="form" />
140 ${configFields(s.doc, {
141 found: s.found,
142 ...(s.errors === undefined ? {} : { errors: s.errors }),
143 ...(s.typed === undefined ? {} : { typed: s.typed }),
144 })}
145 <div class="form-actions">
146 ${review}
147 ${btn("Source", { href: blobHref(scope, s.ref, s.path, "?view=source"), variant: "outline" })}
148 ${when(scope.caps.write, () =>
149 btn("Edit as text", { href: `${scope.base}/edit/${encodeURIComponent(s.ref)}/${s.path}`, variant: "ghost" }),
150 )}
151 </div>
152 </form>`,
153 });
154 }
155
156 async function reviewPage(
157 ctx: Ctx,
158 scope: RepoScope,
159 s: FormState,
160 diff: { before: string; after: string },
161 o: { message?: string; description?: string; error?: string } = {},
162 ): Promise<Response> {
163 const proposing = !(await commitsDirectly(ctx, scope, s.ref));
164 const lines = lineDiff(diff.before, diff.after);
165 return page({
166 ctx,
167 ...(s.flash === undefined ? {} : { flash: s.flash }),
168 title: `Review ${s.path} · ${scope.facts.name}`,
169 crumbs: scope.crumbs,
170 palette: scope.palette,
171 body: html`${head(scope, s, proposing, "Review")}
172 <pre class="card mono config-diff" aria-label="The change">${lines.map(
173 (l) => html`<span data-op="${l.op === " " ? "same" : l.op === "+" ? "add" : "del"}">${l.op} ${l.text}</span>\n`,
174 )}</pre>
175 <form method="post" action="${scope.base}/config" class="card">
176 ${hidden(s, s.doc)}
177 ${field({
178 name: "message",
179 label: "Commit message",
180 value: o.message ?? `Update ${s.path}`,
181 required: true,
182 ...(o.error === undefined ? {} : { error: o.error }),
183 })}
184 ${field({ name: "description", label: "Description", type: "textarea", rows: 3, value: o.description ?? "" })}
185 <p class="muted small">${
186 proposing
187 ? scope.caps.write
188 ? html`This goes on a new branch, with a pull request into <span class="mono">${s.ref}</span>.`
outdated

Same here: use btn() with formnovalidate, not a raw() string.

189 : html`You cannot push here, so this goes on a branch of your fork, with a pull request into <span class="mono">${s.ref}</span>.`
190 : html`One commit on <span class="mono">${s.ref}</span>.`
191 }</p>
192 <div class="form-actions">
193 ${btn(proposing ? "Propose change" : `Commit to ${s.ref}`, { name: "step", value: "save", variant: "primary", icon: proposing ? "fork" : "check" })}
194 ${btn("Back to the form", { name: "step", value: "edit", variant: "outline", formnovalidate: true })}
195 </div>
196 </form>`,
197 });
198 }
199
200 // GET /:owner/:name/config/:ref/*path
201 export async function configPage(ctx: Ctx): Promise<Response> {
202 const ref = ctx.params.ref ?? "";
203 const path = ctx.params.path ?? "";
204 const scope = configContext(ctx, ref, path);
205 if (scope instanceof Response) return scope;
206 const blob = await scope.source.blob(ref, path);
207 if (!blob.ok) return notFoundPage(ctx);
208 const tooBig = (message: string): Response =>
209 flashTo(ctx, blobHref(scope, ref, path, "?view=source"), { tone: "danger", message });
210 if (blob.value.kind !== "text" || blob.value.size > MAX_FORM_BYTES)
211 return tooBig("This file is too large to edit as a form. Edit it as text, or with git.");
212 const text = new TextDecoder().decode(blob.value.body);
213 const parsed = parseJson(text);
214 if (!parsed.ok) {
215 const e = parsed.error;
216 return tooBig(`Not valid JSON: ${e.message} at line ${e.line}, column ${e.column}. Fix it in the source first.`);
217 }
218 if (countFields(parsed.value) > MAX_FIELDS)
219 return tooBig(`This file has more than ${MAX_FIELDS.toLocaleString()} values, too many for a form. Edit it as text.`);
220 return formPage(ctx, scope, {
221 ref,
222 path,
223 base: digestOf(text),
224 doc: parsed.value,
225 found: await schemaFor(scope, ref, path, parsed.value),
226 });
227 }
228
229 /** The document the form carried, with what was typed into it. */
230 function docFrom(form: Form): { doc: Json; errors: Map<string, string>; typed: Map<string, string> } | undefined {
231 const parsed = parseJson(formValue(form, "doc"));
232 if (!parsed.ok || countFields(parsed.value) > MAX_FIELDS) return undefined;
233 if (formValue(form, "from") !== "form") return { doc: parsed.value, errors: new Map(), typed: new Map() };
234 return readValues(parsed.value, (name) => {
235 const v = form.get(name);
236 return typeof v === "string" ? v : undefined;
237 });
238 }
239
240 // POST /:owner/:name/config
241 export async function configSubmit(ctx: Ctx): Promise<Response> {
242 // Who may write is settled before the body is read: a private repository
243 // answers a stranger 404, as every page about it does.
244 if (!loadRepo(ctx)) return notFoundPage(ctx);
245 if (!ctx.auth.user) return signInFirst(ctx);
246 const form = await readForm(ctx, "rw");
247 if (form instanceof Response) return form;
248 const ref = formValue(form, "ref");
249 const path = formValue(form, "path");
250 const scope = configContext(ctx, ref, path);
251 if (scope instanceof Response) return scope;
252 const repo = scope.repo;
253 const user = ctx.auth.user;
254 if (repo === undefined || user === undefined) return notFoundPage(ctx);
255
256 const read = docFrom(form);
257 if (read === undefined) return flashTo(ctx, blobHref(scope, ref, path), { tone: "danger", message: "That form could not be read. Open the file again." });
258 const current = await readText(scope, ref, path, MAX_FORM_BYTES);
259 if (current === undefined) return notFoundPage(ctx);
260 const original = parseJson(current);
261 const found = await schemaFor(scope, ref, path, read.doc);
262 let state: FormState = { ref, path, base: formValue(form, "base"), doc: read.doc, found };
263
264 const op = formValue(form, "op");
265 if (op !== "") return formPage(ctx, scope, { ...state, doc: applyOp(read.doc, op, found) });
266
267 const step = formValue(form, "step");
268 if (step === "edit") return formPage(ctx, scope, state);
269
270 const errors = new Map([...validate(read.doc, found), ...read.errors]);
271 if (errors.size > 0)
272 return formPage(ctx, scope, {
273 ...state,
274 errors,
275 typed: read.typed,
276 flash: { tone: "danger", message: errors.size === 1 ? "One field needs a fix." : `${errors.size} fields need a fix.` },
277 });
278
279 if (state.base !== digestOf(current)) {
280 // Someone else saved first. Their file is now the base; the form keeps
281 // this person's values, and a second review shows them over the new file.
282 return formPage(ctx, scope, {
283 ...state,
284 base: digestOf(current),
285 flash: { tone: "danger", message: `${path} changed on ${ref} since you opened it. Check your values, then review again.` },
286 });
287 }
288 if (original.ok && sameDoc(original.value, read.doc))
289 return formPage(ctx, scope, { ...state, flash: { tone: "success", message: "Nothing changed, so there is nothing to save." } });
290
291 const after = stringify(read.doc, detectStyle(current));
292 if (step !== "save") return reviewPage(ctx, scope, state, { before: current, after });
293
294 const subject = formValue(form, "message").trim();
295 const description = formValue(form, "description").trim();
296 if (subject === "")
297 return reviewPage(ctx, scope, state, { before: current, after }, { description, error: "A commit needs a message." });
298 const message = description === "" ? subject : `${subject}\n\n${description}`;
299 const files = [{ path, content: after }];
300
301 if (await commitsDirectly(ctx, scope, ref)) {
302 const written = await editFiles(ctx.app, user, repo, { branch: ref, base: ref, message, files });
303 if (!written.ok) {
304 state = { ...state, flash: { tone: "danger", message: editErrorText(written.error) } };
305 return reviewPage(ctx, scope, state, { before: current, after }, { message: subject, description });
306 }
307 return flashTo(ctx, blobHref(scope, ref, path), { tone: "success", message: `Committed to ${ref}.` });
308 }
309
310 const proposed = await proposeEdit(ctx.app, user, repo, { base: ref, files, message, title: subject, body: description });
311 if (!proposed.ok) {
312 const words = proposeEditText(proposed.error);
313 state = { ...state, flash: { tone: "danger", message: words } };
314 return reviewPage(ctx, scope, state, { before: current, after }, { message: subject, description });
315 }
316 const pull = proposed.value.pull;
317 return flashTo(ctx, `${scope.base}/pulls/${pull.number}`, {
318 tone: "success",
319 message: `Proposed on ${proposed.value.head.ownerName}/${proposed.value.head.name}:${proposed.value.branch}, as this pull request.`,
320 });
321 }
src/web/render/config-form.ts added +316
@@ -0,0 +1,316 @@
1 /**
2 * A JSON document as form fields, and a submitted form back as a document.
3 *
4 * Every field is named for the JSON pointer of its value: `v/servers/0/port`
5 * carries text, `j/mode` carries a JSON value (an enum's select), and a
6 * boolean is a checkbox whose absence means false. The document's shape
7 * travels beside them in a hidden `doc`, so a plain form POST — no script —
8 * can add, remove and reorder cards: each of those is a submit button named
9 * `op` (`opValue`), applied by `applyOp` before the form is drawn again.
10 *
11 * Without a schema a field's control comes from its value; with one, from
12 * the schema too: an enum is a select, `description` is the help text, and
13 * `required`, `minimum`, `maximum` and `pattern` are the input's own
14 * attributes, so a browser checks them inline before the form is sent.
15 */
16
17 import { html, raw, when, type Html } from "../html.ts";
18 import { btn, field } from "../ui.ts";
19 import {
20 blankLike,
21 fromPlain,
22 pointer,
23 splitPointer,
24 updateAt,
25 type Json,
26 } from "./json-doc.ts";
27 import { isJsonNumber, schemaAt, type FoundSchema, type Schema } from "./json-schema.ts";
28
29 /** More values than this and the form is no help: the source editor is. */
30 export const MAX_FIELDS = 2_000;
31
32 /** How many values (leaves) a document has, for `MAX_FIELDS`. */
33 export function countFields(doc: Json): number {
34 if (doc.t === "array") return doc.items.reduce((n, v) => n + countFields(v), 1);
35 if (doc.t === "object") return doc.entries.reduce((n, [, v]) => n + countFields(v), 1);
36 return 1;
37 }
38
39 export interface FieldsOptions {
40 readonly found?: FoundSchema | undefined;
41 /** Messages by pointer, each drawn under its field. */
42 readonly errors?: ReadonlyMap<string, string>;
43 /** What was typed into a field that could not hold it (a bad number). */
44 readonly typed?: ReadonlyMap<string, string>;
45 /** Draw the values only: no inputs that submit, no buttons. */
46 readonly readonly?: boolean;
47 }
48
49 export type Op = "add" | "remove" | "up" | "down" | "key";
50
51 /** A structural button's value: which change, at which pointer. */
52 export const opValue = (op: Op, at: string, key?: string): string =>
53 JSON.stringify(key === undefined ? [op, at] : [op, at, key]);
54
55 const isScalar = (v: Json): boolean => v.t !== "array" && v.t !== "object";
56
57 const typeList = (s: Schema | undefined): readonly string[] =>
58 s?.type === undefined ? [] : typeof s.type === "string" ? [s.type] : s.type;
59
60 /** What a new value under schema `s` starts as, shaped like `like` when the schema is silent. */
61 function blankFor(s: Schema | undefined, like: Json | undefined): Json {
62 if (s?.default !== undefined) return fromPlain(s.default);
63 if (Array.isArray(s?.enum) && s.enum.length > 0) return fromPlain(s.enum[0]);
64 const t = typeList(s)[0];
65 if (t === "string") return { t: "string", v: "" };
66 if (t === "number" || t === "integer") return { t: "number", v: "0" };
67 if (t === "boolean") return { t: "boolean", v: false };
68 if (t === "array") return { t: "array", items: [] };
69 if (t === "object") {
70 const props = s?.properties ?? {};
71 const keys = Array.isArray(s?.required) ? s.required.filter((k) => typeof k === "string") : [];
72 return { t: "object", entries: keys.map((k) => [k, blankFor(props[k], undefined)] as const) };
73 }
74 return blankLike(like);
75 }
76
77 const parentOf = (p: string): { parent: string; index: number } | undefined => {
78 const cut = p.lastIndexOf("/");
79 const tail = p.slice(cut + 1);
80 return cut < 0 || !/^(0|[1-9]\d*)$/.test(tail) ? undefined : { parent: p.slice(0, cut), index: Number(tail) };
81 };
82
83 /** One structural change to the document; anything unrecognised changes nothing. */
84 export function applyOp(doc: Json, raw: string, found?: FoundSchema): Json {
85 let parsed: unknown;
86 try {
87 parsed = JSON.parse(raw);
88 } catch {
89 return doc;
90 }
91 if (!Array.isArray(parsed) || typeof parsed[0] !== "string" || typeof parsed[1] !== "string") return doc;
92 const [op, at, key] = parsed as [string, string, unknown];
93 if (splitPointer(at) === undefined) return doc;
94 if (op === "add")
95 return updateAt(doc, at, (arr) =>
96 arr.t !== "array"
97 ? arr
98 : { t: "array", items: [...arr.items, blankFor(schemaAt(found, pointer(at, arr.items.length)), arr.items.at(-1))] },
99 );
100 if (op === "key" && typeof key === "string" && key !== "")
101 return updateAt(doc, at, (obj) =>
102 obj.t !== "object" || obj.entries.some(([k]) => k === key)
103 ? obj
104 : { t: "object", entries: [...obj.entries, [key, blankFor(schemaAt(found, pointer(at, key)), undefined)] as const] },
105 );
106 const place = parentOf(at);
107 if (place === undefined) return doc;
108 const { parent, index } = place;
109 return updateAt(doc, parent, (arr) => {
110 if (arr.t !== "array" || index >= arr.items.length) return arr;
111 const items = [...arr.items];
112 if (op === "remove") items.splice(index, 1);
113 else if (op === "up" || op === "down") {
114 const to = op === "up" ? index - 1 : index + 1;
115 if (to < 0 || to >= items.length) return arr;
116 [items[index], items[to]] = [items[to]!, items[index]!];
117 } else return arr;
118 return { t: "array", items };
119 });
120 }
121
122 /**
123 * The document with the form's values written into it. A number that is not
124 * one keeps the old value and comes back in `typed` with an error, so the
125 * form can be drawn again with the person's text still in it.
126 */
127 export function readValues(
128 doc: Json,
129 get: (name: string) => string | undefined,
130 ): { doc: Json; errors: Map<string, string>; typed: Map<string, string> } {
131 const errors = new Map<string, string>();
132 const typed = new Map<string, string>();
133 const walk = (v: Json, p: string): Json => {
134 if (v.t === "object") return { t: "object", entries: v.entries.map(([k, x]) => [k, walk(x, pointer(p, k))] as const) };
135 if (v.t === "array") return { t: "array", items: v.items.map((x, i) => walk(x, pointer(p, i))) };
136 const chosen = get(`j${p}`);
137 if (chosen !== undefined) {
138 try {
139 return fromPlain(JSON.parse(chosen));
140 } catch {
141 return v;
142 }
143 }
144 const text = get(`v${p}`);
145 if (v.t === "boolean") return { t: "boolean", v: text === "true" };
146 if (text === undefined || v.t === "null") return v;
147 if (v.t === "string") return { t: "string", v: text.replace(/\r\n/g, "\n") };
148 const n = text.trim();
149 if (isJsonNumber(n)) return { t: "number", v: n };
150 errors.set(p, "Must be a number.");
151 typed.set(p, text);
152 return v;
153 };
154 return { doc: walk(doc, ""), errors, typed };
155 }
156
157 // ---- drawing ----
158
159 /** The last segment of a pointer, as a field's label. */
160 const labelOf = (p: string): string => splitPointer(p)?.at(-1) ?? "";
161
162 let fieldSerial = 0;
163
164 const description = (s: Schema | undefined): string | undefined =>
165 typeof s?.description === "string" && s.description !== "" ? s.description : undefined;
166
167 /**
168 * One scalar value as `field()`: its label, control, help and error. The
169 * control follows the schema when it says (an enum is a select), else the
170 * value (a boolean is a checkbox, a number a number input).
171 */
172 function scalarField(
173 v: Json,
174 p: string,
175 s: Schema | undefined,
176 o: FieldsOptions,
177 f: { label: string; required: boolean; labelHidden?: boolean; after?: Html | null },
178 ): Html {
179 const off = o.readonly === true;
180 const help = description(s);
181 const error = o.errors?.get(p);
182 const shared = {
183 id: `cf-${++fieldSerial}`,
184 label: f.required && !off ? `${f.label} (required)` : f.label,
185 ...(help === undefined ? {} : { hint: help }),
186 ...(error === undefined ? {} : { error }),
187 ...(f.labelHidden ? { labelHidden: true } : {}),
188 ...(f.after ? { after: f.after } : {}),
189 };
190 if (Array.isArray(s?.enum) && s.enum.length > 0) {
191 const current = JSON.stringify(v.t === "number" ? Number(v.v) : v.t === "string" || v.t === "boolean" ? v.v : null);
192 const choices = s.enum.map((e) => ({ value: JSON.stringify(e), label: typeof e === "string" ? e : JSON.stringify(e) }));
193 const options = [
194 ...(choices.some((c) => c.value === current) ? [] : [{ value: current, label: current }]),
195 ...choices,
196 ].map((c) => ({ ...c, selected: c.value === current }));
197 return field({ ...shared, name: `j${p}`, type: "select", options, ...(off ? { disabled: true } : {}) });
198 }
199 if (v.t === "boolean")
200 return field({ ...shared, name: `v${p}`, type: "checkbox", value: "true", checked: v.v, ...(off ? { disabled: true } : {}) });
201 if (v.t === "null") return field({ ...shared, name: `v${p}`, value: "null", readonly: true, mono: true });
202 if (v.t === "number")
203 return field({
204 ...shared,
205 name: `v${p}`,
206 type: off ? "text" : "number",
207 value: o.typed?.get(p) ?? v.v,
208 mono: true,
209 step: typeList(s).includes("integer") ? "1" : "any",
210 ...(typeof s?.minimum === "number" ? { min: s.minimum } : {}),
211 ...(typeof s?.maximum === "number" ? { max: s.maximum } : {}),
212 ...(off ? { readonly: true } : { required: true }),
213 });
outdated

Design: this is a hand-rolled .btn. Use btn() from ui.ts, adding a formnovalidate option to it if needed, so buttons keep one source.

214 const text = v.t === "string" ? v.v : "";
215 return field({
216 ...shared,
217 name: `v${p}`,
218 value: text,
219 ...(text.includes("\n") ? { type: "textarea", rows: Math.min(12, text.split("\n").length + 1) } : {}),
220 ...(typeof s?.pattern === "string" ? { pattern: s.pattern } : {}),
221 ...(off ? { readonly: true } : f.required ? { required: true } : {}),
222 });
223 }
224
225 function errorLine(p: string, id: string, o: FieldsOptions): Html | null {
226 const message = o.errors?.get(p);
227 return message === undefined ? null : html`<span class="error" id="${id}-err">${message}</span>`;
228 }
229
230 function hint(s: Schema | undefined, id: string): Html | null {
231 return typeof s?.description === "string" && s.description !== ""
232 ? html`<span class="hint" id="${id}-hint">${s.description}</span>`
233 : null;
234 }
235
236 /** A structural button: it skips the browser's checks, since removing a card must not wait for a field in it. */
237 const opBtn = (label: string, op: Op, at: string, plus?: "plus" | "trash", key?: string): Html =>
238 btn(label, {
239 name: "op",
240 value: opValue(op, at, key),
241 size: "sm",
242 variant: op === "remove" ? "ghost" : "outline",
243 formnovalidate: true,
244 ...(plus === undefined ? {} : { icon: plus }),
245 });
outdated

Design: field() owns .field / label / .hint / .error / data-invalid. Draw scalars through it, extending it for checkbox, select and number bounds, and do not re-create the markup here.

246
247 function node(v: Json, p: string, label: string, parentSchema: Schema | undefined, o: FieldsOptions): Html {
248 const s = schemaAt(o.found, p);
249 const id = `cf-${++fieldSerial}`;
250 const title = typeof s?.title === "string" && s.title !== "" ? s.title : label;
251 const required = Array.isArray(parentSchema?.required) && parentSchema.required.includes(label);
252 const errored = o.errors?.has(p) === true;
253 if (v.t === "object") {
254 const body = objectBody(v, p, s, o);
255 if (p === "") return body;
256 return html`<fieldset class="field config-group" ${errored ? raw("data-invalid") : null}>
257 <legend>${title}</legend>${hint(s, id)}${errorLine(p, id, o)}${body}
258 </fieldset>`;
259 }
260 if (v.t === "array") {
261 const cards = v.items.length > 0 ? !v.items.every(isScalar) : ["object", "array"].includes(typeList(s?.items)[0] ?? "");
262 return html`<fieldset class="field config-group" ${errored ? raw("data-invalid") : null}>
263 <legend>${title} <span class="muted small num">${v.items.length}</span></legend>
264 ${hint(s, id)}${errorLine(p, id, o)}
265 ${cards ? cardList(v, p, title, o) : chipList(v, p, s, o)}
266 ${when(o.readonly !== true, () => html`<div class="form-actions">${opBtn(cards ? `Add ${title}` : "Add", "add", p, "plus")}</div>`)}
267 </fieldset>`;
268 }
269 return scalarField(v, p, s, o, { label: title, required });
270 }
271
272 function objectBody(v: Extract<Json, { t: "object" }>, p: string, s: Schema | undefined, o: FieldsOptions): Html {
273 const present = new Set(v.entries.map(([k]) => k));
274 const missing = Object.keys(s?.properties ?? {}).filter((k) => !present.has(k));
275 return html`${v.entries.map(([k, x]) => node(x, pointer(p, k), k, s, o))}
276 ${when(
277 o.readonly !== true && missing.length > 0,
278 () => html`<div class="form-actions">${missing.map((k) => opBtn(k, "key", p, "plus", k))}</div>`,
279 )}`;
280 }
281
282 function chipList(v: Extract<Json, { t: "array" }>, p: string, s: Schema | undefined, o: FieldsOptions): Html {
283 if (v.items.length === 0) return html`<p class="muted small">Empty.</p>`;
284 return html`<ul class="config-chips">
285 ${v.items.map((x, i) => {
286 const at = pointer(p, i);
287 return html`<li>${scalarField(x, at, s?.items, o, {
288 label: `${labelOf(p)} ${i + 1}`,
289 required: false,
290 labelHidden: true,
291 after: o.readonly === true ? null : opBtn("Remove", "remove", at),
292 })}</li>`;
293 })}
294 </ul>`;
295 }
296
297 function cardList(v: Extract<Json, { t: "array" }>, p: string, title: string, o: FieldsOptions): Html {
298 return html`${v.items.map((x, i) => {
299 const at = pointer(p, i);
300 const last = v.items.length - 1;
301 return html`<div class="card config-card">
302 <div class="bar">
303 <strong>${title} ${i + 1}</strong><span class="spacer"></span>
304 ${when(o.readonly !== true, () => html`${i > 0 ? opBtn("Up", "up", at) : null}${i < last ? opBtn("Down", "down", at) : null}${opBtn("Remove", "remove", at, "trash")}`)}
305 </div>
306 ${x.t === "object" ? objectBody(x, at, schemaAt(o.found, at), o) : node(x, at, `${title} ${i + 1}`, undefined, o)}
307 </div>`;
308 })}`;
309 }
310
311 /** Every field of `doc`, drawn as `o` says. */
312 export function configFields(doc: Json, o: FieldsOptions = {}): Html {
313 fieldSerial = 0;
314 if (doc.t !== "object" && doc.t !== "array") return node(doc, "", "value", undefined, o);
315 return node(doc, "", "", undefined, o);
316 }
src/web/render/config.ts added +62
@@ -0,0 +1,62 @@
1 /**
2 * A JSON config file on the blob page: its values as a form, read-only, with
3 * "Edit as form" one click away (`pages/config.ts` is the editor, and draws
4 * the schema the file names). A file that is not valid JSON says why, at
5 * which line, above its text; Source keeps its highlighted view.
6 */
7
8 import { html, type Html } from "../html.ts";
9 import { btn } from "../ui.ts";
10 import { configFields, countFields, MAX_FIELDS } from "./config-form.ts";
11 import { parseJson } from "./json-doc.ts";
12 import type { Rendered, RenderInput } from "./renderers.ts";
13
14 /** Only JSON draws as a form; YAML, TOML and the rest keep their source. */
15 export const isFormJson = (path: string): boolean => /\.json$/i.test(path);
16
17 /**
18 * `/:owner/:name/media/:ref/*path` → `/:owner/:name/config/:ref/*path`: the
19 * ref is encoded whole, so the last `/media/` before it is the route's. A
20 * mirror's pages (`/origins/…`) edit through the source editor instead.
21 */
22 function editHref(input: RenderInput): string | undefined {
23 const head = input.media.slice(0, input.media.length - input.path.length - 1);
24 const cut = head.lastIndexOf("/media/");
25 if (cut < 0 || head.startsWith("/origins/")) return undefined;
26 return `${head.slice(0, cut)}/config/${head.slice(cut + "/media/".length)}/${input.path}`;
27 }
28
29 function brokenJson(text: string, line: number, column: number, message: string): Html {
30 const lines = text.split("\n");
31 return html`<div class="card" role="alert">
32 <p><strong>Not valid JSON:</strong> ${message} at line ${String(line)}, column ${String(column)}.</p>
33 <p class="muted small">The form needs valid JSON. Source shows the file highlighted.</p>
34 </div>
35 <pre class="card mono">${lines.map((l, i) => (i + 1 === line ? html`<mark>${l}</mark>\n` : `${l}\n`))}</pre>`;
36 }
37
38 export function config(input: RenderInput): Rendered {
39 const text = new TextDecoder().decode(input.body);
40 const lines = text === "" ? 0 : text.replace(/\n$/, "").split("\n").length;
41 const parsed = parseJson(text);
42 if (!parsed.ok) {
43 const e = parsed.error;
44 return { lines, content: brokenJson(text, e.line, e.column, e.message) };
45 }
46 if (countFields(parsed.value) > MAX_FIELDS)
47 return {
48 lines,
49 content: html`<div class="card"><p class="muted">This file has more than ${MAX_FIELDS.toLocaleString()} values, too many for a form. Source shows it whole.</p></div>`,
50 };
51 const edit = editHref(input);
52 return {
53 lines,
54 content: html`<div class="card config-form">
55 <div class="bar mb">
56 <span class="muted small">Settings in this file</span><span class="spacer"></span>
57 ${edit === undefined ? null : btn("Edit as form", { href: edit, size: "sm", variant: "outline", icon: "pencil" })}
58 </div>
59 ${configFields(parsed.value, { readonly: true })}
60 </div>`,
61 };
62 }
src/web/render/json-doc.ts added +346
@@ -0,0 +1,346 @@
1 /**
2 * A JSON document as the config form holds it: ordered, exact, and written
3 * back in the file's own style.
4 *
5 * `JSON.parse` loses two things the form must keep: the order of keys that
6 * look like integers (an object puts those first) and the spelling of a
7 * number (`1.0` comes back `1`). So a document is parsed here into plain
8 * data — objects as ordered entries, numbers as their source text — and a
9 * parse error names its line and column, which `JSON.parse` does not.
10 *
11 * Plain data in, plain data out; nothing here knows about HTML.
12 */
13
14 export type Json =
15 | { readonly t: "string"; readonly v: string }
16 /** The number's own text, so `1.0` stays `1.0`. */
17 | { readonly t: "number"; readonly v: string }
18 | { readonly t: "boolean"; readonly v: boolean }
19 | { readonly t: "null" }
20 | { readonly t: "array"; readonly items: readonly Json[] }
21 | { readonly t: "object"; readonly entries: readonly (readonly [string, Json])[] };
22
23 export interface ParseError {
24 readonly message: string;
25 readonly line: number;
26 readonly column: number;
27 }
28
29 export type Parsed =
30 | { readonly ok: true; readonly value: Json }
31 | { readonly ok: false; readonly error: ParseError };
32
33 /** Deeper than this is not a config file a person edits in a form. */
34 const MAX_DEPTH = 64;
35
36 export const NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
37
38 class Stop extends Error {
39 constructor(
40 message: string,
41 readonly at: number,
42 ) {
43 super(message);
44 }
45 }
46
47 /** Parse strict JSON (RFC 8259) into a `Json`, or say where it went wrong. */
48 export function parseJson(text: string): Parsed {
49 let i = 0;
50 const ws = (): void => {
51 while (i < text.length && " \t\n\r".includes(text[i]!)) i++;
52 };
53 const fail = (message: string): never => {
54 throw new Stop(message, i);
55 };
56 const expect = (word: string): void => {
57 if (text.startsWith(word, i)) i += word.length;
58 else fail(`Expected ${word}`);
59 };
60 const str = (): string => {
61 i++; // the opening quote
62 let out = "";
63 for (;;) {
64 if (i >= text.length) fail("Unterminated string");
65 const c = text[i]!;
66 if (c === '"') {
67 i++;
68 return out;
69 }
70 if (c === "\\") {
71 const e = text[i + 1];
72 const simple: Record<string, string> = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
73 if (e !== undefined && simple[e] !== undefined) {
74 out += simple[e];
75 i += 2;
76 } else if (e === "u" && /^[0-9a-fA-F]{4}$/.test(text.slice(i + 2, i + 6))) {
77 out += String.fromCharCode(parseInt(text.slice(i + 2, i + 6), 16));
78 i += 6;
79 } else fail("Bad escape in string");
80 continue;
81 }
82 if (c.charCodeAt(0) < 0x20) fail("Control character in string");
83 out += c;
84 i++;
85 }
86 };
87 const value = (depth: number): Json => {
88 if (depth > MAX_DEPTH) fail("Nested too deeply");
89 ws();
90 const c = text[i];
91 if (c === "{") {
92 i++;
93 const entries: [string, Json][] = [];
94 const seen = new Set<string>();
95 ws();
96 if (text[i] === "}") {
97 i++;
98 return { t: "object", entries };
99 }
100 for (;;) {
101 ws();
102 if (text[i] !== '"') fail("Expected a key in quotes");
103 const keyAt = i;
104 const key = str();
105 if (seen.has(key)) throw new Stop(`Duplicate key "${key}"`, keyAt);
106 seen.add(key);
107 ws();
108 expect(":");
109 entries.push([key, value(depth + 1)]);
110 ws();
111 if (text[i] === ",") i++;
112 else if (text[i] === "}") {
113 i++;
114 return { t: "object", entries };
115 } else fail("Expected , or }");
116 }
117 }
118 if (c === "[") {
119 i++;
120 const items: Json[] = [];
121 ws();
122 if (text[i] === "]") {
123 i++;
124 return { t: "array", items };
125 }
126 for (;;) {
127 items.push(value(depth + 1));
128 ws();
129 if (text[i] === ",") i++;
130 else if (text[i] === "]") {
131 i++;
132 return { t: "array", items };
133 } else fail("Expected , or ]");
134 }
135 }
136 if (c === '"') return { t: "string", v: str() };
137 if (c === "t") return expect("true"), { t: "boolean", v: true };
138 if (c === "f") return expect("false"), { t: "boolean", v: false };
139 if (c === "n") return expect("null"), { t: "null" };
140 const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i, i + 400));
141 if (m === null || m[0] === "" || m[0] === "-") return fail(c === undefined ? "Unexpected end of file" : `Unexpected ${JSON.stringify(c)}`);
142 i += m[0].length;
143 return { t: "number", v: m[0] };
144 };
145 try {
146 // A byte-order mark is not JSON, but editors write one.
147 if (text.charCodeAt(0) === 0xfeff) i = 1;
148 const v = value(0);
149 ws();
150 if (i < text.length) fail("Unexpected text after the value");
151 return { ok: true, value: v };
152 } catch (e) {
153 if (!(e instanceof Stop)) throw e;
154 const before = text.slice(0, e.at);
155 const line = before.split("\n").length;
156 return {
157 ok: false,
158 error: { message: e.message, line, column: e.at - before.lastIndexOf("\n") },
159 };
160 }
161 }
162
163 /** How a file lays itself out, so a save writes it back the same way. */
164 export interface Style {
165 /** "" for a one-line file; else the indent unit (spaces or a tab). */
166 readonly indent: string;
167 readonly eol: "\n" | "\r\n";
168 readonly finalNewline: boolean;
169 }
170
171 export function detectStyle(text: string): Style {
172 const eol = text.includes("\r\n") ? "\r\n" : "\n";
173 const body = text.replace(/\s+$/, "");
174 const indented = /\n([ \t]+)\S/.exec(body);
175 return {
176 indent: !body.includes("\n") ? "" : (indented?.[1] ?? " "),
177 eol,
178 finalNewline: /\n$/.test(text),
179 };
180 }
181
182 /** The document as text, in `style`: arrays and objects open one per line. */
183 export function stringify(doc: Json, style: Style = { indent: "", eol: "\n", finalNewline: false }): string {
184 const nl = style.indent === "" ? "" : style.eol;
185 const colon = style.indent === "" ? ":" : ": ";
186 const write = (v: Json, pad: string): string => {
187 const inner = pad + style.indent;
188 switch (v.t) {
189 case "string":
190 return JSON.stringify(v.v);
191 case "number":
192 return v.v;
193 case "boolean":
194 return v.v ? "true" : "false";
195 case "null":
196 return "null";
197 case "array":
198 if (v.items.length === 0) return "[]";
199 return `[${nl}${v.items.map((x) => inner + write(x, inner)).join(`,${nl}`)}${nl}${pad}]`;
200 case "object":
201 if (v.entries.length === 0) return "{}";
202 return `{${nl}${v.entries
203 .map(([k, x]) => `${inner}${JSON.stringify(k)}${colon}${write(x, inner)}`)
204 .join(`,${nl}`)}${nl}${pad}}`;
205 }
206 };
207 return write(doc, "") + (style.finalNewline ? style.eol : "");
208 }
209
210 /** Whether two documents say the same thing, key order included. */
211 export const sameDoc = (a: Json, b: Json): boolean => stringify(a) === stringify(b);
212
213 // ---- pointers (RFC 6901), the form's field names ----
214
215 export const pointer = (parent: string, key: string | number): string =>
216 `${parent}/${String(key).replace(/~/g, "~0").replace(/\//g, "~1")}`;
217
218 export function splitPointer(p: string): string[] | undefined {
219 if (p === "") return [];
220 if (!p.startsWith("/")) return undefined;
221 return p
222 .slice(1)
223 .split("/")
224 .map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
225 }
226
227 /** The value at `p`, if there is one. */
228 export function getAt(doc: Json, p: string): Json | undefined {
229 const parts = splitPointer(p);
230 if (parts === undefined) return undefined;
231 let at: Json | undefined = doc;
232 for (const part of parts) {
233 if (at === undefined) return undefined;
234 if (at.t === "object") at = at.entries.find(([k]) => k === part)?.[1];
235 else if (at.t === "array" && /^(0|[1-9]\d*)$/.test(part)) at = at.items[Number(part)];
236 else return undefined;
237 }
238 return at;
239 }
240
241 /**
242 * A copy of `doc` with the value at `p` replaced by `change(old)`. A path
243 * that names nothing leaves the document as it was.
244 */
245 export function updateAt(doc: Json, p: string, change: (old: Json) => Json): Json {
246 const parts = splitPointer(p);
247 if (parts === undefined) return doc;
248 const walk = (at: Json, rest: string[]): Json => {
249 const [head, ...tail] = rest;
250 if (head === undefined) return change(at);
251 if (at.t === "object") {
252 if (!at.entries.some(([k]) => k === head)) return at;
253 return { t: "object", entries: at.entries.map(([k, v]) => [k, k === head ? walk(v, tail) : v] as const) };
254 }
255 if (at.t === "array" && /^(0|[1-9]\d*)$/.test(head)) {
256 const n = Number(head);
257 if (n >= at.items.length) return at;
258 return { t: "array", items: at.items.map((v, j) => (j === n ? walk(v, tail) : v)) };
259 }
260 return at;
261 };
262 return walk(doc, parts);
263 }
264
265 /** An empty value shaped like `like`: what a new card or chip starts as. */
266 export function blankLike(like: Json | undefined): Json {
267 if (like === undefined) return { t: "string", v: "" };
268 switch (like.t) {
269 case "string":
270 return { t: "string", v: "" };
271 case "number":
272 return { t: "number", v: "0" };
273 case "boolean":
274 return { t: "boolean", v: false };
275 case "null":
276 return { t: "null" };
277 case "array":
278 return { t: "array", items: [] };
279 case "object":
280 return { t: "object", entries: like.entries.map(([k, v]) => [k, blankLike(v)] as const) };
281 }
282 }
283
284 /** From a value `JSON.parse` made (a schema's default, an enum member). */
285 export function fromPlain(v: unknown): Json {
286 if (typeof v === "string") return { t: "string", v };
287 if (typeof v === "number" && Number.isFinite(v)) return { t: "number", v: String(v) };
288 if (typeof v === "boolean") return { t: "boolean", v };
289 if (Array.isArray(v)) return { t: "array", items: v.map(fromPlain) };
290 if (v !== null && typeof v === "object")
291 return { t: "object", entries: Object.entries(v).map(([k, x]) => [k, fromPlain(x)] as const) };
292 return { t: "null" };
293 }
294
295 /** Lines of `before` and `after` as a unified-style list, unchanged runs trimmed to context. */
296 export interface DiffLine {
297 readonly op: " " | "-" | "+";
298 readonly text: string;
299 }
300
301 /** Past this many changed lines each side, the diff shows the whole swap. */
302 const DIFF_LCS_MAX = 1_000;
303
304 export function lineDiff(before: string, after: string, context = 3): DiffLine[] {
305 const a = before.split("\n");
306 const b = after.split("\n");
307 let start = 0;
308 while (start < a.length && start < b.length && a[start] === b[start]) start++;
309 let endA = a.length;
310 let endB = b.length;
311 while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) endA--, endB--;
312 const midA = a.slice(start, endA);
313 const midB = b.slice(start, endB);
314 const mid: DiffLine[] = [];
315 if (midA.length > DIFF_LCS_MAX || midB.length > DIFF_LCS_MAX) {
316 for (const t of midA) mid.push({ op: "-", text: t });
317 for (const t of midB) mid.push({ op: "+", text: t });
318 } else {
319 // Longest common subsequence, then walked forwards.
320 const n = midA.length;
321 const m = midB.length;
322 const lcs: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
323 for (let x = n - 1; x >= 0; x--)
324 for (let y = m - 1; y >= 0; y--)
325 lcs[x]![y] = midA[x] === midB[y] ? lcs[x + 1]![y + 1]! + 1 : Math.max(lcs[x + 1]![y]!, lcs[x]![y + 1]!);
326 let x = 0;
327 let y = 0;
328 while (x < n || y < m) {
329 if (x < n && y < m && midA[x] === midB[y]) mid.push({ op: " ", text: midA[x++]! }), y++;
330 else if (y < m && (x === n || lcs[x]![y + 1]! >= lcs[x + 1]![y]!)) mid.push({ op: "+", text: midB[y++]! });
331 else mid.push({ op: "-", text: midA[x++]! });
332 }
333 }
334 const all: DiffLine[] = [
335 ...a.slice(0, start).map((text) => ({ op: " " as const, text })),
336 ...mid,
337 ...a.slice(endA).map((text) => ({ op: " " as const, text })),
338 ];
339 // Keep only the context around a change.
340 const near = all.map(() => false);
341 all.forEach((l, k) => {
342 if (l.op === " ") return;
343 for (let j = Math.max(0, k - context); j <= Math.min(all.length - 1, k + context); j++) near[j] = true;
344 });
345 return all.filter((_, k) => near[k]);
346 }
src/web/render/json-schema.ts added +242
@@ -0,0 +1,242 @@
1 /**
2 * The part of JSON Schema the config form uses: which fields there are, what
3 * type each holds, the choices an enum allows, the help text, and the bounds
4 * a save is checked against. Anything else in a schema is ignored, never an
5 * error: a schema written for another tool still draws a better form.
6 *
7 * Where the schema comes from, first found wins (`findSchema`):
8 * 1. `$schema` in the file, when it is a path in the repository;
9 * 2. a sibling `<name>.schema.json`;
10 * 3. `.forge/schemas.json`, an object mapping globs to schema paths.
11 *
12 * `pattern` is drawn as the input's own `pattern` attribute, checked in the
13 * browser; the server never runs a regular expression a repository wrote.
14 */
15
16 import { NUMBER, getAt, parseJson, pointer, splitPointer, type Json } from "./json-doc.ts";
17
18 export interface Schema {
19 readonly type?: string | readonly string[];
20 readonly title?: string;
21 readonly description?: string;
22 readonly properties?: Readonly<Record<string, Schema>>;
23 readonly items?: Schema;
24 readonly required?: readonly string[];
25 readonly enum?: readonly unknown[];
26 readonly minimum?: number;
27 readonly maximum?: number;
28 readonly pattern?: string;
29 readonly default?: unknown;
30 readonly $ref?: string;
31 }
32
33 /** A schema with the document it came from, for `$ref` inside it. */
34 export interface FoundSchema {
35 readonly root: Schema;
36 /** The repository path it was read from. */
37 readonly from: string;
38 }
39
40 const isObject = (v: unknown): v is Record<string, unknown> =>
41 v !== null && typeof v === "object" && !Array.isArray(v);
42
43 /** A schema file's text as a `Schema`, or undefined when it is not one. */
44 export function parseSchema(text: string): Schema | undefined {
45 try {
46 const v: unknown = JSON.parse(text);
47 return isObject(v) ? (v as Schema) : undefined;
48 } catch {
49 return undefined;
50 }
51 }
52
53 /** Follow a local `$ref` (`#/definitions/x`, `#/$defs/x`), a few hops at most. */
54 function deref(root: Schema, s: Schema | undefined): Schema | undefined {
55 let at = s;
56 for (let hops = 0; at?.$ref !== undefined && hops < 8; hops++) {
57 if (!at.$ref.startsWith("#")) return at;
58 let next: unknown = root;
59 for (const part of splitPointer(at.$ref.slice(1)) ?? []) next = isObject(next) ? next[part] : undefined;
60 at = isObject(next) ? (next as Schema) : undefined;
61 }
62 return isObject(at) ? at : undefined;
63 }
64
65 /** The schema that describes the value at pointer `p`, if the schema says. */
66 export function schemaAt(found: FoundSchema | undefined, p: string): Schema | undefined {
67 if (found === undefined) return undefined;
68 let at = deref(found.root, found.root);
69 for (const part of splitPointer(p) ?? []) {
70 if (at === undefined) return undefined;
71 at = /^(0|[1-9]\d*)$/.test(part) && isObject(at.items)
72 ? deref(found.root, at.items)
73 : deref(found.root, isObject(at.properties) ? at.properties[part] : undefined);
74 }
75 return at;
76 }
77
78 const typeOf = (v: Json): string => (v.t === "number" && /^-?\d+$/.test(v.v) ? "integer" : v.t);
79
80 const typeAllows = (s: Schema, v: Json): boolean => {
81 if (s.type === undefined) return true;
82 const types = typeof s.type === "string" ? [s.type] : s.type;
83 const t = typeOf(v);
84 return types.includes(t) || (t === "integer" && types.includes("number"));
85 };
86
87 /** A JSON value as the plain value an enum lists, for comparing. */
88 const plain = (v: Json): unknown =>
89 v.t === "number" ? Number(v.v) : v.t === "null" ? null : v.t === "string" || v.t === "boolean" ? v.v : undefined;
90
91 /**
92 * What is wrong with `doc` by the schema, as messages keyed by pointer: a
93 * field's own error sits under that field. Empty when the document passes.
94 */
95 export function validate(doc: Json, found: FoundSchema | undefined): Map<string, string> {
96 const errors = new Map<string, string>();
97 if (found === undefined) return errors;
98 const walk = (v: Json, s: Schema | undefined, p: string): void => {
99 if (s === undefined) return;
100 if (!typeAllows(s, v)) {
101 errors.set(p, `Must be ${typeof s.type === "string" ? s.type : (s.type ?? []).join(" or ")}.`);
102 return;
103 }
104 if (Array.isArray(s.enum) && !s.enum.some((e) => e === plain(v)))
105 errors.set(p, `Must be one of ${s.enum.map((e) => JSON.stringify(e)).join(", ")}.`);
106 if (v.t === "number") {
107 const n = Number(v.v);
108 if (typeof s.minimum === "number" && n < s.minimum) errors.set(p, `Must be at least ${s.minimum}.`);
109 if (typeof s.maximum === "number" && n > s.maximum) errors.set(p, `Must be at most ${s.maximum}.`);
110 }
111 if (v.t === "object") {
112 const missing = (Array.isArray(s.required) ? s.required : []).filter(
113 (k) => typeof k === "string" && !v.entries.some(([key]) => key === k),
114 );
115 if (missing.length > 0) errors.set(p, `Needs ${missing.join(", ")}.`);
116 for (const [k, x] of v.entries) walk(x, schemaAt(found, pointer(p, k)), pointer(p, k));
117 }
118 if (v.t === "array") v.items.forEach((x, i) => walk(x, schemaAt(found, `${p}/${i}`), `${p}/${i}`));
119 };
120 walk(doc, schemaAt(found, ""), "");
121 return errors;
122 }
123
124 /** Whether a typed number is a number JSON can hold. */
125 export const isJsonNumber = (text: string): boolean => NUMBER.test(text);
126
127 // ---- finding the schema ----
128
129 /** `a/b/../c` → `a/c`; undefined when the path climbs out of the repository. */
130 export function normalizePath(path: string): string | undefined {
131 const out: string[] = [];
132 for (const part of path.split("/")) {
133 if (part === "" || part === ".") continue;
134 if (part === "..") {
135 if (out.pop() === undefined) return undefined;
136 } else out.push(part);
137 }
138 return out.length === 0 ? undefined : out.join("/");
139 }
140
141 /**
142 * The classic wildcard walk: `many` matches any run of items, `one` any
143 * single item. One restart point (the last `many`) makes it O(n·m) with no
144 * backtracking blow-up, whatever the pattern.
145 */
146 function wildcard<T>(pattern: readonly T[], subject: readonly T[], many: T, one: (p: T, s: T) => boolean): boolean {
147 let p = 0;
148 let s = 0;
149 let star = -1;
150 let resume = 0;
151 while (s < subject.length) {
152 if (p < pattern.length && pattern[p] !== many && one(pattern[p]!, subject[s]!)) {
153 p++;
outdated

High: ReDoS. A glob such as aa*a…b becomes ^.a.a.a…b$, which backtracks exponentially. With 12 repeats against a 34-character path it takes 1.7 s, and it runs synchronously on every GET/POST to /config for a matching file. The glob comes from .forge/schemas.json, which any repository owner writes. Replace the RegExp with a segment-wise wildcard match (O(n·m) with one backtrack point), and add a test with this glob that finishes in a few ms.

154 s++;
155 } else if (p < pattern.length && pattern[p] === many) {
156 star = p++;
157 resume = s;
158 } else if (star >= 0) {
159 p = star + 1;
160 s = ++resume;
161 } else return false;
162 }
163 while (p < pattern.length && pattern[p] === many) p++;
164 return p === pattern.length;
165 }
166
167 /** One path segment against one glob segment: `*` any run, `?` one character. */
168 const segmentMatches = (glob: string, name: string): boolean =>
169 wildcard([...glob.replace(/\*+/g, "*")], [...name], "*", (g, c) => g === "?" || g === c);
170
171 /**
172 * A glob as a whole-path test, segment by segment: a `**` segment matches
173 * any number of folders, `*` and `?` stay inside one. No regular expression
174 * is built from a glob a repository wrote.
175 */
176 export function globMatches(glob: string, path: string): boolean {
177 const globParts = glob.replace(/^\/+/, "").split("/");
178 const pathParts = path.replace(/^\/+/, "").split("/");
179 return wildcard(globParts, pathParts, "**", segmentMatches);
180 }
181
182 const MAX_GLOBS = 200;
183 const MAX_GLOB = 200;
184
185 /** Where a schema for `path` might be, in the order they are tried. */
186 export async function findSchema(
187 path: string,
outdated

This is not linear: capping the glob at 200 characters still allows 66 .* in a row. Remove the comment along with the RegExp.

188 doc: Json,
189 read: (path: string) => Promise<string | undefined>,
190 ): Promise<FoundSchema | undefined> {
191 const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/") + 1) : "";
192 const tryRead = async (candidate: string | undefined): Promise<FoundSchema | undefined> => {
193 if (candidate === undefined || candidate === path) return undefined;
194 const text = await read(candidate);
195 const root = text === undefined ? undefined : parseSchema(text);
196 return root === undefined ? undefined : { root, from: candidate };
197 };
198
199 const declared = doc.t === "object" ? getAt(doc, "/$schema") : undefined;
200 if (declared?.t === "string" && !/^[a-z][a-z0-9+.-]*:/i.test(declared.v)) {
201 const found = await tryRead(
202 normalizePath(declared.v.startsWith("/") ? declared.v : dir + declared.v),
203 );
204 if (found !== undefined) return found;
205 }
206
207 const name = path.slice(dir.length).replace(/\.json$/i, "");
208 const sibling = await tryRead(`${dir}${name}.schema.json`);
209 if (sibling !== undefined) return sibling;
210
211 const map = parseSchema((await read(".forge/schemas.json")) ?? "");
212 if (map !== undefined) {
213 // A bounded map of short globs, each matched in O(glob × path).
214 for (const [glob, target] of Object.entries(map).slice(0, MAX_GLOBS)) {
215 if (typeof target !== "string" || glob.length > MAX_GLOB || !globMatches(glob, path)) continue;
216 const found = await tryRead(normalizePath(target));
217 if (found !== undefined) return found;
218 }
219 }
220 return undefined;
221 }
222
223 /**
224 * Why a JSON file's new text breaks the schema it names, as one sentence, or
225 * undefined when it keeps it (or is not JSON, or has no schema). The
226 * terminal asks this before a commit, as the form does before a save.
227 */
228 export async function schemaRefusal(
229 path: string,
230 text: string,
231 read: (path: string) => Promise<string | undefined>,
232 ): Promise<string | undefined> {
233 if (!/\.json$/i.test(path)) return undefined;
234 const parsed = parseJson(text);
235 if (!parsed.ok) return undefined;
236 const found = await findSchema(path, parsed.value, read);
237 const errors = [...validate(parsed.value, found)];
238 if (errors.length === 0) return undefined;
239 const [at, message] = errors[0]!;
240 const more = errors.length > 1 ? ` (and ${errors.length - 1} more)` : "";
241 return `${path} breaks ${found!.from}: ${at === "" ? "the top level" : at} ${message.charAt(0).toLowerCase()}${message.slice(1)}${more}`;
242 }
src/web/render/renderers.ts +5 −1
@@ -7,6 +7,7 @@
77 import type { Html } from "../html.ts";
88 import type { Blob } from "../../view/source.ts";
99 import { audio } from "./audio.ts";
10 import { config, isFormJson } from "./config.ts";
1011 import { csv } from "./csv.ts";
1112 import { image } from "./image.ts";
1213 import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts";
@@ -49,6 +50,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
4950 csv,
5051 audio,
5152 video,
53 config,
5254 };
5355
5456 /**
@@ -81,7 +83,9 @@ export function rendererFor(
8183 : claimedMedia(path)?.kind;
8284 if (kind === undefined || blob.size > RENDER_CAP[kind]) return undefined;
8385 if (blob.kind === "too_large" && !MEDIA_KINDS.has(kind)) return undefined;
84 const render = renderers[kind];
86 // A kind may draw only some of its files: `config` draws JSON as a form,
87 // and leaves YAML and TOML their source.
88 const render = kind === "config" && !isFormJson(path) ? undefined : renderers[kind];
8589 if (render === undefined) return undefined;
8690 return { render, body: blob.kind === "too_large" ? NO_BYTES : blob.body };
8791 }
src/web/style/content.ts +12
@@ -839,6 +839,18 @@ textarea.editor:focus-visible { outline: none; }
839839 #needs-you #asks-phone-more > .stack { margin-top: 0; }
840840 }
841841 }
842 /* The config form (#7336): nested objects as quiet fieldsets, scalar
843 arrays as a wrapping row of chips, object arrays as cards. */
844 .config-group { border-left: 2px solid var(--border); padding-left: 12px; }
845 .config-group > legend { font-weight: 600; margin-bottom: 8px; }
846 .config-card { padding: 12px; margin: 0 0 12px; }
847 .config-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
848 .config-chips > li { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
849 .config-chips .input { width: auto; min-width: 8rem; }
850 .config-chips .field { margin: 0; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
851 .config-diff { overflow-x: auto; white-space: pre; padding: 12px; }
852 .config-diff > [data-op="add"] { background: var(--add-bg); color: var(--add-fg); }
853 .config-diff > [data-op="del"] { background: var(--del-bg); color: var(--del-fg); }
842854 /* Reduced motion: the pulses hold still at a legible level, the scroll-tied
843855 fade stays at its resting width, and these transitions land at once. */
844856 @media (prefers-reduced-motion: reduce) {
src/web/ui.ts +43 −6
@@ -170,6 +170,9 @@ export interface BtnOptions {
170170 * opener handle back into this page. */
171171 external?: boolean;
172172 disabled?: boolean;
173 /** A submit that skips the browser's checks: a structural step (add,
174 * remove, go back) must not wait for a field to be filled in. */
175 formnovalidate?: boolean;
173176 current?: boolean;
174177 /** A toggle: emits `aria-pressed` with its state. */
175178 pressed?: boolean;
@@ -206,6 +209,7 @@ export function btn(content: Html | string, o: BtnOptions = {}): Html {
206209 ${o.value !== undefined ? html`value="${o.value}"` : null}
207210 ${o.form ? html`form="${o.form}"` : null}
208211 ${o.disabled ? raw("disabled") : null}
212 ${o.formnovalidate ? raw("formnovalidate") : null}
209213 ${btnAttrs(o)}
210214 >
211215 ${inner}
@@ -1137,6 +1141,20 @@ export interface FieldOptions {
11371141 /** `false` for names and paths, which a spelling checker only underlines. */
11381142 spellcheck?: boolean;
11391143 id?: string;
1144 /** `type: "number"`'s bounds and step. */
1145 min?: number;
1146 max?: number;
1147 step?: string;
1148 /** `type: "checkbox"`: whether it is ticked; `value` is what it sends. */
1149 checked?: boolean;
1150 /** `type: "select"`: the choices, in order. */
1151 options?: readonly { value: string; label: string; selected?: boolean }[];
1152 /** Shown but not sent (a read-only view of a checkbox or a select). */
1153 disabled?: boolean;
1154 /** The label is for a screen reader only (a chip in a row of them). */
1155 labelHidden?: boolean;
1156 /** Drawn right after the control, before the hint: a chip's Remove. */
1157 after?: Html;
11401158 }
11411159
11421160 /** Label, control, hint and error, wired together with `aria-describedby`. */
@@ -1145,10 +1163,12 @@ export function field(o: FieldOptions): Html {
11451163 const described = [o.hint ? `${id}-hint` : "", o.error ? `${id}-err` : ""]
11461164 .filter(Boolean)
11471165 .join(" ");
1166 const aria = html`${described !== "" ? html`aria-describedby="${described}"` : null}
1167 ${o.error ? raw('aria-invalid="true"') : null}
1168 ${o.disabled ? raw("disabled") : null}`;
11481169 const shared = html`class="input${o.mono ? " mono" : ""}" id="${id}"
11491170 name="${o.name}"
1150 ${described !== "" ? html`aria-describedby="${described}"` : null}
1151 ${o.error ? raw('aria-invalid="true"') : null}
1171 ${aria}
11521172 ${o.required ? raw("required") : null}
11531173 ${o.autofocus ? raw("autofocus") : null}
11541174 ${o.readonly ? raw("readonly") : null}
@@ -1159,18 +1179,35 @@ export function field(o: FieldOptions): Html {
11591179 ? html`<textarea ${shared} rows="${o.rows ?? 6}" data-grow>
11601180 ${o.value ?? ""}</textarea
11611181 >`
1162 : html`<input
1163 ${shared}
1182 : o.type === "select"
1183 ? html`<select ${shared}>
1184 ${(o.options ?? []).map(
1185 (c) => html`<option value="${c.value}" ${c.selected ? raw("selected") : null}>${c.label}</option>`,
1186 )}
1187 </select>`
1188 : html`<input
1189 ${o.type === "checkbox" ? html`id="${id}" name="${o.name}" ${aria}` : shared}
11641190 type="${o.type ?? "text"}"
11651191 value="${o.value ?? ""}"
1192 ${o.checked ? raw("checked") : null}
11661193 ${o.autocomplete ? html`autocomplete="${o.autocomplete}"` : null}
11671194 ${o.pattern ? html`pattern="${o.pattern}"` : null}
11681195 ${o.minlength !== undefined ? html`minlength="${o.minlength}"` : null}
11691196 ${o.maxlength !== undefined ? html`maxlength="${o.maxlength}"` : null}
1197 ${o.min !== undefined ? html`min="${String(o.min)}"` : null}
1198 ${o.max !== undefined ? html`max="${String(o.max)}"` : null}
1199 ${o.step !== undefined ? html`step="${o.step}"` : null}
11701200 />`;
1201 const label =
1202 o.type === "checkbox"
1203 ? html`<label class="check">${control} ${o.label}</label>`
1204 : o.labelHidden
1205 ? html`<label for="${id}" class="sr-only">${o.label}</label>${control}`
1206 : html`<label for="${id}">${o.label}</label>
1207 ${control}`;
11711208 return html`<div class="field" ${o.error ? raw("data-invalid") : null}>
1172 <label for="${id}">${o.label}</label>
1173 ${control}
1209 ${label}
1210 ${o.after ?? null}
11741211 ${o.hint ? html`<span class="hint" id="${id}-hint">${o.hint}</span>` : null}
11751212 ${o.error
11761213 ? html`<span class="error" id="${id}-err">${o.error}</span>`
test/config-form.test.ts added +451
@@ -0,0 +1,451 @@
1 /**
2 * Config files as a form (#7336): a JSON file's values as typed fields, a
3 * schema's choices and bounds, and a save that writes the file back in its
4 * own order and indent — committed by a writer, proposed by anyone else.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { setRole } from "../src/actions/roles.ts";
14 import { createSession } from "../src/core/sessions.ts";
15 import { getRepo, updateRepoSettings } from "../src/core/repos.ts";
16 import { createAgent } from "../src/actions/crew.ts";
17 import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts";
18 import { signUp } from "../src/actions/users.ts";
19 import { proposeEditText } from "../src/actions/propose_edit.ts";
20 import { fixtureSlot } from "./helpers/slot.ts";
21 import { listPulls } from "../src/core/pulls.ts";
22 import { repoDir } from "../src/git/repos.ts";
23 import { serve, type Servers } from "../src/http/server.ts";
24 import { localSource } from "../src/view/local.ts";
25 import { applyOp, configFields, opValue, readValues } from "../src/web/render/config-form.ts";
26 import { detectStyle, parseJson, sameDoc, stringify, type Json } from "../src/web/render/json-doc.ts";
27 import { findSchema, globMatches, validate } from "../src/web/render/json-schema.ts";
28 import { digestOf } from "../src/web/pages/config.ts";
29 import { seedAdmin } from "./helpers/admin.ts";
30 import { signUpProven } from "./helpers/verified.ts";
31 import { gitClientEnv } from "./helpers/git-client.ts";
32 import { quietLogs } from "./helpers/globals.ts";
33
34 quietLogs();
35
36 const doc = (text: string): Json => {
37 const parsed = parseJson(text);
38 if (!parsed.ok) throw new Error(parsed.error.message);
39 return parsed.value;
40 };
41
42 describe("a JSON document, kept exact", () => {
43 test("a round trip keeps key order, indent and the final newline", () => {
44 for (const text of [
45 '{\n "b": 1,\n "a": [\n true,\n null\n ],\n "2": "two",\n "1": 1.0\n}\n',
46 '{\n\t"z": {\n\t\t"y": []\n\t},\n\t"x": {}\n}',
47 '{\r\n "only": "four spaces"\r\n}\r\n',
48 '{"compact":[1,2],"x":{"y":"z"}}',
49 ])
50 expect(stringify(doc(text), detectStyle(text))).toBe(text);
51 });
52
53 test("invalid JSON names its line and column", () => {
54 const parsed = parseJson('{\n "a": 1,\n "b": ,\n}');
55 expect(parsed.ok).toBe(false);
56 if (!parsed.ok) expect(parsed.error).toMatchObject({ line: 3, column: 8 });
57 expect(parseJson('{"a":1,"a":2}').ok).toBe(false);
58 });
59 });
60
61 describe("fields inferred from the values", () => {
62 const html = String(
63 configFields(
64 doc('{"name":"lamp","port":8080,"on":true,"gone":null,"db":{"host":"h"},"tags":["a","b"],"servers":[{"host":"x"},{"host":"y"}]}'),
65 ),
66 );
67
68 test("each type gets its control", () => {
69 expect(html).toMatch(/type="text"[^>]*value="lamp"|value="lamp"[^>]*type="text"/s);
70 expect(html).toMatch(/name="v\/port"[^>]*type="number"/);
71 expect(html).toMatch(/name="v\/on"[^>]*type="checkbox"\s*value="true"\s*checked/);
72 expect(html).toMatch(/name="v\/gone"[^>]*readonly[^>]*value="null"/);
73 expect(html).toMatch(/<fieldset class="field config-group"[^>]*>\s*<legend>db<\/legend>/);
74 expect(html).toContain('class="config-chips"');
75 expect(html).toContain('name="v/tags/1"');
76 expect(html.match(/class="card config-card"/g)?.length).toBe(2);
77 expect(html).toContain('name="v/servers/1/host"');
78 });
79
80 test("cards add, remove and move like the boards editor", () => {
81 let d = doc('{"servers":[{"host":"x","port":1},{"host":"y","port":2}]}');
82 d = applyOp(d, opValue("down", "/servers/0"));
83 expect(stringify(d)).toBe('{"servers":[{"host":"y","port":2},{"host":"x","port":1}]}');
84 d = applyOp(d, opValue("add", "/servers"));
85 expect(stringify(d)).toBe('{"servers":[{"host":"y","port":2},{"host":"x","port":1},{"host":"","port":0}]}');
86 d = applyOp(d, opValue("remove", "/servers/0"));
87 d = applyOp(d, opValue("up", "/servers/1"));
88 expect(stringify(d)).toBe('{"servers":[{"host":"","port":0},{"host":"x","port":1}]}');
89 expect(stringify(applyOp(d, "not json"))).toBe(stringify(d));
90 });
91
92 test("the form's values write back by type, and a bad number is held, not saved", () => {
93 const form: Record<string, string> = { "v/name": "lit", "v/port": "80x", "v/tags/0": "c" };
94 const read = readValues(doc('{"name":"lamp","port":8080,"on":true,"tags":["a"]}'), (n) => form[n]);
95 expect(stringify(read.doc)).toBe('{"name":"lit","port":8080,"on":false,"tags":["c"]}');
96 expect(read.errors.get("/port")).toBe("Must be a number.");
97 expect(read.typed.get("/port")).toBe("80x");
98 });
99 });
100
101 describe("fields from a schema", () => {
102 const schema = {
103 root: {
104 type: "object",
105 required: ["mode", "port"],
106 properties: {
107 mode: { enum: ["dev", "prod"], description: "Where it runs." },
108 port: { type: "integer", minimum: 1, maximum: 65535 },
109 name: { type: "string", pattern: "^[a-z]+$" },
110 extra: { type: "boolean" },
111 },
112 },
113 from: "app.schema.json",
114 };
115
116 test("an enum is a select, a description is help, bounds are attributes", () => {
117 const html = String(configFields(doc('{"mode":"dev","port":80,"name":"x"}'), { found: schema }));
118 expect(html).toMatch(/<select class="input"[^>]*name="j\/mode"/);
119 expect(html).toContain('<option value="&quot;prod&quot;"');
120 // Every structural button skips the browser's checks, through btn().
121 expect(html.match(/<button[^>]*>/g)!.every((b) => b.includes("formnovalidate"))).toBe(true);
122 expect(html).toContain("Where it runs.");
123 expect(html).toMatch(/min="1"[\s\S]*max="65535"/);
124 expect(html).toContain('pattern="^[a-z]+$"');
125 // A property the file lacks is one button away.
126 expect(html).toMatch(/>extra\s*<\/button>/);
127 // Help text is read with its field.
128 expect(html).toMatch(/name="j\/mode"\s*aria-describedby="cf-\d+-hint"/);
129 });
130
131 test("required, minimum, maximum and enum are checked on the server too", () => {
132 const errors = validate(doc('{"mode":"test","port":70000}'), schema);
133 expect(errors.get("/mode")).toContain("one of");
134 expect(errors.get("/port")).toBe("Must be at most 65535.");
135 expect(validate(doc('{"port":1}'), schema).get("")).toBe("Needs mode.");
136 expect(validate(doc('{"mode":"dev","port":1}'), schema).size).toBe(0);
137 });
138
139 test("found by $schema, then a sibling, then .forge/schemas.json", async () => {
140 const files: Record<string, string> = {
141 "conf/s.json": '{"title":"declared"}',
142 "conf/app.schema.json": '{"title":"sibling"}',
143 ".forge/schemas.json": '{"conf/**/*.json":"schemas/any.json"}',
144 "schemas/any.json": '{"title":"mapped"}',
145 };
146 const read = async (p: string): Promise<string | undefined> => files[p];
147 expect((await findSchema("conf/app.json", doc('{"$schema":"./s.json"}'), read))?.from).toBe("conf/s.json");
148 expect((await findSchema("conf/app.json", doc("{}"), read))?.from).toBe("conf/app.schema.json");
149 expect((await findSchema("conf/deep/x.json", doc("{}"), read))?.from).toBe("schemas/any.json");
150 expect(await findSchema("other.json", doc('{"$schema":"https://x.dev/s.json"}'), read)).toBeUndefined();
151 expect(globMatches("**/*.json", "a.json")).toBe(true);
152 expect(globMatches("conf/**/*.json", "conf/a/b/c.json")).toBe(true);
153 expect(globMatches("a?c.json", "abc.json")).toBe(true);
154 expect(globMatches("*.json", "a/b.json")).toBe(false);
155 });
156 });
157
158 test("a glob a repository wrote cannot stall the forge", () => {
159 // As a regular expression this backtracked exponentially: 1.7 s at 30
160 // characters of path, doubling every few more. The segment walk is
161 // O(glob × path); the bound is only a hang guard.
162 const t0 = performance.now();
163 expect(globMatches("**a".repeat(66) + "b", `${"a".repeat(60)}.json`)).toBe(false);
164 expect(globMatches("*a*a*a*a*a*a*a*a*a*a*a*a*b", `${"a".repeat(200)}.json`)).toBe(false);
165 expect(performance.now() - t0).toBeLessThan(5_000);
166 });
167
168 describe("the config form on the web", () => {
169 let dataDir: string;
170 let app: App;
171 let servers: Servers;
172 let base = "";
173 let origin = "";
174 let alice = "";
175 let kim = "";
176 const SETTINGS = '{\n "name": "lamp",\n "port": 8080,\n "tags": [\n "a"\n ]\n}\n';
177
178 const get = (path: string, cookie = ""): Promise<Response> =>
179 fetch(`${base}${path}`, { redirect: "manual", ...(cookie === "" ? {} : { headers: { cookie } }) });
180 const post = (path: string, fields: Record<string, string>, cookie = ""): Promise<Response> =>
181 fetch(`${base}${path}`, {
182 method: "POST",
183 headers: { origin, ...(cookie === "" ? {} : { cookie }) },
184 body: new URLSearchParams(fields),
185 redirect: "manual",
186 });
187 const fileAt = async (owner: string, name: string, ref: string, path: string): Promise<string | undefined> => {
188 const repo = getRepo(app.db, owner, name)!;
189 const blob = await localSource(app, repo, undefined).blob(ref, path);
190 return blob.ok && blob.value.kind === "text" ? new TextDecoder().decode(blob.value.body) : undefined;
191 };
192 const commitCount = async (owner: string, name: string): Promise<number> => {
193 const proc = Bun.spawn(["git", "rev-list", "--count", "main"], {
194 cwd: repoDir(app.reposRoot, owner, name),
195 env: gitClientEnv(),
196 stdout: "pipe",
197 });
198 return Number((await new Response(proc.stdout).text()).trim());
199 };
200 const save = (repo: string, edited: string, cookie: string, before = SETTINGS): Promise<Response> =>
201 post(`/alice/${repo}/config`, {
202 ref: "main",
203 path: "settings.json",
204 base: digestOf(before),
205 doc: edited,
206 step: "save",
207 message: "Port 9090",
208 }, cookie);
209
210 beforeAll(async () => {
211 dataDir = await mkdtemp(join(tmpdir(), "forge-config-form-"));
212 app = createApp({ dataDir, port: 0 });
213 servers = await serve(app);
214 base = servers.url;
215 origin = new URL(base).origin;
216 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-config-passphrase" });
217 const k = await signUpProven(app, { username: "kim", email: "[email protected]", password: "a-long-config-passphrase" });
218 if (!a.ok || !k.ok) throw new Error("signup failed");
219 // A fork is a member's: a guest's proposal is refused before it starts.
220 if (!setRole(app, a.value, "kim", "member").ok) throw new Error("role failed");
221 alice = `forge_session=${createSession(app.db, a.value.id).token}`;
222 kim = `forge_session=${createSession(app.db, k.value.id).token}`;
223 for (const [name, visibility] of [["lamp", "public"], ["vault", "private"]] as const) {
224 const made = await createRepo(app, a.value, { name, visibility });
225 if (!made.ok) throw new Error("repo failed");
226 await commitToBranch(app, a.value, made.value, {
227 message: "first",
228 files: [
229 { path: "settings.json", content: SETTINGS },
230 { path: "broken.json", content: '{\n "a": 1,\n oops\n}\n' },
231 ],
232 });
233 }
234 });
235
236 afterAll(async () => {
237 servers.stop();
238 app.close();
239 await rm(dataDir, { recursive: true, force: true });
240 });
241
242 test("the blob page draws a JSON file as a form, with Source and Edit as form one click away", async () => {
243 const html = await (await get("/alice/lamp/blob/main/settings.json")).text();
244 expect(html).toContain('class="card config-form"');
245 expect(html).toContain("?view=source");
246 expect(html).toContain('href="/alice/lamp/config/main/settings.json"');
247 const source = await (await get("/alice/lamp/blob/main/settings.json?view=source")).text();
248 expect(source).not.toContain('class="card config-form"');
249 });
250
251 test("the editor is a plain form that posts", async () => {
252 const res = await get("/alice/lamp/config/main/settings.json", alice);
253 expect(res.status).toBe(200);
254 const html = await res.text();
255 expect(html).toContain('action="/alice/lamp/config"');
256 expect(html).toContain('name="v/port"');
257 expect(html).toContain("Review changes");
258 // A reader is offered the proposal, not the commit.
259 expect(await (await get("/alice/lamp/config/main/settings.json", kim)).text()).toContain("Review and propose");
260 });
261
262 test("review shows the diff, and a no-op writes nothing", async () => {
263 const fields = { ref: "main", path: "settings.json", base: digestOf(SETTINGS), doc: stringify(doc(SETTINGS)), from: "form", "v/name": "lamp", "v/port": "9090", "v/tags/0": "a", step: "review" };
264 const review = await (await post("/alice/lamp/config", fields, alice)).text();
265 expect(review).toContain('data-op="del">- &quot;port&quot;: 8080,');
266 expect(review).toContain('data-op="add">+ &quot;port&quot;: 9090,');
267 expect(review).toContain("Commit to main");
268
269 const before = await commitCount("alice", "lamp");
270 const same = await (await post("/alice/lamp/config", { ...fields, "v/port": "8080" }, alice)).text();
271 expect(same).toContain("Nothing changed");
272 const again = await save("lamp", stringify(doc(SETTINGS)), alice);
273 expect(again.status).toBe(200);
274 expect(await commitCount("alice", "lamp")).toBe(before);
275 });
276
277 test("a writer's save makes one commit, in the file's own order and indent", async () => {
278 const before = await commitCount("alice", "lamp");
279 const res = await save("lamp", '{"name":"lamp","port":9090,"tags":["a"]}', alice);
280 expect(res.status).toBe(303);
281 expect(await commitCount("alice", "lamp")).toBe(before + 1);
282 expect(await fileAt("alice", "lamp", "main", "settings.json")).toBe(SETTINGS.replace("8080", "9090"));
283 });
284
285 test("a save over a file that moved since it was opened is held", async () => {
286 const res = await save("lamp", '{"name":"lamp","port":1,"tags":["a"]}', alice);
287 expect(res.status).toBe(200);
288 expect(await res.text()).toContain("changed on main since you opened it");
289 });
290
291 test("a reader's save opens a pull from their fork", async () => {
292 const current = (await fileAt("alice", "lamp", "main", "settings.json"))!;
293 const res = await save("lamp", '{"name":"kim","port":9090,"tags":["a"]}', kim, current);
294 expect(res.status).toBe(303);
295 const upstream = getRepo(app.db, "alice", "lamp")!;
296 const pulls = listPulls(app.db, upstream.id, { state: "open" });
297 expect(pulls.length).toBe(1);
298 expect(res.headers.get("location")).toBe(`/alice/lamp/pulls/${pulls[0]!.number}`);
299 expect(pulls[0]!.headRepoId).toBe(getRepo(app.db, "kim", "lamp")!.id);
300 // Nothing landed on the branch the reader cannot push to.
301 expect(await fileAt("alice", "lamp", "main", "settings.json")).toBe(current);
302 });
303
304 test("invalid JSON falls back to the source with the error and its line", async () => {
305 const blob = await (await get("/alice/lamp/blob/main/broken.json")).text();
306 expect(blob).toContain("Not valid JSON");
307 expect(blob).toContain("at line 3, column 3");
308 const res = await get("/alice/lamp/config/main/broken.json", alice);
309 expect(res.status).toBe(303);
310 expect(res.headers.get("location")).toContain("/alice/lamp/blob/main/broken.json?view=source");
311 });
312
313 test("a private repository's file is not editable signed out", async () => {
314 expect((await get("/alice/vault/config/main/settings.json")).status).toBe(404);
315 expect((await save("vault", '{"name":"x"}', "")).status).toBe(404);
316 expect((await get("/alice/vault/config/main/settings.json", kim)).status).toBe(404);
317 expect(await fileAt("alice", "vault", "main", "settings.json")).toBe(SETTINGS);
318 });
319
320 test("the hidden document is equal to the one drawn", () => {
321 expect(sameDoc(doc(SETTINGS), doc(stringify(doc(SETTINGS))))).toBe(true);
322 });
323 });
324
325 describe("who commits and who proposes", () => {
326 let dataDir: string;
327 let app: App;
328 let servers: Servers;
329 let base = "";
330 let origin = "";
331 const cookies: Record<string, string> = {};
332 const SETTINGS = '{\n "port": 8080,\n "tags": [\n "a"\n ]\n}\n';
333
334 const post = (path: string, fields: Record<string, string>, cookie: string): Promise<Response> =>
335 fetch(`${base}${path}`, {
336 method: "POST",
337 headers: { origin, cookie },
338 body: new URLSearchParams(fields),
339 redirect: "manual",
340 });
341 const save = (repo: string, cookie: string): Promise<Response> =>
342 post(`/${repo}/config`, {
343 ref: "main",
344 path: "settings.json",
345 base: digestOf(SETTINGS),
346 doc: '{"port":9090,"tags":["a"]}',
347 step: "save",
348 message: "Port 9090",
349 }, cookie);
350 const mainText = async (owner: string, name: string): Promise<string | undefined> => {
351 const blob = await localSource(app, getRepo(app.db, owner, name)!, undefined).blob("main", "settings.json");
352 return blob.ok && blob.value.kind === "text" ? new TextDecoder().decode(blob.value.body) : undefined;
353 };
354 const session = (id: string): string => `forge_session=${createSession(app.db, id).token}`;
355
356 beforeAll(async () => {
357 dataDir = await mkdtemp(join(tmpdir(), "forge-config-who-"));
358 app = createApp({ dataDir, port: 0 });
359 servers = await serve(app);
360 base = servers.url;
361 origin = new URL(base).origin;
362 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-config-passphrase" });
363 const dev = await signUpProven(app, { username: "dev", email: "[email protected]", password: "a-long-config-passphrase" });
364 const gus = await signUpProven(app, { username: "gus", email: "[email protected]", password: "a-long-config-passphrase" });
365 const una = await signUp(app, { username: "una", email: "[email protected]", password: "a-long-config-passphrase" }, { slot: fixtureSlot(app) });
366 if (!a.ok || !dev.ok || !gus.ok || !una.ok) throw new Error("users failed");
367 const agent = await createAgent(app, a.value, { username: "bot", brief: "Be brief." });
368 if (!agent.ok) throw new Error(String(agent.error));
369 const bot = { value: agent.value.user };
370 for (const [name, user] of [["alice", a], ["dev", dev], ["gus", gus], ["una", una], ["bot", bot]] as const)
371 cookies[name] = session(user.value.id);
372
373 const org = createOrg(app.db, "acme", a.value.id);
374 if (!org.ok) throw new Error("org failed");
375 const writers = createTeam(app.db, org.value.id, "Writers", "write");
376 if (!writers.ok) throw new Error("team failed");
377 addTeamMember(app.db, writers.value, dev.value.id);
378 // An agent with write access as a person would have it: on a write team.
379 addTeamMember(app.db, writers.value, bot.value.id);
380
381 for (const [owner, name] of [["alice", "lamp"], ["acme", "tower"], ["acme", "dock"]] as const) {
382 const made = await createRepo(app, a.value, { owner, name, visibility: "public" });
383 if (!made.ok) throw new Error(`repo failed: ${String(made.error)}`);
384 await commitToBranch(app, a.value, made.value, { message: "first", files: [{ path: "settings.json", content: SETTINGS }] });
385 }
386 updateRepoSettings(app.db, getRepo(app.db, "acme", "tower")!, { protectDefault: true });
387 });
388
389 afterAll(async () => {
390 servers.stop();
391 app.close();
392 await rm(dataDir, { recursive: true, force: true });
393 });
394
395 test("an agent with write access proposes, on a branch of the repository itself", async () => {
396 const page = await (await fetch(`${base}/acme/dock/config/main/settings.json`, { headers: { cookie: cookies.bot! } })).text();
397 expect(page).toContain("Review and propose");
398 const res = await save("acme/dock", cookies.bot!);
399 expect(res.status).toBe(303);
400 const dock = getRepo(app.db, "acme", "dock")!;
401 const pulls = listPulls(app.db, dock.id, { state: "open" });
402 expect(pulls).toHaveLength(1);
403 expect(pulls[0]!.headRepoId ?? dock.id).toBe(dock.id);
404 expect(await mainText("acme", "dock")).toBe(SETTINGS);
405 // A person on the same team, on the same unprotected branch, commits.
406 const direct = await save("acme/dock", cookies.dev!);
407 expect(direct.headers.get("location")).toBe("/acme/dock/blob/main/settings.json");
408 });
409
410 test("a writer who is not an admin, on a protected default, proposes inside the repository", async () => {
411 const res = await save("acme/tower", cookies.dev!);
412 expect(res.status).toBe(303);
413 const tower = getRepo(app.db, "acme", "tower")!;
414 const pulls = listPulls(app.db, tower.id, { state: "open" });
415 expect(pulls).toHaveLength(1);
416 expect(pulls[0]!.headRepoId ?? tower.id).toBe(tower.id);
417 expect(getRepo(app.db, "dev", "tower")).toBeUndefined();
418 expect(await mainText("acme", "tower")).toBe(SETTINGS);
419 });
420
421 test("a guest and an unconfirmed account hear why their proposal could not be made", async () => {
422 const guest = await (await save("alice/lamp", cookies.gus!)).text();
423 expect(guest).toContain(proposeEditText({ step: "fork", reason: "not_member" }));
424 const unconfirmed = await (await save("alice/lamp", cookies.una!)).text();
425 expect(unconfirmed).toContain("Confirm your email first");
426 expect(getRepo(app.db, "gus", "lamp")).toBeUndefined();
427 });
428
429 test("a signed-in stranger to a private repository gets 404 on the POST", async () => {
430 const alice = getRepo(app.db, "alice", "lamp")!;
431 updateRepoSettings(app.db, alice, { visibility: "private" });
432 try {
433 expect((await save("alice/lamp", cookies.gus!)).status).toBe(404);
434 } finally {
435 updateRepoSettings(app.db, getRepo(app.db, "alice", "lamp")!, { visibility: "public" });
436 }
437 });
438
439 test("a card button posted as a form comes back with the change, and saves nothing", async () => {
440 const form = (op: string, doc: string, values: Record<string, string>) =>
441 post("/alice/lamp/config", { ref: "main", path: "settings.json", base: digestOf(SETTINGS), doc, from: "form", op, ...values }, cookies.alice!);
442 const added = await (await form(opValue("add", "/tags"), '{"port":8080,"tags":["a"]}', { "v/port": "7", "v/tags/0": "a" })).text();
443 expect(added).toContain('name="v/tags/1"');
444 // The value typed beside the button rode along into the new form.
445 expect(added).toMatch(/name="v\/port"[^>]*value="7"/);
446 const removed = await (await form(opValue("remove", "/tags/0"), '{"port":8080,"tags":["a","b"]}', { "v/port": "8080", "v/tags/0": "a", "v/tags/1": "b" })).text();
447 expect(removed).toMatch(/name="v\/tags\/0"[^>]*value="b"/);
448 expect(removed).not.toContain('name="v/tags/1"');
449 expect(await mainText("alice", "lamp")).toBe(SETTINGS);
450 });
451 });
test/tui-propose-edit.test.ts added +157
@@ -0,0 +1,157 @@
1 /**
2 * The terminal's side of #7336: a reader of a forge repository proposes a
3 * change from the workspace (a branch on their fork, a pull back), and a
4 * commit that breaks a JSON file's schema is refused before it is written,
5 * as the web form refuses it.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { setRole } from "../src/actions/roles.ts";
14 import { createApp, type App as Forge } from "../src/app.ts";
15 import { getRepo } from "../src/core/repos.ts";
16 import { listPulls } from "../src/core/pulls.ts";
17 import type { User } from "../src/core/users.ts";
18 import { blobForRender } from "../src/git/read.ts";
19 import { createForgeApp } from "../src/tui/forge.ts";
20 import type { TuiIdentity } from "../src/tui/session.ts";
21 import { headlessTerminal } from "../src/tui/term/terminal.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23 import { settleApp, settleUntil } from "./helpers/tui.ts";
24 import { seedAdmin } from "./helpers/admin.ts";
25 import { signUpProven } from "./helpers/verified.ts";
26
27 quietLogs();
28
29 const LIMITS = '{\n "max": 5\n}\n';
30
31 describe("proposing and checking from the terminal", () => {
32 let dataDir: string;
33 let forge: Forge;
34 let alice: User;
35 let kim: User;
36
37 const as = (user: User): TuiIdentity => ({
38 sshUsername: user.username,
39 remoteAddress: "127.0.0.1",
40 fingerprint: "SHA256:test",
41 user,
42 });
43
44 function boot(identity: TuiIdentity) {
45 const terminal = headlessTerminal(120, 40);
46 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
47 app.start();
48 const send = (key: string): void => {
49 const ctrl = key.startsWith("ctrl+");
50 app.dispatch({ type: "key", key: ctrl ? key.slice(5) : key, ctrl, alt: false, shift: false });
51 };
52 return {
53 text: () => app.screen.toText(),
54 async press(...keys: string[]) {
55 for (const key of keys) send(key);
56 await settleApp(app);
57 },
58 async type(text: string) {
59 for (const ch of text) send(ch);
60 await settleApp(app);
61 },
62 settle: () => settleApp(app),
63 until: (what: string) => settleUntil(app, what),
64 stop: () => app.stop(),
65 };
66 }
67 type Harness = ReturnType<typeof boot>;
68
69 async function openBeacon(h: Harness): Promise<void> {
70 await h.settle();
71 await h.press("/");
72 await h.type("beacon");
73 await h.press("enter");
74 await h.press("enter");
75 await h.until("alice/beacon");
76 }
77
78 async function openFile(h: Harness, query: string): Promise<void> {
79 await h.press("t");
80 await h.type(query);
81 await h.press("enter");
82 }
83
84 const fileAt = async (owner: string, ref: string, path: string): Promise<string | undefined> => {
85 const blob = await blobForRender(join(forge.reposRoot, owner, "beacon.git"), ref, path, 1 << 20);
86 return blob.kind === "text" ? new TextDecoder().decode(blob.body) : undefined;
87 };
88
89 beforeAll(async () => {
90 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-propose-"));
91 forge = createApp({ dataDir, port: 0 });
92 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
93 const k = await signUpProven(forge, { username: "kim", email: "[email protected]", password: "a-long-passphrase" });
94 if (!a.ok || !k.ok) throw new Error("signup failed");
95 alice = a.value;
96 kim = k.value;
97 if (!setRole(forge, alice, "kim", "member").ok) throw new Error("role failed");
98 const made = await createRepo(forge, alice, { name: "beacon", visibility: "public" });
99 if (!made.ok) throw new Error("repo failed");
100 await commitToBranch(forge, alice, made.value, {
101 message: "seed",
102 files: [
103 { path: "README.md", content: "# Beacon\n" },
104 { path: "notes.txt", content: "a note\n" },
105 { path: "limits.json", content: LIMITS },
106 { path: "limits.schema.json", content: '{"properties":{"max":{"type":"integer","maximum":100}}}' },
107 ],
108 });
109 });
110
111 afterAll(async () => {
112 forge.close();
113 await rm(dataDir, { recursive: true, force: true });
114 });
115
116 test("a commit that breaks the file's schema is refused, and nothing lands", async () => {
117 const h = boot(as(alice));
118 try {
119 await openBeacon(h);
120 await openFile(h, "limitsjson");
121 await h.press("e", "down", "end");
122 await h.type("00");
123 await h.press("esc", "c");
124 await h.until("Commit limits.json");
125 await h.press("ctrl+s");
126 await h.until("breaks limits.schema.json");
127 expect(h.text()).toContain("/max must be at most");
128 expect(await fileAt("alice", "main", "limits.json")).toBe(LIMITS);
129 } finally {
130 h.stop();
131 }
132 });
133
134 test("a reader proposes from the terminal: a branch on their fork and a pull back", async () => {
135 const h = boot(as(kim));
136 try {
137 await openBeacon(h);
138 await openFile(h, "notes");
139 await h.press("e", "end");
140 await h.type(" from kim");
141 await h.press("esc", "c");
142 await h.until("Propose a change");
143 await h.press("ctrl+s");
144 await h.until("opened pull #");
145 const beacon = getRepo(forge.db, "alice", "beacon")!;
146 const pulls = listPulls(forge.db, beacon.id, { state: "open" });
147 expect(pulls).toHaveLength(1);
148 const fork = getRepo(forge.db, "kim", "beacon")!;
149 expect(pulls[0]!.headRepoId).toBe(fork.id);
150 expect(await fileAt("kim", pulls[0]!.headBranch, "notes.txt")).toBe("a note from kim\n");
151 // Nothing moved on the repository the reader cannot write.
152 expect(await fileAt("alice", "main", "notes.txt")).toBe("a note\n");
153 } finally {
154 h.stop();
155 }
156 });
157 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs