diff --git a/web/boneyard.config.json b/web/boneyard.config.json index c71c715f..1b323e73 100644 --- a/web/boneyard.config.json +++ b/web/boneyard.config.json @@ -37,6 +37,12 @@ "repo-commitview--full-commit": { "route": "/iframe.html?id=repo-commitview--full-commit&viewMode=story" }, + "repo-repoforkform--skeleton-fixture": { + "route": "/iframe.html?id=repo-repoforkform--skeleton-fixture&viewMode=story" + }, + "repo-repocreationoptions--loading-row": { + "route": "/iframe.html?id=repo-repocreationoptions--loading-row&viewMode=story" + }, "repo-issues-issueform--create": { "route": "/iframe.html?id=repo-issues-issueform--create&viewMode=story" }, diff --git a/web/src/lib/api/awaitRecord.test.ts b/web/src/lib/api/awaitRecord.test.ts new file mode 100644 index 00000000..5fd2e618 --- /dev/null +++ b/web/src/lib/api/awaitRecord.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Cid, ResourceUri } from "@atcute/lexicons/syntax"; +import type { BobbinContext } from "./client"; +import { awaitIndexedRecord, awaitRecord } from "./awaitRecord"; + +const revision = { + uri: "at://did:plc:alice/sh.tangled.repo/demo" as ResourceUri, + cid: "bafyrevision" as Cid +}; + +const contextFor = (data: Record) => { + const call = vi.fn(async () => ({ ok: true, data })); + const ctx = { xrpc: { call } } as unknown as BobbinContext; + return { call, ctx }; +}; + +describe("awaitRecord", () => { + it("forwards a written revision and returns bobbin's result", async () => { + const { call, ctx } = contextFor({ status: "indexed", cid: revision.cid }); + + await expect(awaitRecord(ctx, revision)).resolves.toMatchObject({ status: "indexed" }); + expect(call).toHaveBeenCalledWith(expect.anything(), { + params: { uri: revision.uri, cid: revision.cid } + }); + }); + + it("supports cid-less delete waits", async () => { + const { call, ctx } = contextFor({ status: "deleted" }); + + await expect(awaitRecord(ctx, { uri: revision.uri })).resolves.toMatchObject({ + status: "deleted" + }); + expect(call).toHaveBeenCalledWith(expect.anything(), { params: { uri: revision.uri } }); + }); +}); + +describe("awaitIndexedRecord", () => { + it("rejects a revision bobbin did not index", async () => { + const { ctx } = contextFor({ status: "rejected", detail: "invalid repository" }); + + await expect(awaitIndexedRecord(ctx, revision)).rejects.toThrow("invalid repository"); + }); +}); diff --git a/web/src/lib/api/awaitRecord.ts b/web/src/lib/api/awaitRecord.ts new file mode 100644 index 00000000..e38ed24d --- /dev/null +++ b/web/src/lib/api/awaitRecord.ts @@ -0,0 +1,30 @@ +import { ok } from "@atcute/client"; +import type { Cid, ResourceUri } from "@atcute/lexicons/syntax"; +import type { BobbinContext } from "./client"; +import { mainSchema as awaitRecordSchema } from "./lexicons/types/sh/tangled/bobbin/awaitRecord"; + +export interface RecordRevision { + uri: ResourceUri; + cid?: Cid; +} + +export const awaitRecord = async (ctx: BobbinContext, revision: RecordRevision) => { + return ok( + ctx.xrpc.call(awaitRecordSchema, { + params: { + uri: revision.uri, + ...(revision.cid ? { cid: revision.cid } : {}) + } + }) + ); +}; + +export const awaitIndexedRecord = async ( + ctx: BobbinContext, + revision: RecordRevision +): Promise => { + const result = await awaitRecord(ctx, revision); + if (result.status !== "indexed") { + throw new Error(result.detail || result.reason || "Bobbin rejected the record."); + } +}; diff --git a/web/src/lib/api/repoCreate.test.ts b/web/src/lib/api/repoCreate.test.ts new file mode 100644 index 00000000..91d85a5e --- /dev/null +++ b/web/src/lib/api/repoCreate.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Did } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; + +const agent = { + sub: "did:plc:alice" +} as unknown as OAuthUserAgent; + +interface HarnessOptions { + createError?: Error; + putError?: Error; +} + +// mocks have to be installed before the module is loaded +const load = async ({ createError, putError }: HarnessOptions = {}) => { + vi.resetModules(); + const events: string[] = []; + const records: unknown[] = []; + const knotInputs: Record[] = []; + const createRecord = vi.fn(async (_agent, _collection, record) => { + events.push("record:create"); + records.push(record); + return { uri: "at://did:plc:alice/sh.tangled.repo/demo", cid: "initial-cid" }; + }); + const putRecord = vi.fn(async (_agent, _collection, _rkey, record, swapRecord) => { + events.push("record:put"); + if (putError) throw putError; + records.push(record); + return { + uri: "at://did:plc:alice/sh.tangled.repo/demo", + cid: "committed-cid", + swapRecord + }; + }); + const deleteRecord = vi.fn(async () => { + events.push("record:delete"); + }); + + vi.doMock("./write", () => ({ createRecord, putRecord, deleteRecord })); + vi.doMock("./client", () => ({ + createBobbinClient: ({ serviceUrl }: { serviceUrl: string }) => ({ + xrpc: { + call: async (_schema: unknown, options: { input?: Record }) => { + if (serviceUrl === "https://bobbin.test") { + events.push("bobbin:await"); + return { ok: true, data: { status: "indexed" } }; + } + if (options.input) knotInputs.push(options.input); + if (options.input && "repo" in options.input) { + events.push("knot:delete"); + return { ok: true, data: null }; + } + events.push("knot:create"); + if (createError) throw createError; + return { ok: true, data: { repoDid: "did:plc:repo" } }; + } + } + }) + })); + + return { + api: await import("./repoCreate"), + events, + records, + knotInputs, + createRecord, + putRecord, + deleteRecord + }; +}; + +const input = { + ownerDid: "did:plc:alice" as Did, + ownerHandle: "alice.test", + name: "Demo.git", + description: "a repo", + defaultBranch: "main", + knot: "knot.test", + spindle: "spindle.test" +}; + +describe("repository creation", () => { + it("announces the record before creating the knot repo, then commits the repo DID", async () => { + const { api, events, records, putRecord } = await load(); + + const creation = await api.createRepo(agent, "https://bobbin.test", input); + + expect(creation.name).toBe("Demo"); + expect(events).toEqual([ + "record:create", + "bobbin:await", + "knot:create", + "record:put", + "bobbin:await" + ]); + expect(records[0]).toMatchObject({ + $type: "sh.tangled.repo", + knot: "knot.test", + name: "Demo", + spindle: "spindle.test" + }); + expect(records[1]).toMatchObject({ repoDid: "did:plc:repo" }); + expect(putRecord).toHaveBeenCalledWith( + agent, + "sh.tangled.repo", + "demo", + expect.anything(), + "initial-cid" + ); + }); + + it("removes the pds record before cleaning up the knot when commit fails", async () => { + const { api, events, deleteRecord } = await load({ putError: new Error("pds refused") }); + + await expect(api.createRepo(agent, "https://bobbin.test", input)).rejects.toThrow( + "pds refused" + ); + + expect(events.slice(-2)).toEqual(["record:delete", "knot:delete"]); + expect(deleteRecord).toHaveBeenCalledWith(agent, "sh.tangled.repo", "demo", "initial-cid"); + }); + + it("removes the announced record when the knot rejects creation", async () => { + const { api, events } = await load({ createError: new Error("no capacity") }); + + await expect(api.createRepo(agent, "https://bobbin.test", input)).rejects.toThrow( + "no capacity" + ); + + expect(events).toContain("record:delete"); + expect(events).not.toContain("knot:delete"); + }); + + it("passes fork identity and clone source to both halves", async () => { + const { api, records, knotInputs } = await load(); + await api.createRepo(agent, "https://bobbin.test", { + ...input, + name: "fork", + source: { + record: "did:plc:upstream", + cloneUrl: "https://knot.source/did:plc:upstream" + } + }); + + expect(records[0]).toMatchObject({ source: "did:plc:upstream" }); + expect(knotInputs[0]).toMatchObject({ + source: "https://knot.source/did:plc:upstream" + }); + }); +}); + +describe("repository names", () => { + it.each(["", ".hidden", "trail.", "a/b", "a..b", "self", "bad name"])( + "rejects %j", + async (name) => { + const { api } = await load(); + expect(() => api.validateRepoName(name)).toThrow(); + } + ); +}); diff --git a/web/src/lib/api/repoCreate.ts b/web/src/lib/api/repoCreate.ts new file mode 100644 index 00000000..6d5b5c69 --- /dev/null +++ b/web/src/lib/api/repoCreate.ts @@ -0,0 +1,215 @@ +import { ok } from "@atcute/client"; +import type { Cid, Did, Nsid, ResourceUri } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { awaitIndexedRecord } from "./awaitRecord"; +import { createBobbinClient, type BobbinContext } from "./client"; +import { mainSchema as createRepoSchema } from "./lexicons/types/sh/tangled/repo/create"; +import { mainSchema as deleteRepoSchema } from "./lexicons/types/sh/tangled/repo/delete"; +import type { RepoRecord } from "./records"; +import { createRecord, deleteRecord, putRecord, type WrittenRecord } from "./write"; + +const REPO_COLLECTION = "sh.tangled.repo" as Nsid; +const DEFAULT_LABELS = [ + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix", + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue", + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate", + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation", + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee" +] as ResourceUri[]; + +export interface RepoCreationInput { + ownerDid: Did; + ownerHandle: string; + name: string; + description?: string; + defaultBranch?: string; + knot: string; + spindle?: string; + source?: { + record: string; + cloneUrl: string; + }; +} + +export interface CreatedRepo { + ownerHandle: string; + name: string; + repoDid: Did; +} + +export const validateRepoName = (rawName: string): string => { + if (rawName.length === 0) throw new Error("Repository name cannot be empty."); + if (rawName.length > 100) throw new Error("Repository name must be 100 characters or fewer."); + if (rawName.includes("/") || rawName.includes("\\")) { + throw new Error("Repository name contains invalid path characters."); + } + if (rawName.startsWith(".") || rawName.endsWith(".")) { + throw new Error("Repository name contains an invalid path sequence."); + } + if (!/^[a-zA-Z0-9._-]+$/.test(rawName)) { + throw new Error( + "Repository name can only contain alphanumeric characters, periods, hyphens, and underscores." + ); + } + if (rawName.includes("..")) throw new Error("Repository name cannot contain sequential dots."); + if (rawName.toLowerCase() === "self") + throw new Error(`Repository name ${JSON.stringify(rawName)} is reserved.`); + + return rawName.endsWith(".git") ? rawName.slice(0, -4) : rawName; +}; + +export const knotServiceUrl = (knot: string): string => + /^[a-z][a-z0-9+.-]*:\/\//i.test(knot) ? knot.replace(/\/+$/, "") : `https://${knot}`; + +const messageOf = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +const sleep = (milliseconds: number): Promise => { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, milliseconds); + return promise; +}; + +const deleteOptimisticRecord = async ( + agent: OAuthUserAgent, + rkey: string, + cid: Cid +): Promise => { + try { + await deleteRecord(agent, REPO_COLLECTION, rkey, cid); + return null; + } catch (cause) { + return `the repository record could not be reverted: ${messageOf(cause)}`; + } +}; + +const deleteKnotRepo = async ( + agent: OAuthUserAgent, + input: RepoCreationInput, + rkey: string, + repoDid: Did +): Promise => { + const ctx = createBobbinClient({ + serviceUrl: knotServiceUrl(input.knot), + agent + }); + let lastError = "unknown error"; + for (const delay of [0, 2_000, 5_000]) { + if (delay > 0) await sleep(delay); + try { + await ok( + ctx.xrpc.call(deleteRepoSchema, { + input: { + repo: repoDid, + did: input.ownerDid, + name: rkey, + rkey: rkey as never + } + }) + ); + return null; + } catch (cause) { + lastError = messageOf(cause); + } + } + return `the repository on ${input.knot} could not be reverted: ${lastError}`; +}; + +const completeCreation = async ( + agent: OAuthUserAgent, + bobbin: BobbinContext, + input: RepoCreationInput, + rkey: string, + record: RepoRecord, + initial: WrittenRecord +): Promise<{ repoDid: Did }> => { + let repoDid: Did | undefined; + let currentCid = initial.cid; + try { + const knot = createBobbinClient({ + serviceUrl: knotServiceUrl(input.knot), + agent + }); + const created = await ok( + knot.xrpc.call(createRepoSchema, { + input: { + rkey: rkey as never, + name: rkey, + ...(input.defaultBranch ? { defaultBranch: input.defaultBranch } : {}), + ...(input.source ? { source: input.source.cloneUrl } : {}) + } + }) + ); + repoDid = created.repoDid; + if (!repoDid) { + throw new Error("Knot failed to mint a repo DID. The knot may need to be upgraded."); + } + + const committed = await putRecord( + agent, + REPO_COLLECTION, + rkey, + { ...record, repoDid }, + initial.cid + ); + currentCid = committed.cid; + await awaitIndexedRecord(bobbin, committed); + return { repoDid }; + } catch (cause) { + const recordRollback = await deleteOptimisticRecord(agent, rkey, currentCid); + const knotRollback = + repoDid && !recordRollback ? await deleteKnotRepo(agent, input, rkey, repoDid) : null; + const rollback = [recordRollback, knotRollback].filter(Boolean).join("; "); + throw new Error( + `Failed to create repository: ${messageOf(cause)}${rollback ? `. Also, ${rollback}.` : ""}`, + { cause } + ); + } +}; + +// announce first so bobbin observes each side of the transaction in order +export const createRepo = async ( + agent: OAuthUserAgent, + bobbinUrl: string, + input: RepoCreationInput +): Promise => { + const name = validateRepoName(input.name); + const rkey = name.toLowerCase(); + const description = input.description?.trim() || undefined; + const spindle = input.spindle?.trim() || undefined; + if ([...(description ?? "")].length > 140) { + throw new Error("Description must be 140 characters or fewer."); + } + if (!input.knot) throw new Error("Select a knot."); + if (agent.sub !== input.ownerDid) throw new Error("The active account changed. Try again."); + + const record: RepoRecord = { + $type: "sh.tangled.repo", + knot: input.knot, + createdAt: new Date().toISOString() as never, + labels: DEFAULT_LABELS, + ...(name !== rkey ? { name } : {}), + ...(description ? { description } : {}), + ...(spindle ? { spindle } : {}), + ...(input.source ? { source: input.source.record as never } : {}) + }; + + const bobbin = createBobbinClient({ serviceUrl: bobbinUrl }); + const initial = await createRecord(agent, REPO_COLLECTION, record, rkey); + try { + await awaitIndexedRecord(bobbin, initial); + } catch (cause) { + const rollback = await deleteOptimisticRecord(agent, rkey, initial.cid); + throw new Error( + `Failed to announce repository creation: ${messageOf(cause)}${rollback ? `. Also, ${rollback}.` : ""}`, + { cause } + ); + } + + const { repoDid } = await completeCreation(agent, bobbin, input, rkey, record, initial); + return { + ownerHandle: input.ownerHandle, + name, + repoDid + }; +}; diff --git a/web/src/lib/api/repoCreationOptions.ts b/web/src/lib/api/repoCreationOptions.ts new file mode 100644 index 00000000..091f6f77 --- /dev/null +++ b/web/src/lib/api/repoCreationOptions.ts @@ -0,0 +1,24 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import { createBobbinClient } from "./client"; +import { stream } from "./load"; +import { availableKnots, availableSpindles } from "./repoCreationTargets"; + +interface RepoCreationParent { + auth?: { did: string } | null; + publicConfig: { bobbinUrl: string }; +} + +export const repoCreationOptions = ( + parent: RepoCreationParent, + fetch: typeof globalThis.fetch +): { knots: Promise; spindles: Promise } => { + if (!parent.auth?.did) { + return { knots: Promise.resolve([]), spindles: Promise.resolve([]) }; + } + const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch }); + const did = parent.auth.did as Did; + return { + knots: stream(availableKnots(ctx, did).catch(() => [])), + spindles: stream(availableSpindles(ctx, did).catch(() => [])) + }; +}; diff --git a/web/src/lib/api/repoCreationTargets.test.ts b/web/src/lib/api/repoCreationTargets.test.ts new file mode 100644 index 00000000..40731905 --- /dev/null +++ b/web/src/lib/api/repoCreationTargets.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Did } from "@atcute/lexicons/syntax"; +import type { BobbinContext } from "./client"; +import { availableKnots, availableSpindles } from "./repoCreationTargets"; + +const did = "did:plc:alice" as Did; + +describe("availableSpindles", () => { + it("merges owned spindles and memberships across pages", async () => { + const responses = [ + { + items: [ + { + uri: "at://did:plc:alice/sh.tangled.spindle/owned.example", + value: {} + } + ], + cursor: "next" + }, + { + items: [ + { + uri: "at://did:plc:owner/sh.tangled.spindle.member/one", + value: { instance: "member.example" } + }, + { + uri: "at://did:plc:owner/sh.tangled.spindle.member/two", + value: { instance: "owned.example" } + }, + { uri: "at://did:plc:owner/sh.tangled.spindle.member/bad", value: {} } + ] + }, + { + items: [ + { + uri: "at://did:plc:alice/sh.tangled.spindle/another.example", + value: {} + } + ] + } + ]; + const call = vi.fn(async (_schema: unknown, _options: unknown) => ({ + ok: true, + data: responses.shift() + })); + const ctx = { xrpc: { call } } as unknown as BobbinContext; + + await expect(availableSpindles(ctx, did)).resolves.toEqual([ + "another.example", + "member.example", + "owned.example" + ]); + expect(call).toHaveBeenCalledTimes(3); + expect(call.mock.calls[0][1]).toMatchObject({ params: { subject: did, limit: 1_000 } }); + expect(call.mock.calls[2][1]).toMatchObject({ params: { cursor: "next" } }); + }); +}); + +describe("availableKnots", () => { + it("merges owned knots and memberships", async () => { + const responses = [ + { + items: [ + { + uri: "at://did:plc:alice/sh.tangled.knot/owned.example", + value: {} + } + ] + }, + { + items: [ + { + uri: "at://did:plc:owner/sh.tangled.knot.member/one", + value: { domain: "member.example" } + }, + { + uri: "at://did:plc:owner/sh.tangled.knot.member/two", + value: { domain: "owned.example" } + } + ] + } + ]; + const call = vi.fn(async (_schema: unknown, _options: unknown) => ({ + ok: true, + data: responses.shift() + })); + const ctx = { xrpc: { call } } as unknown as BobbinContext; + + await expect(availableKnots(ctx, did)).resolves.toEqual([ + "member.example", + "owned.example" + ]); + expect(call).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/src/lib/api/repoCreationTargets.ts b/web/src/lib/api/repoCreationTargets.ts new file mode 100644 index 00000000..6f65cdc3 --- /dev/null +++ b/web/src/lib/api/repoCreationTargets.ts @@ -0,0 +1,92 @@ +import { ok } from "@atcute/client"; +import type { Did } from "@atcute/lexicons/syntax"; +import type { BobbinContext } from "./client"; +import { mainSchema as listKnotMembersSchema } from "./lexicons/types/sh/tangled/knot/listMembers"; +import { mainSchema as listKnotsSchema } from "./lexicons/types/sh/tangled/knot/listKnots"; +import { mainSchema as listSpindleMembersSchema } from "./lexicons/types/sh/tangled/spindle/listMembers"; +import { mainSchema as listSpindlesSchema } from "./lexicons/types/sh/tangled/spindle/listSpindles"; +import { rkeyFromUri } from "./uri"; + +interface Page { + items: T[]; + cursor?: string; +} + +interface RecordItem { + uri: string; + value: unknown; +} + +const allPages = async (load: (cursor?: string) => Promise>): Promise => { + const items: T[] = []; + let cursor: string | undefined; + do { + const page = await load(cursor); + items.push(...page.items); + cursor = page.cursor || undefined; + } while (cursor); + return items; +}; + +const knotDomain = (value: unknown): string | null => { + if (typeof value !== "object" || value === null || !("domain" in value)) return null; + return typeof value.domain === "string" && value.domain ? value.domain : null; +}; + +const spindleInstance = (value: unknown): string | null => { + if (typeof value !== "object" || value === null || !("instance" in value)) return null; + return typeof value.instance === "string" && value.instance ? value.instance : null; +}; + +const targetNames = ( + owned: RecordItem[], + memberships: RecordItem[], + memberName: (value: unknown) => string | null +): string[] => { + const names = new Set(owned.map((item) => rkeyFromUri(item.uri))); + for (const item of memberships) { + const name = memberName(item.value); + if (name) names.add(name); + } + return [...names].sort((a, b) => a.localeCompare(b)); +}; + +export const availableKnots = async (ctx: BobbinContext, did: Did): Promise => { + const [owned, memberships] = await Promise.all([ + allPages((cursor) => + ok( + ctx.xrpc.call(listKnotsSchema, { + params: { subject: did, limit: 1_000, cursor } + }) + ) + ), + allPages((cursor) => + ok( + ctx.xrpc.call(listKnotMembersSchema, { + params: { subject: did, limit: 1_000, cursor } + }) + ) + ) + ]); + return targetNames(owned, memberships, knotDomain); +}; + +export const availableSpindles = async (ctx: BobbinContext, did: Did): Promise => { + const [owned, memberships] = await Promise.all([ + allPages((cursor) => + ok( + ctx.xrpc.call(listSpindlesSchema, { + params: { subject: did, limit: 1_000, cursor } + }) + ) + ), + allPages((cursor) => + ok( + ctx.xrpc.call(listSpindleMembersSchema, { + params: { subject: did, limit: 1_000, cursor } + }) + ) + ) + ]); + return targetNames(owned, memberships, spindleInstance); +}; diff --git a/web/src/lib/api/write.test.ts b/web/src/lib/api/write.test.ts index 39639dce..8098dfb3 100644 --- a/web/src/lib/api/write.test.ts +++ b/web/src/lib/api/write.test.ts @@ -42,6 +42,14 @@ describe("record writes", () => { expect(inputs[0]).toMatchObject({ rkey: "3lk", swapRecord: "bafyread" }); }); + it("forwards the swap guard on deletes used for rollback", async () => { + const { write, inputs } = await load(); + + await write.deleteRecord(agent, collection, "3lk", "bafycreated"); + + expect(inputs[0]).toMatchObject({ rkey: "3lk", swapRecord: "bafycreated" }); + }); + it("takes a minted rkey instead of letting the pds assign one", async () => { const { write, inputs } = await load(); diff --git a/web/src/lib/api/write.ts b/web/src/lib/api/write.ts index 37246211..ba2a76bf 100644 --- a/web/src/lib/api/write.ts +++ b/web/src/lib/api/write.ts @@ -2,14 +2,14 @@ import { ok } from "@atcute/client"; import { mainSchema as createRecordSchema } from "@atcute/atproto/types/repo/createRecord"; import { mainSchema as deleteRecordSchema } from "@atcute/atproto/types/repo/deleteRecord"; import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRecord"; -import type { Nsid, RecordKey } from "@atcute/lexicons/syntax"; +import type { Cid, Nsid, RecordKey, ResourceUri } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { createClient } from "$lib/auth/agent"; import { clear } from "./cache"; export interface WrittenRecord { - uri: string; - cid?: string; + uri: ResourceUri; + cid: Cid; } // every record write in the app goes through here, so the read cache is dropped @@ -60,11 +60,17 @@ export const putRecord = async ( export const deleteRecord = async ( agent: OAuthUserAgent, collection: Nsid, - rkey: string + rkey: string, + swapRecord?: string ): Promise => { await ok( createClient(agent).call(deleteRecordSchema, { - input: { repo: agent.sub, collection, rkey: rkey as RecordKey } + input: { + repo: agent.sub, + collection, + rkey: rkey as RecordKey, + ...(swapRecord ? { swapRecord } : {}) + } }) ); clear(); diff --git a/web/src/lib/bones/repo-repocreationoptions--loading-row.bones.json b/web/src/lib/bones/repo-repocreationoptions--loading-row.bones.json new file mode 100644 index 00000000..ad2d03ef --- /dev/null +++ b/web/src/lib/bones/repo-repocreationoptions--loading-row.bones.json @@ -0,0 +1,35 @@ +{ + "breakpoints": { + "375": { + "name": "repo-repocreationoptions--loading-row", + "viewportWidth": 145, + "width": 145, + "height": 24, + "bones": [ + [0, 3, 11.0381, 16, "50%"], + [16.5571, 2, 83.4429, 17, 4] + ] + }, + "768": { + "name": "repo-repocreationoptions--loading-row", + "viewportWidth": 145, + "width": 145, + "height": 24, + "bones": [ + [0, 3, 11.0381, 16, "50%"], + [16.5571, 2, 83.4429, 17, 4] + ] + }, + "1024": { + "name": "repo-repocreationoptions--loading-row", + "viewportWidth": 145, + "width": 145, + "height": 24, + "bones": [ + [0, 3, 11.0381, 16, "50%"], + [16.5571, 2, 83.4429, 17, 4] + ] + } + }, + "_hash": "ddde24a7912049a68d7b5c7fcfb4323e" +} diff --git a/web/src/lib/bones/repo-repoforkform--skeleton-fixture.bones.json b/web/src/lib/bones/repo-repoforkform--skeleton-fixture.bones.json new file mode 100644 index 00000000..cb2c8353 --- /dev/null +++ b/web/src/lib/bones/repo-repoforkform--skeleton-fixture.bones.json @@ -0,0 +1,133 @@ +{ + "breakpoints": { + "375": { + "name": "repo-repoforkform--skeleton-fixture", + "viewportWidth": 343, + "width": 343, + "height": 993, + "bones": [ + [11.6618, 60, 5.8309, 20, 0], + [11.6618, 94, 87.6868, 24, 4], + [4.6647, 128, 90.6706, 817, 4, true, 15, true], + [4.9563, 129, 90.0875, 93, 0, true, 4, false], + [11.9534, 147, 52.2048, 15, 4], + [11.9534, 167, 72.7815, 15, 4], + [11.9534, 187, 24.5399, 15, 4], + [11.9534, 246, 76.0933, 331, 0, true, 8, false], + [19.242, 249, 15.2697, 20, 4], + [19.242, 275, 47.3078, 15, 4], + [19.242, 311, 27.5465, 15, 4], + [19.242, 333, 68.8047, 38, 4], + [19.242, 377, 66.404, 15, 4], + [19.242, 397, 64.723, 15, 4], + [19.242, 417, 22.203, 15, 4], + [19.242, 453, 18.6817, 15, 4], + [19.242, 475, 68.8047, 38, 4], + [19.242, 519, 31.1726, 15, 4], + [11.9534, 577, 76.0933, 248, 0, true, 8, false], + [19.242, 580, 30.2615, 20, 4], + [19.242, 606, 52.9519, 15, 4], + [19.242, 642, 21.4559, 15, 4], + [20.4082, 671, 2.3324, 8, "50%"], + [26.2391, 666, 32.3478, 17, 4], + [19.242, 696, 4.6647, 16, "50%"], + [26.2391, 695, 32.3478, 17, 4], + [19.242, 727, 67.479, 15, 4], + [19.242, 747, 67.9437, 15, 4], + [19.242, 767, 15.1376, 15, 4], + [11.9534, 825, 76.0933, 27, 0, true, 8, false], + [8.7464, 826, 6.9971, 24, "50%", true, 0, true], + [9.9125, 830, 4.6647, 16, 0], + [19.242, 828, 22.4216, 20, 4], + [19.242, 894, 4.6647, 16, "50%"], + [26.2391, 894, 20.6177, 17, 4], + [19.242, 923, 4.6647, 16, "50%"], + [26.2391, 923, 35.2633, 17, 4], + [19.242, 954, 62.5774, 15, 4], + [19.242, 974, 56.5871, 15, 4], + [44.2602, 884, 43.7864, 36, 4] + ] + }, + "768": { + "name": "repo-repoforkform--skeleton-fixture", + "viewportWidth": 736, + "width": 736, + "height": 834, + "bones": [ + [9.7826, 60, 2.7174, 20, 0], + [20.2976, 58, 40.8649, 24, 4], + [6.5217, 92, 86.9565, 694, 4, true, 15, true], + [6.6576, 93, 86.6848, 73, 0, true, 4, false], + [9.9185, 111, 24.3291, 15, 4], + [9.9185, 131, 45.7923, 15, 4], + [9.9185, 190, 80.163, 292, 0, true, 8, false], + [13.3152, 193, 7.1162, 20, 4], + [13.3152, 219, 22.047, 15, 4], + [13.3152, 255, 12.8376, 15, 4], + [13.3152, 277, 25.7515, 39, "4px 0px 0px 4px", true, 13, true], + [14.538, 286, 2.8533, 21, "50%"], + [17.9348, 289, 20.045, 15, 4], + [39.0667, 278, 51.0148, 38, "0px 4px 4px 0px"], + [13.3152, 322, 72.3314, 15, 4], + [13.3152, 358, 8.7063, 15, 4], + [13.3152, 380, 76.7663, 38, 4], + [13.3152, 424, 14.5274, 15, 4], + [9.9185, 482, 80.163, 184, 0, true, 8, false], + [13.3152, 485, 14.1028, 20, 4], + [13.3152, 511, 24.6773, 15, 4], + [13.3152, 547, 9.9992, 15, 4], + [13.8587, 576, 1.087, 8, "50%"], + [16.5761, 571, 15.0752, 17, 4], + [31.6512, 572, 2.1739, 16, "50%"], + [34.9121, 571, 15.0752, 17, 4], + [13.3152, 608, 71.0407, 15, 4], + [9.9185, 666, 80.163, 27, 0, true, 8, false], + [8.4239, 668, 3.2609, 24, "50%", true, 0, true], + [8.9674, 672, 2.1739, 16, 0], + [13.3152, 669, 10.4492, 20, 4], + [69.6756, 725, 20.4059, 36, 4] + ] + }, + "1024": { + "name": "repo-repoforkform--skeleton-fixture", + "viewportWidth": 992, + "width": 992, + "height": 834, + "bones": [ + [20.1613, 60, 2.0161, 20, 0], + [27.9628, 58, 30.3191, 24, 4], + [17.7419, 92, 64.5161, 694, 4, true, 15, true], + [17.8427, 93, 64.3145, 73, 0, true, 4, false], + [20.2621, 111, 18.0507, 15, 4], + [20.2621, 131, 33.9749, 15, 4], + [20.2621, 190, 59.4758, 292, 0, true, 8, false], + [22.7823, 193, 5.2797, 20, 4], + [22.7823, 219, 16.3574, 15, 4], + [22.7823, 255, 9.5246, 15, 4], + [22.7823, 277, 19.106, 39, "4px 0px 0px 4px", true, 13, true], + [23.6895, 286, 2.1169, 21, "50%"], + [26.2097, 289, 14.8721, 15, 4], + [41.8882, 278, 37.8497, 38, "0px 4px 4px 0px"], + [22.7823, 322, 53.6653, 15, 4], + [22.7823, 358, 6.4595, 15, 4], + [22.7823, 380, 56.9556, 38, 4], + [22.7823, 424, 10.7784, 15, 4], + [20.2621, 482, 59.4758, 184, 0, true, 8, false], + [22.7823, 485, 10.4634, 20, 4], + [22.7823, 511, 18.309, 15, 4], + [22.7823, 547, 7.4187, 15, 4], + [23.1855, 576, 0.8065, 8, "50%"], + [25.2016, 571, 11.1848, 17, 4], + [36.3864, 572, 1.6129, 16, "50%"], + [38.8058, 571, 11.1848, 17, 4], + [22.7823, 608, 52.7076, 15, 4], + [20.2621, 666, 59.4758, 27, 0, true, 8, false], + [19.1532, 668, 2.4194, 24, "50%", true, 0, true], + [19.5565, 672, 1.6129, 16, 0], + [22.7823, 669, 7.7526, 20, 4], + [64.598, 725, 15.1399, 36, 4] + ] + } + }, + "_hash": "2a9ce6dff94efb163c9a92cae66b6eb6" +} diff --git a/web/src/lib/components/repo/RepoCreationForm.svelte b/web/src/lib/components/repo/RepoCreationForm.svelte new file mode 100644 index 00000000..ef83a581 --- /dev/null +++ b/web/src/lib/components/repo/RepoCreationForm.svelte @@ -0,0 +1,314 @@ + + + + +
{ + event.preventDefault(); + if (!action.loading) void action.run(); + }} +> +
+
+ 1 +
+
+

