import clsx from 'clsx';
import {
ArrowLeftRight,
CircleChevronUp,
CirclePlus,
ChevronDown,
ChevronUp,
Copy,
Diff,
Eye,
FolderCode,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
LoaderCircle,
MessageSquarePlus,
Pencil,
RefreshCcwDot,
Reply,
Search,
X,
} from 'lucide-solid';
import type { Did } from '@atcute/lexicons/syntax';
import { A, useNavigate, useParams, useSearchParams, useLocation } from '@solidjs/router';
import { createQuery, useQueryClient } from '@tanstack/solid-query';
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js';
import { resolveActor } from '../../lib/api/identity';
import { createPull, createPullComment, fetchPullRoundPatch, getPull, listPullsPage, mergePull, setPullStatus } from '../../lib/api/pulls';
import { parseAtUri } from '../../lib/api/records';
import { compareBranches, getRepoBranches, type RepoContext, compareFork, listRepoRecords } from '../../lib/api/repos';
import { useAuth } from '../../lib/auth';
import { useIssuePreloader } from '../../lib/preloading';
import { Avatar, ErrorState, LoadingState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common';
import { AtUriPanel, BranchPill, DiffView, MarkdownBlock, PullComposeSkeleton, PullDiffSkeleton, PullDiffView, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton, IssueOrPullRow, ThreadCommentView } from '../../components/repo';
import { RepoFrame, pullQueryKey, pullsQueryKey, useRepoQuery, pullPatchQueryKey } from './shared';
import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, parseIntegerSearchParam, pullHref, uniqueCommenters, saveRecentIssueOrPull } from '../../lib/repo-utils';
import { buildPullFilterQuery, parsePastedPatchPrefill, parsePullFilter, type PullSourceMode, getRoundIndexForComment, buildPRCommentThreads } from './pulls-helpers';
export const PullsPage: Component = () => {
const auth = useAuth();
const repoQuery = useRepoQuery();
const issuePreloader = useIssuePreloader();
const [searchParams, setSearchParams] = useSearchParams();
const filter = createMemo(() => parsePullFilter(searchParams.q));
const stateFilter = createMemo(() => filter().state);
const authorFilter = createMemo(() => filter().author);
const textFilter = createMemo(() => filter().query);
const filterQuery = createMemo(() => buildPullFilterQuery(filter()));
const [filterDraft, setFilterDraft] = createSignal(filterQuery());
const pageOffset = createMemo(() => parseIntegerSearchParam(searchParams.offset, 0, 0));
const pageLimit = createMemo(() => parseIntegerSearchParam(searchParams.limit, REPO_LIST_PAGE_LIMIT, 1));
const basePath = createMemo(() => {
const repo = repoQuery.data;
return repo ? `/${repo.owner.handle}/${repo.slug}/pulls` : '/';
});
createEffect(() => {
setFilterDraft(filterQuery());
});
const setStateFilter = (state: 'open' | 'closed' | 'merged') => {
setSearchParams({
q: buildPullFilterQuery({ ...filter(), state }),
offset: '0',
limit: String(pageLimit()),
});
};
const applyFilter = (event: SubmitEvent) => {
event.preventDefault();
setSearchParams({
q: buildPullFilterQuery(parsePullFilter(filterDraft())),
offset: '0',
limit: String(pageLimit()),
});
};
const resetFilter = () => {
setSearchParams({
q: 'state:open',
offset: '0',
limit: String(pageLimit()),
});
};
const pullsQuery = createQuery(() => {
const repo = repoQuery.data;
return {
queryKey: [...pullsQueryKey(repo?.repoDid ?? ''), stateFilter(), authorFilter(), textFilter(), pageOffset(), pageLimit()],
enabled: Boolean(repo),
queryFn: async () => {
const author = authorFilter() ? (await resolveActor(authorFilter()!)).did : undefined;
return listPullsPage(repo!, {
state: stateFilter(),
author,
query: textFilter(),
offset: pageOffset(),
limit: pageLimit(),
});
},
};
});
const activeCount = createMemo(() => {
const data = pullsQuery.data;
if (!data) return undefined;
return data.totalCount === undefined ? `${pageOffset() + data.items.length}+` : String(data.totalCount);
});
const paginated = createMemo(() => pullsQuery.data?.items ?? []);
return (
<RepoFrame active="pulls" loadingFallback={<RepoListPageSkeleton kind="pulls" showCreate={Boolean(auth.agent())} />}>
<div class="rounded bg-white p-4 dark:bg-gray-800">
<div class="untangled-filter-grid">
<div class="untangled-toggle-group">
<ToggleButton
active={stateFilter() === 'open'}
onClick={() => setStateFilter('open')}
label="open"
meta={stateFilter() === 'open' ? activeCount() : undefined}
/>
<ToggleButton
active={stateFilter() === 'merged'}
onClick={() => setStateFilter('merged')}
label="merged"
meta={stateFilter() === 'merged' ? activeCount() : undefined}
/>
<ToggleButton
active={stateFilter() === 'closed'}
onClick={() => setStateFilter('closed')}
label="closed"
meta={stateFilter() === 'closed' ? activeCount() : undefined}
/>
</div>
<form onSubmit={applyFilter} class="untangled-repo-filter-search">
<input
value={filterDraft()}
onInput={(event) => setFilterDraft(event.currentTarget.value)}
placeholder="search pulls..."
class="untangled-repo-filter-input"
/>
<button type="button" class="untangled-repo-filter-reset" aria-label="reset filters" onClick={resetFilter}>
<X class="size-4" />
</button>
<button type="submit" class="untangled-repo-filter-submit" aria-label="apply filters">
<Search class="size-4" />
</button>
</form>
<Show when={auth.agent() && repoQuery.data}>
<A
href={`/${repoQuery.data!.owner.handle}/${repoQuery.data!.slug}/pulls/new`}
class={clsx(buttonStyles('primary'), 'col-start-3 justify-center no-underline hover:no-underline')}
>
<CirclePlus class="size-4" />
new pull
</A>
</Show>
</div>
</div>
<div class="mt-2">
<Show
when={!pullsQuery.isLoading}
fallback={
<RepoListSkeleton kind="pulls" />
}
>
<Show
when={!pullsQuery.error}
fallback={
<div class={cardStyles('p-6')}>
<ErrorState message={getErrorMessage(pullsQuery.error)} />
</div>
}
>
<Show
when={paginated().length > 0}
fallback={
<div class={cardStyles('px-6 py-4 text-sm text-gray-500 dark:text-gray-400')}>
No {stateFilter()} pull requests
<Show when={authorFilter()}>
{' '}by {authorFilter()}
</Show>
.
</div>
}
>
<div class="flex flex-col gap-2">
<div class="contents">
<For each={paginated()}>
{(pull) => (
<IssueOrPullRow
title={pull.value.title}
number={pull.number || pull.rkey}
state={pull.state}
kind="pull"
author={{ did: pull.author.did, handle: pull.author.handle }}
createdAt={pull.value.createdAt}
href={pullHref(repoQuery.data!, pull.number || pull.rkey)}
{...issuePreloader(
`${repoQuery.data!.owner.handle}/${repoQuery.data!.slug}`,
'pull',
pull.uri,
pull.number || pull.rkey,
repoQuery.data!,
)}
meta={
<>
<span>·</span>
<span>{pull.value.rounds.length} round{pull.value.rounds.length === 1 ? '' : 's'}</span>
</>
}
>
<div class="mt-3 flex flex-wrap items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<span>targeting</span>
<BranchPill name={pull.value.target.branch} />
<span>from</span>
<BranchPill name={pull.value.source?.branch ?? pull.value.target.branch} />
</div>
</IssueOrPullRow>
)}
</For>
</div>
</div>
<Show when={repoQuery.data}>
<PaginationControls
basePath={basePath()}
offset={pageOffset()}
limit={pageLimit()}
totalCount={pullsQuery.data?.totalCount}
hasNext={pullsQuery.data?.hasNext}
query={{ q: filterQuery() }}
/>
</Show>
</Show>
</Show>
</Show>
</div>
</RepoFrame>
);
};
export const NewPullPage: Component = () => {
const auth = useAuth();
const repoQuery = useRepoQuery();
const navigate = useNavigate();
const client = useQueryClient();
const [sourceMode, setSourceMode] = createSignal<PullSourceMode>('branch');
const [sourceBranch, setSourceBranch] = createSignal('');
const [targetBranch, setTargetBranch] = createSignal('');
const [patchText, setPatchText] = createSignal('');
const [title, setTitle] = createSignal('');
const [body, setBody] = createSignal('');
const [touchedTitle, setTouchedTitle] = createSignal(false);
const [touchedBody, setTouchedBody] = createSignal(false);
const [previewingBody, setPreviewingBody] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [submitting, setSubmitting] = createSignal(false);
const [selectedForkDid, setSelectedForkDid] = createSignal('');
const [forkSourceBranch, setForkSourceBranch] = createSignal('');
const viewerQuery = createQuery(() => {
return {
queryKey: ['viewer', auth.currentDid()],
enabled: Boolean(auth.currentDid()),
queryFn: async () => resolveActor(auth.currentDid()!),
};
});
const myReposQuery = createQuery(() => {
return {
queryKey: ['myRepos', auth.currentDid()],
enabled: Boolean(auth.currentDid() && sourceMode() === 'fork'),
queryFn: async () => listRepoRecords(auth.currentDid()!),
};
});
const forks = createMemo(() => {
const repos = myReposQuery.data ?? [];
const currentRepo = repoQuery.data;
if (!currentRepo) return [];
return repos.filter((r) => {
const source = r.value.source;
if (!source) return false;
return source === currentRepo.repoDid || source === currentRepo.record.uri;
});
});
const selectedForkRecord = createMemo(() => {
return forks().find((f) => f.value.repoDid === selectedForkDid());
});
createEffect(() => {
const availableForks = forks();
if (availableForks.length > 0 && !selectedForkDid()) {
setSelectedForkDid(availableForks[0].value.repoDid!);
}
});
createEffect(() => {
selectedForkDid();
setForkSourceBranch('');
});
const selectedForkContext = createMemo<RepoContext | null>(() => {
const forkRec = selectedForkRecord();
const viewer = viewerQuery.data;
if (!forkRec || !viewer) return null;
return {
owner: viewer,
record: forkRec,
repoDid: forkRec.value.repoDid!,
slug: forkRec.value.name || forkRec.rkey,
rkey: forkRec.rkey,
knot: forkRec.value.knot,
};
});
const forkBranchesQuery = createQuery(() => {
const forkCtx = selectedForkContext();
return {
queryKey: ['branches', forkCtx?.repoDid],
enabled: Boolean(sourceMode() === 'fork' && forkCtx),
queryFn: async () => getRepoBranches(forkCtx!),
};
});
createEffect(() => {
const branches = forkBranchesQuery.data?.branches ?? [];
if (branches.length === 0) return;
if (!forkSourceBranch()) {
const fallback = branches.find((branch) => branch.is_default)?.reference.name || branches[0].reference.name;
setForkSourceBranch(fallback);
}
});
const branchesQuery = createQuery(() => {
const repo = repoQuery.data;
return {
queryKey: ['branches', repo?.repoDid],
enabled: Boolean(repo),
queryFn: async () => getRepoBranches(repo!),
};
});
createEffect(() => {
const branches = branchesQuery.data?.branches ?? [];
if (branches.length === 0) return;
if (!targetBranch()) {
const fallback = branches.find((branch) => branch.is_default)?.reference.name || branches[0].reference.name;
setTargetBranch(fallback);
}
if (!sourceBranch()) {
const fallback =
branches.find((branch) => branch.reference.name !== targetBranch())?.reference.name || branches[0].reference.name;
setSourceBranch(fallback);
}
});
const compareQuery = createQuery(() => {
const repo = repoQuery.data;
const source = sourceBranch();
const target = targetBranch();
const mode = sourceMode();
return {
queryKey: ['compare', repo?.repoDid, mode, target, source],
enabled: Boolean(mode === 'branch' && repo && source && target && source !== target),
queryFn: async () => compareBranches(repo!, target, source),
};
});
const forkCompareQuery = createQuery(() => {
const agent = auth.agent();
const forkCtx = selectedForkContext();
const target = targetBranch();
const source = forkSourceBranch();
const mode = sourceMode();
return {
queryKey: ['compare-fork', forkCtx?.repoDid, target, source],
enabled: Boolean(mode === 'fork' && agent && forkCtx && target && source),
queryFn: async () => compareFork(agent!, forkCtx!, target, source),
};
});
createEffect(() => {
if (sourceMode() !== 'branch') return;
const comparison = compareQuery.data;
if (!comparison || comparison.format_patch.length === 0) return;
const patch = comparison.format_patch[0];
if (!touchedTitle()) setTitle(patch.Title || '');
if (!touchedBody()) setBody(patch.Body || '');
});
createEffect(() => {
if (sourceMode() !== 'fork') return;
const comparison = forkCompareQuery.data;
if (!comparison || comparison.format_patch.length === 0) return;
const patch = comparison.format_patch[0];
if (!touchedTitle()) setTitle(patch.Title || '');
if (!touchedBody()) setBody(patch.Body || '');
});
createEffect(() => {
if (sourceMode() !== 'patch') return;
const prefill = parsePastedPatchPrefill(patchText());
if (!prefill.title) return;
if (!touchedTitle()) setTitle(prefill.title);
if (!touchedBody()) setBody(prefill.body);
});
const pastedPatch = createMemo(() => patchText().trim());
const hasPastedPatch = createMemo(() => sourceMode() === 'patch' && Boolean(pastedPatch()));
const activePatch = createMemo(() => {
if (sourceMode() === 'patch') return pastedPatch();
if (sourceMode() === 'fork') return forkCompareQuery.data?.patch ?? '';
return compareQuery.data?.patch ?? '';
});
const hasComparison = createMemo(() => {
if (sourceMode() === 'branch') return Boolean(compareQuery.data);
if (sourceMode() === 'fork') return Boolean(forkCompareQuery.data);
return false;
});
const commits = createMemo(() => {
if (sourceMode() === 'branch') return compareQuery.data?.format_patch ?? [];
if (sourceMode() === 'fork') return forkCompareQuery.data?.format_patch ?? [];
return [];
});
const hasCommits = createMemo(() => commits().length > 0);
const hasPatch = createMemo(() => Boolean(activePatch()));
const showDetails = createMemo(() => hasCommits() || hasPatch());
const submit = async (event: SubmitEvent) => {
event.preventDefault();
const agent = auth.agent();
const repo = repoQuery.data;
const patch = activePatch();
const mode = sourceMode();
if (!agent || !repo) {
setError('Sign in first.');
return;
}
if (!targetBranch()) {
setError('Target branch required.');
return;
}
if (mode === 'branch' && !sourceBranch()) {
setError('Source branch required.');
return;
}
if (mode === 'fork' && !selectedForkDid()) {
setError('Fork selection required.');
return;
}
if (mode === 'fork' && !forkSourceBranch()) {
setError('Fork branch required.');
return;
}
if (!patch) {
setError(mode === 'patch' ? 'Paste a patch first.' : 'No patch available for the selected branches.');
return;
}
if (!title().trim()) {
setError('Title required.');
return;
}
setSubmitting(true);
setError(null);
try {
const created = await createPull(agent, repo, {
title: title(),
body: body(),
sourceBranch: mode === 'patch' ? targetBranch() : (mode === 'fork' ? forkSourceBranch() : sourceBranch()),
targetBranch: targetBranch(),
patch,
sourceRepoDid: mode === 'fork' ? (selectedForkDid() as Did) : undefined,
});
await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid) });
navigate(pullHref(repo, created.rkey));
} catch (cause) {
setError(getErrorMessage(cause));
} finally {
setSubmitting(false);
}
};
return (
<RepoFrame active="pulls" loadingFallback={<PullComposeSkeleton />}>
<div id="pr-compose-host" class="flex flex-col bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white">
<form onSubmit={submit} class="flex flex-col gap-6">
<section class="untangled-new-pr-step">
<div class="untangled-new-pr-step-line untangled-new-pr-step-line-after" />
<div class="untangled-new-pr-heading">
<PullComposeSectionNumber value={1} />
<h3 class="my-0 text-sm font-bold uppercase tracking-wide dark:text-white">source</h3>
</div>
<div class="ml-10 flex flex-col gap-3">
<section class="flex flex-col gap-3">
<div class="flex gap-1 p-1.5 rounded-md bg-slate-100 dark:bg-gray-900 border dark:border-gray-700 items-center justify-stretch">
<PullSourceTab
active={sourceMode() === 'branch'}
title="Compare branches"
description="Select a source branch"
onClick={() => setSourceMode('branch')}
/>
<PullSourceTab
active={sourceMode() === 'fork'}
title="Compare forks"
description="Select a fork and branch"
onClick={() => setSourceMode('fork')}
/>
<PullSourceTab
active={sourceMode() === 'patch'}
title="Paste patch"
description={
<span>
Paste a <code class="font-mono">git diff</code> or <code class="font-mono">git format-patch</code>
</span>
}
onClick={() => setSourceMode('patch')}
/>
</div>
<div class="flex flex-col md:flex-row md:flex-wrap gap-3">
<div class="flex flex-col gap-1">
<label for="targetBranch" class="text-xs uppercase tracking-wide text-gray-800 dark:text-gray-200">
Merge into
</label>
<div class="flex flex-wrap gap-2 items-center">
<select
id="targetBranch"
name="targetBranch"
required
value={targetBranch()}
onChange={(event) => setTargetBranch(event.currentTarget.value)}
class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
>
<option disabled selected={!targetBranch()}>
Target branch
</option>
<For each={branchesQuery.data?.branches ?? []}>
{(branch) => {
const name = branch.reference.name;
const isSource = sourceMode() === 'branch' && name === sourceBranch();
return (
<option value={name} disabled={isSource}>
{name}
{isSource ? ' (source)' : ''}
</option>
);
}}
</For>
</select>
<Show when={branchesQuery.isLoading}>
<LoaderCircle class="size-4 animate-spin text-gray-500 dark:text-gray-400" />
</Show>
</div>
</div>
<Show when={sourceMode() === 'branch'}>
<div id="patch-strategy" class="flex flex-col gap-1">
<label for="sourceBranch" class="text-xs uppercase tracking-wide text-gray-800 dark:text-gray-200">
Pull from
</label>
<div class="flex flex-wrap gap-2 items-center">
<select
id="sourceBranch"
name="sourceBranch"
required
value={sourceBranch()}
onChange={(event) => setSourceBranch(event.currentTarget.value)}
class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
>
<option disabled selected={!sourceBranch()}>
Source branch
</option>
<For each={branchesQuery.data?.branches ?? []}>
{(branch, index) => {
const name = branch.reference.name;
const isTarget = name === targetBranch();
return (
<option value={name} disabled={isTarget}>
{name}
{index() === 0 ? ' (new)' : ''}
{isTarget ? ' (target)' : ''}
</option>
);
}}
</For>
</select>
<Show when={compareQuery.isFetching}>
<LoaderCircle class="size-4 animate-spin text-gray-500 dark:text-gray-400" />
</Show>
</div>
</div>
</Show>
<Show when={sourceMode() === 'fork'}>
<div id="patch-strategy" class="flex flex-col gap-1">
<label for="forkSelect" class="text-xs uppercase tracking-wide text-gray-800 dark:text-gray-200">
Pull from
</label>
<div class="flex flex-wrap gap-2 items-center">
<select
id="forkSelect"
name="fork"
required
value={selectedForkDid()}
onChange={(event) => setSelectedForkDid(event.currentTarget.value)}
class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
>
<option disabled selected={!selectedForkDid()}>
Select a fork
</option>
<For each={forks()}>
{(fork) => (
<option value={fork.value.repoDid}>
{viewerQuery.data?.handle ?? auth.currentDid()}/{fork.value.name || fork.rkey}
</option>
)}
</For>
</select>
<Show when={myReposQuery.isLoading || viewerQuery.isLoading}>
<LoaderCircle class="size-4 animate-spin text-gray-500 dark:text-gray-400" />
</Show>
</div>
</div>
<Show when={selectedForkDid()}>
<div class="flex flex-col gap-1">
<label for="forkSourceBranch" class="text-xs uppercase tracking-wide text-gray-800 dark:text-gray-200">
Fork Branch
</label>
<div class="flex flex-wrap gap-2 items-center">
<select
id="forkSourceBranch"
name="forkSourceBranch"
required
value={forkSourceBranch()}
onChange={(event) => setForkSourceBranch(event.currentTarget.value)}
class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
>
<option disabled selected={!forkSourceBranch()}>
Source branch
</option>
<For each={forkBranchesQuery.data?.branches ?? []}>
{(branch, index) => {
const name = branch.reference.name;
return (
<option value={name}>
{name}
{index() === 0 ? ' (new)' : ''}
</option>
);
}}
</For>
</select>
<Show when={forkCompareQuery.isFetching || forkBranchesQuery.isLoading}>
<LoaderCircle class="size-4 animate-spin text-gray-500 dark:text-gray-400" />
</Show>
</div>
</div>
</Show>
</Show>
</div>
<Show when={sourceMode() === 'fork' && forks().length === 0 && !myReposQuery.isLoading && !viewerQuery.isLoading}>
<div class="text-sm text-gray-500 dark:text-gray-400">
No forks found for this repository.
</div>
</Show>
<Show when={sourceMode() === 'patch'}>
<div id="patch-upload">
<div class="flex items-center justify-between gap-2">
<p class="my-0">
You can paste a <code>git diff</code> or a <code>git format-patch</code> patch series here.
</p>
</div>
<textarea
name="patch"
id="patch"
rows="12"
value={patchText()}
onInput={(event) => setPatchText(event.currentTarget.value)}
class="w-full mt-2 resize-y font-mono dark:bg-gray-800 dark:text-white dark:border-gray-700"
placeholder={`diff --git a/file.txt b/file.txt
index 1234567..abcdefg 100644
--- a/file.txt
+++ b/file.txt`}
/>
</div>
</Show>
<Show when={branchesQuery.error}>
<div id="patch-error" class="error dark:text-red-300">
{getErrorMessage(branchesQuery.error)}
</div>
</Show>
</section>
</div>
</section>
<section class="untangled-new-pr-step">
<div
class={clsx(
'untangled-new-pr-step-line untangled-new-pr-step-line-before',
showDetails() ? 'untangled-new-pr-step-line-through' : 'untangled-new-pr-step-line-fade',
)}
/>
<div class="untangled-new-pr-heading">
<PullComposeSectionNumber value={2} />
<h3 class="my-0 text-sm font-bold uppercase tracking-wide dark:text-white">review</h3>
</div>
<div class="ml-10 flex flex-col gap-3">
<section class="flex flex-col gap-3">
<Switch>
<Match when={sourceMode() === 'branch' && compareQuery.isLoading}>
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800">
<LoadingState label="Generating comparison..." />
</div>
</Match>
<Match when={sourceMode() === 'branch' && compareQuery.error}>
<div class="p-4 border border-red-200 dark:border-red-800 rounded bg-red-50 dark:bg-gray-800">
<ErrorState message={getErrorMessage(compareQuery.error)} />
</div>
</Match>
<Match when={sourceMode() === 'fork' && forkCompareQuery.isLoading}>
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800">
<LoadingState label="Generating comparison..." />
</div>
</Match>
<Match when={sourceMode() === 'fork' && forkCompareQuery.error}>
<div class="p-4 border border-red-200 dark:border-red-800 rounded bg-red-50 dark:bg-gray-800">
<ErrorState message={getErrorMessage(forkCompareQuery.error)} />
</div>
</Match>
<Match when={sourceMode() === 'patch' && !hasPastedPatch()}>
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
Paste a patch above to see a comparison.
</div>
</Match>
<Match when={sourceMode() === 'patch' && hasPastedPatch()}>
<div class="flex items-center justify-between gap-3 min-w-0 text-sm text-gray-500 dark:text-gray-400">
<span class="flex-shrink-0">Pasted patch</span>
<span class="inline-flex items-center gap-2 font-mono text-gray-600 dark:text-gray-300 truncate">
<span>{targetBranch()}</span>
</span>
</div>
</Match>
<Match when={sourceMode() === 'branch' && (!sourceBranch() || !targetBranch() || sourceBranch() === targetBranch())}>
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
Pick a source and target above to see a comparison.
</div>
</Match>
<Match when={sourceMode() === 'fork' && (!selectedForkDid() || !forkSourceBranch())}>
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
Pick a fork and source branch above to see a comparison.
</div>
</Match>
<Match when={sourceMode() === 'branch' && hasComparison() && !hasCommits()}>
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
No commits between target and source. Make sure your source branch has commits not on the target.
</div>
</Match>
<Match when={sourceMode() === 'fork' && hasComparison() && !hasCommits()}>
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
No commits between target and source. Make sure your source branch has commits not on the target.
</div>
</Match>
<Match when={(sourceMode() === 'branch' || sourceMode() === 'fork') && hasCommits()}>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 min-w-0 text-sm text-gray-500 dark:text-gray-400">
<span class="flex-shrink-0">
{commits().length} commit{commits().length === 1 ? '' : 's'}
</span>
<span class="inline-flex items-center gap-2 font-mono text-gray-600 dark:text-gray-300 truncate">
<span>{targetBranch()}</span>
<ArrowLeftRight class="w-4 h-4 flex-shrink-0" />
<span>
{sourceMode() === 'fork' ? `${viewerQuery.data?.handle ?? auth.currentDid()}/${selectedForkRecord()?.value.name || selectedForkRecord()?.rkey}:${forkSourceBranch()}` : sourceBranch()}
</span>
</span>
</div>
<ul class="flex flex-col gap-2">
<For each={commits()}>
{(patch) => (
<li class="border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 px-3 py-2 flex items-center gap-3">
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0">
<GitPullRequest class="w-4 h-4" />
</span>
<span class="flex-1 min-w-0 truncate text-sm dark:text-gray-200">{patch.Title}</span>
<span class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
<Show when={patch.AuthorDate}>
<span>{formatRelativeTime(patch.AuthorDate)}</span>
</Show>
<Show when={patch.SHA}>
<span class="font-mono bg-gray-100 dark:bg-gray-900 text-gray-700 dark:text-gray-300 px-2 py-0.5 rounded">
{patch.SHA.slice(0, 8)}
</span>
</Show>
</span>
<Show when={patch.SHA}>
<span class="-my-2 self-stretch w-px bg-gray-200 dark:bg-gray-700" />
<button
type="button"
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300"
title="Copy SHA"
onClick={() => void navigator.clipboard?.writeText(patch.SHA)}
>
<Copy class="w-4 h-4" />
</button>
<Show when={repoQuery.data}>
{(repo) => (
<A
href={`/${repo().owner.handle}/${repo().slug}/tree/${patch.SHA}`}
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300"
title="Browse repository at this commit"
>
<FolderCode class="w-4 h-4" />
</A>
)}
</Show>
</Show>
</li>
)}
</For>
</ul>
</div>
</Match>
</Switch>
<Show when={hasPatch()}>
<div class="untangled-new-pr-diff">
<DiffView patch={activePatch()} wrap maxHeight="40rem" />
</div>
</Show>
</section>
</div>
</section>
<Show when={showDetails()}>
<section class="untangled-new-pr-step">
<div class="untangled-new-pr-step-line untangled-new-pr-step-line-before untangled-new-pr-step-line-fade" />
<div class="untangled-new-pr-heading">
<PullComposeSectionNumber value={3} />
<h3 class="my-0 text-sm font-bold uppercase tracking-wide dark:text-white">details</h3>
</div>
<div class="ml-10 flex flex-col gap-3">
<section class="flex flex-col md:flex-row gap-6">
<div class="flex-1 min-w-0 flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="title" class="text-xs uppercase tracking-wide text-gray-800 dark:text-gray-200">
title
</label>
<input
type="text"
name="title"
id="title"
value={title()}
onInput={(event) => {
setTouchedTitle(true);
setTitle(event.currentTarget.value);
}}
class="w-full dark:bg-gray-800 dark:text-white dark:border-gray-700"
placeholder="One-line summary of your change."
/>
</div>
<div class="flex flex-col gap-2" data-md-editor="pull-body">
<div class="btn-group self-start text-gray-600 dark:text-gray-300">
<button
type="button"
class={clsx('btn-group-item group', !previewingBody() && 'active')}
onClick={() => setPreviewingBody(false)}
>
<Pencil class="w-3.5 h-3.5" />
Write
</button>
<button
type="button"
class={clsx('btn-group-item group', previewingBody() && 'active')}
onClick={() => setPreviewingBody(true)}
>
<Eye class="w-3.5 h-3.5" />
Preview
</button>
</div>
<Show
when={previewingBody()}
fallback={
<textarea
id="pull-body"
name="body"
rows="6"
value={body()}
onInput={(event) => {
setTouchedBody(true);
setBody(event.currentTarget.value);
}}
class="w-full resize-y dark:bg-gray-800 dark:text-white dark:border-gray-700"
placeholder="Describe your change. Markdown is supported."
/>
}
>
<div class="min-h-[6rem] p-3 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-900">
<Show
when={body().trim()}
fallback={<span class="text-gray-400 dark:text-gray-500 italic">Nothing to preview.</span>}
>
<MarkdownBlock markdown={body()} />
</Show>
</div>
</Show>
</div>
<div class="flex items-center justify-end gap-4 mt-auto">
<button
type="submit"
class={clsx(buttonStyles('primary'), 'justify-center min-w-48')}
disabled={submitting()}
>
<GitPullRequest class="w-4 h-4" />
<Show when={submitting()} fallback="Create pull request">
<span class="inline-flex items-center gap-2">
Creating
<LoaderCircle class="w-4 h-4 animate-spin" />
</span>
</Show>
</button>
</div>
</div>
</section>
</div>
</section>
</Show>
</form>
<Show when={error()}>
<div id="pull" class="error dark:text-red-300 mt-4 ml-10">
{error()}
</div>
</Show>
</div>
</RepoFrame>
);
};
const PullComposeSectionNumber: Component<{ value: number }> = (props) => (
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300 text-xs font-bold">
{props.value}
</span>
);
const PullSourceTab: Component<{
active: boolean;
title: string;
description: JSX.Element;
onClick: () => void;
}> = (props) => (
<button
type="button"
class={clsx(
'group flex-1 p-3 text-left hover:no-underline flex flex-col gap-1 rounded',
props.active
? 'bg-white dark:bg-gray-800 shadow-sm cursor-default border border-gray-200 dark:border-gray-700'
: 'bg-transparent hover:bg-white/50 dark:hover:bg-gray-800/50',
)}
onClick={props.onClick}
>
<span class="font-medium text-sm dark:text-white flex items-center gap-2">{props.title}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{props.description}</span>
</button>
);
const updatePullStatusData = (
data: unknown,
pullUri: string,
state: 'open' | 'closed' | 'merged',
): unknown => {
if (!data || typeof data !== 'object') return data;
if ('pull' in data) {
const detail = data as { pull?: { uri?: string; state?: string } };
if (detail.pull?.uri === pullUri) {
return {
...detail,
pull: {
...detail.pull,
state,
},
};
}
}
if ('items' in data) {
const page = data as { items?: Array<{ uri?: string; state?: string }> };
if (Array.isArray(page.items)) {
return {
...page,
items: page.items.map((pull) => (pull.uri === pullUri ? { ...pull, state } : pull)),
};
}
}
return data;
};
export const PullPage: Component = () => {
const auth = useAuth();
const repoQuery = useRepoQuery();
const params = useParams();
const client = useQueryClient();
const location = useLocation();
const pullRef = createMemo(() => params.pullRef ?? '');
const [roundIndex, setRoundIndex] = createSignal(0);
const [comment, setComment] = createSignal('');
const [composerOpen, setComposerOpen] = createSignal(false);
const [workingAction, setWorkingAction] = createSignal<'comment' | 'state' | 'merge' | null>(null);
const working = createMemo(() => workingAction() !== null);
const [error, setError] = createSignal<string | null>(null);
const [mobileDrawerOpen, setMobileDrawerOpen] = createSignal(false);
const pullQuery = createQuery(() => {
const repo = repoQuery.data;
return {
queryKey: pullQueryKey(repo?.repoDid ?? '', pullRef()),
enabled: Boolean(repo && pullRef()),
queryFn: async () => getPull(repo!, pullRef()),
};
});
const commentsByRound = createMemo(() => {
const detailData = pullQuery.data;
if (!detailData) return {};
const rounds = detailData.pull.value.rounds || [];
const groups: Record<number, typeof detailData.comments> = {};
for (let i = 0; i < rounds.length; i++) {
groups[i] = [];
}
for (const comment of detailData.comments) {
const roundVal = (comment.value as any).pullRoundIdx ?? (comment.value as any).round;
const roundIdx = typeof roundVal === 'number'
? roundVal
: getRoundIndexForComment(comment.value.createdAt, rounds);
if (!groups[roundIdx]) {
groups[roundIdx] = [];
}
groups[roundIdx].push(comment);
}
return groups;
});
createEffect(() => {
const rounds = pullQuery.data?.pull.value.rounds.length ?? 0;
if (rounds > 0) {
setRoundIndex(rounds - 1);
}
});
createEffect(() => {
const repo = repoQuery.data;
const detail = pullQuery.data;
if (repo && detail) {
saveRecentIssueOrPull(repo.owner.handle, repo.slug, {
uri: detail.pull.uri,
kind: 'pull',
title: detail.pull.value.title,
number: detail.pull.number,
state: detail.pull.state,
author: detail.pull.author.handle,
});
}
});
createEffect(() => {
const hash = location.hash;
const cleanHash = hash.startsWith('#') ? hash.slice(1) : hash;
if (cleanHash && cleanHash.startsWith('comment-') && pullQuery.data?.comments) {
setTimeout(() => {
const element = document.getElementById(cleanHash);
if (element) {
const details = element.closest('details');
if (details) {
details.open = true;
}
// Remove highlight from any other elements first
document.querySelectorAll('.untangled-comment-highlight').forEach((el) => {
el.classList.remove('untangled-comment-highlight');
});
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('untangled-comment-highlight');
}
}, 150);
}
});
const patchQuery = createQuery(() => {
const detail = pullQuery.data;
return {
queryKey: pullPatchQueryKey(detail?.pull.uri ?? '', roundIndex()),
enabled: Boolean(detail),
queryFn: async () => fetchPullRoundPatch(detail!.pull, roundIndex()),
};
});
const invalidate = async (repo: RepoContext, pullRef: string, pullUri: string) => {
await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid) });
await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, pullRef) });
await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, parseAtUri(pullUri).rkey) });
};
const updatePullStatusCaches = (
repo: RepoContext,
currentPullRef: string,
pullUri: string,
state: 'open' | 'closed' | 'merged',
) => {
client.setQueryData(pullQueryKey(repo.repoDid, currentPullRef), (data: unknown) =>
updatePullStatusData(data, pullUri, state),
);
client.setQueryData(pullQueryKey(repo.repoDid, parseAtUri(pullUri).rkey), (data: unknown) =>
updatePullStatusData(data, pullUri, state),
);
client.setQueriesData<unknown>({ queryKey: pullsQueryKey(repo.repoDid) }, (data: unknown) =>
updatePullStatusData(data, pullUri, state),
);
};
const markPullQueriesStale = async (repo: RepoContext, currentPullRef: string, pullUri: string) => {
await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid), refetchType: 'none' });
await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, currentPullRef), refetchType: 'none' });
await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, parseAtUri(pullUri).rkey), refetchType: 'none' });
};
const toggleState = async () => {
const agent = auth.agent();
const repo = repoQuery.data;
const detail = pullQuery.data;
if (!agent || !repo || !detail) {
return;
}
const nextState = detail.pull.state === 'open' ? 'closed' : 'open';
setWorkingAction('state');
setError(null);
try {
await setPullStatus(agent, detail.pull.uri, nextState);
updatePullStatusCaches(repo, pullRef(), detail.pull.uri, nextState);
await markPullQueriesStale(repo, pullRef(), detail.pull.uri);
} catch (cause) {
setError(getErrorMessage(cause));
} finally {
setWorkingAction(null);
}
};
const mergeCurrentPull = async () => {
const agent = auth.agent();
const repo = repoQuery.data;
const detail = pullQuery.data;
if (!agent || !repo || !detail || detail.pull.state !== 'open') {
return;
}
const latestRoundIndex = detail.pull.value.rounds.length - 1;
if (latestRoundIndex < 0) {
setError('No pull request patch is available to merge');
return;
}
const confirmed = window.confirm(
`Are you sure you want to merge pull #${detail.pull.number || detail.pull.rkey} into the ${detail.pull.value.target.branch} branch?`,
);
if (!confirmed) return;
setWorkingAction('merge');
setError(null);
try {
const patch =
roundIndex() === latestRoundIndex && patchQuery.data
? patchQuery.data
: await fetchPullRoundPatch(detail.pull, latestRoundIndex);
await mergePull(agent, repo, {
pull: detail.pull,
patch,
});
updatePullStatusCaches(repo, pullRef(), detail.pull.uri, 'merged');
await markPullQueriesStale(repo, pullRef(), detail.pull.uri);
await client.invalidateQueries({ queryKey: ['repo-tree', repo.repoDid] });
await client.invalidateQueries({ queryKey: ['repo-log', repo.repoDid] });
await client.invalidateQueries({ queryKey: ['repo-branches', repo.repoDid] });
await client.invalidateQueries({ queryKey: ['repo-languages', repo.repoDid] });
} catch (cause) {
setError(getErrorMessage(cause));
} finally {
setWorkingAction(null);
}
};
const addComment = async (event: SubmitEvent) => {
event.preventDefault();
const agent = auth.agent();
const repo = repoQuery.data;
const detail = pullQuery.data;
if (!agent || !repo || !detail || !comment().trim()) {
return;
}
setWorkingAction('comment');
setError(null);
try {
await createPullComment(
agent,
detail.pull.uri,
detail.pull.cid ?? '',
comment(),
detail.pull.value.rounds.length - 1,
);
setComment('');
setComposerOpen(false);
await invalidate(repo, pullRef(), detail.pull.uri);
} catch (cause) {
setError(getErrorMessage(cause));
} finally {
setWorkingAction(null);
}
};
const participants = createMemo(() => {
const detail = pullQuery.data;
if (!detail) return [];
const seen = new Set<string>();
const result: Array<{ did: string; handle: string }> = [];
const add = (participant: { did: string; handle: string }) => {
if (seen.has(participant.did)) return;
seen.add(participant.did);
result.push(participant);
};
add(detail.pull.author);
for (const commenter of uniqueCommenters(detail.comments)) add(commenter);
return result;
});
const roundLabel = createMemo(() => `round #${roundIndex()}`);
const statusEvent = createMemo(() => {
const state = pullQuery.data?.pull.state;
if (state === 'merged') {
return {
icon: <GitMerge class="size-4" />,
text: 'pull request successfully merged',
class: 'untangled-pr-history-event-merged',
};
}
if (state === 'closed') {
return {
icon: <GitPullRequestClosed class="size-4" />,
text: 'pull request closed',
class: 'untangled-pr-history-event-closed',
};
}
return {
icon: <GitPullRequest class="size-4" />,
text: 'pull request opened',
class: 'untangled-pr-history-event-open',
};
});
return (
<RepoFrame active="pulls" loadingFallback={<RepoThreadSkeleton kind="pull" />}>
<Switch>
<Match when={pullQuery.isLoading}>
<RepoThreadSkeleton kind="pull" />
</Match>
<Match when={pullQuery.error}>
<div class={cardStyles('p-6')}>
<ErrorState message={getErrorMessage(pullQuery.error)} />
</div>
</Match>
<Match when={pullQuery.data}>
{(detail) => (
<div class="untangled-pr-detail">
<div class="untangled-pr-header-grid">
<section class="untangled-pr-summary-card">
<header>
<h1 class="m-0 text-2xl">
{detail().pull.value.title}{' '}
<span class="text-gray-500 dark:text-gray-400">#{detail().pull.number || detail().pull.rkey}</span>
</h1>
</header>
<div class="mt-3 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<StateBadge state={detail().pull.state} kind="pull" />
<span>opened by</span>
<Avatar did={detail().pull.author.did} size="size-6" />
<a href={`/${detail().pull.author.did}`} class="text-gray-700 dark:text-gray-200">{detail().pull.author.handle}</a>
<span class="select-none">·</span>
<span>{formatRelativeTime(detail().pull.value.createdAt)}</span>
<span class="select-none">·</span>
<span>targeting</span>
<BranchPill name={detail().pull.value.target.branch} />
</div>
<Show when={detail().pull.value.body}>
<div class="mt-8 min-w-0 text-base leading-7 text-gray-700 dark:text-gray-300">
<MarkdownBlock markdown={detail().pull.value.body!} />
</div>
</Show>
<div class="mt-4 flex flex-wrap items-center justify-end gap-2">
<Show when={auth.agent()}>
<Show when={detail().pull.state === 'open'}>
<button
type="button"
class={buttonStyles('primary')}
disabled={working() || detail().pull.value.rounds.length === 0}
onClick={() => void mergeCurrentPull()}
>
<Show when={workingAction() === 'merge'} fallback={<GitMerge class="size-4" />}>
<LoaderCircle class="size-4 animate-spin" />
</Show>
merge
</button>
</Show>
<Show when={detail().pull.state !== 'merged'}>
<button type="button" class={buttonStyles()} disabled={working()} onClick={() => void toggleState()}>
<Show
when={workingAction() === 'state'}
fallback={
<Show when={detail().pull.state === 'open'} fallback={<RefreshCcwDot class="size-4" />}>
<GitPullRequestClosed class="size-4" />
</Show>
}
>
<LoaderCircle class="size-4 animate-spin" />
</Show>
{detail().pull.state === 'open' ? 'close pull' : 'reopen pull'}
</button>
</Show>
</Show>
</div>
<Show when={error()}>
<p class="mt-3 text-sm text-red-500">{error()}</p>
</Show>
</section>
<aside class="untangled-pr-meta untangled-thread-meta">
<div class="untangled-pr-meta-section">
<h2>Labels</h2>
<p>None yet.</p>
</div>
<div class="untangled-pr-meta-section">
<h2>Assignee</h2>
<p>None yet.</p>
</div>
<div class="untangled-pr-meta-section">
<h2>
Participants <span>{participants().length}</span>
</h2>
<div class="mt-3 flex flex-wrap gap-2">
<For each={participants()}>
{(participant) => <Avatar did={participant.did} size="size-7" />}
</For>
</div>
</div>
<AtUriPanel uri={detail().pull.uri} />
</aside>
</div>
<Switch>
<Match when={patchQuery.isLoading}>
<PullDiffSkeleton />
</Match>
<Match when={patchQuery.error}>
<div class={cardStyles('p-6')}>
<ErrorState message={getErrorMessage(patchQuery.error)} />
</div>
</Match>
<Match when={patchQuery.data}>
<PullDiffView
patch={patchQuery.data!}
roundLabel={roundLabel()}
storageKey={`reviewed:${repoQuery.data!.owner.handle}/${repoQuery.data!.slug}/pulls/${detail().pull.number || parseAtUri(detail().pull.uri).rkey}/round/${roundIndex()}`}
mobileDrawerOpen={mobileDrawerOpen()}
onMobileDrawerClose={() => setMobileDrawerOpen(false)}
history={
<details
open={true}
class="rounded-t shadow-lg md:shadow-none group/panel w-full"
>
<summary
class={clsx(
"h-12 flex gap-4 items-center justify-between",
"rounded-t cursor-pointer list-none px-4",
"text-white md:text-black md:dark:text-white",
detail().pull.state === 'open'
? 'bg-green-600 dark:bg-green-700'
: detail().pull.state === 'merged'
? 'bg-purple-600 dark:bg-purple-700'
: 'bg-red-600 dark:bg-red-700',
"md:bg-white md:dark:bg-gray-800",
"shadow-sm border-t md:border-x border-gray-200 dark:border-gray-700",
"md:pointer-events-none"
)}
onClick={(e) => {
e.preventDefault();
setMobileDrawerOpen(!mobileDrawerOpen());
}}
>
<h3 class="text-base font-semibold text-white md:text-gray-900 md:dark:text-white">history</h3>
<span class="pointer-events-auto cursor-text flex items-center gap-2 text-sm text-white md:text-gray-500 md:dark:text-gray-400">
<span>
{detail().pull.value.rounds.length} round{detail().pull.value.rounds.length === 1 ? '' : 's'}
</span>
<span class="select-none before:content-['\00B7']"></span>
<span>
{detail().comments.length} comment{detail().comments.length === 1 ? '' : 's'}
</span>
<span class="md:hidden inline ml-2">
<Show when={mobileDrawerOpen()} fallback={<ChevronUp class="size-4" />}>
<ChevronDown class="size-4" />
</Show>
</span>
</span>
</summary>
<div class="max-h-[85vh] md:max-h-[calc(100vh-3rem-3rem)] w-full flex flex-col gap-4 overflow-y-auto bg-slate-100 dark:bg-gray-900 md:bg-transparent p-0 untangled-pr-history-body">
<For each={detail().pull.value.rounds}>
{(round, index) => {
const isSelected = () => index() === roundIndex();
const isLatest = () => index() === detail().pull.value.rounds.length - 1;
const roundComments = () => commentsByRound()[index()] || [];
const roundThreads = createMemo(() => buildPRCommentThreads(roundComments()));
return (
<div
class={clsx(
'w-full border overflow-clip shadow-sm text-sm',
index() === 0 ? 'rounded-b' : 'rounded',
index() > 0 && 'mt-4',
isSelected()
? 'bg-blue-50/25 border-blue-200 dark:bg-blue-900/10 dark:border-blue-900'
: 'bg-gray-50 border-gray-200 dark:bg-gray-900 dark:border-gray-700'
)}
>
{/* Submission Header */}
<div
class={clsx(
'px-6 py-4 flex gap-2 border-b',
isSelected()
? 'bg-blue-50 dark:bg-blue-950 border-blue-100 dark:border-blue-900'
: 'bg-white dark:bg-gray-800 border-gray-100 dark:border-gray-700'
)}
>
<div class="shrink-0">
<Avatar did={detail().pull.author.did} size="size-8 mr-1" />
</div>
<div class="flex-1 min-w-0 flex flex-col justify-center">
<div class="flex items-center justify-between">
<span
class={clsx(
'inline-flex items-center gap-2 text-sm',
isSelected()
? 'text-gray-600 dark:text-gray-300'
: 'text-gray-500 dark:text-gray-400'
)}
>
<a
href={`/${detail().pull.author.did}`}
class={clsx(
'hover:underline font-medium',
isSelected()
? 'text-gray-800 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300'
)}
>
{detail().pull.author.handle}
</a>
<span>submitted</span>
<span
class={clsx(
'px-2 py-0.5 rounded font-mono text-xs border',
isSelected()
? 'text-blue-800 dark:text-white bg-blue-100 dark:bg-blue-600 border-blue-200 dark:border-blue-500'
: 'text-black dark:text-white bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600'
)}
>
#{index()}
</span>
<span class="select-none">·</span>
<span>{formatRelativeTime(round.createdAt)}</span>
</span>
<Show when={!isSelected()}>
<button
type="button"
class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline text-sm font-medium cursor-pointer"
onClick={() => setRoundIndex(index())}
>
<Diff class="size-4" />
diff
</button>
</Show>
</div>
</div>
</div>
{/* Comments section */}
<Show when={roundComments().length > 0}>
<details
class="relative ml-10 pr-4 group/comments mt-0"
open={isSelected()}
>
<summary class="cursor-pointer list-none select-none">
<div class="hidden group-open/comments:block absolute -left-8 top-0 bottom-0 w-16 flex items-center justify-center pointer-events-none">
<div class="absolute left-1/2 -translate-x-1/2 top-0 bottom-0 w-px bg-gray-200 dark:bg-gray-700"></div>
</div>
<div class="group-open/comments:hidden block relative py-2">
<div class="absolute -left-8 top-0 bottom-0 w-16 flex items-center justify-center pointer-events-none">
<div class="absolute left-1/2 -translate-x-1/2 h-1/3 top-0 bottom-0 w-px bg-gray-200 dark:bg-gray-700"></div>
</div>
<span class="text-gray-500 dark:text-gray-400 text-sm flex items-center gap-1.5 -ml-2 relative hover:text-gray-600 dark:hover:text-gray-300">
<CirclePlus class="size-3.5" />
Expand {roundComments().length} comment{roundComments().length === 1 ? '' : 's'}
</span>
</div>
</summary>
<div class="relative border-l border-gray-200 dark:border-gray-700 pr-4 pb-2">
<For each={roundThreads()}>
{(thread) => (
<div class="flex flex-col -ml-4">
<ThreadCommentView
id={`comment-${thread.item.rkey}`}
author={{ handle: thread.item.author.handle, did: thread.item.author.did }}
createdAt={thread.item.value.createdAt}
markdown={thread.item.value.body}
isAuthor={thread.item.author.did === detail().pull.author.did}
/>
<Show when={thread.replies.length > 0}>
<div class="relative ml-10 border-l border-gray-200 dark:border-gray-700">
<For each={thread.replies}>
{(reply) => (
<div class="-ml-4">
<ThreadCommentView
id={`comment-${reply.rkey}`}
author={{ handle: reply.author.handle, did: reply.author.did }}
createdAt={reply.value.createdAt}
markdown={reply.value.body}
isAuthor={reply.author.did === detail().pull.author.did}
isReply={true}
/>
</div>
)}
</For>
</div>
</Show>
</div>
)}
</For>
<button
type="button"
class="flex items-center gap-2 -ml-2 relative cursor-pointer text-sm text-gray-500 dark:text-gray-400 mt-4 pb-4 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
onClick={(e) => {
const detailsEl = e.currentTarget.closest('details');
if (detailsEl) detailsEl.open = false;
}}
>
<CircleChevronUp class="size-4" />
Collapse comment{roundComments().length === 1 ? '' : 's'}
</button>
</div>
</details>
</Show>
{/* Latest round status and comment form */}
<Show when={isLatest()}>
{/* State event banner */}
<Show when={detail().pull.state !== 'open'}>
<div class={clsx('untangled-pr-history-event mx-4 mb-3 mt-3 text-sm font-medium', statusEvent().class)}>
{statusEvent().icon}
<span>{statusEvent().text}</span>
</div>
</Show>
{/* Reply/Comment Form Actions Strip */}
<Show when={auth.agent()}>
<div class="bg-gray-50 dark:bg-gray-900 px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex flex-col gap-3">
<Show when={!composerOpen()}>
<div class="flex items-center justify-start">
<button
type="button"
class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline text-sm font-medium cursor-pointer"
onClick={() => setComposerOpen(true)}
>
<MessageSquarePlus class="size-4" />
comment
</button>
</div>
</Show>
<Show when={composerOpen()}>
<form onSubmit={addComment} class="flex flex-col gap-2 w-full">
<textarea
value={comment()}
onInput={(event) => setComment(event.currentTarget.value)}
class={textareaStyles()}
rows="4"
placeholder="Add to the discussion..."
/>
<Show when={error()}>
<p class="text-sm text-red-500">{error()}</p>
</Show>
<div class="flex items-center justify-end gap-2 text-sm w-full">
<button
type="button"
class="btn-flat text-red-500 dark:text-red-400 flex items-center gap-1.5 cursor-pointer font-medium"
onClick={() => {
setComment('');
setComposerOpen(false);
}}
>
<X class="size-4" />
<span>cancel</span>
</button>
<button
type="submit"
class={clsx(buttonStyles('primary'), 'justify-center')}
disabled={working() || !comment().trim()}
>
<Show when={workingAction() === 'comment'} fallback={<Reply class="size-4" />}>
<LoaderCircle class="size-4 animate-spin" />
</Show>
<span>reply</span>
</button>
</div>
</form>
</Show>
</div>
</Show>
</Show>
</div>
);
}}
</For>
</div>
</details>
}
/>
</Match>
</Switch>
</div>
)}
</Match>
</Switch>
</RepoFrame>
);
};