Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601<script module lang="ts"> import { tv } from "tailwind-variants";
export const composeTab = tv({ base: "group flex flex-1 flex-col gap-1 rounded-sm p-3 text-left transition-colors disabled:cursor-not-allowed", variants: { active: { true: "cursor-default border border-border-default bg-background-default shadow-xs", false: "border border-transparent hover:bg-background-default/60" } } });</script>
<script lang="ts"> import { untrack } from "svelte"; import { SvelteURLSearchParams } from "svelte/reactivity"; import { browser } from "$app/environment"; 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 GitPullRequestCreate from "$icon/git-pull-request-create"; import TriangleAlert from "$icon/triangle-alert"; import { awaitIndexedOrSaved } from "$lib/api/awaitRecord"; import { createBobbinClient } from "$lib/api/client"; import { gitTarget } from "$lib/api/gitclient"; import { formatPatchUrl, getBranch, getMergeBase } from "$lib/api/knotmirror"; import { getPatchStream } from "$lib/api/patchStream"; import { keepCommit, putPull } from "$lib/api/pull"; import { pingIndexNow } from "$lib/api/indexnow"; import { createEntitySubscription } from "$lib/api/graph"; import { composeQuery, loadCompose, type ComposeData, type PullSource } from "$lib/api/pullCompose"; import { loadDiff, type RepoDiffDeps, type RepoDiffSpec } from "$lib/api/repoDiff"; import { createAction, createLoad } 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 Card from "$lib/components/ui/Card.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 StepMarker from "$lib/components/ui/StepMarker.svelte"; import TabPanel from "$lib/components/ui/TabPanel.svelte"; import Textarea from "$lib/components/ui/Textarea.svelte"; import DiffView from "$lib/components/repo/DiffView.svelte"; import PullCommitList from "$lib/components/repo/pulls/PullCommitList.svelte"; import PullCommitListHeader from "$lib/components/repo/pulls/PullCommitListHeader.svelte"; import type { PullRecord } from "$lib/api/records"; import type { BranchEntry } from "$lib/api/repo"; import type { Range } from "$lib/components/ui/RangeSelector.svelte"; import type { SelectOption } from "$lib/components/ui/selectField"; import type { RepoInfo } from "$lib/components/repo/types"; import type { MarkupContext } from "$lib/markup";
/** 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); let showChangeIds = $state(true); 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 commits = $derived(view.commits); const hasChanges = $derived(view.showDetails && !view.commitsError && commits.length > 0); 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: `${fork.owner}/${fork.name}` })) );
let stagedRange = $state<Range | null>(null); $effect(() => { void view; stagedRange = null; }); const commitRange = $derived<Range>(stagedRange ?? { start: 0, end: commits.length });
const diffSpec = $derived<RepoDiffSpec>({ kind: "diff", repo: view.sourceRepoDid as Did, version: { base: commits[commitRange.end]?.hash ?? view.mergeBase, head: commits[commitRange.start]?.hash ?? commits[0]?.hash ?? "" } });
const diffDeps = $derived<RepoDiffDeps>({ ctx: createBobbinClient({ serviceUrl: bobbinUrl, fetch: globalThis.fetch }), git: gitTarget( { bobbinUrl, knotMirrorUrl }, { uri: repo.uri, repoDid: view.sourceRepoDid }, globalThis.fetch ) });
const diff = createLoad(async () => { if (!hasChanges || !diffSpec.version.base || !diffSpec.version.head) return undefined; return await loadDiff(diffDeps, diffSpec); });
const patchUrl = $derived( hasChanges && diffDeps.git.via === "mirror" && diffSpec.version.base ? formatPatchUrl(diffDeps.git.ctx.serviceUrl, { repo: view.sourceRepoDid, from: diffSpec.version.base, to: diffSpec.version.head }) : undefined ); const patchStream = $derived(browser && patchUrl ? getPatchStream(patchUrl) : undefined);
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 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"] }; const saved = await putPull(agent, rkey, record); if (bobbinUrl) { const ctx = createBobbinClient({ serviceUrl: bobbinUrl }); await awaitIndexedOrSaved(ctx, { uri: saved.uri, cid: saved.cid }); } // auto-subscribe the creator to notifications on this PR createEntitySubscription(agent, uri).catch((err) => { console.error("Failed to auto-subscribe to pull:", err); });
if (typeof window !== "undefined") { const pullPath = window.location.pathname.replace(/\/new\/?$/, `/${rkey}`); pingIndexNow(`https://tangled.org${pullPath}`); }
await goto(resolve(base as "/")); });
const canSubmit = $derived(title.trim() !== "" && !save.loading);
const PATCH_PLACEHOLDER = [ "diff --git a/file.txt b/file.txt", "index 1234567..abcdefg 100644", "--- a/file.txt", "+++ b/file.txt" ].join("\n");</script>
{#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={composeTab({ active: view.source === mode })} 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 gap-4"> <TabPanel class="py-6"> {#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 pl-3"> <section class="relative flex flex-col gap-3 border-l border-border-default pb-10 pl-6"> <StepMarker class="absolute top-0 -left-3">1</StepMarker> <h3 class="my-0 typography-paragraph-large font-medium text-foreground-default"> Source </h3> <div class="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 typography-monospace-regular" 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} rich label="Pull from" searchPlaceholder="Find a fork…" emptyLabel="No fork matches" 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> {/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 view.source === "fork" && !(view.fork && view.forkBranches.length > 0)} <p class="typography-paragraph-small text-foreground-muted"> Select a fork first to view available branches </p> {/if} {/if} </div> </section>
<section class="relative flex flex-col gap-3 border-l border-border-default pl-6"> <StepMarker class="absolute top-0 -left-3">2</StepMarker> <h3 class="my-0 typography-paragraph-large font-medium text-foreground-default"> Details </h3> {#if !view.showDetails} {@render notice( view.source === "patch" ? "Paste a patch above to describe your change." : "Pick a source and target above to describe your change." )} {:else} <div class="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 gap-2"> <AccountSelector {accounts} bind:value={() => acting?.did ?? "", (did) => (picked = did)} label="Open as" disabled={save.loading} group="pull-compose" class="mr-auto" /> <Button type="submit" variant="primary" icon={save.loading ? Spinner : GitPullRequestCreate} disabled={!canSubmit} > Create pull request </Button> </div> </div> {/if} </section> </form> </TabPanel>
{#if view.showDetails && view.source !== "patch"} <Card padding="small" class="space-y-2"> {#if view.commitsError} {@render notice(view.commitsError, true)} {:else if 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} <PullCommitListHeader {commits} bind:showChangeIds /> <PullCommitList {commits} bind:showChangeIds selection={{ mode: "range", range: commitRange, oncommit: (range) => (stagedRange = range) }} /> {/if} </Card> {/if}
{#if hasChanges} <DiffView deps={diffDeps} diff={diff.data} error={diff.error} loading={diff.loading} {patchUrl} patch={patchStream} treeCollapsed /> {/if}</div>