Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454<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, getMergeBase, getRef } 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 Spinner from "$lib/components/ui/Spinner.svelte"; import StepMarker from "$lib/components/ui/StepMarker.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 { BranchSummary } from "$lib/api/repo"; import type { Range } from "$lib/components/ui/RangeSelector.svelte"; import type { SelectOption } from "$lib/components/ui/selectField"; import SourceSelector from "$lib/components/repo/SourceSelector.svelte"; 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; markup: MarkupContext; compose: ComposeData; }
let { ownerHandle, repoName, repoDid, repo, bobbinUrl, 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 }, 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: BranchSummary) => (branch.isDefault ? "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.name === view.targetBranch; return { value: branch.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.name, hint: index === 0 ? "new" : defaultHint(branch) })) );
const forkOptions = $derived( view.forks.map((fork): SelectOption => ({ value: fork.repoDid, label: `${fork.owner}/${fork.name}` })) );
let stagedRange = $state<Range | null>(null); let stagedWhole = $state(true); $effect(() => { void view; stagedRange = null; stagedWhole = true; }); 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 }, { 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 && 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."); } 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 bobbin = createBobbinClient({ serviceUrl: bobbinUrl, fetch: globalThis.fetch });
const [mergeBase, head] = await Promise.all([ getMergeBase(bobbin, { repo: view.sourceRepoDid, base: view.targetBranch, head: view.sourceBranch }), getRef(bobbin, { repo: view.sourceRepoDid, ref: `refs/heads/${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.oid, 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.oid, 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);</script>
{#snippet notice(message: string, danger = false)} <div class="rounded-sm border px-md py-sm 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}
<div class="flex flex-col gap-md"> <Card class="py-xl"> {#if view.prefillError} <div class="mb-md flex items-center gap-xs rounded-sm border border-border-danger bg-background-danger-subtle px-sm py-xs typography-paragraph-small text-foreground-danger" > <TriangleAlert class="size-3.5 shrink-0" aria-hidden="true" /> <span>{view.prefillError}</span> </div> {/if}
<form onsubmit={save.run} class="flex flex-col pl-sm"> <section class="relative flex flex-col gap-sm border-l border-border-default pb-3xl pl-xl" > <StepMarker class="absolute top-0 -left-3">1</StepMarker> <h3 class="my-none typography-paragraph-large font-medium text-foreground-default"> Source </h3> <SourceSelector source={view.source} sourceBranch={view.sourceBranch} targetBranch={view.targetBranch} fork={view.fork} {targetOptions} {branchOptions} {forkBranchOptions} {forkOptions} forkDisabled={laneLoading} bind:patch onSource={(mode) => navigate({ source: mode })} onTargetBranch={(branch) => navigate({ targetBranch: branch })} onSourceBranch={(branch) => navigate({ sourceBranch: branch })} onFork={(value) => navigate({ fork: value })} /> </section>
<section class="relative flex flex-col gap-sm border-l border-border-default pl-xl"> <StepMarker class="absolute top-0 -left-3">2</StepMarker> <h3 class="my-none 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-md"> <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-xs"> <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> </Card>
{#if view.showDetails && view.source !== "patch"} <Card padding="small" class="space-y-xs"> {#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, whole: stagedWhole, oncommit: (range, whole) => { stagedRange = range; stagedWhole = whole; } }} /> {/if} </Card> {/if}
{#if hasChanges} <DiffView deps={diffDeps} diff={diff.data} error={diff.error} loading={diff.loading} {patchUrl} patch={patchStream} treeCollapsed /> {/if}</div>