Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Svelte
at icy/tmrrpn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611<script lang="ts"> import { untrack } from "svelte"; import { SvelteURLSearchParams } from "svelte/reactivity"; import { goto } from "$app/navigation"; import { resolve } from "$app/paths"; import { now as tidNow } from "@atcute/tid"; import type { Did } from "@atcute/lexicons/syntax"; import ArrowLeftRight from "$icon/arrow-left-right"; import Copy from "$icon/copy"; import CopyCheck from "$icon/copy-check"; import FolderCode from "$icon/folder-code"; import GitCommitVertical from "$icon/git-commit-vertical"; import GitPullRequestCreate from "$icon/git-pull-request-create"; import TriangleAlert from "$icon/triangle-alert"; import { createBobbinClient } from "$lib/api/client"; import { getBranch, getMergeBase } from "$lib/api/knotmirror"; import { keepCommit, putPull } from "$lib/api/pull"; import { createEntitySubscription } from "$lib/api/graph"; import { composeQuery, loadCompose, type ComposeData, type PullSource } from "$lib/api/pullCompose"; import { createAction } from "$lib/action.svelte"; import { getAuth } from "$lib/auth.svelte"; import AccountSelector from "$lib/components/auth/AccountSelector.svelte"; import Button from "$lib/components/ui/Button.svelte"; import ErrorAlert from "$lib/components/ui/Error.svelte"; import Field from "$lib/components/ui/Field.svelte"; import Input from "$lib/components/ui/Input.svelte"; import MarkdownEditor from "$lib/components/ui/MarkdownEditor.svelte"; import Select from "$lib/components/ui/Select.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte"; import Textarea from "$lib/components/ui/Textarea.svelte"; import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; import type { PullRecord } from "$lib/api/records"; import type { BranchEntry } from "$lib/api/repo"; import type { SelectOption } from "$lib/components/ui/selectField"; import type { RepoInfo } from "$lib/components/repo/types"; import type { MarkupContext } from "$lib/markup"; import { formatCount } from "$lib/format";
/** everything the route's load derived, passed through whole */ interface Props { ownerHandle: string; repoName: string; repoDid: string; repo: RepoInfo; bobbinUrl: string; knotMirrorUrl: string; markup: MarkupContext; compose: ComposeData; }
let { ownerHandle, repoName, repoDid, repo, bobbinUrl, knotMirrorUrl, markup, compose }: Props = $props();
const auth = getAuth(); const accounts = $derived(auth?.accounts ?? []);
let picked = $state<string | null>(null); const acting = $derived( accounts.find((account) => account.did === (picked ?? auth?.currentDid)) );
// the load can't see the pick, a diverged one refetches the fork lane here let diverged = $state<ComposeData | null>(null); let laneLoading = $state(false); $effect(() => { if (!acting || acting.did === auth?.currentDid || compose.source !== "fork") { diverged = null; laneLoading = false; return; } const params = new SvelteURLSearchParams( composeQuery({ source: compose.source, sourceBranch: compose.sourceBranch, targetBranch: compose.targetBranch, fork: compose.fork }) ); if (compose.patch) params.set("patch", compose.patch); let live = true; laneLoading = true; void loadCompose({ config: { bobbinUrl, knotMirrorUrl }, repo, viewer: { did: acting.did, handle: acting.handle }, params, fetch: globalThis.fetch }) .then((lane) => { if (!live) return; diverged = lane; laneLoading = false; }) .catch(() => { if (!live) return; diverged = null; laneLoading = false; }); return () => { live = false; }; }); const view = $derived(diverged ?? compose); const base = $derived(`/${ownerHandle}/${repoName}/pulls`);
let title = $state(""); let body = $state(""); // patch mode is not supported let patch = $state(untrack(() => view.patch));
// selects rewrite the url and the load recomputes const navigate = ( change: Partial<Pick<ComposeData, "source" | "sourceBranch" | "targetBranch" | "fork">> ) => { const next = { source: view.source, sourceBranch: view.sourceBranch, targetBranch: view.targetBranch, fork: view.fork, ...change }; // a mode change drops the other mode's branch pick, which no longer names // anything the new candidate list has if (change.source && change.source !== view.source) next.sourceBranch = ""; if (change.fork && change.fork !== view.fork) next.sourceBranch = ""; const query = composeQuery(next); void goto(resolve(`${base}/new${query ? `?${query}` : ""}` as "/"), { replaceState: true, noScroll: true, keepFocus: true }); };
const defaultHint = (branch: BranchEntry) => (branch.is_default ? "default" : undefined);
const targetOptions = $derived( view.branches.map((branch): SelectOption => { // picking the source as the target would compare a branch with itself const isSource = view.source === "branch" && Boolean(view.sourceBranch) && branch.name === view.sourceBranch; return { value: branch.name, hint: isSource ? "source" : branch.isDefault ? "default" : undefined, disabled: isSource }; }) );
const branchOptions = $derived( view.sourceBranches.map((branch, index): SelectOption => { const isTarget = Boolean(view.targetBranch) && branch.reference.name === view.targetBranch; return { value: branch.reference.name, // the list is recency sorted, so the head of it is the newest branch hint: isTarget ? "target" : index === 0 ? "new" : defaultHint(branch), disabled: isTarget }; }) );
const forkBranchOptions = $derived( view.forkBranches.map((branch, index): SelectOption => ({ value: branch.reference.name, hint: index === 0 ? "new" : defaultHint(branch) })) );
// shared by every branch picker, so the copy cannot drift between them const branchSearch = { rich: true, searchPlaceholder: "Find a branch…", emptyLabel: "No branch matches" };
const forkOptions = $derived( view.forks.map((fork): SelectOption => ({ value: fork.repoDid, label: `${ownerHandle}/${fork.name}` })) );
const save = createAction(async (event: SubmitEvent) => { event.preventDefault(); if (!auth || !acting) throw new Error("You must be signed in to open a pull request.");
if (title.trim() === "") throw new Error("Title is required"); if (!view.targetBranch) throw new Error("Target branch is required."); if (view.source === "patch") throw new Error("Patch based PR is currently unsupported."); if (!view.sourceBranch) throw new Error("Neither source branch nor patch supplied."); if (view.sourceRepoDid === repoDid && view.sourceBranch === view.targetBranch) { throw new Error("Source and target branch must be different."); } if (!knotMirrorUrl) { throw new Error("No knot mirror is configured, so branch refs can't be resolved."); }
const agent = await auth.agentFor(acting.did as Did);
// keepCommit pins against the record's at-uri, so the rkey is minted first const rkey = tidNow(); const uri = `at://${agent.sub}/sh.tangled.repo.pull/${rkey}`; const mirror = createBobbinClient({ serviceUrl: knotMirrorUrl, fetch: globalThis.fetch });
const [mergeBase, head] = await Promise.all([ getMergeBase(mirror, { repo: view.sourceRepoDid, base: view.targetBranch, head: view.sourceBranch }), getBranch(mirror, { repo: view.sourceRepoDid, name: view.sourceBranch }) ]);
// pin before the record lands, so gc can't drop the head in between console.log("keepCommit", { repo: view.sourceRepoDid, oid: head.hash, record: uri }); await keepCommit(agent, view.sourceKnot, { repo: view.sourceRepoDid, oid: head.hash, record: uri });
const createdAt = new Date().toISOString(); const record: PullRecord = { $type: "sh.tangled.repo.pull", title, body, target: { repo: repoDid, branch: view.targetBranch } as PullRecord["target"], source: { repo: view.sourceRepoDid, branch: view.sourceBranch } as PullRecord["source"], rounds: [], versions: [{ base: mergeBase.commit, head: head.hash, createdAt }], createdAt: createdAt as PullRecord["createdAt"] }; await putPull(agent, rkey, record); // auto-subscribe the creator to notifications on this PR createEntitySubscription(agent, uri).catch((err) => { console.error("Failed to auto-subscribe to pull:", err); }); await goto(resolve(base as "/")); });
const canSubmit = $derived(title.trim() !== "" && !save.loading);
// hash of the row whose copy button last fired, so its icon can flip let copied = $state(""); const copySha = async (hash: string) => { await navigator.clipboard.writeText(hash); copied = hash; setTimeout(() => (copied = copied === hash ? "" : copied), 1500); };
const PATCH_PLACEHOLDER = [ "diff --git a/file.txt b/file.txt", "index 1234567..abcdefg 100644", "--- a/file.txt", "+++ b/file.txt" ].join("\n");
const tabBase = "group flex flex-1 flex-col gap-1 rounded-sm p-3 text-left transition-colors disabled:cursor-not-allowed"; const tabActive = "cursor-default border border-border-default bg-background-default shadow-xs"; const tabIdle = "border border-transparent hover:bg-background-default/60"; const connector = "absolute top-3 -bottom-6 left-3 w-px -translate-x-1/2 bg-border-default";</script>
{#snippet stepNumber(n: number)} <span class="inline-flex size-6 items-center justify-center rounded-full bg-background-emphasis typography-paragraph-small font-bold text-foreground-subtle" >{n}</span >{/snippet}
{#snippet notice(message: string, danger = false)} <div class="rounded-sm border px-4 py-3 typography-paragraph-small {danger ? 'border-border-danger bg-background-danger-subtle text-foreground-danger' : 'border-border-default bg-background-subtle text-foreground-muted'}" > {message} </div>{/snippet}
{#snippet tab(mode: PullSource, heading: string, description: string, disabled = false)} <button type="button" {disabled} class="{tabBase} {view.source === mode ? tabActive : tabIdle}" onclick={() => view.source !== mode && navigate({ source: mode })} > <span class="typography-paragraph-small font-medium text-foreground-default">{heading}</span > <span class="typography-paragraph-small text-foreground-muted">{description}</span> </button>{/snippet}
<div class="flex flex-col"> {#if view.prefillError} <div class="mb-4 flex items-center gap-2 rounded-sm border border-border-danger bg-background-danger-subtle px-3 py-2 typography-paragraph-small text-foreground-danger" > <TriangleAlert class="size-4 shrink-0" aria-hidden="true" /> <span>{view.prefillError}</span> </div> {/if}
<form onsubmit={save.run} class="flex flex-col gap-6"> <section class="relative flex flex-col gap-3"> <div class={connector}></div> <div class="relative flex items-center gap-4"> <div class="relative z-10 shrink-0">{@render stepNumber(1)}</div> <h3 class="my-0 typography-paragraph-small font-bold tracking-wide text-foreground-default" > Source </h3> <AccountSelector {accounts} bind:value={() => acting?.did ?? "", (did) => (picked = did)} label="Open as" disabled={save.loading} group="pull-compose" class="ml-auto" /> </div> <div class="ml-10 flex flex-col gap-3"> <div class="flex items-center justify-stretch gap-1 rounded-md border border-border-default bg-background-subtle p-1.5" > {@render tab("branch", "Compare branches", "Select a source branch")} {@render tab("fork", "Compare forks", "Select a fork and branch as the source")} {@render tab( "patch", "Paste patch", "Paste a git diff or git format-patch", true )} </div>
{#if view.source === "patch"} <Field label="Merge into" class="max-w-xs"> {#snippet children({ id })} <Select {id} {...branchSearch} label="Merge into" options={targetOptions} value={view.targetBranch} placeholder={view.targetBranch ? undefined : "Target branch"} onSelect={(value) => navigate({ targetBranch: value })} /> {/snippet} </Field> <div class="flex flex-col gap-2"> <p class="my-0 typography-paragraph-small text-foreground-muted"> You can paste a <code>git diff</code> or a <code>git format-patch</code> patch series here. </p> <Textarea rows={12} bind:value={patch} class="resize-y font-mono" placeholder={PATCH_PLACEHOLDER} /> </div> {:else} <div class="flex flex-col gap-3 md:flex-row md:flex-wrap"> <Field label="Merge into" class="min-w-48"> {#snippet children({ id })} <Select {id} {...branchSearch} label="Merge into" options={targetOptions} value={view.targetBranch} placeholder={view.targetBranch ? undefined : "Target branch"} onSelect={(value) => navigate({ targetBranch: value })} /> {/snippet} </Field>
{#if view.source === "fork"} <Field label="Pull from" class="min-w-48"> {#snippet children({ id })} <Select {id} options={forkOptions} value={view.fork} placeholder={view.fork ? undefined : "Select a fork"} disabled={laneLoading} onSelect={(value) => navigate({ fork: value })} /> {/snippet} </Field> {#if view.fork && view.forkBranches.length > 0} <Field label="Branch" class="min-w-48"> {#snippet children({ id })} <Select {id} {...branchSearch} label="Branch" options={forkBranchOptions} value={view.sourceBranch} placeholder={view.sourceBranch ? undefined : "Source branch"} onSelect={(value) => navigate({ sourceBranch: value })} /> {/snippet} </Field> {:else} <p class="self-end typography-paragraph-small text-foreground-muted" > Select a fork first to view available branches </p> {/if} {:else} <Field label="Pull from" class="min-w-48"> {#snippet children({ id })} <Select {id} {...branchSearch} label="Pull from" options={branchOptions} value={view.sourceBranch} placeholder={view.sourceBranch ? undefined : "Source branch"} onSelect={(value) => navigate({ sourceBranch: value })} /> {/snippet} </Field> {/if} </div> {/if} </div> </section>
<section class="relative flex flex-col gap-3"> <div class="{connector} -top-6"></div> <div class="relative flex items-center gap-4"> <div class="relative z-10 shrink-0">{@render stepNumber(2)}</div> <h3 class="my-0 typography-paragraph-small font-bold tracking-wide text-foreground-default" > Review </h3> </div> <div class="ml-10 flex flex-col gap-3"> {#if !view.showDetails} {@render notice( view.source === "patch" ? "Paste a patch above to see a comparison." : "Pick a source and target above to see a comparison." )} {:else if view.source === "patch"} <!-- TODO: implement patch based PR --> {:else if view.commitsError} {@render notice(view.commitsError, true)} {:else if view.commits.length === 0} {@render notice( view.sourceBranch === view.targetBranch ? "Source and target are the same branch, nothing to merge." : "No commits between target and source. Make sure your source branch has commits not on the target." )} {:else} <div class="flex min-w-0 items-center justify-between gap-3 typography-paragraph-small text-foreground-muted" > <span class="shrink-0"> {formatCount(view.commits.length)} {view.commits.length === 1 ? "commit" : "commits"} </span> <span class="inline-flex items-center gap-2 truncate typography-monospace-small text-foreground-subtle" > <span>{view.targetBranch}</span> <ArrowLeftRight class="size-4 shrink-0" aria-hidden="true" /> <span>{view.sourceBranch}</span> </span> </div> <ul class="m-0 flex list-none flex-col gap-2 p-0"> {#each view.commits as commit (commit.hash)} <li class="flex items-center gap-3 rounded-sm border border-border-default bg-background-default px-3 py-2" > <GitCommitVertical class="size-4 shrink-0 text-foreground-subtle" aria-hidden="true" /> <span class="min-w-0 flex-1 truncate typography-paragraph-small"> {commit.subject} </span> <span class="flex shrink-0 items-center gap-2 typography-paragraph-small text-foreground-muted" > {#if commit.when}<TimeAgo value={commit.when} />{/if} <code class="rounded-sm bg-background-inset px-2 py-0.5 typography-monospace-small" > {commit.shortHash} </code> </span> <span class="-my-2 w-px self-stretch bg-border-default"></span> <span class="flex shrink-0 items-center gap-1"> <button type="button" class="cursor-pointer rounded-sm p-1 text-foreground-subtle hover:bg-background-muted" title="Copy SHA" onclick={() => copySha(commit.hash)} > {#if copied === commit.hash} <CopyCheck class="size-4" aria-hidden="true" /> {:else} <Copy class="size-4" aria-hidden="true" /> {/if} </button> <a href={resolve( `/${view.sourceRepoPath}/tree/${commit.hash}` as "/" )} class="rounded-sm p-1 text-foreground-subtle hover:bg-background-muted" title="Browse repository at this commit" > <FolderCode class="size-4" aria-hidden="true" /> </a> </span> </li> {/each} </ul> <div class="rounded-sm border border-border-default bg-background-subtle px-4 py-6 text-center typography-paragraph-small text-foreground-muted" > Diff preview (TODO) </div> {/if} </div> </section>
{#if view.showDetails} <section class="relative flex flex-col gap-3"> <div class="{connector} -top-6 -bottom-12 mask-[linear-gradient(to_bottom,black_calc(100%-1.5rem),transparent)]" ></div> <div class="relative flex items-center gap-4"> <div class="relative z-10 shrink-0">{@render stepNumber(3)}</div> <h3 class="my-0 typography-paragraph-small font-bold tracking-wide text-foreground-default" > Details </h3> </div> <div class="ml-10 flex flex-col gap-4"> <Field label="Title" required> {#snippet children({ id })} <Input {id} name="title" required bind:value={title} disabled={save.loading} placeholder="One-line summary of your change." /> {/snippet} </Field>
<Field label="Body"> {#snippet children({ id })} <MarkdownEditor {id} name="body" rows={6} placeholder="Describe your change. Markdown is supported." {markup} bind:value={body} disabled={save.loading} /> {/snippet} </Field>
<ErrorAlert label={save.error} />
<div class="flex items-center justify-end"> <Button type="submit" variant="primary" icon={save.loading ? Spinner : GitPullRequestCreate} disabled={!canSubmit} > Create pull request </Button> </div> </div> </section> {/if} </form></div>