import clsx from 'clsx';
import { File, Folder, GitBranch, GitCommitHorizontal, List } from 'lucide-solid';
import { A, useNavigate, useParams } from '@solidjs/router';
import { createQuery, keepPreviousData, useQueryClient } from '@tanstack/solid-query';
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, type Accessor, type Component } from 'solid-js';
import { buildBlobDataUrl, decodeBlobBytes, decodeBlobText, getRepoBlob, getRepoBranches, getRepoDefaultBranch, getRepoLanguages, getRepoLog, getRepoTags, getRepoTree, type CommitHeadline, type RepoContext, type BranchEntry } from '../../lib/api/repos';
import { Avatar, ErrorState, PlaceholderAvatar, buttonStyles, cardStyles } from '../../components/common';
import { CloneDropdown, CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoBlobSkeleton, RepoTreeSkeleton, PathBreadcrumbs, TreeLatestCommitPanel, RepoLanguageBar, LoadingFilePath, TreeRowsSkeleton, RepoOverviewSidebarSkeleton } from '../../components/repo';
import { RepoFrame, useRepoQuery } from './shared';
import { blobHref, commitHref, commitsHref, countLines, decodeRoutePath, formatBytes, formatRelativeTime, getErrorMessage, getParentPath, imageLike, isDirectory, joinPath, markdownLike, safeDecode, sortedTreeEntries, svgLike, treeHref, updateRecentRepoLanguage, videoLike } from '../../lib/repo-utils';
import { LOADING_DELAY_MS, hasNamedRef, normalizeLanguages, resolveDefaultBranchName, resolveRouteRefAndPath, shouldAnimateNavigation, useDelayedLoading } from './code-helpers';
import { useAuth } from '../../lib/auth';
const readmeIsAvailable = (
readme: { filename: string; contents: string } | undefined,
entries: Array<{ name: string; mode: string }>,
) => {
if (!readme) return false;
if (readme.contents.trim().length > 0) return true;
const filename = readme.filename.trim().toLowerCase();
return filename.length > 0 && entries.some((entry) => !entry.mode.startsWith('004') && entry.name.toLowerCase() === filename);
};
const RepoCodePageLegacy: Component = () => {
const params = useParams();
const navigate = useNavigate();
const repoQuery = useRepoQuery();
const ref = createMemo(() => safeDecode(params.ref ?? 'HEAD'));
const path = createMemo(() => decodeRoutePath(params.path));
const codeQuery = createQuery(() => {
const repo = repoQuery.data;
return {
queryKey: ['repo-code', repo?.repoDid, ref(), path()],
enabled: Boolean(repo),
queryFn: async () => {
const currentRepo = repo!;
const [tree, branches, tags, log] = await Promise.all([
getRepoTree(currentRepo, ref(), path()),
getRepoBranches(currentRepo),
getRepoTags(currentRepo),
getRepoLog(currentRepo, ref()),
]);
const defaultBranchName = branches.branches.find((branch) => branch.is_default)?.reference.name;
const languageRef = ref() === 'HEAD' ? (defaultBranchName ?? ref()) : ref();
const languages = await getRepoLanguages(currentRepo, languageRef);
return { tree, branches, tags, languages, log };
},
};
});
return (
<RepoFrame active="code" loadingFallback={<RepoTreeSkeleton nested={Boolean(path())} />}>
<Switch>
<Match when={codeQuery.isLoading}>
<RepoTreeSkeleton nested={Boolean(path())} />
</Match>
<Match when={codeQuery.error}>
<div class={cardStyles('p-6')}>
<ErrorState message={getErrorMessage(codeQuery.error)} />
</div>
</Match>
<Match when={Boolean(codeQuery.data && repoQuery.data)}>
{(() => {
const repo = repoQuery.data!;
const data = codeQuery.data!;
const languages = normalizeLanguages(data.languages?.languages ?? [], data.languages?.totalSize);
const branches = data.branches?.branches ?? [];
const tags = data.tags?.tags ?? [];
const commits = data.log?.commits ?? [];
const segments = path() ? path().split('/') : [];
const defaultBranch =
branches.find((branch) => branch.is_default)?.reference.name || ref();
const parentPath = data.tree.dotdot ?? getParentPath(path());
const sortedFiles = sortedTreeEntries(data.tree.files ?? []);
const latestCommit =
data.tree.lastCommit ??
(commits[0]
? {
hash: commits[0].this,
message: commits[0].message,
when: commits[0].committer?.When || commits[0].author?.When || '',
author: commits[0].author
? {
email: commits[0].author.Email,
name: commits[0].author.Name,
when: commits[0].author.When,
}
: undefined,
}
: undefined);
return (
<>
<div class="tree">
<RepoLanguageBar languages={languages} />
<div class="flex items-center justify-between pb-5 gap-3 flex-wrap">
<div class="flex gap-2 items-center">
<select
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
value={ref()}
onChange={(event) =>
navigate(treeHref(repo, event.currentTarget.value, path()))
}
>
<optgroup label={`branches (${branches.length})`}>
<For each={branches}>
{(branch) => <option value={branch.reference.name}>{branch.reference.name}</option>}
</For>
</optgroup>
<optgroup label={`tags (${tags.length})`}>
<Show
when={tags.length > 0}
fallback={<option disabled>no tags found</option>}
>
<For each={tags.slice(0, 40)}>
{(tag) => <option value={tag.name}>{tag.name}</option>}
</For>
</Show>
</optgroup>
</select>
<Show when={ref() !== defaultBranch}>
<button
type="button"
class={buttonStyles()}
onClick={() => navigate(treeHref(repo, defaultBranch))}
>
default
</button>
</Show>
</div>
<div class="flex items-center gap-4 text-sm font-medium">
<span class="inline-flex items-center gap-1">
<GitCommitHorizontal class="size-4" />
{data.log.total ?? commits.length}
</span>
<span class="inline-flex items-center gap-1">
<GitBranch class="size-4" />
{branches.length}
</span>
<span class="inline-flex items-center gap-1">
<span>#</span>
{tags.length}
</span>
</div>
</div>
<Show when={segments.length > 0}>
<div class="pb-3 mb-3 border-b border-gray-200 dark:border-gray-700 text-sm text-gray-500 dark:text-gray-400 overflow-auto whitespace-nowrap">
<A href={treeHref(repo, ref())} class="hover:underline">
{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(repo, ref(), segments.slice(0, index() + 1).join('/'))}
class="hover:underline"
>
{segment}
</A>
</Show>
</>
)}
</For>
</div>
</Show>
<Show when={latestCommit}>
{(commit) => (
<div class="pb-2 mb-3 border-b border-gray-200 dark:border-gray-700 flex items-center flex-wrap gap-2">
<div class="flex flex-wrap gap-1">
<span class="text-sm text-gray-700 dark:text-gray-300">
{commit().message.trim().split('\n')[0]}
</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
{commit().hash.slice(0, 8)} · {formatRelativeTime(commit().when)}
</span>
</div>
)}
</Show>
<div class="flex flex-col divide-y divide-gray-100 dark:divide-gray-700/60">
<Show when={parentPath !== null}>
<FileRow
name=".."
href={treeHref(repo, ref(), parentPath ?? '')}
icon={<Folder class="size-4 flex-shrink-0" />}
/>
</Show>
<For each={sortedFiles}>
{(entry) => (
<FileRow
name={entry.name}
href={
isDirectory(entry)
? treeHref(repo, ref(), joinPath(path(), entry.name))
: blobHref(repo, ref(), joinPath(path(), entry.name))
}
icon={
isDirectory(entry) ? (
<Folder class="size-4 flex-shrink-0" />
) : (
<File class="size-4 flex-shrink-0" />
)
}
lastCommit={entry.last_commit}
lastCommitHref={entry.last_commit ? commitHref(repo, entry.last_commit.hash) : undefined}
commitMessage={entry.last_commit?.message}
/>
)}
</For>
</div>
</div>
<Show when={readmeIsAvailable(data.tree.readme, sortedFiles) && data.tree.readme}>
{(readme) => <ReadmeCard filename={readme().filename} markdown={readme().contents} />}
</Show>
</>
);
})()}
</Match>
</Switch>
</RepoFrame>
);
};
void RepoCodePageLegacy;
const getKnotHost = (knotUrl: string): string => {
try {
const url = new URL(knotUrl);
let host = url.hostname;
if (host === 'knot1.tangled.sh') {
host = 'tangled.org';
}
return host;
} catch {
return knotUrl;
}
};
interface EmptyRepoViewProps {
repo: RepoContext;
branches: BranchEntry[];
}
const EmptyRepoView: Component<EmptyRepoViewProps> = (props) => {
const auth = useAuth();
const isOwner = createMemo(() => {
const current = auth.currentDid();
return Boolean(current && current === props.repo.owner.did);
});
return (
<section class="bg-white dark:bg-gray-800 px-6 py-6 rounded relative w-full mx-auto dark:text-white min-h-[250px] flex items-center justify-center">
<Switch>
<Match when={props.branches.length > 0}>
<div class="flex flex-col items-center w-full max-w-xl mx-auto">
<p class="text-center text-gray-500 dark:text-gray-400">
This branch is empty. Other branches in this repository are populated:
</p>
<div class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full bg-gray-50 dark:bg-gray-900/50">
<For each={props.branches}>
{(branch) => (
<A
href={treeHref(props.repo, branch.reference.name)}
class="no-underline hover:bg-gray-100 dark:hover:bg-gray-800 block p-3 transition-colors duration-150"
>
<div class="flex items-center justify-between">
<span class="font-medium text-blue-600 dark:text-blue-400">{branch.reference.name}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{branch.commit?.Committer?.When ? formatRelativeTime(branch.commit.Committer.When) : ''}
</span>
</div>
</A>
)}
</For>
</div>
</div>
</Match>
<Match when={isOwner()}>
<div class="w-full max-w-2xl mx-auto">
<div class="py-4 flex flex-col gap-5 text-gray-700 dark:text-gray-300">
<p class="font-semibold text-lg text-gray-900 dark:text-white">This is an empty repository. To get started:</p>
<div class="space-y-4">
<p class="flex items-start gap-3">
<span class="text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full w-5 h-5 flex items-center justify-center font-mono flex-shrink-0 mt-0.5">1</span>
<span>
First, generate a new{' '}
<a
href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key"
target="_blank"
rel="noopener noreferrer"
class="underline text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
SSH key pair
</a>
.
</span>
</p>
<p class="flex items-start gap-3">
<span class="text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full w-5 h-5 flex items-center justify-center font-mono flex-shrink-0 mt-0.5">2</span>
<span>
Then add the public key to your account from the{' '}
<A
href="/settings/keys"
class="underline text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
keys page
</A>{' '}
in your settings.
</span>
</p>
<p class="flex items-start gap-3">
<span class="text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full w-5 h-5 flex items-center justify-center font-mono flex-shrink-0 mt-0.5">3</span>
<span class="break-all">
Configure your remote to:{' '}
<code class="bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded font-mono text-sm border border-gray-200 dark:border-gray-800 text-gray-900 dark:text-gray-100 select-all block mt-1.5 w-fit">
git@{getKnotHost(props.repo.knot)}:{props.repo.repoDid}
</code>
</span>
</p>
<p class="flex items-start gap-3">
<span class="text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full w-5 h-5 flex items-center justify-center font-mono flex-shrink-0 mt-0.5">4</span>
<span>Push!</span>
</p>
</div>
</div>
</div>
</Match>
<Match when={true}>
<p class="text-gray-400 dark:text-gray-500 py-12 text-center text-lg">
This is an empty repository.
</p>
</Match>
</Switch>
</section>
);
};
export const RepoCodePage: Component = () => {
const params = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const repoQuery = useRepoQuery();
const [pendingTreeHref, setPendingTreeHref] = createSignal<string>();
let navigationAttempt = 0;
const routeRef = createMemo(() => safeDecode(params.ref ?? 'HEAD'));
const routePath = createMemo(() => decodeRoutePath(params.path));
const branchesQuery = createQuery(() => {
const repo = repoQuery.data;
return {
queryKey: ['repo-branches', repo?.repoDid],
enabled: Boolean(repo),
queryFn: async () => getRepoBranches(repo!),
};
}); const defaultBranchQuery = createQuery(() => {
const repo = repoQuery.data;
const hasBranches = branchesQuery.data && branchesQuery.data.branches.length > 0;
return {
queryKey: ['repo-default-branch', repo?.repoDid],
enabled: Boolean(repo && hasBranches),
queryFn: async () => getRepoDefaultBranch(repo!),
};
});
const defaultBranch = createMemo(
() =>
defaultBranchQuery.data?.name ??
defaultBranchQuery.data?.branch ??
defaultBranchQuery.data?.reference?.name ??
resolveDefaultBranchName(branchesQuery.data?.branches),
);
const defaultBranchReady = createMemo(() => Boolean(defaultBranchQuery.data || defaultBranchQuery.error || branchesQuery.data));
const routeTarget = createMemo(() =>
resolveRouteRefAndPath(
routeRef(),
routePath(),
branchesQuery.data?.branches.map((branch) => branch.reference.name),
),
);
const ref = createMemo(() => routeTarget().ref);
const path = createMemo(() => routeTarget().path);
const effectiveRef = createMemo(() => {
const branches = branchesQuery.data?.branches;
if (!branches || branches.length === 0) {
return undefined;
}
return (ref() === 'HEAD' ? (defaultBranch() ?? (defaultBranchReady() ? 'HEAD' : undefined)) : ref());
});
const treeQuery = createQuery(() => {
const repo = repoQuery.data;
const currentRef = effectiveRef();
return {
queryKey: ['repo-tree', repo?.repoDid, currentRef, path()],
enabled: Boolean(repo && currentRef),
queryFn: async () => ({
tree: await getRepoTree(repo!, currentRef!, path()),
path: path(),
ref: currentRef!,
}),
placeholderData: keepPreviousData,
};
});
const selectedTreeRef = createMemo(() => {
const currentTreeRef = treeQuery.data?.ref;
if (currentTreeRef && currentTreeRef !== 'HEAD') return currentTreeRef;
const currentRouteRef = ref();
return currentRouteRef === 'HEAD' ? (defaultBranch() ?? currentRouteRef) : currentRouteRef;
});
const tagsQuery = createQuery(() => {
const repo = repoQuery.data;
const hasBranches = branchesQuery.data && branchesQuery.data.branches.length > 0;
return {
queryKey: ['repo-tags', repo?.repoDid],
enabled: Boolean(repo && hasBranches),
queryFn: async () => getRepoTags(repo!),
};
});
const logQuery = createQuery(() => {
const repo = repoQuery.data;
const currentRef = effectiveRef();
return {
queryKey: ['repo-log', repo?.repoDid, currentRef],
enabled: Boolean(repo && currentRef),
queryFn: async () => getRepoLog(repo!, currentRef!),
placeholderData: keepPreviousData,
};
});
const languageRef = effectiveRef;
const languagesQuery = createQuery(() => {
const repo = repoQuery.data;
const currentLanguageRef = languageRef();
return {
queryKey: ['repo-languages', repo?.repoDid, currentLanguageRef],
enabled: Boolean(repo && currentLanguageRef),
queryFn: async () => getRepoLanguages(repo!, currentLanguageRef!),
placeholderData: keepPreviousData,
};
});
const overviewError = createMemo(
() => branchesQuery.error || tagsQuery.error,
);
const overviewReady = createMemo(() =>
Boolean(repoQuery.data && branchesQuery.data && tagsQuery.data),
);
const languagesLoading = createMemo(() => !languagesQuery.data && (languagesQuery.isLoading || languagesQuery.isFetching));
const showOverviewSkeleton = useDelayedLoading(() => !overviewReady() && !overviewError());
const isRefNotFoundError = (err: unknown): boolean => {
if (!err) return false;
if (typeof err === 'object') {
const e = err as any;
if (e.error === 'RefNotFound' || e.status === 404 || e.name === 'RefNotFound' || e.code === 'RefNotFound') {
return true;
}
}
const msg = getErrorMessage(err).toLowerCase();
return (
msg.includes('refnotfound') ||
msg.includes('failed to access ref') ||
msg.includes('reference not found') ||
msg.includes('ref not found')
);
};
const isEmptyRepo = createMemo(() => {
return Boolean(branchesQuery.data && branchesQuery.data.branches.length === 0);
});
const isEmptyBranch = createMemo(() => {
if (branchesQuery.data && branchesQuery.data.branches.length > 0) {
if (treeQuery.error && isRefNotFoundError(treeQuery.error)) {
return true;
}
}
return false;
});
createEffect(() => {
if (!treeQuery.isFetching) setPendingTreeHref(undefined);
});
createEffect(() => {
const repo = repoQuery.data;
const treeData = treeQuery.data;
if (repo && treeData) {
const tree = treeData.tree;
const parent = tree?.parent ?? tree?.dotdot;
if (tree && (!tree.files || tree.files.length === 0) && parent === treeData.path) {
navigate(blobHref(repo, treeData.ref, treeData.path), { replace: true });
}
}
});
createEffect(() => {
const repo = repoQuery.data;
const langs = languagesQuery.data;
if (repo && langs?.languages?.length) {
const normalized = normalizeLanguages(langs.languages, langs.totalSize);
const top = normalized[0]?.name;
if (top) updateRecentRepoLanguage(repo.owner.handle, repo.slug, top);
}
});
const handleFileClick = (
event: MouseEvent,
href: string,
repo: Parameters<typeof getRepoBlob>[0],
refName: string,
filePath: string,
) => {
if (!shouldAnimateNavigation(event)) return;
event.preventDefault();
const attempt = ++navigationAttempt;
setPendingTreeHref(href);
const delay = new Promise<'delay'>((resolve) => {
setTimeout(() => resolve('delay'), LOADING_DELAY_MS);
});
const load = queryClient
.ensureQueryData({
queryKey: ['blob', repo.repoDid, refName, filePath],
queryFn: async () => getRepoBlob(repo, refName, filePath),
})
.then(
() => 'loaded' as const,
() => 'loaded' as const,
);
void Promise.race([delay, load]).then((result) => {
if (attempt !== navigationAttempt) return;
if (result === 'loaded') setPendingTreeHref(undefined);
navigate(href);
});
};
return (
<RepoFrame active="code" loadingFallback={<RepoTreeSkeleton nested={Boolean(routePath())} />}>
<Switch>
<Match when={!overviewReady() && !overviewError() && !isEmptyRepo() && !isEmptyBranch() && showOverviewSkeleton()}>
<RepoTreeSkeleton nested={Boolean(path())} />
</Match>
<Match when={isEmptyRepo()}>
<EmptyRepoView repo={repoQuery.data!} branches={[]} />
</Match>
<Match when={isEmptyBranch()}>
<EmptyRepoView repo={repoQuery.data!} branches={branchesQuery.data?.branches ?? []} />
</Match>
<Match when={overviewError()}>
<div class={cardStyles('p-6')}>
<ErrorState message={getErrorMessage(overviewError())} />
</div>
</Match> <Match when={overviewReady()}>
{(() => {
const repo = repoQuery.data!;
const treeData = () => treeQuery.data;
const tree = () => treeData()?.tree;
const languages = normalizeLanguages(languagesQuery.data?.languages ?? [], languagesQuery.data?.totalSize);
const branches = branchesQuery.data?.branches ?? [];
const tags = tagsQuery.data?.tags ?? [];
const commits = () => logQuery.data?.commits ?? [];
const activeRef = selectedTreeRef();
const currentDefaultBranch = defaultBranch() ?? activeRef;
const activeRefInOptions = hasNamedRef(activeRef, branches, tags);
const treeRef = activeRef;
const treePath = () => treeData()?.path ?? '';
const parentPath = () => tree() ? (tree()!.dotdot ?? getParentPath(treePath())) : null;
const sortedFiles = () => sortedTreeEntries(tree()?.files ?? []);
const isNestedTreePath = () => treePath().length > 0;
return (
<>
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white">
<RepoLanguageBar languages={languages} loading={languagesLoading()} />
<div class="flex items-center justify-between gap-4 pb-5">
<div class="flex items-center gap-2">
<select
class="untangled-branch-select min-w-32 rounded border border-gray-300 bg-white px-3 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
value={selectedTreeRef()}
ref={(element) => {
createEffect(() => {
const nextValue = selectedTreeRef();
branchesQuery.data?.branches.length;
tagsQuery.data?.tags.length;
queueMicrotask(() => {
element.value = nextValue;
});
});
}}
onChange={(event) => navigate(treeHref(repo, event.currentTarget.value, path()))}
>
<Show when={!activeRefInOptions}>
<option value={activeRef} selected>
{activeRef}
</option>
</Show>
<optgroup label={`branches (${branches.length})`}>
<For each={branches}>
{(branch) => (
<option value={branch.reference.name} selected={branch.reference.name === activeRef}>
{branch.reference.name}
</option>
)}
</For>
</optgroup>
<optgroup label={`tags (${tags.length})`}>
<Show when={tags.length > 0} fallback={<option disabled>no tags found</option>}>
<For each={tags.slice(0, 40)}>
{(tag) => (
<option value={tag.name} selected={tag.name === activeRef}>
{tag.name}
</option>
)}
</For>
</Show>
</optgroup>
</select>
<button type="button" class={buttonStyles()} title="Compare branches or tags">
<GitBranch class="size-4" />
</button>
</div>
<CloneDropdown repo={repo} refName={activeRef} />
</div>
<Show when={isNestedTreePath()}>
<div>
<PathBreadcrumbs repo={repo} refName={treeRef} path={treePath()} withDivider />
<Show when={tree()?.lastCommit}>
{(commit) => <TreeLatestCommitPanel repo={repo} commit={commit()} />}
</Show>
</div>
</Show>
<div class={clsx('untangled-repo-overview', isNestedTreePath() && 'untangled-repo-overview--path')}>
<div class="untangled-file-tree-pane min-w-0 pr-0 md:pr-6">
<Show when={!treeQuery.isLoading} fallback={<TreeRowsSkeleton />}>
<Switch fallback={
<div class="space-y-3">
<Show when={parentPath() !== null}>
<OverviewFileRow
name=".."
href={treeHref(repo, treeRef, parentPath() ?? '')}
icon={<Folder class="size-4 flex-shrink-0 fill-current" />}
class={pendingTreeHref() === treeHref(repo, treeRef, parentPath() ?? '') ? 'untangled-file-row-loading' : undefined}
onClick={(event) => {
if (shouldAnimateNavigation(event)) {
navigationAttempt += 1;
setPendingTreeHref(treeHref(repo, treeRef, parentPath() ?? ''));
}
}}
/>
</Show>
<For each={sortedFiles()}>
{(entry) => {
const entryPath = joinPath(treePath(), entry.name);
const directory = isDirectory(entry);
const href = directory ? treeHref(repo, treeRef, entryPath) : blobHref(repo, treeRef, entryPath);
const currentRef = effectiveRef();
let hoverTimer: ReturnType<typeof setTimeout> | undefined;
const handleMouseEnter = () => {
hoverTimer = setTimeout(() => {
if (!directory) {
queryClient.prefetchQuery({
queryKey: ['blob', repo.repoDid, treeRef, entryPath],
queryFn: () => getRepoBlob(repo, treeRef, entryPath),
});
return;
}
queryClient.prefetchQuery({
queryKey: ['repo-tree', repo.repoDid, currentRef, entryPath],
queryFn: async () => ({
tree: await getRepoTree(repo, currentRef!, entryPath),
path: entryPath,
ref: currentRef!,
}),
});
}, 30);
};
const handleMouseLeave = () => {
clearTimeout(hoverTimer);
};
return (
<OverviewFileRow
name={entry.name}
href={href}
icon={
directory ? (
<Folder class="size-4 flex-shrink-0 fill-current" />
) : (
<File class="size-4 flex-shrink-0" />
)
}
lastCommit={entry.last_commit}
lastCommitHref={entry.last_commit ? commitHref(repo, entry.last_commit.hash) : undefined}
class={pendingTreeHref() === href ? 'untangled-file-row-loading' : undefined}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={(event) => {
if (directory) {
if (shouldAnimateNavigation(event)) {
navigationAttempt += 1;
setPendingTreeHref(href);
}
return;
}
handleFileClick(event, href, repo, treeRef, entryPath);
}}
/>
);
}}
</For>
</div>
}>
<Match when={treeQuery.error}>
<div class={cardStyles('p-4')}>
<ErrorState message={getErrorMessage(treeQuery.error)} />
</div>
</Match>
</Switch>
</Show>
</div>
<Show when={!isNestedTreePath()}>
<Show when={!logQuery.isLoading} fallback={<RepoOverviewSidebarSkeleton />}>
<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">
<A href={commitsHref(repo, activeRef)} class="flex items-center gap-2 pb-3 font-bold no-underline hover:underline dark:text-white">
<List class="size-4" />
<span>commits</span>
<span class="rounded bg-gray-200 px-1 py-0.5 text-sm font-normal dark:bg-gray-700">
{logQuery.data?.total ?? commits().length}
</span>
</A>
<Switch fallback={
<div class="space-y-6">
<For each={commits().slice(0, 6)}>
{(commit) => (
<div>
<div id="commit-message">
<div class="text-base cursor-pointer">
<div><div>
<a
href={`/${repo.owner.handle}/${repo.slug}/commit/${commit.this}`}
class="inline no-underline hover:underline dark:text-white"
>
{commit.message.trim().split('\n')[0]}
</a>
</div></div>
</div>
</div>
<div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap">
<span class="font-mono">
<a
href={`/${repo.owner.handle}/${repo.slug}/commit/${commit.this}`}
class="no-underline hover:underline bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 px-2 py-1 rounded flex items-center gap-2"
>
{commit.this.slice(0, 8)}
<GitCommitHorizontal class="w-3 h-3" />
</a>
</span>
<span class="mx-1 before:content-['·'] before:select-none" />
<span class="flex items-center gap-1">
<Show
when={commit.author?.Email?.startsWith('did:') ? commit.author.Email : undefined}
fallback={<PlaceholderAvatar size="size-6" iconSize="size-4" />}
>
{(did) => <Avatar did={did()} size="size-6" />}
</Show>
<span>{commit.author?.Name || commit.committer?.Name || 'unknown author'}</span>
</span>
<div class="inline-block px-1 select-none after:content-['·']" />
<time datetime={commit.committer?.When || commit.author?.When || ''}>
{formatRelativeTime(commit.committer?.When || commit.author?.When || '')}
</time>
<Show when={activeRef === currentDefaultBranch}>
<div class="inline-block px-1 select-none after:content-['·']" />
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-[2px] inline-flex items-center">
{currentDefaultBranch}
</span>
</Show>
</div>
</div>
)}
</For>
</div>
}>
<Match when={logQuery.error}>
<div class={cardStyles('p-4 mb-4')}>
<ErrorState message={getErrorMessage(logQuery.error)} />
</div>
</Match>
</Switch>
<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">
<GitBranch class="size-4" />
<span>branches</span>
<span class="rounded bg-gray-200 px-1 py-0.5 text-sm font-normal dark:bg-gray-700">
{branches.length}
</span>
</div>
<div class="space-y-1 text-sm">
<For each={branches.slice(0, 3)}>
{(branch) => (
<div class="flex items-center justify-between gap-2">
<span>
{branch.reference.name}
<Show when={branch.is_default}>
<span class="ml-2 rounded bg-gray-200 px-1 py-0.5 font-mono text-xs dark:bg-gray-700">
default
</span>
</Show>
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{branch.commit?.Committer?.When ? formatRelativeTime(branch.commit.Committer.When) : ''}
</span>
</div>
)}
</For>
</div>
</div>
</div>
</Show>
</Show>
</div>
</section>
<Show when={tree() && tree()!.readme && readmeIsAvailable(tree()!.readme, sortedFiles())}>
<ReadmeCard
filename={tree()!.readme!.filename}
markdown={tree()!.readme!.contents}
repo={repo}
refName={activeRef}
path={treePath()}
/>
</Show>
</>
);
})()}
</Match>
</Switch>
</RepoFrame>
);
};
export const BlobPage: Component = () => {
const params = useParams();
const repoQuery = useRepoQuery();
const [showRendered, setShowRendered] = createSignal(true);
const [wrapContent, setWrapContent] = createSignal(false);
const ref = createMemo(() => safeDecode(params.ref));
const path = createMemo(() => decodeRoutePath(params.path));
const blobQuery = createQuery(() => {
const repo = repoQuery.data;
return {
queryKey: ['blob', repo?.repoDid, ref(), path()],
enabled: Boolean(repo && path()),
queryFn: async () => getRepoBlob(repo!, ref(), path()),
};
});
createEffect(() => {
const blob = blobQuery.data;
setWrapContent(false);
setShowRendered(Boolean(blob && (markdownLike(path(), blob.mimeType) || svgLike(path(), blob.mimeType))));
});
return (
<RepoFrame active="code" loadingFallback={<RepoBlobSkeleton />}>
<Switch>
<Match when={blobQuery.isLoading && repoQuery.data}>
<LoadingFilePath repo={repoQuery.data!} refName={ref()} path={path()} />
</Match>
<Match when={blobQuery.error}>
<div class={cardStyles('p-6')}>
<ErrorState message={getErrorMessage(blobQuery.error)} />
</div>
</Match>
<Match when={Boolean(blobQuery.data && repoQuery.data)}>
{(() => {
const repo = repoQuery.data!;
const blob = blobQuery.data!;
const text = createMemo(() => decodeBlobText(blob));
const imageUrl = createMemo(() => buildBlobDataUrl(blob));
const rawUrl = createMemo(() => buildBlobDataUrl(blob));
const isMarkdown = createMemo(() => markdownLike(path(), blob.mimeType));
const isSvg = createMemo(() => svgLike(path(), blob.mimeType));
const isVideo = createMemo(() => videoLike(path(), blob.mimeType));
const isImage = createMemo(() => imageLike(path(), blob.mimeType));
const showingText = createMemo(
() => blob.content !== undefined && !blob.fileTooLarge && (!blob.isBinary || isSvg()),
);
const lineCount = createMemo(() => countLines(text()));
const commitLink = (commit: Accessor<CommitHeadline>) => `/${repo.owner.handle}/${repo.slug}/commit/${commit().hash}`;
const [blobUrl, setBlobUrl] = createSignal<string>();
createEffect(() => {
const bytes = decodeBlobBytes(blob);
const webBlob = new Blob([bytes.buffer as any], { type: blob.mimeType });
const url = URL.createObjectURL(webBlob);
setBlobUrl(url);
onCleanup(() => URL.revokeObjectURL(url));
});
return (
<div class="untangled-blob-card peer">
<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={repo} refName={ref()} path={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(repo, ref())}>{ref()}</A></span>
<Show when={showingText()}>
<span class="select-none px-1 md:px-2">·</span>
<span>{lineCount()} lines</span>
</Show>
<Show when={blob.size}>
<span class="select-none px-1 md:px-2">·</span>
<span>{formatBytes(blob.size!)}</span>
</Show>
<Show when={blobUrl()}>
<span class="select-none px-1 md:px-2">·</span>
<a href={blobUrl()!} target="_blank" rel="noreferrer" class="hover:underline">
view raw
</a>
</Show>
<Show when={isMarkdown() || isSvg()}>
<span class="select-none px-1 md:px-2">·</span>
<button type="button" onClick={() => setShowRendered((value) => !value)}>
view {showRendered() ? 'code' : 'rendered'}
</button>
</Show>
<Show when={showingText()}>
<div class="hidden">
<span class="select-none px-1 md:px-2">·</span>
<label class="flex lowercase font-normal px-1 py-0 gap-1 text-xs md:text-sm">
<input
type="checkbox"
checked={wrapContent()}
onChange={(event) => setWrapContent(event.currentTarget.checked)}
/>
wrap content
</label>
</div>
</Show>
</div>
</div>
</div>
<Show when={blob.lastCommit}>
{(commit) => (
<div class="untangled-blob-commit-panel">
<div class="flex min-w-0 flex-wrap items-center gap-1">
<Show
when={commit().author?.email?.startsWith('did:') ? 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">
{commit().author?.name || 'unknown author'}
</span>
<span class="select-none text-gray-400 dark:text-gray-500">·</span>
<A href={commitLink(commit)} class="truncate text-gray-900 dark:text-gray-100">{commit().message.trim().split('\n')[0]}</A>
<span class="select-none text-gray-400 dark:text-gray-500">·</span>
<A href={commitLink(commit)} class="text-gray-400 dark:text-gray-500">{formatRelativeTime(commit().when)}</A>
</div>
<A
href={commitLink(commit)}
class="untangled-blob-commit-link"
>
{commit().hash.slice(0, 8)}
</A>
</div>
)}
</Show>
<Switch>
<Match when={blob.submodule}>
<p class="untangled-blob-message text-center text-gray-400 dark:text-gray-500">
This directory is a git submodule of{' '}
<a href={blob.submodule!.url} class="underline">
{blob.submodule!.url}
</a>
.
</p>
</Match>
<Match when={blob.fileTooLarge}>
<p class="untangled-blob-message text-center text-gray-400 dark:text-gray-500">
This file is too large to render.
</p>
</Match>
<Match when={isVideo() && rawUrl()}>
<div class="untangled-blob-preview text-center">
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
<source src={rawUrl()!} />
</video>
</div>
</Match>
<Match when={isImage() && imageUrl() && (!isSvg() || showRendered())}>
<div class="untangled-blob-preview text-center">
<img src={imageUrl()!} alt={path()} class="max-w-full h-auto mx-auto rounded border border-gray-200 dark:border-gray-700" />
</div>
</Match>
<Match when={isMarkdown() && showRendered()}>
<div class="untangled-blob-rendered overflow-auto relative">
<MarkdownBlock
markdown={text()}
variant="readme"
repo={repo}
refName={ref()}
path={getParentPath(path()) ?? ''}
/>
</div>
</Match>
<Match when={blob.content !== undefined}>
<CodeView id="blob-contents" text={text()} wrap={wrapContent()} path={path()} />
</Match>
<Match when={true}>
<p class="untangled-blob-message text-center text-gray-400 dark:text-gray-500">
Previews are not supported for this file type.
</p>
</Match>
</Switch>
</div>
);
})()}
</Match>
</Switch>
</RepoFrame>
);
};