import { CircleDot, GitFork, GitPullRequest, Globe, LoaderCircle, Rss, SquareChartGantt, Star } from 'lucide-solid';
import type { Did } from '@atcute/lexicons/syntax';
import { A, type RouteSectionProps, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { For, Match, Show, Switch, createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type Component, type JSX } from 'solid-js';
import { getRepoIssueCount } from '../../lib/api/issues';
import { getRepoPullCount } from '../../lib/api/pulls';
import { getRepo, getRepoForkCount, getRepoByDid } from '../../lib/api/repos';
import { resolveActor } from '../../lib/api/identity';
import { parseAtUri } from '../../lib/api/records';
import { createRepoStar, deleteRepoStar, getRepoStarSummary } from '../../lib/api/stars';
import { useAuth } from '../../lib/auth';
import { useLiveEvents } from '../../lib/live-events';
import { Avatar, ErrorState, cardStyles } from '../../components/common';
import { RepoFrameSkeleton, RepoTabLink } from '../../components/repo';
import { getErrorMessage, saveRecentRepo, trimUri } from '../../lib/repo-utils';
import { repoQueryKey, issuesQueryKey, issueQueryKey, pullsQueryKey, pullQueryKey, pullPatchQueryKey } from '../../lib/api/keys';
export { repoQueryKey, issuesQueryKey, issueQueryKey, pullsQueryKey, pullQueryKey, pullPatchQueryKey };
type OptimisticStarState = { state: 'starred'; rkey?: string } | { state: 'unstarred'; rkey: string };
const createRepoQuery = () => {
const params = useParams();
const owner = createMemo(() => params.owner ?? '');
const repo = createMemo(() => params.repo ?? '');
return createQuery(() => ({
queryKey: repoQueryKey(owner(), repo()),
enabled: Boolean(owner() && repo()),
queryFn: async () => getRepo(owner(), repo()),
}));
};
type RepoQuery = ReturnType<typeof createRepoQuery>;
const RepoQueryContext = createContext<RepoQuery>();
export const RepoLayout: Component<RouteSectionProps> = (props) => {
const repoQuery = createRepoQuery();
return <RepoQueryContext.Provider value={repoQuery}>{props.children}</RepoQueryContext.Provider>;
};
export const useRepoQuery = () => {
const repoQuery = useContext(RepoQueryContext);
if (!repoQuery) {
throw new Error('useRepoQuery must be used inside RepoLayout');
}
return repoQuery;
};
export const RepoFrame: Component<{
active: 'code' | 'issues' | 'pulls';
children: JSX.Element;
fullWidth?: boolean;
loadingFallback?: JSX.Element;
}> = (props) => {
const auth = useAuth();
const live = useLiveEvents();
const repoQuery = useRepoQuery();
const [starError, setStarError] = createSignal<string | null>(null);
const [starWorking, setStarWorking] = createSignal(false);
const [optimisticStar, setOptimisticStar] = createSignal<OptimisticStarState | null>(null);
const starSummaryQuery = createQuery(() => ({
queryKey: ['repo-star-summary', repoQuery.data?.repoDid, auth.currentDid()],
enabled: Boolean(repoQuery.data),
queryFn: async () => getRepoStarSummary(repoQuery.data!, auth.currentDid()),
}));
const issueCountQuery = createQuery(() => ({
queryKey: [...issuesQueryKey(repoQuery.data?.repoDid ?? ''), 'count'],
enabled: Boolean(repoQuery.data),
queryFn: async () => getRepoIssueCount(repoQuery.data!),
}));
const pullCountQuery = createQuery(() => ({
queryKey: [...pullsQueryKey(repoQuery.data?.repoDid ?? ''), 'count'],
enabled: Boolean(repoQuery.data),
queryFn: async () => getRepoPullCount(repoQuery.data!),
}));
const forkCountQuery = createQuery(() => ({
queryKey: ['repo-fork-count', repoQuery.data?.repoDid],
enabled: Boolean(repoQuery.data),
queryFn: async () => getRepoForkCount(repoQuery.data!),
}));
const sourceRepoInfo = createMemo(() => {
const source = repoQuery.data?.record.value.source;
if (!source) return null;
try {
if (source.startsWith('at://')) {
const parsed = parseAtUri(source);
return {
did: parsed.did,
rkey: parsed.rkey,
};
}
if (source.startsWith('did:')) {
return {
did: source,
rkey: '',
};
}
} catch {
// ignore
}
return null;
});
const sourceActorQuery = createQuery(() => {
const info = sourceRepoInfo();
return {
queryKey: ['actor', info?.did],
enabled: Boolean(info && info.rkey),
queryFn: async () => resolveActor(info!.did),
};
});
const sourceRepoQuery = createQuery(() => {
const info = sourceRepoInfo();
return {
queryKey: info ? ['source-repo', info.did, info.rkey] : ['source-repo'],
enabled: Boolean(info),
queryFn: async () => {
if (info!.rkey) {
return getRepo(info!.did, info!.rkey);
} else {
return getRepoByDid(info!.did as Did);
}
},
};
});
const starSummaryLoading = createMemo(() => !starSummaryQuery.data && (starSummaryQuery.isLoading || starSummaryQuery.isFetching));
const starBusy = createMemo(() => starWorking() || starSummaryLoading());
createEffect(() => {
repoQuery.data?.repoDid;
auth.currentDid();
setOptimisticStar(null);
setStarError(null);
});
const visibleStarSummary = createMemo(() => {
const summary = starSummaryQuery.data;
const optimistic = optimisticStar();
if (!summary) {
return optimistic?.state === 'starred'
? {
count: 1,
isStarred: true,
currentUserStarRkey: optimistic.rkey,
}
: summary;
}
if (optimistic?.state === 'starred' && !summary.isStarred) {
return {
...summary,
count: summary.count + 1,
isStarred: true,
currentUserStarRkey: optimistic.rkey,
};
}
if (optimistic?.state === 'starred') {
return {
...summary,
isStarred: true,
currentUserStarRkey: optimistic.rkey ?? summary.currentUserStarRkey,
};
}
if (
optimistic?.state !== 'unstarred' ||
!summary.currentUserStarRkey ||
summary.currentUserStarRkey !== optimistic.rkey
) {
return summary;
}
return {
...summary,
count: Math.max(0, summary.count - 1),
isStarred: false,
currentUserStarRkey: undefined,
};
});
const star = async () => {
const agent = auth.agent();
const repo = repoQuery.data;
if (!agent || !repo || starSummaryLoading() || visibleStarSummary()?.isStarred) {
return;
}
setStarWorking(true);
setStarError(null);
setOptimisticStar({ state: 'starred' });
try {
const result = await createRepoStar(agent, repo);
setOptimisticStar({ state: 'starred', rkey: result.rkey });
} catch (cause) {
setOptimisticStar(null);
setStarError(getErrorMessage(cause));
} finally {
setStarWorking(false);
}
};
const unstar = async () => {
const agent = auth.agent();
const rkey = visibleStarSummary()?.currentUserStarRkey;
if (!agent || !rkey || starSummaryLoading()) {
return;
}
setStarWorking(true);
setStarError(null);
setOptimisticStar({ state: 'unstarred', rkey });
try {
await deleteRepoStar(agent, rkey);
} catch (cause) {
setOptimisticStar(null);
setStarError(getErrorMessage(cause));
} finally {
setStarWorking(false);
}
};
createEffect(() => {
const repo = repoQuery.data;
if (!repo) {
return;
}
saveRecentRepo(repo, {
stars: starSummaryQuery.data?.count,
openIssues: issueCountQuery.data,
openPRs: pullCountQuery.data,
});
live.subscribe({
subjects: [repo.repoDid],
sources: [
'sh.tangled.repo.issue:repoDid',
'sh.tangled.repo.issue:repo',
'sh.tangled.repo.pull:target.repo',
],
});
onCleanup(() => {
live.clear();
});
});
return (
<Switch>
<Match when={repoQuery.isLoading}>
<div class={props.fullWidth ? 'untangled-repo-frame-full' : undefined}>
<RepoFrameSkeleton active={props.active}>{props.loadingFallback}</RepoFrameSkeleton>
</div>
</Match>
<Match when={repoQuery.error}>
<div class={cardStyles('p-6')}>
<ErrorState message={getErrorMessage(repoQuery.error)} />
</div>
</Match>
<Match when={repoQuery.data}>
{(repo) => {
const renderRepoActions = () => (
<div class="untangled-repo-actions">
<div class="untangled-repo-action">
<Show
when={starSummaryLoading()}
fallback={
<Show
when={visibleStarSummary()?.isStarred}
fallback={
<Show
when={auth.agent()}
fallback={
<a
href={`https://tangled.org/${repo().owner.handle}/${repo().slug}/stars`}
target="_blank"
rel="noreferrer"
class="untangled-repo-action-main"
>
<Star class="size-4" />
<span>star</span>
</a>
}
>
<button
type="button"
class="untangled-repo-action-main"
disabled={starBusy()}
title={starError() ?? undefined}
onClick={() => void star()}
>
<Star class="size-4" />
<span>star</span>
</button>
</Show>
}
>
<button
type="button"
class="untangled-repo-action-main untangled-repo-action-starred"
disabled={starBusy() || !visibleStarSummary()?.currentUserStarRkey}
title={starError() ?? undefined}
onClick={() => void unstar()}
>
<Star class="size-4 fill-current" />
<span>unstar</span>
</button>
</Show>
}
>
<button
type="button"
class="untangled-repo-action-main"
disabled
aria-label="Loading stars"
>
<LoaderCircle class="size-4 animate-spin" />
</button>
</Show>
<Show
when={starSummaryLoading()}
fallback={
<a
href={`https://tangled.org/${repo().owner.handle}/${repo().slug}/stars`}
target="_blank"
rel="noreferrer"
class="untangled-repo-action-count"
title="Starred by"
>
{visibleStarSummary()?.count ?? 0}
</a>
}
>
<span class="untangled-repo-action-count" aria-disabled="true">
-
</span>
</Show>
</div>
<div class="untangled-repo-action">
<A
href={`/${repo().owner.handle}/${repo().slug}/fork`}
class="untangled-repo-action-main"
>
<GitFork class="size-4" />
<span>fork</span>
</A>
<Show
when={forkCountQuery.isLoading}
fallback={
<a
href={`https://tangled.org/${repo().owner.handle}/${repo().slug}/forks`}
target="_blank"
rel="noreferrer"
class="untangled-repo-action-count"
title="Forked by"
>
{forkCountQuery.data ?? 0}
</a>
}
>
<span class="untangled-repo-action-count" aria-disabled="true">
<LoaderCircle class="size-3 animate-spin" />
</span>
</Show>
</div>
<a
href={`https://tangled.org/${repo().owner.handle}/${repo().slug}/feed.atom`}
target="_blank"
rel="noreferrer"
class="btn text-sm cursor-pointer list-none flex items-center gap-2"
>
<Rss class="size-4" />
<span class="hidden md:inline">atom</span>
</a>
</div>
);
return (
<div class={props.fullWidth ? 'untangled-repo-frame-full' : undefined}>
<section id="repo-header" class="mb-2 py-2 px-4">
<div class="flex flex-col sm:flex-row items-start gap-4 justify-between mb-2">
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2 flex-wrap text-lg">
<Avatar did={repo().owner.did} size="size-8" />
<A href={`/${repo().owner.handle}`} class="font-medium">
{repo().owner.handle}
</A>
<span class="select-none">/</span>
<A href={`/${repo().owner.handle}/${repo().slug}`} class="font-bold">
{repo().slug}
</A>
</div>
<Show when={sourceRepoInfo()}>
{(info) => (
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 ml-10">
<GitFork class="size-3 shrink-0" />
<span>forked from</span>
<Show
when={sourceRepoQuery.data}
fallback={
<span class="font-semibold">
{info().rkey
? `${sourceActorQuery.data?.handle ?? info().did}/${info().rkey}`
: info().did}
</span>
}
>
{(source) => (
<A
href={`/${source().owner.handle}/${source().slug}`}
class="hover:underline font-semibold"
>
{source().owner.handle}/{source().record.value.name || source().slug}
</A>
)}
</Show>
</div>
)}
</Show>
</div>
<div class="hidden sm:block sm:flex-shrink-0">
{renderRepoActions()}
</div>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300">
<Show
when={repo().record.value.description}
fallback={<span class="italic">this repo has no description</span>}
>
<span>{repo().record.value.description}</span>
</Show>
<Show when={repo().record.value.website}>
<a
href={repo().record.value.website}
target="_blank"
rel="noreferrer"
class="flex items-center gap-1"
>
<Globe class="size-4 shrink-0" />
{trimUri(repo().record.value.website!)}
</a>
</Show>
<Show when={(repo().record.value.topics ?? []).length > 0}>
<div class="flex items-center gap-1">
<For each={repo().record.value.topics ?? []}>
{(topic) => <span class="untangled-topic dark:bg-gray-700">{topic}</span>}
</For>
</div>
</Show>
</div>
<div class="block sm:hidden mt-4">
{renderRepoActions()}
</div>
</section>
<section class="w-full flex flex-col">
<nav class="w-full pl-4 overflow-auto">
<div class="flex z-60">
<RepoTabLink
active={props.active === 'code'}
href={`/${repo().owner.handle}/${repo().slug}`}
icon={<SquareChartGantt class="size-4" />}
label="overview"
/>
<RepoTabLink
active={props.active === 'issues'}
href={`/${repo().owner.handle}/${repo().slug}/issues`}
icon={<CircleDot class="size-4" />}
label="issues"
meta={issueCountQuery.data === undefined ? undefined : String(issueCountQuery.data)}
reserveMeta
/>
<RepoTabLink
active={props.active === 'pulls'}
href={`/${repo().owner.handle}/${repo().slug}/pulls`}
icon={<GitPullRequest class="size-4" />}
label="pulls"
meta={pullCountQuery.data === undefined ? undefined : String(pullCountQuery.data)}
reserveMeta
/>
</div>
</nav>
{props.children}
</section>
</div>
)}}
</Match>
</Switch>
);
};