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 = () => (
{(_, index) => (
)}
); export const RepoBlobSkeleton: Component = () => (
{(_, index) => (
)}
); export const RepoListSkeleton: Component<{ kind: 'issues' | 'pulls' }> = (props) => (
{(_, index) => (
)}
); const IssueThreadSkeleton: Component = () => (
); const PullHeaderSkeleton: Component = () => (
); export const PullDiffSkeleton: Component = () => (
{(_, fileIndex) => (
{(_, index) => (
)}
)}
); 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) => (
{props.icon} {props.name} {(when) => ( {formatRelativeTime(when())}} > {(href) => ( {formatRelativeTime(when())} )} )}
); export const FileRow: Component<{ name: string; href: string; icon: JSX.Element; lastCommit?: TreeEntry['last_commit']; lastCommitHref?: string; commitMessage?: string; }> = (props) => (
{(when) => ( {formatRelativeTime(when())}}> {(href) => ( {formatRelativeTime(when())} )} )}
); export const ReadmeCard: Component<{ filename?: string; markdown: string }> = (props) => (
{props.filename}
); 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) => (
Leave a reply...
)}
); 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(' { 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 (
{(line, index) => ( )}
); }; 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 (
setExpanded(!expanded())} />
{(file) => (
{file.path}
)}
0}>
Show {hiddenFiles().length} more file{hiddenFiles().length === 1 ? '' : 's'} Hide {hiddenFiles().length} file{hiddenFiles().length === 1 ? '' : 's'}
{(file) => (
{file.path}
)}
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 (
setExpanded(!expanded())} />
{(file) => (
{file.path}
...
)}
); }; 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 (
AT URI
{props.uri}
); };