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 @@
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ Fork
+ {repo.ownerHandle}/{repo.name}
+
+
+
+
+
+
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 @@
-
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);