Details

+

+ Basic repository information. +

+
+
+ +
+ + +
+

+ Choose a unique, descriptive name. Use letters, numbers, periods, + underscores, and hyphens. +

+
+ +
+ + +

+ {[...description].length}/140 characters +

+
+
+
+
+ +
+
+ 2 +
+
+

+ Configuration +

+

+ Repository settings and hosting. +

+
+ {#if showDefaultBranch} +
+ + +

+ The primary branch where development happens. Common choices are "main" + or "master". +

+
+ {/if} + +
+
+ Select a knot +
+
+ {#if knotOptions === null} + + {:else} + {#each knotOptions as knot (knot)} +
+ + {knot} + +
+ {:else} +

+ No knots are available for repository creation. +

+ {/each} + {/if} +
+

+ A knot hosts repository data and handles Git operations. You can also + register your own knot. +

+
+
+
+
+ +
+ +
+
+
+
+
+

+ Advanced +

+
+
+ +
+
+
+ Select a spindle +
+
+
+ No spindle +
+ {#await spindles} + + {:then options} + {#each options as spindle (spindle)} +
+ + {spindle} + +
+ {/each} + {/await} +
+

+ A spindle runs your CI workflows. You can also + register your own spindle. +

+
+
+
+ + +
+ +
+ diff --git a/web/src/lib/components/repo/RepoCreationOptions.stories.svelte b/web/src/lib/components/repo/RepoCreationOptions.stories.svelte new file mode 100644 index 00000000..c689e699 --- /dev/null +++ b/web/src/lib/components/repo/RepoCreationOptions.stories.svelte @@ -0,0 +1,13 @@ + + + + spindle.tangled.sh + diff --git a/web/src/lib/components/repo/RepoForkForm.stories.svelte b/web/src/lib/components/repo/RepoForkForm.stories.svelte new file mode 100644 index 00000000..dba3bc3b --- /dev/null +++ b/web/src/lib/components/repo/RepoForkForm.stories.svelte @@ -0,0 +1,30 @@ + + + diff --git a/web/src/lib/components/repo/RepoForkForm.svelte b/web/src/lib/components/repo/RepoForkForm.svelte new file mode 100644 index 00000000..00837cfd --- /dev/null +++ b/web/src/lib/components/repo/RepoForkForm.svelte @@ -0,0 +1,62 @@ + + +
+
+

+

+
+ +
+
+

A fork is a copy of a repository.

+

+ Forking gives you your own copy to work on independently. +

+
+ + +
+
diff --git a/web/src/routes/[handle]/[repo]/+layout@.svelte b/web/src/routes/[handle]/[repo]/+layout@.svelte index 95f847a2..d9c051e4 100644 --- a/web/src/routes/[handle]/[repo]/+layout@.svelte +++ b/web/src/routes/[handle]/[repo]/+layout@.svelte @@ -38,6 +38,7 @@ // the pull page is full screen view const bare = $derived((page.route.id ?? "").startsWith("/[handle]/[repo]/pulls/")); + const standalone = $derived((page.route.id ?? "").endsWith("/fork")); // commit and pull pages break out of the reading column, everything else // stays capped @@ -82,7 +83,7 @@ ? null : resolved.ownerHandle; if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { - goto(resolve(`/${canonical}/${name}${search}` as "/")); + void goto(resolve(`/${canonical}/${name}${search}` as "/")); } }) // failures surface through the error boundary instead @@ -111,30 +112,34 @@ {/if} -
+
{#key `${page.params.handle}/${page.params.repo}`} - - {#snippet skeleton()} - {#if bare} - - {:else} - - {/if} - {/snippet} - - {#snippet failed(_error: unknown)} - - {/snippet} - - + {#if !standalone} + + {#snippet skeleton()} + {#if bare} + + {:else} + + {/if} + {/snippet} + + {#snippet failed(_error: unknown)} + + {/snippet} + + + {/if} {@render children()} diff --git a/web/src/routes/[handle]/[repo]/fork/+page.server.ts b/web/src/routes/[handle]/[repo]/fork/+page.server.ts new file mode 100644 index 00000000..a68ccc6c --- /dev/null +++ b/web/src/routes/[handle]/[repo]/fork/+page.server.ts @@ -0,0 +1,6 @@ +import { requireAuth } from "$lib/auth/guards"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = (event) => { + requireAuth(event); +}; diff --git a/web/src/routes/[handle]/[repo]/fork/+page.svelte b/web/src/routes/[handle]/[repo]/fork/+page.svelte new file mode 100644 index 00000000..e6e35161 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/fork/+page.svelte @@ -0,0 +1,30 @@ + + + + Fork · {decodeURIComponent(page.params.handle ?? "")}/{decodeURIComponent( + page.params.repo ?? "" + )} · Tangled + + + + {#snippet skeleton()} + + {/snippet} + {@const repo = await data.repo} + + diff --git a/web/src/routes/[handle]/[repo]/fork/+page.ts b/web/src/routes/[handle]/[repo]/fork/+page.ts new file mode 100644 index 00000000..c045bc7b --- /dev/null +++ b/web/src/routes/[handle]/[repo]/fork/+page.ts @@ -0,0 +1,5 @@ +import { repoCreationOptions } from "$lib/api/repoCreationOptions"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async (event) => + repoCreationOptions(await event.parent(), event.fetch); diff --git a/web/src/routes/repo/new/+page.svelte b/web/src/routes/repo/new/+page.svelte index 408386ac..c5bd3b68 100644 --- a/web/src/routes/repo/new/+page.svelte +++ b/web/src/routes/repo/new/+page.svelte @@ -1,20 +1,18 @@ @@ -32,152 +30,16 @@

-
-
-
-
-
- 1 -
-
- -
-

- General -

-
- Basic repository information. -
- -
-
- -
- - -
-

- Choose a unique, descriptive name for your repository. Use letters, - numbers, and hyphens. -

-
- -
- - -

- Optional. A short description to help others understand what your - project does (max 140 characters). -

-
-
-
-
- -
-
-
- 2 -
-
- -
-

- Configuration -

-
- Repository settings and hosting. -
- -
-
- - -

- The primary branch where development happens. Common choices are - "main" or "master". -

-
- -
- - Select a knot - -
- {#each knots as knot (knot)} - - {knot} - - {/each} -
-

- A knot hosts repository data and handles Git operations. You can - also - register your own knot. -

-
-
-
-
- -
- -
-
+
+
diff --git a/web/src/routes/repo/new/+page.ts b/web/src/routes/repo/new/+page.ts new file mode 100644 index 00000000..c045bc7b --- /dev/null +++ b/web/src/routes/repo/new/+page.ts @@ -0,0 +1,5 @@ +import { repoCreationOptions } from "$lib/api/repoCreationOptions"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async (event) => + repoCreationOptions(await event.parent(), event.fetch);