import clsx from 'clsx';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js/lib/common';
import {
	Check,
	ChevronRight,
	Circle,
	Columns2,
	Copy,
	Download,
	ExternalLink,
	FileText,
	FoldVertical,
	Folder,
	PanelLeftClose,
	PanelLeftOpen,
	PanelRightClose,
	PanelRightOpen,
	Square,
	SquareCheckBig,
	UnfoldVertical,
} from 'lucide-solid';
import { marked } from 'marked';
import { A } from '@solidjs/router';
import { For, Show, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js';
import type { RepoContext, TreeEntry } from '../lib/api/repos';
import { formatRelativeTime } from '../lib/repo-utils';
import { getTangledAppviewService } from '../lib/settings';
import { Avatar, SkeletonBlock, buttonStyles, cardStyles } from './common';

marked.setOptions({
	gfm: true,
	breaks: false,
});

export const RepoTabLink: Component<{
	active: boolean;
	href: string;
	icon: JSX.Element;
	label: string;
	meta?: string;
}> = (props) => (
	<A href={props.href} class="relative -mr-px group no-underline hover:no-underline">
		<div
			class={clsx(
				'untangled-repo-tab px-4 py-1 mr-1 text-black dark:text-white text-center relative rounded-t whitespace-nowrap',
				props.active ? 'untangled-repo-tab-active -mb-px bg-white dark:bg-gray-800' : 'untangled-repo-tab-idle',
			)}
		>
			<span class="flex items-center justify-center gap-2">
				{props.icon}
				{props.label}
				<Show when={props.meta}>
					<span class="rounded bg-gray-200 px-1 text-sm text-gray-700 dark:bg-gray-700 dark:text-gray-200">
						{props.meta}
					</span>
				</Show>
			</span>
		</div>
	</A>
);

const skeletonRows = Array.from({ length: 7 });
const listSkeletonRows = Array.from({ length: 10 });
const skeletonCodeLines = Array.from({ length: 18 });
const prFileTreeSkeletonRows = Array.from({ length: 8 });
const prDiffFileSkeletons = Array.from({ length: 2 });
const prDiffCodeLines = Array.from({ length: 8 });

export const RepoTreeSkeleton: Component = () => (
	<div class="flex flex-col gap-4">
		<div class={cardStyles('overflow-hidden')}>
			<SkeletonBlock class="h-2 rounded-none" />
			<div class="flex flex-wrap items-center gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
				<SkeletonBlock class="h-8 w-32" />
				<SkeletonBlock class="h-6 w-48" />
				<SkeletonBlock class="ml-auto h-6 w-24" />
			</div>
			<div class="divide-y divide-gray-100 dark:divide-gray-700">
				<For each={skeletonRows}>
					{(_, index) => (
						<div class="untangled-skeleton-file-row px-4 py-3">
							<div class="flex min-w-0 items-center gap-3">
								<SkeletonBlock class="size-5 shrink-0 rounded-sm" />
								<SkeletonBlock class={index() % 3 === 0 ? 'h-5 w-1/3' : 'h-5 w-2/3'} />
							</div>
							<SkeletonBlock class="h-4 w-24 justify-self-end" />
						</div>
					)}
				</For>
			</div>
		</div>
		<div class={cardStyles('overflow-hidden')}>
			<div class="border-b border-gray-200 px-4 py-3 dark:border-gray-700">
				<SkeletonBlock class="h-5 w-28" />
			</div>
			<div class="space-y-3 p-5">
				<SkeletonBlock class="h-5 w-3/4" />
				<SkeletonBlock class="h-5 w-11/12" />
				<SkeletonBlock class="h-5 w-2/3" />
				<SkeletonBlock class="h-5 w-5/6" />
			</div>
		</div>
	</div>
);

export const RepoBlobSkeleton: Component = () => (
	<div class={cardStyles('overflow-hidden')}>
		<div class="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
			<div class="min-w-0 flex-1 space-y-2">
				<SkeletonBlock class="h-5 w-2/3" />
				<SkeletonBlock class="h-4 w-52" />
			</div>
			<SkeletonBlock class="h-8 w-24" />
		</div>
		<div class="flex items-center justify-between gap-4 bg-gray-900 px-4 py-3">
			<SkeletonBlock class="untangled-skeleton-on-dark h-5 w-2/3" />
			<SkeletonBlock class="untangled-skeleton-on-dark h-5 w-20" />
		</div>
		<div class="space-y-2 p-4">
			<For each={skeletonCodeLines}>
				{(_, index) => (
					<div class="untangled-skeleton-code-line">
						<SkeletonBlock class="h-4 w-8" />
						<SkeletonBlock class={index() % 5 === 0 ? 'h-4 w-1/2' : index() % 3 === 0 ? 'h-4 w-2/3' : 'h-4 w-11/12'} />
					</div>
				)}
			</For>
		</div>
	</div>
);

export const RepoListSkeleton: Component<{ kind: 'issues' | 'pulls' }> = (props) => (
	<div class="flex flex-col gap-3">
		<For each={listSkeletonRows}>
			{(_, index) => (
				<div class="rounded bg-white px-6 py-4 shadow-sm dark:bg-gray-800">
					<SkeletonBlock class={index() % 2 === 0 ? 'h-5 w-2/3' : 'h-5 w-1/2'} />
					<div class="mt-3 flex flex-wrap items-center gap-2">
						<SkeletonBlock class="h-7 w-20" />
						<SkeletonBlock class="size-5 rounded-full" />
						<SkeletonBlock class="h-4 w-24" />
						<SkeletonBlock class="h-4 w-16" />
					</div>
					<Show when={props.kind === 'pulls'}>
						<div class="mt-3 flex flex-wrap items-center gap-2">
							<SkeletonBlock class="h-4 w-14" />
							<SkeletonBlock class="h-5 w-20" />
							<SkeletonBlock class="h-4 w-10" />
							<SkeletonBlock class="h-5 w-24" />
						</div>
					</Show>
				</div>
			)}
		</For>
	</div>
);

const IssueThreadSkeleton: Component = () => (
	<div class="grid grid-cols-1 gap-4 w-full md:grid-cols-10">
		<div class="col-span-1 min-w-0 md:col-span-8">
			<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
				<SkeletonBlock class="h-7 w-3/4" />
				<div class="mt-3 flex flex-wrap items-center gap-2">
					<SkeletonBlock class="h-7 w-20" />
					<SkeletonBlock class="size-6 rounded-full" />
					<SkeletonBlock class="h-4 w-28" />
				</div>
				<div class="mt-5 space-y-3">
					<SkeletonBlock class="h-5 w-11/12" />
					<SkeletonBlock class="h-5 w-4/5" />
					<SkeletonBlock class="h-5 w-2/3" />
				</div>
				<SkeletonBlock class="mt-5 size-8" />
			</section>
			<div class="mt-4 flex flex-col gap-4">
				<div class="overflow-hidden rounded border border-gray-200 bg-gray-50 shadow-sm dark:border-gray-700 dark:bg-gray-800/50">
					<div class="flex items-center gap-2 bg-white px-6 py-4 text-sm dark:bg-gray-800">
						<SkeletonBlock class="size-8 rounded-full" />
						<SkeletonBlock class="h-5 w-32" />
					</div>
					<div class="space-y-3 p-6">
						<SkeletonBlock class="h-5 w-full" />
						<SkeletonBlock class="h-5 w-5/6" />
					</div>
				</div>
				<div class="bg-white dark:bg-gray-800 rounded shadow-sm py-4 px-4 relative flex min-w-0 flex-col gap-3">
					<SkeletonBlock class="h-4 w-24" />
					<SkeletonBlock class="h-28 w-full" />
					<SkeletonBlock class="h-8 w-24 self-end" />
				</div>
			</div>
		</div>
		<aside class="col-span-1 flex min-w-0 flex-col gap-6 text-sm md:col-span-2">
			<section>
				<SkeletonBlock class="h-4 w-16" />
				<SkeletonBlock class="mt-2 h-4 w-20" />
			</section>
			<section>
				<SkeletonBlock class="h-4 w-20" />
				<SkeletonBlock class="mt-2 h-4 w-20" />
			</section>
			<section>
				<SkeletonBlock class="h-4 w-28" />
				<div class="untangled-overlap-avatars mt-2 flex">
					<SkeletonBlock class="size-8 rounded-full" />
					<SkeletonBlock class="size-8 rounded-full" />
				</div>
			</section>
			<section>
				<SkeletonBlock class="h-4 w-16" />
				<SkeletonBlock class="mt-2 h-12 w-full" />
			</section>
		</aside>
	</div>
);

const PullHeaderSkeleton: Component = () => (
	<div class="untangled-pr-header-grid">
		<section class="untangled-pr-summary-card">
			<SkeletonBlock class="h-8 w-3/4" />
			<div class="mt-3 flex flex-wrap items-center gap-2">
				<SkeletonBlock class="h-7 w-20" />
				<SkeletonBlock class="h-4 w-16" />
				<SkeletonBlock class="size-6 rounded-full" />
				<SkeletonBlock class="h-4 w-28" />
				<SkeletonBlock class="h-4 w-16" />
				<SkeletonBlock class="h-4 w-14" />
				<SkeletonBlock class="h-5 w-24" />
			</div>
			<div class="mt-8 space-y-3">
				<SkeletonBlock class="h-5 w-11/12" />
				<SkeletonBlock class="h-5 w-4/5" />
				<SkeletonBlock class="h-5 w-2/3" />
			</div>
			<div class="mt-4 flex">
				<SkeletonBlock class="ml-auto h-9 w-24" />
			</div>
		</section>

		<aside class="untangled-pr-meta">
			<section class="untangled-pr-meta-section">
				<SkeletonBlock class="h-4 w-16" />
				<SkeletonBlock class="mt-2 h-4 w-20" />
			</section>
			<section class="untangled-pr-meta-section">
				<SkeletonBlock class="h-4 w-20" />
				<SkeletonBlock class="mt-2 h-4 w-20" />
			</section>
			<section class="untangled-pr-meta-section">
				<SkeletonBlock class="h-4 w-32" />
				<div class="mt-3 flex flex-wrap gap-2">
					<SkeletonBlock class="size-7 rounded-full" />
					<SkeletonBlock class="size-7 rounded-full" />
					<SkeletonBlock class="size-7 rounded-full" />
				</div>
			</section>
			<section class="untangled-pr-meta-section">
				<SkeletonBlock class="h-4 w-16" />
				<div class="mt-2 flex min-w-0 items-center gap-2">
					<SkeletonBlock class="h-5 w-full" />
					<SkeletonBlock class="size-5" />
				</div>
			</section>
		</aside>
	</div>
);

export const PullDiffSkeleton: Component = () => (
	<section class="untangled-pr-diff-breakout">
		<div class="untangled-pr-diff-toolbar">
			<div class="untangled-pr-diff-toolbar-main">
				<SkeletonBlock class="h-8 w-8" />
				<SkeletonBlock class="h-7 w-20" />
				<SkeletonBlock class="h-4 w-28" />
				<SkeletonBlock class="hidden h-5 w-px sm:inline-block" />
				<SkeletonBlock class="h-4 w-12" />
				<SkeletonBlock class="h-6 w-16" />
			</div>
			<div class="untangled-pr-diff-toolbar-actions">
				<SkeletonBlock class="h-8 w-28" />
			</div>
			<div class="untangled-pr-diff-toolbar-history">
				<SkeletonBlock class="h-8 w-8" />
			</div>
		</div>

		<div class="untangled-pr-diff-grid">
			<aside class="untangled-pr-file-tree" aria-label="changed files loading">
				<div class="untangled-pr-file-tree-list">
					<For each={prFileTreeSkeletonRows}>
						{(_, index) => (
							<div class="untangled-pr-file-tree-entry">
								<SkeletonBlock class="size-4 shrink-0 rounded-sm" />
								<SkeletonBlock class={index() % 3 === 0 ? 'h-4 w-24' : 'h-4 w-32'} />
							</div>
						)}
					</For>
				</div>
			</aside>

			<div class="untangled-pr-diff-files">
				<For each={prDiffFileSkeletons}>
					{(_, fileIndex) => (
						<div class="untangled-pr-diff-file">
							<div class="untangled-pr-diff-file-header">
								<div class="flex min-w-0 items-center gap-2">
									<SkeletonBlock class="untangled-skeleton-on-dark size-4 shrink-0" />
									<SkeletonBlock class="untangled-skeleton-on-dark h-7 w-20" />
									<SkeletonBlock class={fileIndex() === 0 ? 'untangled-skeleton-on-dark h-5 w-2/3' : 'untangled-skeleton-on-dark h-5 w-1/2'} />
									<SkeletonBlock class="untangled-skeleton-on-dark size-4 shrink-0 rounded-full" />
								</div>
								<SkeletonBlock class="untangled-skeleton-on-dark h-4 w-20 shrink-0" />
							</div>
							<div class="untangled-pr-diff-ellipsis">
								<SkeletonBlock class="untangled-skeleton-on-dark mx-auto h-4 w-10" />
							</div>
							<div class="space-y-2 bg-white p-4 dark:bg-gray-900">
								<For each={prDiffCodeLines}>
									{(_, index) => (
										<div class="untangled-skeleton-code-line">
											<SkeletonBlock class="h-4 w-8" />
											<SkeletonBlock class={index() % 4 === 0 ? 'h-4 w-1/2' : index() % 3 === 0 ? 'h-4 w-2/3' : 'h-4 w-11/12'} />
										</div>
									)}
								</For>
							</div>
						</div>
					)}
				</For>
			</div>

			<aside class="untangled-pr-history">
				<div class="untangled-pr-history-card">
					<header>
						<SkeletonBlock class="h-5 w-20" />
						<div>
							<SkeletonBlock class="h-4 w-16" />
							<SkeletonBlock class="h-4 w-20" />
						</div>
					</header>
					<div class="untangled-pr-history-submission">
						<SkeletonBlock class="size-8 rounded-full" />
						<div class="min-w-0 flex-1 space-y-2">
							<SkeletonBlock class="h-4 w-2/3" />
							<SkeletonBlock class="h-4 w-1/2" />
						</div>
					</div>
					<div class="px-4">
						<SkeletonBlock class="h-11 w-full" />
					</div>
					<div class="p-4">
						<div class="flex items-center gap-2">
							<SkeletonBlock class="size-8 rounded-full" />
							<SkeletonBlock class="h-5 w-32" />
						</div>
						<div class="mt-4 space-y-3">
							<SkeletonBlock class="h-5 w-full" />
							<SkeletonBlock class="h-5 w-5/6" />
						</div>
					</div>
					<div class="untangled-pr-history-comment">
						<SkeletonBlock class="h-28 w-full" />
						<SkeletonBlock class="h-9 w-24" />
					</div>
				</div>
			</aside>
		</div>
	</section>
);

const PullThreadSkeleton: Component = () => (
	<div class="untangled-pr-detail">
		<PullHeaderSkeleton />
		<PullDiffSkeleton />
	</div>
);

export const RepoThreadSkeleton: Component<{ kind: 'issue' | 'pull' }> = (props) => (
	<Show when={props.kind === 'pull'} fallback={<IssueThreadSkeleton />}>
		<PullThreadSkeleton />
	</Show>
);

export const RepoFrameSkeleton: Component<{ active: 'code' | 'issues' | 'pulls' }> = (props) => (
	<>
		<section class="flex w-full flex-col gap-4 px-4 py-5">
			<div class="flex flex-wrap items-start justify-between gap-4">
				<div class="min-w-0 flex-1">
					<div class="flex min-w-0 items-center gap-2">
						<SkeletonBlock class="size-8 rounded-full" />
						<SkeletonBlock class="h-6 w-40" />
					</div>
					<div class="mt-3 flex flex-wrap items-center gap-2">
						<SkeletonBlock class="h-5 w-72" />
						<SkeletonBlock class="h-5 w-24" />
					</div>
				</div>
				<div class="flex items-center gap-2">
					<SkeletonBlock class="h-9 w-24" />
					<SkeletonBlock class="h-9 w-20" />
				</div>
			</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="#" icon={<SkeletonBlock class="size-4" />} label="overview" />
					<RepoTabLink active={props.active === 'issues'} href="#" icon={<SkeletonBlock class="size-4" />} label="issues" />
					<RepoTabLink active={props.active === 'pulls'} href="#" icon={<SkeletonBlock class="size-4" />} label="pulls" />
				</div>
			</nav>
			<div class="mt-2 px-4 pb-6">
				<Show when={props.active === 'code'} fallback={<RepoListSkeleton kind={props.active === 'issues' ? 'issues' : 'pulls'} />}>
					<RepoTreeSkeleton />
				</Show>
			</div>
		</section>
	</>
);

export const OverviewFileRow: Component<{
	name: string;
	href: string;
	icon: JSX.Element;
	lastCommit?: TreeEntry['last_commit'];
	lastCommitHref?: string;
	class?: string;
	onMouseEnter?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>;
	onMouseLeave?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>;
	onClick?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>;
}> = (props) => (
	<div class={clsx('untangled-file-row', props.class)}>
		<A href={props.href} class="flex min-w-0 items-center gap-2 no-underline hover:underline dark:text-white" onClick={props.onClick} onMouseEnter={props.onMouseEnter} onMouseLeave={props.onMouseLeave}>
			{props.icon}
			<span class="truncate">{props.name}</span>
		</A>
		<Show when={props.lastCommit?.when}>
			{(when) => (
				<Show
					when={props.lastCommitHref}
					fallback={<span class="untangled-file-row-time text-sm text-gray-500 dark:text-gray-400">{formatRelativeTime(when())}</span>}
				>
					{(href) => (
						<A href={href()} class="untangled-file-row-time text-sm text-gray-500 no-underline hover:underline dark:text-gray-400">
							{formatRelativeTime(when())}
						</A>
					)}
				</Show>
			)}
		</Show>
	</div>
);

export const FileRow: Component<{
	name: string;
	href: string;
	icon: JSX.Element;
	lastCommit?: TreeEntry['last_commit'];
	lastCommitHref?: string;
	commitMessage?: string;
}> = (props) => (
	<div class="grid grid-cols-12 gap-4 items-center py-1">
		<div class="col-span-8 md:col-span-4">
			<A href={props.href} class="no-underline hover:underline dark:text-white">
				<div class="flex items-center gap-2">
					{props.icon}
					<span class="truncate">{props.name}</span>
				</div>
			</A>
		</div>
		<div class="col-span-0 md:col-span-6 hidden md:block overflow-hidden">
			<Show when={props.commitMessage}>
				<div class="text-gray-500 dark:text-gray-400 block truncate">
					{props.commitMessage?.trim().split('\n')[0]}
				</div>
			</Show>
		</div>
		<div class="col-span-4 md:col-span-2 text-sm text-right text-gray-500 dark:text-gray-400">
			<Show when={props.lastCommit?.when}>
				{(when) => (
					<Show when={props.lastCommitHref} fallback={<span>{formatRelativeTime(when())}</span>}>
						{(href) => (
							<A href={href()} class="text-gray-500 no-underline hover:underline dark:text-gray-400">
								{formatRelativeTime(when())}
							</A>
						)}
					</Show>
				)}
			</Show>
		</div>
	</div>
);

export const ReadmeCard: Component<{ filename?: string; markdown: string }> = (props) => (
	<div class="mt-4 rounded bg-white dark:bg-gray-800 shadow-sm w-full mx-auto overflow-hidden">
		<Show when={props.filename}>
			<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
				<FileText class="w-4 h-4 text-gray-600 dark:text-gray-400" />
				<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{props.filename}</span>
			</div>
		</Show>
		<section class="px-6 py-6 overflow-auto">
			<MarkdownBlock markdown={props.markdown} variant="readme" />
		</section>
	</div>
);

const cloneSshHost = (repo: RepoContext): string => {
	try {
		const host = new URL(repo.knot).hostname;
		return host === 'knot1.tangled.sh' ? 'tangled.org' : host;
	} catch {
		const host = repo.knot.replace(/^https?:\/\//, '').split('/')[0].replace(/:\d+$/, '');
		return host === 'knot1.tangled.sh' ? 'tangled.org' : host;
	}
};

const appviewArchiveUrl = (repo: RepoContext, refName: string): string => {
	const owner = encodeURIComponent(repo.owner.handle);
	const slug = encodeURIComponent(repo.slug);
	const ref = encodeURIComponent(refName);
	return new URL(`/${owner}/${slug}/archive/${ref}`, getTangledAppviewService()).toString();
};

const selectContents = (element: HTMLElement) => {
	const selection = window.getSelection();
	if (!selection) return;

	const range = document.createRange();
	range.selectNodeContents(element);
	selection.removeAllRanges();
	selection.addRange(range);
};

const CloneUrlItem: Component<{
	label: string;
	handleUrl: string;
	permaUrl: string;
	permalink: boolean;
}> = (props) => {
	const [copied, setCopied] = createSignal(false);
	const visibleUrl = createMemo(() => (props.permalink ? props.permaUrl : props.handleUrl));
	let copyTimer: ReturnType<typeof setTimeout> | undefined;

	onCleanup(() => clearTimeout(copyTimer));

	const copy = () => {
		clearTimeout(copyTimer);
		void navigator.clipboard?.writeText(visibleUrl());
		setCopied(true);
		copyTimer = setTimeout(() => setCopied(false), 2000);
	};

	return (
		<div class="mt-4">
			<label class="mb-1 block text-xs font-medium text-gray-700 normal-case dark:text-gray-300">
				{props.label}
			</label>
			<div class="untangled-clone-url-row">
				<span
					class="untangled-clone-url-text"
					onClick={(event) => selectContents(event.currentTarget)}
					title={visibleUrl()}
				>
					{visibleUrl()}
				</span>
				<button
					type="button"
					class="untangled-clone-copy"
					title="Copy to clipboard"
					aria-label={`Copy ${props.label} clone URL`}
					onClick={copy}
				>
					<Show when={copied()} fallback={<Copy class="size-4" />}>
						<Check class="size-4" />
					</Show>
				</button>
			</div>
		</div>
	);
};

export const CloneDropdown: Component<{ repo: RepoContext; refName: string }> = (props) => {
	const [open, setOpen] = createSignal(false);
	const [permalink, setPermalink] = createSignal(false);
	const sshHost = createMemo(() => cloneSshHost(props.repo));
	let root: HTMLDivElement | undefined;

	onMount(() => {
		const closeOnOutsideClick = (event: MouseEvent) => {
			if (!open() || !root || root.contains(event.target as Node)) return;
			setOpen(false);
		};
		const closeOnEscape = (event: KeyboardEvent) => {
			if (event.key === 'Escape') setOpen(false);
		};

		document.addEventListener('mousedown', closeOnOutsideClick);
		document.addEventListener('keydown', closeOnEscape);
		onCleanup(() => {
			document.removeEventListener('mousedown', closeOnOutsideClick);
			document.removeEventListener('keydown', closeOnEscape);
		});
	});

	return (
		<div class="untangled-clone-dropdown" ref={root}>
			<button
				type="button"
				class={clsx(buttonStyles('primary'), 'cursor-pointer px-4')}
				aria-expanded={open()}
				aria-haspopup="dialog"
				onClick={() => setOpen(!open())}
			>
				<Download class="size-4" />
				<span class="hidden md:inline">code</span>
			</button>
			<Show when={open()}>
				<div class="untangled-clone-popover" role="dialog" aria-label="Clone this repository">
					<div class="flex items-center justify-between gap-3">
						<h3 class="m-0 text-sm font-semibold text-gray-900 dark:text-white">
							Clone this repository
						</h3>
						<label class="flex cursor-pointer items-center gap-1 text-xs font-normal normal-case text-gray-700 dark:text-gray-300">
							<input
								type="checkbox"
								class="sr-only"
								checked={permalink()}
								onChange={(event) => setPermalink(event.currentTarget.checked)}
							/>
							<Show when={permalink()} fallback={<Square class="size-4" />}>
								<SquareCheckBig class="size-4" />
							</Show>
							<span>Use permalink</span>
						</label>
					</div>

					<CloneUrlItem
						label="HTTPS"
						handleUrl={`https://tangled.org/${props.repo.owner.handle}/${props.repo.slug}`}
						permaUrl={`https://tangled.org/${props.repo.repoDid}`}
						permalink={permalink()}
					/>
					<CloneUrlItem
						label="SSH"
						handleUrl={`git@${sshHost()}:${props.repo.owner.handle}/${props.repo.slug}`}
						permaUrl={`git@${sshHost()}:${props.repo.repoDid}`}
						permalink={permalink()}
					/>

					<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
						For self-hosted knots, clone URLs may differ based on your setup.
					</p>

					<a
						href={appviewArchiveUrl(props.repo, props.refName)}
						class={clsx(buttonStyles('primary'), 'mt-4 flex w-full justify-center text-sm hover:text-gray-100')}
					>
						<Download class="size-4" />
						Download tar.gz
					</a>
				</div>
			</Show>
		</div>
	);
};

export const CommentCard: Component<{
	author: string;
	did: string;
	createdAt: string;
	markdown: string;
	replyDid?: string | null;
	variant?: 'default' | 'issue';
}> = (props) => (
	<Show
		when={props.variant === 'issue'}
		fallback={
			<div class="overflow-hidden rounded border border-gray-200 bg-gray-50 shadow-sm dark:border-gray-700 dark:bg-gray-800/50">
				<div class="flex items-center gap-2 bg-white px-6 py-4 text-sm text-gray-500 dark:bg-gray-800 dark:text-gray-400">
					<Avatar did={props.did} size="size-8" />
					<span class="text-gray-700 dark:text-gray-200">{props.author}</span>
					<span class="select-none">·</span>
					<span>{formatRelativeTime(props.createdAt)}</span>
				</div>
				<div class="min-w-0 border-t border-gray-200 px-6 py-4 dark:border-gray-700">
					<MarkdownBlock markdown={props.markdown} />
				</div>
			</div>
		}
	>
	<div class="overflow-hidden rounded border border-gray-200 bg-gray-50 shadow-sm dark:border-gray-700 dark:bg-gray-800/50">
		<div class="flex gap-2 rounded bg-white px-6 py-4 dark:bg-gray-800">
			<div class="shrink-0">
				<Avatar did={props.did} size="size-8 mr-1" />
			</div>
			<div class="min-w-0 flex-1">
				<div class="text-sm text-gray-500 dark:text-gray-400">
					<span class="text-gray-700 dark:text-gray-200">{props.author}</span>
					<span class="px-1 select-none">·</span>
					<span>{formatRelativeTime(props.createdAt)}</span>
				</div>
				<MarkdownBlock markdown={props.markdown} />
			</div>
		</div>
		<Show when={props.replyDid}>
			{(did) => (
				<div class="flex items-center gap-2 border-t border-gray-200 px-6 py-3 text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
					<Avatar did={did()} size="size-6" />
					<span>Leave a reply...</span>
				</div>
			)}
		</Show>
	</div>
	</Show>
);

export const MarkdownBlock: Component<{ markdown: string; variant?: 'default' | 'readme' | 'code' }> = (props) => {
	const html = createMemo(() =>
		DOMPurify.sanitize(marked.parse(props.markdown, { async: false }) as string),
	);
	const classes = createMemo(() =>
		props.variant === 'readme'
			? 'untangled-readme-prose prose prose-sm sm:prose-base min-w-0 max-w-none dark:prose-invert prose-p:text-gray-800 dark:prose-p:text-gray-200 prose-li:text-gray-800 dark:prose-li:text-gray-200 prose-pre:max-w-full prose-pre:overflow-x-auto prose-pre:rounded-md prose-pre:bg-gray-950 dark:prose-pre:bg-gray-950 prose-code:break-words'
			: props.variant === 'code'
				? 'font-mono text-sm whitespace-pre-wrap break-words'
				: 'prose prose-sm sm:prose-base min-w-0 max-w-none break-words untangled-anywhere dark:prose-invert prose-p:text-gray-800 dark:prose-p:text-gray-200 prose-li:text-gray-800 dark:prose-li:text-gray-200 prose-pre:max-w-full prose-pre:overflow-x-auto prose-pre:rounded-md prose-pre:bg-gray-950 dark:prose-pre:bg-gray-950 prose-code:break-words prose-a:break-all',
	);

	return <div class={classes()} innerHTML={html()} />;
};

const splitCodeLines = (value: string): string[] => {
	const normalized = value.endsWith('\n') ? value.slice(0, -1) : value;
	return normalized.length === 0 ? [''] : normalized.split('\n');
};

const escapeHtml = (value: string) =>
	value
		.replace(/&/g, '&amp;')
		.replace(/</g, '&lt;')
		.replace(/>/g, '&gt;')
		.replace(/"/g, '&quot;')
		.replace(/'/g, '&#39;');

const languageAliases: Record<string, string> = {
	cjs: 'javascript',
	conf: 'ini',
	cts: 'typescript',
	cxx: 'cpp',
	dockerfile: 'bash',
	env: 'ini',
	h: 'c',
	hpp: 'cpp',
	htm: 'xml',
	html: 'xml',
	js: 'javascript',
	jsonc: 'json',
	jsx: 'javascript',
	ksh: 'bash',
	m: 'objectivec',
	markdown: 'markdown',
	md: 'markdown',
	mjs: 'javascript',
	mm: 'objectivec',
	mts: 'typescript',
	patch: 'diff',
	pl: 'perl',
	pm: 'perl',
	pyw: 'python',
	rake: 'ruby',
	rs: 'rust',
	sh: 'bash',
	svg: 'xml',
	toml: 'ini',
	ts: 'typescript',
	tsx: 'typescript',
	txt: 'plaintext',
	yml: 'yaml',
	zsh: 'bash',
};

const filenameLanguages: Record<string, string> = {
	dockerfile: 'bash',
	gemfile: 'ruby',
	makefile: 'makefile',
	rakefile: 'ruby',
};

const languageFromPath = (path?: string) => {
	if (!path) return undefined;

	const filename = path.split('/').pop()?.toLowerCase() ?? '';
	if (filenameLanguages[filename]) return filenameLanguages[filename];

	const extension = filename.includes('.') ? filename.split('.').pop() : filename;
	if (!extension) return undefined;

	return languageAliases[extension] ?? extension;
};

const resolveHighlightLanguage = (language?: string, path?: string) => {
	const candidate = (language ?? languageFromPath(path))?.toLowerCase();
	if (!candidate || candidate === 'plaintext' || candidate === 'text') return undefined;
	return hljs.getLanguage(candidate) ? candidate : undefined;
};

const closeOpenSpans = (count: number) => '</span>'.repeat(count);

const splitHighlightedHtmlLines = (html: string): string[] => {
	const lineParts = html.split('\n');
	const activeTags: string[] = [];

	return lineParts.map((linePart) => {
		const prefix = activeTags.join('');

		for (const tag of linePart.match(/<\/?span\b[^>]*>/g) ?? []) {
			if (tag.startsWith('</')) {
				activeTags.pop();
			} else {
				activeTags.push(tag);
			}
		}

		return `${prefix}${linePart}${closeOpenSpans(activeTags.length)}`;
	});
};

const highlightCodeLines = (text: string, language?: string): string[] => {
	const normalized = text.endsWith('\n') ? text.slice(0, -1) : text;
	const fallback = () => splitCodeLines(normalized).map(escapeHtml);

	if (!language) return fallback();

	try {
		const highlighted = hljs.highlight(normalized, { language, ignoreIllegals: true }).value;
		return splitHighlightedHtmlLines(highlighted);
	} catch {
		return fallback();
	}
};

const diffMetadataLine = (line: string) =>
	line.startsWith('diff --git ') ||
	line.startsWith('index ') ||
	line.startsWith('---') ||
	line.startsWith('+++') ||
	line.startsWith('@@') ||
	line.startsWith('Binary files ') ||
	line.startsWith('new file mode ') ||
	line.startsWith('deleted file mode ') ||
	line.startsWith('old mode ') ||
	line.startsWith('new mode ') ||
	line.startsWith('similarity index ') ||
	line.startsWith('dissimilarity index ') ||
	line.startsWith('rename from ') ||
	line.startsWith('rename to ');

const highlightDiffLine = (line: string, language?: string) => {
	if (diffMetadataLine(line)) return escapeHtml(line);

	const marker = line[0];
	const isChangedLine = marker === '+' || marker === '-' || marker === ' ';
	if (!isChangedLine) return escapeHtml(line);

	const content = line.slice(1);
	const highlighted = highlightCodeLines(content, language)[0] ?? escapeHtml(content);
	return `<span class="untangled-code-diff-op">${escapeHtml(marker)}</span>${highlighted}`;
};

export const CodeView: Component<{
	text: string;
	id?: string;
	wrap?: boolean;
	maxHeight?: string;
	diff?: boolean;
	path?: string;
	language?: string;
}> = (props) => {
	const lines = createMemo(() => splitCodeLines(props.text));
	const highlightedLines = createMemo(() => {
		const language = resolveHighlightLanguage(props.language, props.path);
		if (props.diff) return lines().map((line) => highlightDiffLine(line, language));
		return highlightCodeLines(props.text, language);
	});
	const lineClass = (line: string) =>
		props.diff
			? clsx(
					line.startsWith('+') && !line.startsWith('+++') && 'untangled-code-line-add',
					line.startsWith('-') && !line.startsWith('---') && 'untangled-code-line-del',
					line.startsWith('@@') && 'untangled-code-line-hunk',
				)
			: undefined;

	return (
		<div class="untangled-code-scroll" style={{ 'max-height': props.maxHeight }}>
			<div
				id={props.id}
				class={clsx('untangled-code-view', props.wrap && 'untangled-code-view-wrap')}
			>
				<For each={lines()}>
					{(line, index) => (
						<div class={clsx('untangled-code-line', lineClass(line))}>
							<a id={`L${index() + 1}`} href={`#L${index() + 1}`} class="untangled-code-line-number">
								{index() + 1}
							</a>
							<span class="untangled-code-line-content" innerHTML={highlightedLines()[index()] || ' '} />
						</div>
					)}
				</For>
			</div>
		</div>
	);
};

interface DiffFile {
	path: string;
	text: string;
	additions: number;
	deletions: number;
}

const parseDiffFiles = (patch: string): DiffFile[] => {
	const files: DiffFile[] = [];
	let current: { path: string; lines: string[]; additions: number; deletions: number } | null = null;

	const pushCurrent = () => {
		if (!current) return;
		const hasRenderableDiff = current.lines.some(
			(line) =>
				line.startsWith('@@') ||
				line.startsWith('Binary files ') ||
				line.startsWith('new file mode ') ||
				line.startsWith('deleted file mode ') ||
				line.startsWith('old mode ') ||
				line.startsWith('similarity index ') ||
				line.startsWith('rename from '),
		);
		if (!hasRenderableDiff && current.additions === 0 && current.deletions === 0) {
			current = null;
			return;
		}
		files.push({
			path: current.path,
			text: current.lines.join('\n'),
			additions: current.additions,
			deletions: current.deletions,
		});
	};

	for (const line of splitCodeLines(patch)) {
		if (line.startsWith('diff --git ')) {
			pushCurrent();
			const match = /^diff --git a\/(.*?) b\/(.*)$/.exec(line);
			current = {
				path: match?.[2] ?? line.replace('diff --git ', ''),
				lines: [line],
				additions: 0,
				deletions: 0,
			};
			continue;
		}

		if (!current) {
			current = { path: 'patch', lines: [], additions: 0, deletions: 0 };
		}

		if (line.startsWith('+') && !line.startsWith('+++')) current.additions += 1;
		if (line.startsWith('-') && !line.startsWith('---')) current.deletions += 1;
		current.lines.push(line);
	}

	pushCurrent();
	return files;
};

const changedFilesLabel = (count: number) => `${count} changed file${count === 1 ? '' : 's'}`;

const DiffToolbarStats: Component<{ additions: number; deletions: number; fileCount: number; class?: string }> = (props) => (
	<>
		<DiffStatPill additions={props.additions} deletions={props.deletions} />
		<span class={props.class ?? 'text-xs text-gray-600 dark:text-gray-300'}>{changedFilesLabel(props.fileCount)}</span>
	</>
);

const DiffCollapseButton: Component<{ expanded: boolean; onToggle: () => void }> = (props) => (
	<button type="button" class="untangled-diff-tool-button" onClick={props.onToggle}>
		<Show when={props.expanded} fallback={<UnfoldVertical class="size-4" />}>
			<FoldVertical class="size-4" />
		</Show>
		<span>{props.expanded ? 'collapse all' : 'expand all'}</span>
	</button>
);

export const DiffView: Component<{
	patch: string;
	maxHeight?: string;
	wrap?: boolean;
	defaultExpanded?: boolean;
	showFileTree?: boolean;
	variant?: 'default' | 'commit';
}> = (props) => {
	const files = createMemo(() => parseDiffFiles(props.patch));
	const stats = createMemo(() => totalDiffStats(files()));
	const [expanded, setExpanded] = createSignal(props.defaultExpanded ?? false);
	const [filesVisible, setFilesVisible] = createSignal(true);
	const previewLimit = 5;
	const showFileTree = createMemo(() => Boolean(props.showFileTree && files().length > 0));
	const tree = createMemo(() => diffFileTree(files()));
	const visibleFiles = createMemo(() => (showFileTree() ? files() : files().slice(0, previewLimit)));
	const hiddenFiles = createMemo(() => (showFileTree() ? [] : files().slice(previewLimit)));

	return (
		<div class={clsx('untangled-diff-view', props.variant === 'commit' && 'untangled-diff-view-commit')}>
			<div class="untangled-diff-panel">
				<div class="untangled-diff-toolbar">
					<div class="flex min-w-0 items-center gap-2">
						<Show when={showFileTree()}>
							<button
								type="button"
								class="untangled-diff-sidebar-toggle"
								aria-label={filesVisible() ? 'collapse changed files sidebar' : 'expand changed files sidebar'}
								aria-pressed={filesVisible()}
								onClick={() => setFilesVisible(!filesVisible())}
							>
								<Show when={filesVisible()} fallback={<PanelLeftOpen class="size-4" />}>
									<PanelLeftClose class="size-4" />
								</Show>
							</button>
						</Show>
						<DiffToolbarStats additions={stats().additions} deletions={stats().deletions} fileCount={files().length} />
					</div>
					<div class="flex items-center gap-2">
						<DiffCollapseButton expanded={expanded()} onToggle={() => setExpanded(!expanded())} />
						<div class="untangled-diff-mode-group">
							<button type="button" class="untangled-diff-mode-button untangled-diff-mode-button-active">
								<FileText class="w-4 h-4" />
								unified
							</button>
							<button type="button" class="untangled-diff-mode-button" disabled title="Split diff is not available yet">
								<Columns2 class="w-4 h-4" />
								split
							</button>
						</div>
					</div>
				</div>
				<div class={clsx('untangled-diff-panel-body', showFileTree() && filesVisible() && 'untangled-diff-panel-body-with-tree')}>
					<Show when={showFileTree() && filesVisible()}>
						<aside class="untangled-pr-file-tree" aria-label="changed files">
							<DiffFileTreeNodes nodes={tree()} />
						</aside>
					</Show>

					<div class={showFileTree() ? 'untangled-pr-diff-files' : 'untangled-diff-files'}>
						<For each={visibleFiles()}>
							{(file) => (
								<details id={diffFileElementId(file.path)} class="untangled-diff-file" open={expanded()}>
									<summary class="untangled-diff-file-header">
										<div class="flex min-w-0 items-center gap-2">
											<ChevronRight class="untangled-diff-chevron size-4 shrink-0" />
											<DiffStatPill additions={file.additions} deletions={file.deletions} />
											<span class="truncate font-mono text-sm text-gray-900 dark:text-gray-100">{file.path}</span>
										</div>
									</summary>
									<CodeView text={file.text} wrap={props.wrap} maxHeight={props.maxHeight} diff path={file.path} />
								</details>
							)}
						</For>
						<Show when={hiddenFiles().length > 0}>
							<details class="untangled-diff-show-more">
								<summary class="untangled-diff-show-more-summary">
									<span class="untangled-diff-show-more-closed">
										<ChevronRight class="untangled-diff-chevron size-4 shrink-0" />
										Show {hiddenFiles().length} more file{hiddenFiles().length === 1 ? '' : 's'}
									</span>
									<span class="untangled-diff-show-more-open">
										<ChevronRight class="untangled-diff-chevron size-4 shrink-0" />
										Hide {hiddenFiles().length} file{hiddenFiles().length === 1 ? '' : 's'}
									</span>
								</summary>
								<div class="untangled-diff-show-more-files">
									<For each={hiddenFiles()}>
										{(file) => (
											<details id={diffFileElementId(file.path)} class="untangled-diff-file" open={expanded()}>
												<summary class="untangled-diff-file-header">
													<div class="flex min-w-0 items-center gap-2">
														<ChevronRight class="untangled-diff-chevron size-4 shrink-0" />
														<DiffStatPill additions={file.additions} deletions={file.deletions} />
														<span class="truncate font-mono text-sm text-gray-900 dark:text-gray-100">{file.path}</span>
													</div>
												</summary>
												<CodeView text={file.text} wrap={props.wrap} maxHeight={props.maxHeight} diff path={file.path} />
											</details>
										)}
									</For>
								</div>
							</details>
						</Show>
						<Show when={files().length === 0}>
							<div class="px-4 py-8 text-center text-sm text-gray-400">No differences found.</div>
						</Show>
					</div>
				</div>
			</div>
		</div>
	);
};

const totalDiffStats = (files: DiffFile[]) =>
	files.reduce(
		(acc, file) => ({
			additions: acc.additions + file.additions,
			deletions: acc.deletions + file.deletions,
		}),
		{ additions: 0, deletions: 0 },
	);

type DiffFileTreeNode = {
	name: string;
	path: string;
	file?: DiffFile;
	children?: DiffFileTreeNode[];
};

const sortDiffFileTree = (nodes: DiffFileTreeNode[]): DiffFileTreeNode[] =>
	nodes
		.sort((a, b) => a.name.localeCompare(b.name))
		.map((node) => ({
			...node,
			children: node.children ? sortDiffFileTree(node.children) : undefined,
		}));

const diffFileTree = (files: DiffFile[]) => {
	const roots: DiffFileTreeNode[] = [];

	for (const file of files) {
		const parts = file.path.split('/').filter(Boolean);
		const pathParts = parts.length > 0 ? parts : [file.path || 'patch'];
		let siblings = roots;
		let path = '';

		for (const [index, part] of pathParts.entries()) {
			path = path ? `${path}/${part}` : part;
			const isFile = index === pathParts.length - 1;

			if (isFile) {
				siblings.push({ name: part, path: file.path, file });
				continue;
			}

			let node = siblings.find((child) => !child.file && child.name === part);
			if (!node) {
				node = { name: part, path, children: [] };
				siblings.push(node);
			}

			siblings = node.children!;
		}
	}

	return sortDiffFileTree(roots);
};

const diffFileElementId = (path: string) =>
	`diff-${Array.from(path, (char) => char.codePointAt(0)!.toString(16).padStart(4, '0')).join('-')}`;

const scrollToDiffFile = (path: string, event: MouseEvent) => {
	if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;

	const target = document.getElementById(diffFileElementId(path));
	if (!target) return;

	event.preventDefault();
	target.scrollIntoView({ behavior: 'smooth', block: 'start' });

	const hash = `#${target.id}`;
	if (window.location.hash !== hash) {
		window.history.pushState(null, '', `${window.location.pathname}${window.location.search}${hash}`);
	}
};

const DiffFileTreeNodes: Component<{ nodes: DiffFileTreeNode[] }> = (props) => (
	<div class="untangled-pr-file-tree-list">
		<For each={props.nodes}>{(node) => <DiffFileTreeNodeView node={node} />}</For>
	</div>
);

const DiffFileTreeNodeView: Component<{ node: DiffFileTreeNode }> = (props) => (
	<div class="untangled-pr-file-tree-node">
		<Show
			when={props.node.file}
			fallback={
				<>
					<div class="untangled-pr-file-tree-entry" title={props.node.path}>
						<Folder class="size-4 shrink-0" />
						<span class="truncate">{props.node.name}</span>
					</div>
					<Show when={props.node.children?.length}>
						<div class="untangled-pr-file-tree-children">
							<DiffFileTreeNodes nodes={props.node.children!} />
						</div>
					</Show>
				</>
			}
		>
			{(file) => (
				<a
					href={`#${diffFileElementId(file().path)}`}
					class="untangled-pr-file-tree-entry untangled-pr-file-tree-link"
					title={file().path}
					onClick={(event) => scrollToDiffFile(file().path, event)}
				>
					<FileText class="size-4 shrink-0" />
					<span class="truncate">{props.node.name}</span>
				</a>
			)}
		</Show>
	</div>
);

const DiffStatPill: Component<{ additions: number; deletions: number }> = (props) => (
	<div class="untangled-diff-stat-pill">
		<Show when={props.additions > 0}>
			<span class="untangled-diff-stat-add">+{props.additions}</span>
		</Show>
		<Show when={props.deletions > 0}>
			<span class="untangled-diff-stat-del">-{props.deletions}</span>
		</Show>
	</div>
);

export const PullDiffView: Component<{ patch: string; roundLabel: string; history: JSX.Element }> = (props) => {
	const files = createMemo(() => parseDiffFiles(props.patch));
	const stats = createMemo(() => totalDiffStats(files()));
	const tree = createMemo(() => diffFileTree(files()));
	const [expanded, setExpanded] = createSignal(true);
	const [filesVisible, setFilesVisible] = createSignal(true);
	const [historyVisible, setHistoryVisible] = createSignal(true);

	return (
		<section class="untangled-pr-diff-breakout">
			<div
				class={clsx(
					'untangled-pr-diff-toolbar',
					!filesVisible() && 'untangled-pr-diff-toolbar-sidebar-collapsed',
					!historyVisible() && 'untangled-pr-diff-toolbar-history-collapsed',
				)}
			>
				<div class="untangled-pr-diff-toolbar-main">
					<button
						type="button"
						class="untangled-pr-sidebar-toggle"
						aria-label={filesVisible() ? 'collapse changed files sidebar' : 'expand changed files sidebar'}
						aria-pressed={filesVisible()}
						onClick={() => setFilesVisible(!filesVisible())}
					>
						<Show when={filesVisible()} fallback={<PanelLeftOpen class="size-4" />}>
							<PanelLeftClose class="size-4" />
						</Show>
					</button>
					<DiffToolbarStats additions={stats().additions} deletions={stats().deletions} fileCount={files().length} class="text-xs text-gray-600 dark:text-gray-400" />
					<span class="hidden h-5 w-px bg-gray-300 dark:bg-gray-700 sm:inline-block" />
					<span class="uppercase tracking-wide">diff</span>
					<span class="rounded border border-gray-300 px-2 py-0.5 font-mono text-xs text-gray-700 dark:border-gray-600 dark:text-gray-200">
						{props.roundLabel}
					</span>
				</div>
				<div class="untangled-pr-diff-toolbar-actions">
					<DiffCollapseButton expanded={expanded()} onToggle={() => setExpanded(!expanded())} />
				</div>
				<div class="untangled-pr-diff-toolbar-history">
					<button
						type="button"
						class="untangled-pr-sidebar-toggle"
						aria-label={historyVisible() ? 'collapse history panel' : 'expand history panel'}
						aria-pressed={historyVisible()}
						onClick={() => setHistoryVisible(!historyVisible())}
					>
						<Show when={historyVisible()} fallback={<PanelRightOpen class="size-4" />}>
							<PanelRightClose class="size-4" />
						</Show>
					</button>
				</div>
			</div>

			<div
				class={clsx(
					'untangled-pr-diff-grid',
					!filesVisible() && 'untangled-pr-diff-grid-sidebar-collapsed',
					!historyVisible() && 'untangled-pr-diff-grid-history-collapsed',
				)}
			>
				<Show when={filesVisible()}>
					<aside class="untangled-pr-file-tree" aria-label="changed files">
						<DiffFileTreeNodes nodes={tree()} />
					</aside>
				</Show>

				<div class="untangled-pr-diff-files">
					<For each={files()}>
						{(file) => (
							<details id={diffFileElementId(file.path)} class="untangled-pr-diff-file" open={expanded()}>
								<summary class="untangled-pr-diff-file-header">
									<div class="flex min-w-0 items-center gap-2">
										<ChevronRight class="untangled-pr-diff-chevron size-4 shrink-0" />
										<DiffStatPill additions={file.additions} deletions={file.deletions} />
										<span class="truncate text-base text-gray-100">{file.path}</span>
										<Circle class="size-4 shrink-0 text-gray-500" />
									</div>
									<label class="flex shrink-0 items-center gap-1 text-xs text-gray-400">
										<Circle class="size-4" />
										reviewed
									</label>
								</summary>
								<div class="untangled-pr-diff-ellipsis">...</div>
								<CodeView text={file.text} diff path={file.path} />
							</details>
						)}
					</For>
				</div>

				<Show when={historyVisible()}>
					<aside class="untangled-pr-history">{props.history}</aside>
				</Show>
			</div>
		</section>
	);
};

export const AsideCard: Component<{ title: string; children: JSX.Element }> = (props) => (
	<div class={cardStyles('p-4')}>
		<div class="mb-3 font-medium">{props.title}</div>
		{props.children}
	</div>
);

export const BranchPill: Component<{ name: string }> = (props) => (
	<span class="inline-flex items-center rounded bg-gray-200 px-1 py-0.5 font-mono text-xs text-gray-700 dark:bg-gray-700 dark:text-gray-200">
		{props.name}
	</span>
);

export const AtUriPanel: Component<{ uri: string }> = (props) => {
	const selectUri: JSX.EventHandlerUnion<HTMLSpanElement, MouseEvent> = (event) => {
		const selection = window.getSelection();
		if (!selection) return;

		const range = document.createRange();
		range.selectNodeContents(event.currentTarget);
		selection.removeAllRanges();
		selection.addRange(range);
	};

	return (
		<section class="untangled-at-uri-panel">
			<div class="untangled-at-uri-header">
				<span>AT URI</span>
				<div class="untangled-at-uri-actions">
					<button
						type="button"
						class="untangled-at-uri-action"
						aria-label="copy AT URI"
						title="Copy AT URI"
						onClick={() => void navigator.clipboard?.writeText(props.uri)}
					>
						<Copy class="size-4" />
					</button>
					<a
						href={`https://pdsls.dev/${props.uri}`}
						target="_blank"
						rel="noreferrer"
						class="untangled-at-uri-action"
						aria-label="view AT URI in PDSls"
						title="View in PDSls"
					>
						<ExternalLink class="size-4" />
					</a>
				</div>
			</div>
			<span class="untangled-at-uri" title={props.uri} onClick={selectUri}>
				{props.uri}
			</span>
		</section>
	);
};
