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) => (
{props.icon}
{props.label}
{props.meta}
);
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 = () => (
);
export const RepoBlobSkeleton: Component = () => (
);
export const RepoListSkeleton: Component<{ kind: 'issues' | 'pulls' }> = (props) => (
);
const IssueThreadSkeleton: Component = () => (
);
const PullHeaderSkeleton: Component = () => (
);
export const PullDiffSkeleton: Component = () => (
);
const PullThreadSkeleton: Component = () => (
);
export const RepoThreadSkeleton: Component<{ kind: 'issue' | 'pull' }> = (props) => (
}>
);
export const RepoFrameSkeleton: Component<{ active: 'code' | 'issues' | 'pulls' }> = (props) => (
<>
}>
>
);
export const OverviewFileRow: Component<{
name: string;
href: string;
icon: JSX.Element;
lastCommit?: TreeEntry['last_commit'];
lastCommitHref?: string;
class?: string;
onMouseEnter?: JSX.EventHandlerUnion;
onMouseLeave?: JSX.EventHandlerUnion;
onClick?: JSX.EventHandlerUnion;
}> = (props) => (
);
export const FileRow: Component<{
name: string;
href: string;
icon: JSX.Element;
lastCommit?: TreeEntry['last_commit'];
lastCommitHref?: string;
commitMessage?: string;
}> = (props) => (
);
export const ReadmeCard: Component<{ filename?: string; markdown: string }> = (props) => (
);
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 | undefined;
onCleanup(() => clearTimeout(copyTimer));
const copy = () => {
clearTimeout(copyTimer);
void navigator.clipboard?.writeText(visibleUrl());
setCopied(true);
copyTimer = setTimeout(() => setCopied(false), 2000);
};
return (
selectContents(event.currentTarget)}
title={visibleUrl()}
>
{visibleUrl()}
);
};
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 (
);
};
export const CommentCard: Component<{
author: string;
did: string;
createdAt: string;
markdown: string;
replyDid?: string | null;
variant?: 'default' | 'issue';
}> = (props) => (
{props.author}
·
{formatRelativeTime(props.createdAt)}
}
>
{props.author}
·
{formatRelativeTime(props.createdAt)}
{(did) => (
)}
);
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 ;
};
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, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
const languageAliases: Record = {
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 = {
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) => ''.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 `${escapeHtml(marker)}${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 (
);
};
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) => (
<>
{changedFilesLabel(props.fileCount)}
>
);
const DiffCollapseButton: Component<{ expanded: boolean; onToggle: () => void }> = (props) => (
);
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 (
{(file) => (
)}
0}>
Show {hiddenFiles().length} more file{hiddenFiles().length === 1 ? '' : 's'}
Hide {hiddenFiles().length} file{hiddenFiles().length === 1 ? '' : 's'}
{(file) => (
)}
No differences found.
);
};
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) => (
{(node) => }
);
const DiffFileTreeNodeView: Component<{ node: DiffFileTreeNode }> = (props) => (
);
const DiffStatPill: Component<{ additions: number; deletions: number }> = (props) => (
0}>
+{props.additions}
0}>
-{props.deletions}
);
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 (
diff
{props.roundLabel}
setExpanded(!expanded())} />
);
};
export const AsideCard: Component<{ title: string; children: JSX.Element }> = (props) => (
{props.title}
{props.children}
);
export const BranchPill: Component<{ name: string }> = (props) => (
{props.name}
);
export const AtUriPanel: Component<{ uri: string }> = (props) => {
const selectUri: JSX.EventHandlerUnion = (event) => {
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(event.currentTarget);
selection.removeAllRanges();
selection.addRange(range);
};
return (
);
};