import clsx from 'clsx';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js/lib/common';
import {
Check,
Copy,
Download,
ExternalLink,
FileText,
File,
Square,
SquareCheckBig,
BookMarked,
GitFork,
Star,
CircleDot,
GitPullRequest,
LoaderCircle,
MessageSquarePlus,
Reply,
X,
} from 'lucide-solid';
import { useAuth } from '../lib/auth';
import { resolveActor } from '../lib/api/identity';
import { marked } from 'marked';
import { A, useNavigate, useLocation } from '@solidjs/router';
import { For, Show, createMemo, createSignal, createEffect, onCleanup, onMount, type Component, type JSX } from 'solid-js';
import type { RepoContext, TreeEntry } from '../lib/api/repos';
import type { Did } from '@atcute/lexicons/syntax';
import { createQuery } from '@tanstack/solid-query';
import { getRepo, getRepoLanguages, getRepoForkCount, getRepoByDid } from '../lib/api/repos';
import { getRepoStarCount } from '../lib/api/stars';
import { getRepoIssueCount } from '../lib/api/issues';
import { getRepoPullCount } from '../lib/api/pulls';
import { formatRelativeTime, languageColor, encodePath, formatLanguagePercent, blobHref, treeHref, getErrorMessage } from '../lib/repo-utils';
import { getTangledAppviewService } from '../lib/settings';
import { Avatar, SkeletonBlock, StateBadge, PlaceholderAvatar, buttonStyles, cardStyles, textareaStyles } from './common';
import { useRepoPreloader } from '../lib/preloading';
marked.setOptions({
gfm: true,
breaks: false,
});
marked.use({
renderer: {
code({ text, lang }) {
const validLanguage = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
const highlighted = hljs.highlight(text, { language: validLanguage }).value;
return `<pre><code class="hljs language-${validLanguage}">${highlighted}</code></pre>`;
},
},
});
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
if (data.attrName === 'class') {
const classes = data.attrValue.split(/\s+/);
const filtered = classes.filter(
(cls) => cls === 'hljs' || cls.startsWith('hljs-') || cls.startsWith('language-'),
);
if (filtered.length > 0) {
data.attrValue = filtered.join(' ');
} else {
return (data.keepAttr = false);
}
}
});
export const RepoTabLink: Component<{
active: boolean;
href: string;
icon: JSX.Element;
label: string;
meta?: string;
reserveMeta?: boolean;
}> = (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 !== undefined || props.reserveMeta}>
<span
aria-hidden={props.meta === undefined ? 'true' : undefined}
class={clsx(
'untangled-repo-tab-meta rounded bg-gray-200 px-1 text-sm text-gray-700 dark:bg-gray-700 dark:text-gray-200',
props.meta === undefined && 'untangled-repo-tab-meta-placeholder',
)}
>
{props.meta ?? '0'}
</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 RepoLanguageBarSkeleton: Component = () => (
<div class="group -my-4 -m-6 mb-4" aria-hidden="true">
<div class="untangled-language-bar untangled-language-bar-loading">
<SkeletonBlock class="untangled-language-bar-skeleton" />
</div>
</div>
);
export const TreeRowsSkeleton: Component = () => (
<div class="space-y-3">
<For each={skeletonRows}>
{(_, index) => (
<div class="untangled-file-row">
<div class="flex min-w-0 items-center gap-2">
<SkeletonBlock class="size-4 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-16 justify-self-end" />
</div>
)}
</For>
</div>
);
export const RepoOverviewSidebarSkeleton: Component = () => (
<div class="min-w-0 md:pt-0 md:pl-6 border-gray-100 dark:border-gray-700 md:border-t-0 md:border-l">
<div class="flex items-center gap-2 pb-3 font-bold">
<SkeletonBlock class="size-4" />
<SkeletonBlock class="h-5 w-20" />
<SkeletonBlock class="h-5 w-8" />
</div>
<div class="space-y-6">
<For each={Array.from({ length: 5 })}>
{(_, index) => (
<div>
<SkeletonBlock class={index() % 2 === 0 ? 'h-5 w-5/6' : 'h-5 w-2/3'} />
<div class="mt-2 flex flex-wrap items-center gap-2">
<SkeletonBlock class="h-6 w-20" />
<SkeletonBlock class="size-6 rounded-full" />
<SkeletonBlock class="h-4 w-24" />
</div>
</div>
)}
</For>
</div>
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<div class="flex items-center gap-2 pb-2 font-bold">
<SkeletonBlock class="size-4" />
<SkeletonBlock class="h-5 w-20" />
<SkeletonBlock class="h-5 w-8" />
</div>
<div class="space-y-2">
<SkeletonBlock class="h-4 w-2/3" />
<SkeletonBlock class="h-4 w-1/2" />
</div>
</div>
</div>
);
export const RepoTreeSkeleton: Component<{ nested?: boolean }> = (props) => (
<>
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white">
<RepoLanguageBarSkeleton />
<div class="flex items-center justify-between gap-4 pb-5">
<div class="flex items-center gap-2">
<SkeletonBlock class="untangled-skeleton-branch-select" />
<SkeletonBlock class="untangled-skeleton-compare-button" />
</div>
<SkeletonBlock class="untangled-skeleton-clone-button" />
</div>
<Show when={props.nested}>
<div>
<div class="pb-2 mb-3 border-b border-gray-200 dark:border-gray-700">
<SkeletonBlock class="h-5 w-2/3" />
</div>
<div class="pb-2 mb-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-wrap gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<SkeletonBlock class="size-6 rounded-full" />
<SkeletonBlock class="h-4 w-28" />
<SkeletonBlock class="h-4 w-2/5" />
<SkeletonBlock class="h-4 w-20" />
</div>
<SkeletonBlock class="h-6 w-20" />
</div>
</div>
</Show>
<div class={clsx('untangled-repo-overview', props.nested && 'untangled-repo-overview--path')}>
<div class="untangled-file-tree-pane min-w-0 pr-0 md:pr-6">
<TreeRowsSkeleton />
</div>
<Show when={!props.nested}>
<RepoOverviewSidebarSkeleton />
</Show>
</div>
</section>
<div class="mt-4 rounded bg-white dark:bg-gray-800 shadow-sm w-full mx-auto overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
<SkeletonBlock class="size-4" />
<SkeletonBlock class="h-4 w-24" />
</div>
<section class="px-6 py-6 overflow-auto">
<div class="space-y-3">
<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>
</section>
</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>
);
export const RepoListPageSkeleton: Component<{
kind: 'issues' | 'pulls';
showCreate?: boolean;
}> = (props) => (
<>
<div class="rounded bg-white p-4 dark:bg-gray-800">
<div class="untangled-filter-grid">
<div class="flex items-stretch rounded border border-gray-300 dark:border-gray-700">
<SkeletonBlock class="h-8 w-20 rounded-none" />
<SkeletonBlock class="h-8 w-20 rounded-none" />
<Show when={props.kind === 'pulls'}>
<SkeletonBlock class="h-8 w-24 rounded-none" />
</Show>
</div>
<div class="untangled-repo-filter-search">
<SkeletonBlock class="h-8 w-full" />
</div>
<Show when={props.showCreate}>
<SkeletonBlock class="h-8 w-28 justify-self-end" />
</Show>
</div>
</div>
<div class="mt-2">
<RepoListSkeleton kind={props.kind} />
</div>
</>
);
export const RepoFormSkeleton: Component<{ titleWidth?: string }> = (props) => (
<div class={cardStyles('p-6')}>
<div class="flex flex-col gap-4">
<SkeletonBlock class={clsx('h-8', props.titleWidth ?? 'w-40')} />
<SkeletonBlock class="h-10 w-full" />
<SkeletonBlock class="h-64 w-full" />
<SkeletonBlock class="h-9 w-32 self-end" />
</div>
</div>
);
export const PullComposeSkeleton: Component = () => (
<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">
<div class="flex flex-col gap-6">
<For each={Array.from({ length: 3 })}>
{(_, index) => (
<section class="untangled-new-pr-step">
<div class="untangled-new-pr-heading">
<SkeletonBlock class="size-6 rounded-full" />
<SkeletonBlock class={index() === 0 ? 'h-4 w-16' : 'h-4 w-20'} />
</div>
<div class="ml-10 flex flex-col gap-3">
<SkeletonBlock class="h-14 w-full" />
<SkeletonBlock class={index() === 1 ? 'h-56 w-full' : 'h-10 w-2/3'} />
</div>
</section>
)}
</For>
</div>
</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>
);
const defaultRepoFrameSkeletonContent = (active: 'code' | 'issues' | 'pulls') =>
active === 'code' ? <RepoTreeSkeleton /> : <RepoListPageSkeleton kind={active === 'issues' ? 'issues' : 'pulls'} />;
export const RepoFrameSkeleton: Component<{
active: 'code' | 'issues' | 'pulls';
children?: JSX.Element;
}> = (props) => (
<>
<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 gap-2">
<div class="flex items-center gap-2 flex-wrap text-lg">
<SkeletonBlock class="size-8 rounded-full" />
<SkeletonBlock class="h-6 w-40" />
</div>
</div>
<div class="hidden sm:block sm:flex-shrink-0">
<div class="untangled-repo-actions">
<SkeletonBlock class="h-8 w-32" />
<SkeletonBlock class="h-8 w-20" />
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
<SkeletonBlock class="h-5 w-72" />
<SkeletonBlock class="h-5 w-24" />
</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" reserveMeta />
<RepoTabLink active={props.active === 'pulls'} href="#" icon={<SkeletonBlock class="size-4" />} label="pulls" reserveMeta />
</div>
</nav>
{props.children ?? defaultRepoFrameSkeletonContent(props.active)}
</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;
repo?: RepoContext;
refName?: string;
path?: 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"
repo={props.repo}
refName={props.refName}
path={props.path}
/>
</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 resolveRelativePath = (currentPath: string, relativePath: string): string => {
let normalized = relativePath;
if (normalized.startsWith('/')) {
normalized = normalized.slice(1);
currentPath = '';
}
if (normalized.startsWith('./')) {
normalized = normalized.slice(2);
}
if (!currentPath) {
return normalized;
}
const currentSegments = currentPath.split('/').filter(Boolean);
const relativeSegments = normalized.split('/');
for (const segment of relativeSegments) {
if (segment === '..') {
currentSegments.pop();
} else if (segment !== '.' && segment !== '') {
currentSegments.push(segment);
}
}
return currentSegments.join('/');
};
export const MarkdownBlock: Component<{
markdown: string;
variant?: 'default' | 'readme' | 'code';
repo?: RepoContext;
refName?: string;
path?: string;
}> = (props) => {
const navigate = useNavigate();
const html = createMemo(() => {
const options: any = { async: false };
if (props.repo && props.refName) {
const repo = props.repo;
const refName = props.refName;
options.walkTokens = (token: any) => {
if (token.type === 'link' || token.type === 'image') {
const href = token.href;
if (href && !href.startsWith('#') && !/^[a-z][a-z0-9+.-]*:/i.test(href)) {
const [pathAndQuery, fragment] = href.split('#');
const [pathPart, queryPart] = pathAndQuery.split('?');
const resolvedPath = resolveRelativePath(props.path ?? '', pathPart);
const typeSegment = token.type === 'image' ? 'raw' : 'tree';
const newHref = `/${repo.owner.handle}/${repo.slug}/${typeSegment}/${encodeURIComponent(refName)}${resolvedPath ? `/${encodePath(resolvedPath)}` : ''}${queryPart ? `?${queryPart}` : ''}${fragment ? `#${fragment}` : ''}`;
token.href = newHref;
}
}
};
}
return DOMPurify.sanitize(marked.parse(props.markdown, options) as unknown as string, {
ADD_ATTR: ['class'],
});
});
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',
);
const onMarkdownClick = (event: MouseEvent) => {
const anchor = (event.target as HTMLElement).closest('a');
if (!anchor || event.defaultPrevented) {
return;
}
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
const href = anchor.getAttribute('href');
if (!href) {
return;
}
if (href.startsWith('/') && !href.startsWith('//')) {
event.preventDefault();
navigate(href);
return;
}
try {
const url = new URL(href, window.location.origin);
if (url.hostname === 'tangled.org' || url.hostname === 'tangled.sh') {
event.preventDefault();
navigate(url.pathname + url.search + url.hash);
}
} catch {
// ignore
}
};
return <div class={classes()} innerHTML={html()} onClick={onMarkdownClick} />;
};
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>
);
};
export interface RepoCardProps {
owner: string;
name: string;
description?: string;
isFork?: boolean;
language?: string;
stars?: number;
forks?: number;
openIssues?: number;
openPRs?: number;
compact?: boolean;
href?: string;
noBorder?: boolean;
showOwner?: boolean;
repoDid?: string;
}
export interface RepoStatsListProps {
repoDid?: string;
owner?: string;
name?: string;
language?: string;
stars?: number;
forks?: number;
openIssues?: number;
openPRs?: number;
}
export const RepoStatsList: Component<RepoStatsListProps> = (props) => {
const repoContextQuery = createQuery(() => ({
queryKey: props.repoDid ? ['repo-by-did', props.repoDid] : ['repo-context', props.owner, props.name],
queryFn: async () => {
if (props.repoDid) {
return getRepoByDid(props.repoDid as Did);
} else if (props.owner && props.name) {
return getRepo(props.owner, props.name);
}
return null;
},
enabled: Boolean(props.repoDid || (props.owner && props.name)),
staleTime: 300_000,
}));
const repoContext = () => repoContextQuery.data;
const starCountQuery = createQuery(() => ({
queryKey: ['repo-stars-count', repoContext()?.repoDid],
queryFn: () => getRepoStarCount(repoContext()!),
enabled: !!repoContext() && props.stars === undefined,
staleTime: 300_000,
}));
const forkCountQuery = createQuery(() => ({
queryKey: ['repo-fork-count', repoContext()?.repoDid],
queryFn: () => getRepoForkCount(repoContext()!),
enabled: !!repoContext() && props.forks === undefined,
staleTime: 300_000,
}));
const issueCountQuery = createQuery(() => ({
queryKey: ['repo-issues-count', repoContext()?.repoDid],
queryFn: () => getRepoIssueCount(repoContext()!),
enabled: !!repoContext() && props.openIssues === undefined,
staleTime: 300_000,
}));
const pullCountQuery = createQuery(() => ({
queryKey: ['repo-pulls-count', repoContext()?.repoDid],
queryFn: () => getRepoPullCount(repoContext()!),
enabled: !!repoContext() && props.openPRs === undefined,
staleTime: 300_000,
}));
const languagesQuery = createQuery(() => ({
queryKey: ['repo-languages', repoContext()?.repoDid],
queryFn: () => getRepoLanguages(repoContext()!, 'HEAD'),
enabled: !!repoContext() && props.language === undefined,
staleTime: 300_000,
}));
const resolvedLanguage = () => {
if (props.language !== undefined) return props.language;
const list = languagesQuery.data?.languages;
if (!list || list.length === 0) return undefined;
const sorted = [...list].sort((a, b) => b.percentage - a.percentage);
return sorted[0].name;
};
const langColor = () => {
const lang = resolvedLanguage();
return lang ? languageColor(lang) : undefined;
};
const stars = () => props.stars !== undefined ? props.stars : starCountQuery.data;
const forks = () => props.forks !== undefined ? props.forks : forkCountQuery.data;
const openIssues = () => props.openIssues !== undefined ? props.openIssues : issueCountQuery.data;
const openPRs = () => props.openPRs !== undefined ? props.openPRs : pullCountQuery.data;
const isMetricsLoading = () => {
const contextLoading = repoContextQuery.isLoading && (props.repoDid !== undefined || (props.owner !== undefined && props.name !== undefined));
const langLoading = props.language === undefined && (languagesQuery.isLoading || contextLoading);
const starsLoading = props.stars === undefined && (starCountQuery.isLoading || contextLoading);
const forksLoading = props.forks === undefined && (forkCountQuery.isLoading || contextLoading);
const issuesLoading = props.openIssues === undefined && (issueCountQuery.isLoading || contextLoading);
const prsLoading = props.openPRs === undefined && (pullCountQuery.isLoading || contextLoading);
return langLoading || starsLoading || forksLoading || issuesLoading || prsLoading;
};
return (
<Show
when={!isMetricsLoading()}
fallback={
<div class="text-gray-400 text-sm font-mono inline-flex items-center gap-3 mt-auto h-5">
<SkeletonBlock class="h-4 w-16" />
<SkeletonBlock class="h-4 w-4" />
<SkeletonBlock class="h-4 w-8" />
<SkeletonBlock class="h-4 w-6" />
</div>
}
>
<div class="text-gray-400 text-sm font-mono inline-flex flex-wrap gap-3 mt-auto">
<Show when={resolvedLanguage()}>
<div class="flex gap-2 items-center text-sm">
<span
class="rounded-full inline-block shrink-0"
style={{ 'background-color': langColor(), width: '10px', height: '10px' }}
/>
<span>{resolvedLanguage()}</span>
</div>
</Show>
<Show when={stars() !== undefined && stars()! > 0}>
<div class="flex gap-1 items-center text-sm">
<Star class="w-3 h-3 fill-current" />
<span>{stars()}</span>
</div>
</Show>
<Show when={forks() !== undefined && forks()! > 0}>
<div class="flex gap-1 items-center text-sm">
<GitFork class="w-3 h-3" />
<span>{forks()}</span>
</div>
</Show>
<Show when={openIssues() !== undefined && openIssues()! > 0}>
<div class="flex gap-1 items-center text-sm">
<CircleDot class="w-3 h-3" />
<span>{openIssues()}</span>
</div>
</Show>
<Show when={openPRs() !== undefined && openPRs()! > 0}>
<div class="flex gap-1 items-center text-sm">
<GitPullRequest class="w-3 h-3" />
<span>{openPRs()}</span>
</div>
</Show>
</div>
</Show>
);
};
export const RepoCard: Component<RepoCardProps> = (props) => {
const linkHref = () => props.href || `/${props.owner}/${props.name}`;
const preloadRepo = useRepoPreloader();
return (
<div
class={clsx(
props.compact ? 'focus-within:bg-gray-100 dark:focus-within:bg-gray-800/80' : 'min-h-32',
'py-4 px-6 gap-1 flex flex-col drop-shadow-sm bg-white dark:bg-gray-800',
!props.noBorder && 'border border-gray-200 dark:border-gray-700 rounded-sm'
)}
>
<div class="font-medium dark:text-white flex items-center justify-between">
<div class="flex items-center min-w-0 flex-1 mr-2">
<Show
when={props.isFork}
fallback={<BookMarked class="w-4 h-4 mr-1.5 shrink-0" />}
>
<GitFork class="w-4 h-4 mr-1.5 shrink-0" />
</Show>
<A
href={linkHref()}
class={clsx(
'truncate min-w-0 no-underline hover:underline text-black dark:text-white font-bold',
props.compact && 'focus:outline-none'
)}
{...preloadRepo(props.owner, props.name)}
>
<Show when={props.showOwner !== false} fallback={props.name}>
{props.owner}/{props.name}
</Show>
</A>
</div>
</div>
<Show when={props.description}>
<div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
{props.description}
</div>
</Show>
<RepoStatsList {...props} />
</div>
);
};
export const RepoCardSkeleton: Component<{ compact?: boolean; noBorder?: boolean }> = (props) => {
return (
<div
class={clsx(
props.compact ? 'focus-within:bg-gray-100 dark:focus-within:bg-gray-800/80' : 'min-h-32',
'py-4 px-6 gap-1 flex flex-col drop-shadow-sm bg-white dark:bg-gray-800',
!props.noBorder && 'border border-gray-200 dark:border-gray-700 rounded-sm'
)}
>
<div class="font-medium dark:text-white flex items-center justify-between">
<div class="flex items-center min-w-0 flex-1 mr-2 gap-2">
<SkeletonBlock class="w-4 h-4 shrink-0 rounded-sm" />
<SkeletonBlock class="h-5 w-1/3" />
</div>
</div>
<div class="space-y-2 mt-2">
<SkeletonBlock class="h-4 w-5/6" />
<SkeletonBlock class="h-4 w-2/3" />
</div>
<div class="flex items-center gap-3 mt-auto h-5">
<SkeletonBlock class="h-4 w-16" />
<SkeletonBlock class="h-4 w-4" />
<SkeletonBlock class="h-4 w-8" />
<SkeletonBlock class="h-4 w-6" />
</div>
</div>
);
};
export const StringCardSkeleton: Component = () => (
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
<div class="py-4 px-6 rounded bg-white dark:bg-gray-800 flex flex-col gap-2">
<div class="flex items-center gap-2">
<SkeletonBlock class="h-5 w-1/3" />
</div>
<div class="space-y-2 mt-1">
<SkeletonBlock class="h-4 w-5/6" />
<SkeletonBlock class="h-4 w-2/3" />
</div>
<div class="mt-2 flex gap-4">
<SkeletonBlock class="h-3 w-16" />
<SkeletonBlock class="h-3 w-16" />
</div>
</div>
</div>
);
export const StringPageSkeleton: Component = () => (
<div class="flex flex-col gap-4">
<section class="mb-2 py-2 px-4">
<div class="flex flex-col sm:flex-row items-start gap-4 justify-between">
<div class="flex flex-col gap-2 w-full sm:w-1/2">
<div class="flex items-center gap-2">
<SkeletonBlock class="size-6 rounded-full shrink-0" />
<SkeletonBlock class="h-5 w-1/4" />
<span class="text-gray-400">/</span>
<SkeletonBlock class="h-5 w-1/3" />
</div>
<div class="flex flex-col gap-1 mt-1">
<SkeletonBlock class="h-4 w-1/3" />
</div>
</div>
<div class="w-full sm:w-fit flex items-center gap-2">
<SkeletonBlock class="h-8 w-24" />
<SkeletonBlock class="h-8 w-16" />
</div>
</div>
</section>
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full shadow-sm">
<div class="flex flex-col md:flex-row md:justify-between md:items-center pb-2 mb-3 border-b border-gray-200 dark:border-gray-700">
<SkeletonBlock class="h-5 w-1/3 mb-2 md:mb-0" />
<SkeletonBlock class="h-5 w-1/4" />
</div>
<div class="space-y-2 py-2 overflow-hidden">
<For each={Array.from({ length: 36 })}>
{(_, index) => (
<div class="untangled-skeleton-code-line">
<SkeletonBlock class="h-4 w-8" />
<SkeletonBlock class={
index() % 7 === 0 ? 'h-4 w-1/3' :
index() % 5 === 0 ? 'h-4 w-1/2' :
index() % 4 === 0 ? 'h-4 w-3/4' :
index() % 3 === 0 ? 'h-4 w-5/6' :
'h-4 w-11/12'
} />
</div>
)}
</For>
</div>
</section>
</div>
);
export const IssueOrPullRow: Component<{
title: string;
number: number | string;
state: 'open' | 'closed' | 'merged';
kind: 'issue' | 'pull';
author: { did: string; handle: string };
createdAt: string;
href: string;
meta?: JSX.Element;
children?: JSX.Element;
onMouseEnter?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>;
onMouseLeave?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>;
}> = (props) => {
return (
<A
href={props.href}
class="untangled-list-row"
onMouseEnter={props.onMouseEnter}
onMouseLeave={props.onMouseLeave}
>
<div class="min-w-0">
<div class="text-base text-gray-900 dark:text-gray-100">
{props.title}{' '}
<span class="text-gray-500 dark:text-gray-400 font-normal">#{props.number}</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<StateBadge state={props.state} kind={props.kind} />
<Avatar did={props.author.did} size="size-5" />
<span>{props.author.handle}</span>
<span>·</span>
<span>{formatRelativeTime(props.createdAt)}</span>
{props.meta}
</div>
{props.children}
</div>
</A>
);
};
export const ThreadCommentView: Component<{
id?: string;
author: { did: string; handle: string };
createdAt: string;
markdown: string;
isAuthor?: boolean;
isReply?: boolean;
class?: string;
}> = (props) => (
<div
id={props.id}
class={clsx(
'flex gap-2 w-full mx-auto text-sm text-gray-900 dark:text-gray-100',
props.isReply && 'pr-4',
props.class || 'py-4'
)}
>
<div class="shrink-0 h-fit relative z-10">
<Avatar did={props.author.did} size="size-8 mr-1" />
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 mb-0.5">
<A
class="text-gray-700 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white font-medium hover:underline"
href={`/${props.author.did}`}
>
{props.author.handle}
</A>
<Show when={props.isAuthor}>
<span class="text-xs text-gray-400 dark:text-gray-500">(author)</span>
</Show>
<span class="select-none">·</span>
<Show when={props.id} fallback={<span>{formatRelativeTime(props.createdAt)}</span>}>
<a href={`#${props.id}`} class="hover:underline">
{formatRelativeTime(props.createdAt)}
</a>
</Show>
</div>
<div class="min-w-0 break-words text-gray-800 dark:text-gray-200">
<MarkdownBlock markdown={props.markdown} />
</div>
</div>
</div>
);
const commitSummary = (message: string) => message.trim().split('\n')[0] || 'no commit message';
export const PathBreadcrumbs: Component<{
repo: Parameters<typeof treeHref>[0];
refName: string;
path: string;
withDivider?: boolean;
}> = (props) => {
const segments = createMemo(() => props.path.split('/').filter(Boolean));
return (
<div class={clsx('text-base', props.withDivider && 'pb-2 mb-3 border-b border-gray-200 dark:border-gray-700')}>
<div class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500">
<A href={treeHref(props.repo, props.refName)} class="text-gray-500 dark:text-gray-400 hover:underline">
{props.repo.slug}
</A>
<For each={segments()}>
{(segment, index) => (
<>
<span class="px-1">/</span>
<Show
when={index() < segments().length - 1}
fallback={<span class="text-gray-900 dark:text-gray-100">{segment}</span>}
>
<A
href={treeHref(props.repo, props.refName, segments().slice(0, index() + 1).join('/'))}
class="text-gray-500 dark:text-gray-400 hover:underline"
>
{segment}
</A>
</Show>
</>
)}
</For>
</div>
</div>
);
};
export const TreeLatestCommitPanel: Component<{
repo: Parameters<typeof treeHref>[0];
commit: {
hash: string;
message: string;
when: string;
author?: {
email: string;
name: string;
};
};
}> = (props) => (
<div class="pb-2 mb-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-wrap text-sm gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-1">
<Show
when={props.commit.author?.email?.startsWith('did:') ? props.commit.author.email : undefined}
fallback={<PlaceholderAvatar size="size-6" iconSize="size-4" />}
>
{(did) => <Avatar did={did()} size="size-6" />}
</Show>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.commit.author?.name || 'unknown author'}</span>
<span class="select-none text-gray-400 dark:text-gray-500">·</span>
<A
href={`/${props.repo.owner.handle}/${props.repo.slug}/commit/${props.commit.hash}`}
class="min-w-0 truncate no-underline hover:underline text-gray-900 dark:text-white"
>
{commitSummary(props.commit.message)}
</A>
<span class="select-none text-gray-400 dark:text-gray-500">·</span>
<span class="text-gray-500 dark:text-gray-400">{formatRelativeTime(props.commit.when)}</span>
</div>
<A
href={`/${props.repo.owner.handle}/${props.repo.slug}/commit/${props.commit.hash}`}
class="inline-flex w-fit items-center rounded bg-gray-100 px-2 py-1 font-mono text-xs text-gray-700 no-underline hover:underline dark:bg-gray-900 dark:text-gray-300"
>
{props.commit.hash.slice(0, 8)}
</A>
</div>
);
export const RepoLanguageBar: Component<{
languages: Array<{ name: string; percentage: number; color?: string }>;
loading?: boolean;
}> = (props) => (
<Show when={props.languages.length > 0} fallback={<Show when={props.loading}><RepoLanguageBarSkeleton /></Show>}>
<details class="group -my-4 -m-6 mb-4">
<summary class="untangled-language-bar">
<For each={props.languages}>
{(language) => {
const name = language.name || 'Other';
const color = language.color || languageColor(name);
return (
<div
class="untangled-language-segment"
title={`${name} ${formatLanguagePercent(language.percentage)}`}
style={{
width: `${language.percentage}%`,
'background-color': color,
}}
/>
);
}}
</For>
</summary>
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap">
<For each={props.languages}>
{(language) => {
const name = language.name || 'Other';
const color = language.color || languageColor(name);
return (
<div class="flex flex-grow items-center justify-center gap-2 text-xs">
<div
class="size-2 rounded-full"
style={{
background: `radial-gradient(circle at 35% 35%, color-mix(in srgb, ${color} 70%, white), ${color} 30%, color-mix(in srgb, ${color} 85%, black))`,
}}
/>
<div>
{name}{' '}
<span class="text-gray-500 dark:text-gray-400">
{formatLanguagePercent(language.percentage)}
</span>
</div>
</div>
);
}}
</For>
</div>
</details>
</Show>
);
export const LoadingFilePath: Component<{
repo: Parameters<typeof treeHref>[0];
refName: string;
path: string;
}> = (props) => {
const segments = createMemo(() => props.path.split('/').filter(Boolean));
const filename = createMemo(() => segments().at(-1) ?? props.path);
return (
<div class="untangled-blob-card">
<div class="untangled-blob-header pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
<div class="flex flex-col md:flex-row md:justify-between gap-2">
<PathBreadcrumbs repo={props.repo} refName={props.refName} path={props.path} />
<div class="untangled-blob-file-info text-gray-500 md:text-sm flex flex-wrap md:gap-0 items-center">
<span>
at <A href={treeHref(props.repo, props.refName)}>{props.refName}</A>
</span>
</div>
</div>
</div>
<div class="space-y-3">
<OverviewFileRow
name={filename()}
href={blobHref(props.repo, props.refName, props.path)}
icon={<File class="size-4 flex-shrink-0" />}
class="untangled-file-row-loading"
/>
</div>
</div>
);
};
export { CodeView, DiffView, PullDiffView } from './code-view';
export interface GenericComment {
uri: string;
cid?: string;
rkey: string;
author: { did: string; handle: string };
value: {
body: string;
createdAt: string;
replyTo?: any;
};
}
export interface GenericCommentThread {
item: GenericComment;
replies: GenericComment[];
}
export const CommentThreadsSection: Component<{
threads: GenericCommentThread[];
authorDid?: string;
title?: string;
onReplySubmit?: (body: string, parentComment: GenericComment) => Promise<void>;
}> = (props) => {
const auth = useAuth();
const location = useLocation();
const [replyingTo, setReplyingTo] = createSignal<GenericComment | null>(null);
const [replyBody, setReplyBody] = createSignal('');
const [replyWorking, setReplyWorking] = createSignal(false);
const [replyError, setReplyError] = createSignal<string | null>(null);
createEffect(() => {
const hash = location.hash;
const cleanHash = hash.startsWith('#') ? hash.slice(1) : hash;
if (cleanHash && cleanHash.startsWith('comment-') && props.threads.length > 0) {
setTimeout(() => {
const element = document.getElementById(cleanHash);
if (element) {
// 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 viewerQuery = createQuery(() => ({
queryKey: ['viewer', auth.currentDid()],
enabled: Boolean(auth.currentDid()),
queryFn: async () => resolveActor(auth.currentDid()!),
}));
const handleReplySubmit = async (e: SubmitEvent, parent: GenericComment) => {
e.preventDefault();
if (!props.onReplySubmit || !replyBody().trim() || replyWorking()) return;
setReplyWorking(true);
setReplyError(null);
try {
await props.onReplySubmit(replyBody(), parent);
setReplyBody('');
setReplyingTo(null);
} catch (err) {
setReplyError(getErrorMessage(err));
} finally {
setReplyWorking(false);
}
};
return (
<div class={clsx(props.title && 'mt-6', 'space-y-4')}>
<Show when={props.title}>
<h3 class="text-lg font-bold border-b border-gray-200 dark:border-gray-700 pb-2 dark:text-white mb-4">
{props.title}
</h3>
</Show>
<div class="space-y-4">
<For each={props.threads}>
{(thread) => (
<div class="untangled-thread-group">
<ThreadCommentView
id={`comment-${thread.item.rkey}`}
author={thread.item.author}
createdAt={thread.item.value.createdAt}
markdown={thread.item.value.body}
isAuthor={props.authorDid ? thread.item.author.did === props.authorDid : false}
class="untangled-thread-comment"
/>
<Show when={thread.replies.length > 0}>
<div class="untangled-thread-replies-list">
<For each={thread.replies}>
{(reply) => (
<ThreadCommentView
id={`comment-${reply.rkey}`}
author={reply.author}
createdAt={reply.value.createdAt}
markdown={reply.value.body}
isAuthor={props.authorDid ? reply.author.did === props.authorDid : false}
isReply={true}
class="untangled-thread-reply"
/>
)}
</For>
</div>
</Show>
<Show when={props.onReplySubmit && auth.agent()}>
<Show
when={replyingTo()?.uri === thread.item.uri}
fallback={
<button
type="button"
class="untangled-thread-reply-trigger"
onClick={() => {
setReplyingTo(thread.item);
setReplyBody('');
setReplyError(null);
}}
>
<Show when={auth.currentDid()}>
{(did) => <Avatar did={did()} size="size-8 mr-1" />}
</Show>
<span class="w-full p-0">Leave a reply...</span>
</button>
}
>
<form
onSubmit={(event) => void handleReplySubmit(event, thread.item)}
class="untangled-thread-reply-form"
>
<Show when={viewerQuery.data}>
{(viewer) => (
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Avatar did={viewer().did} size="size-6" />
<span>{viewer().handle || viewer().did}</span>
</div>
)}
</Show>
<textarea
value={replyBody()}
onInput={(event) => setReplyBody(event.currentTarget.value)}
class={textareaStyles()}
rows="3"
placeholder="Leave a reply..."
/>
<Show when={replyError()}>
<div class="text-sm text-red-500 mt-1">{replyError()}</div>
</Show>
<div class="flex flex-wrap items-stretch justify-end gap-2 text-sm text-gray-500 dark:text-gray-400">
<button
type="button"
class={clsx(buttonStyles(), 'text-red-500 dark:text-red-400')}
onClick={() => setReplyingTo(null)}
>
<X class="size-4" />
cancel
</button>
<button
type="submit"
class={buttonStyles('primary')}
disabled={replyWorking() || !replyBody().trim()}
>
<Show when={replyWorking()} fallback={<Reply class="size-4" />}>
<LoaderCircle class="size-4 animate-spin" />
</Show>
reply
</button>
</div>
</form>
</Show>
</Show>
</div>
)}
</For>
</div>
</div>
);
};
export const CommentComposer: Component<{
value: string;
onValueChange: (val: string) => void;
onSubmit: (event: SubmitEvent) => void;
working?: boolean;
submitLabel?: string;
placeholder?: string;
error?: string | null;
}> = (props) => {
const auth = useAuth();
const viewerQuery = createQuery(() => ({
queryKey: ['viewer', auth.currentDid()],
enabled: Boolean(auth.currentDid()),
queryFn: async () => resolveActor(auth.currentDid()!),
}));
return (
<form onSubmit={props.onSubmit} class="group/form w-full flex flex-col">
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full border border-gray-200 dark:border-gray-700">
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
<Show when={viewerQuery.data} fallback={<div class="h-6" />}>
{(viewer) => (
<A href={`/${viewer().handle || viewer().did}`} class="flex items-center gap-1 hover:underline text-gray-500 dark:text-gray-400 w-fit">
<Avatar did={viewer().did} size="size-6" />
<span>{viewer().handle || viewer().did}</span>
</A>
)}
</Show>
</div>
<textarea
rows={5}
placeholder={props.placeholder ?? 'Add to the discussion. Markdown is supported.'}
class={textareaStyles()}
required
value={props.value}
onInput={(event) => props.onValueChange(event.currentTarget.value)}
/>
<Show when={props.error}>
<div id="comment-error" class="error text-sm text-red-500 mt-1">{props.error}</div>
</Show>
</div>
<div class="flex gap-2 mt-2">
<button
id="comment-button"
type="submit"
class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline"
disabled={props.working || !props.value.trim()}
>
<Show when={props.working} fallback={<MessageSquarePlus class="w-4 h-4" />}>
<LoaderCircle class="w-4 h-4 animate-spin" />
</Show>
<span>{props.submitLabel ?? 'Comment'}</span>
</button>
</div>
</form>
);
};