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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7336-config-files-open-as-a-form-json-edited → main 10 commits fork 9 threads · 9 unresolved
README.md +1
| @@ -436,6 +436,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 436 | 436 | - [](docs/GIT.md) — cloning over HTTPS and SSH, and partial clones of big repositories. |
| 437 | 437 | - [](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). |
| 438 | 438 | - [](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) — 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. | |
| 439 | 440 | - [](docs/ROLES.md) — guest, member and admin, and what each may do. |
| 440 | 441 | - [](docs/MCP.md) — the forge as a connector; [](docs/STANDARDS.md) is what it answers by default: `.well-known`, feeds, cards, the manifest. |
| 441 | 442 |
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. | ||
| 7 | 7 | |
| 8 | 8 | | component | web class | variants (`data-*`) | states | terminal equivalent | |
| 9 | 9 | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ------ | ------ | |
| 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 | | |
| 11 | 11 | | **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 | | |
| 13 | 13 | | **form field** `field()` | `.field` (`label`, `.hint`, `.error`) | `data-invalid` | — | label line, value line, hint line | |
| 14 | 14 | | **checkbox** | `.check` | — | native, `accent-color` themed | `[x]` / `[ ]` | |
| 15 | 15 | | **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"); | |
| 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 { | ||
| 222 | 222 | newFilePage, |
| 223 | 223 | previewSubmit, |
| 224 | 224 | } from "../web/pages/edit.ts"; |
| 225 | import { configPage, configSubmit } from "../web/pages/config.ts"; | |
| 225 | 226 | import { commitPage, commitsPage } from "../web/pages/commits.ts"; |
| 226 | 227 | import { |
| 227 | 228 | issueCommentSubmit, |
| @@ -1359,6 +1360,9 @@ export function buildRouter(): Router<Handler> { | ||
| 1359 | 1360 | // A mirror of a repository you cannot push to takes its edits as |
| 1360 | 1361 | // proposals: fork at the origin, commit there, open the pull upstream. |
| 1361 | 1362 | 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); | |
| 1362 | 1366 | |
| 1363 | 1367 | router.add("GET", "/:owner/:name/commits", commitsPage); |
| 1364 | 1368 | router.add("GET", "/:owner/:name/commit/:oid", commitPage); |
src/tui/screens/workspace.ts +101 −3
| @@ -35,6 +35,8 @@ | ||
| 35 | 35 | */ |
| 36 | 36 | |
| 37 | 37 | import { editFiles } from "../../actions/contents.ts"; |
| 38 | import { proposeEdit, proposeEditText } from "../../actions/propose_edit.ts"; | |
| 39 | import { schemaRefusal } from "../../web/render/json-schema.ts"; | |
| 38 | 40 | import { err, ok } from "../../kernel/result.ts"; |
| 39 | 41 | import { createBranch } from "../../actions/branches.ts"; |
| 40 | 42 | import { NAME_THE_BRANCH } from "../../actions/branch_words.ts"; |
| @@ -560,8 +562,15 @@ export function workspaceScreen( | ||
| 560 | 562 | /** Set when a change made here has to travel to another forge to land. */ |
| 561 | 563 | const proposal = (): OriginTarget | undefined => |
| 562 | 564 | 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"; | |
| 563 | 572 | /** 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(); | |
| 565 | 574 | const admin = (): boolean => |
| 566 | 575 | repo !== undefined && canAdmin(f.db, repo, f.user); |
| 567 | 576 | const readPin = (): void => { |
| @@ -842,9 +851,28 @@ export function workspaceScreen( | ||
| 842 | 851 | |
| 843 | 852 | // ---- mutations ---- |
| 844 | 853 | |
| 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 | ||
| 845 | 873 | const commit = (): void => { |
| 846 | 874 | const target = proposal(); |
| 847 | if (!writable() && target === undefined) { | |
| 875 | if (!writable() && target === undefined && !forkable()) { | |
| 848 | 876 | readOnlyToast(); |
| 849 | 877 | return; |
| 850 | 878 | } |
| @@ -881,6 +909,10 @@ export function workspaceScreen( | ||
| 881 | 909 | proposeChange(target, files, subject, payload); |
| 882 | 910 | return; |
| 883 | 911 | } |
| 912 | if (forkable() && repo !== undefined) { | |
| 913 | proposeHere(repo, files, subject, payload); | |
| 914 | return; | |
| 915 | } | |
| 884 | 916 | if (repo === undefined) { |
| 885 | 917 | readOnlyToast(); |
| 886 | 918 | return; |
| @@ -896,6 +928,8 @@ export function workspaceScreen( | ||
| 896 | 928 | files, |
| 897 | 929 | defaultBranchName: suggestBranch(f, "edit", slug(first.path)), |
| 898 | 930 | commit: async (plan) => { |
| 931 | const broken = await schemaCheck(payload(plan.paths)); | |
| 932 | if (broken !== undefined) return err(broken); | |
| 899 | 933 | const made = await editFiles(f.app, f.user, here, { |
| 900 | 934 | branch: ref, |
| 901 | 935 | base: ref, |
| @@ -929,6 +963,68 @@ export function workspaceScreen( | ||
| 929 | 963 | ); |
| 930 | 964 | }; |
| 931 | 965 | |
| 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 | ||
| 932 | 1028 | /** |
| 933 | 1029 | * The same dialog, aimed at somebody else's forge. |
| 934 | 1030 | * |
| @@ -2325,7 +2421,9 @@ export function workspaceScreen( | ||
| 2325 | 2421 | ? "" |
| 2326 | 2422 | : ` · ${tableProblem(t.table)}` |
| 2327 | 2423 | }` |
| 2328 | }${writable() || target !== undefined ? "" : " · read only"}`; | |
| 2424 | }${ | |
| 2425 | writable() || target !== undefined ? "" : forkable() ? " · proposes from your fork" : " · read only" | |
| 2426 | }`; | |
| 2329 | 2427 | if (tail === "") return; |
| 2330 | 2428 | const shown = truncate(tail, Math.max(0, r.w - 4)); |
| 2331 | 2429 | 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 |
||
| 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 |
||
| 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 |
||
| 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: |
||
| 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 |
||
| 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 |
||
| 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 @@ | ||
| 7 | 7 | import type { Html } from "../html.ts"; |
| 8 | 8 | import type { Blob } from "../../view/source.ts"; |
| 9 | 9 | import { audio } from "./audio.ts"; |
| 10 | import { config, isFormJson } from "./config.ts"; | |
| 10 | 11 | import { csv } from "./csv.ts"; |
| 11 | 12 | import { image } from "./image.ts"; |
| 12 | 13 | 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>>> = { | ||
| 49 | 50 | csv, |
| 50 | 51 | audio, |
| 51 | 52 | video, |
| 53 | config, | |
| 52 | 54 | }; |
| 53 | 55 | |
| 54 | 56 | /** |
| @@ -81,7 +83,9 @@ export function rendererFor( | ||
| 81 | 83 | : claimedMedia(path)?.kind; |
| 82 | 84 | if (kind === undefined || blob.size > RENDER_CAP[kind]) return undefined; |
| 83 | 85 | 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]; | |
| 85 | 89 | if (render === undefined) return undefined; |
| 86 | 90 | return { render, body: blob.kind === "too_large" ? NO_BYTES : blob.body }; |
| 87 | 91 | } |
src/web/style/content.ts +12
| @@ -839,6 +839,18 @@ textarea.editor:focus-visible { outline: none; } | ||
| 839 | 839 | #needs-you #asks-phone-more > .stack { margin-top: 0; } |
| 840 | 840 | } |
| 841 | 841 | } |
| 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); } | |
| 842 | 854 | /* Reduced motion: the pulses hold still at a legible level, the scroll-tied |
| 843 | 855 | fade stays at its resting width, and these transitions land at once. */ |
| 844 | 856 | @media (prefers-reduced-motion: reduce) { |
src/web/ui.ts +43 −6
| @@ -170,6 +170,9 @@ export interface BtnOptions { | ||
| 170 | 170 | * opener handle back into this page. */ |
| 171 | 171 | external?: boolean; |
| 172 | 172 | 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; | |
| 173 | 176 | current?: boolean; |
| 174 | 177 | /** A toggle: emits `aria-pressed` with its state. */ |
| 175 | 178 | pressed?: boolean; |
| @@ -206,6 +209,7 @@ export function btn(content: Html | string, o: BtnOptions = {}): Html { | ||
| 206 | 209 | ${o.value !== undefined ? html`value="${o.value}"` : null} |
| 207 | 210 | ${o.form ? html`form="${o.form}"` : null} |
| 208 | 211 | ${o.disabled ? raw("disabled") : null} |
| 212 | ${o.formnovalidate ? raw("formnovalidate") : null} | |
| 209 | 213 | ${btnAttrs(o)} |
| 210 | 214 | > |
| 211 | 215 | ${inner} |
| @@ -1137,6 +1141,20 @@ export interface FieldOptions { | ||
| 1137 | 1141 | /** `false` for names and paths, which a spelling checker only underlines. */ |
| 1138 | 1142 | spellcheck?: boolean; |
| 1139 | 1143 | 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; | |
| 1140 | 1158 | } |
| 1141 | 1159 | |
| 1142 | 1160 | /** Label, control, hint and error, wired together with `aria-describedby`. */ |
| @@ -1145,10 +1163,12 @@ export function field(o: FieldOptions): Html { | ||
| 1145 | 1163 | const described = [o.hint ? `${id}-hint` : "", o.error ? `${id}-err` : ""] |
| 1146 | 1164 | .filter(Boolean) |
| 1147 | 1165 | .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}`; | |
| 1148 | 1169 | const shared = html`class="input${o.mono ? " mono" : ""}" id="${id}" |
| 1149 | 1170 | name="${o.name}" |
| 1150 | ${described !== "" ? html`aria-describedby="${described}"` : null} | |
| 1151 | ${o.error ? raw('aria-invalid="true"') : null} | |
| 1171 | ${aria} | |
| 1152 | 1172 | ${o.required ? raw("required") : null} |
| 1153 | 1173 | ${o.autofocus ? raw("autofocus") : null} |
| 1154 | 1174 | ${o.readonly ? raw("readonly") : null} |
| @@ -1159,18 +1179,35 @@ export function field(o: FieldOptions): Html { | ||
| 1159 | 1179 | ? html`<textarea ${shared} rows="${o.rows ?? 6}" data-grow> |
| 1160 | 1180 | ${o.value ?? ""}</textarea |
| 1161 | 1181 | >` |
| 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} | |
| 1164 | 1190 | type="${o.type ?? "text"}" |
| 1165 | 1191 | value="${o.value ?? ""}" |
| 1192 | ${o.checked ? raw("checked") : null} | |
| 1166 | 1193 | ${o.autocomplete ? html`autocomplete="${o.autocomplete}"` : null} |
| 1167 | 1194 | ${o.pattern ? html`pattern="${o.pattern}"` : null} |
| 1168 | 1195 | ${o.minlength !== undefined ? html`minlength="${o.minlength}"` : null} |
| 1169 | 1196 | ${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} | |
| 1170 | 1200 | />`; |
| 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}`; | |
| 1171 | 1208 | 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} | |
| 1174 | 1211 | ${o.hint ? html`<span class="hint" id="${id}-hint">${o.hint}</span>` : null} |
| 1175 | 1212 | ${o.error |
| 1176 | 1213 | ? 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=""prod""'); | |
| 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">- "port": 8080,'); | |
| 266 | expect(review).toContain('data-op="add">+ "port": 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 | }); |
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.