import clsx from 'clsx'; import hljs from 'highlight.js'; import { For, Show, createSignal, createMemo, createEffect, onCleanup, onMount, type Component, type JSX } from 'solid-js'; import { ChevronRight, Circle, CircleCheck, Columns2, FileText, FoldVertical, Folder, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, UnfoldVertical, } from 'lucide-solid'; 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 customExtensionLanguages: Record = { cjs: 'javascript', mjs: 'javascript', cts: 'typescript', mts: 'typescript', conf: 'ini', env: 'ini', }; 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; // Check if highlight.js registers this extension directly or as a built-in alias if (hljs.getLanguage(extension)) { return extension; } return customExtensionLanguages[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}`; }; /** Parse a URL hash like #L5 or #L3-L8 into {start, end} (1-based, inclusive). */ const parseLineHash = (hash: string): { start: number; end: number } | null => { const m = /^#L(\d+)(?:-L?(\d+))?$/.exec(hash); if (!m) return null; const start = parseInt(m[1], 10); const end = m[2] ? parseInt(m[2], 10) : start; if (isNaN(start) || isNaN(end)) return null; return { start: Math.min(start, end), end: Math.max(start, end) }; }; 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; // Line selection state (only used in non-diff mode) const [selection, setSelection] = createSignal<{ start: number; end: number } | null>(null); // The line number of the last plain click (anchor for shift-click ranges) let anchorLine: number | null = null; const applyHashSelection = (scroll = false) => { const parsed = parseLineHash(window.location.hash); setSelection(parsed); if (scroll && parsed) { requestAnimationFrame(() => { const target = document.getElementById(`L${parsed.start}`); target?.scrollIntoView({ behavior: 'instant', block: 'center' }); }); } }; const handleLineNumberClick = (lineNum: number, event: MouseEvent) => { event.preventDefault(); let hash: string; if (event.shiftKey && anchorLine !== null) { const lo = Math.min(anchorLine, lineNum); const hi = Math.max(anchorLine, lineNum); hash = `#L${lo}-L${hi}`; } else { anchorLine = lineNum; hash = `#L${lineNum}`; } history.pushState(null, '', hash); setSelection(parseLineHash(hash)); }; onMount(() => { if (props.diff) return; applyHashSelection(true); const onHashChange = () => applyHashSelection(false); window.addEventListener('hashchange', onHashChange); window.addEventListener('popstate', onHashChange); onCleanup(() => { window.removeEventListener('hashchange', onHashChange); window.removeEventListener('popstate', onHashChange); }); }); const isLineHighlighted = (lineNum: number) => { const s = selection(); if (!s) return false; return lineNum >= s.start && lineNum <= s.end; }; return (
{(line, index) => { const lineNum = index() + 1; 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) => ( ); 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[]; reviewedFiles?: () => Set; }> = (props) => (
{(node) => }
); const DiffFileTreeNodeView: Component<{ node: DiffFileTreeNode; reviewedFiles?: () => Set; }> = (props) => (
{props.node.name}
} > {(file) => { const isReviewed = () => props.reviewedFiles?.().has(file().path) ?? false; return ( scrollToDiffFile(file().path, event)} > {props.node.name} ✓ ); }}
); const DiffStatPill: Component<{ additions: number; deletions: number }> = (props) => (
0}> +{props.additions} 0}> -{props.deletions}
); const SidebarToggleButton: Component<{ visible: boolean; onToggle: () => void; class?: string; ariaLabelCollapse: string; ariaLabelExpand: string; panel?: 'left' | 'right'; }> = (props) => ( ); const DiffFileEntry: Component<{ file: DiffFile; open: boolean; onToggle?: (open: boolean) => void; isReviewed?: boolean; onToggleReview?: () => void; wrap?: boolean; maxHeight?: string; className?: string; headerClassName?: string; chevronClassName?: string; pathClassName?: string; showEllipsis?: boolean; }> = (props) => (
props.onToggle?.(e.currentTarget.open) : undefined} >
{props.file.path}
...
); 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 (
setFilesVisible(!filesVisible())} class="untangled-diff-sidebar-toggle" ariaLabelCollapse="collapse changed files sidebar" ariaLabelExpand="expand changed files sidebar" />
setExpanded(!expanded())} />
{(file) => ( )} 0}>
Show {hiddenFiles().length} more file{hiddenFiles().length === 1 ? '' : 's'} Hide {hiddenFiles().length} file{hiddenFiles().length === 1 ? '' : 's'}
{(file) => ( )}
No differences found.
); }; export const PullDiffView: Component<{ patch: string; roundLabel: string; storageKey: string; history: JSX.Element; mobileDrawerOpen?: boolean; onMobileDrawerClose?: () => void; }> = (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); const [isDesktop, setIsDesktop] = createSignal(window.matchMedia('(min-width: 768px)').matches); onMount(() => { const media = window.matchMedia('(min-width: 768px)'); const listener = (e: MediaQueryListEvent) => setIsDesktop(e.matches); media.addEventListener('change', listener); onCleanup(() => media.removeEventListener('change', listener)); }); createEffect(() => { if (!isDesktop() && props.mobileDrawerOpen) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } }); onCleanup(() => { document.body.style.overflow = ''; }); const loadReviewed = (): Set => { try { const data = localStorage.getItem(props.storageKey); if (!data) return new Set(); const entry = JSON.parse(data); return new Set(Array.isArray(entry) ? entry : (entry.files || [])); } catch { return new Set(); } }; const [reviewed, setReviewed] = createSignal>(new Set()); createEffect(() => { setReviewed(loadReviewed()); }); const saveReviewed = (newSet: Set) => { const currentFilePaths = new Set(files().map((f) => f.path)); const filesToSave = Array.from(newSet).filter((path) => currentFilePaths.has(path)); try { localStorage.setItem( props.storageKey, JSON.stringify({ files: filesToSave, ts: Date.now(), }), ); } catch (e) { console.error('Failed to save reviewed files:', e); } }; const pruneStale = () => { const now = Date.now(); const REVIEWED_PREFIX = 'reviewed:'; const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith(REVIEWED_PREFIX) && key !== props.storageKey) { try { const entry = JSON.parse(localStorage.getItem(key) || '{}'); if (!entry.ts || now - entry.ts > MAX_AGE_MS) { localStorage.removeItem(key); } } catch { localStorage.removeItem(key); } } } }; createEffect(() => { if (Math.random() < 0.1) { pruneStale(); } }); const [localOpen, setLocalOpen] = createSignal>({}); const [hasSetInitial, setHasSetInitial] = createSignal(false); createEffect(() => { if (files().length > 0) { const isExpanded = expanded(); const next: Record = {}; for (const f of files()) { if (!hasSetInitial() && reviewed().has(f.path)) { next[f.path] = false; } else { next[f.path] = isExpanded; } } setLocalOpen(next); setHasSetInitial(true); } }); const toggleReview = (path: string) => { const nextReviewed = new Set(reviewed()); if (nextReviewed.has(path)) { nextReviewed.delete(path); } else { nextReviewed.add(path); setLocalOpen((prev) => ({ ...prev, [path]: false })); } setReviewed(nextReviewed); saveReviewed(nextReviewed); }; return (
{/* backdrop overlay - only visible on mobile when open */}
props.onMobileDrawerClose?.()} />
setFilesVisible(!filesVisible())} class="untangled-pr-sidebar-toggle" ariaLabelCollapse="collapse changed files sidebar" ariaLabelExpand="expand changed files sidebar" />
setExpanded(!expanded())} />
setHistoryVisible(!historyVisible())} class="untangled-pr-sidebar-toggle" ariaLabelCollapse="collapse history panel" ariaLabelExpand="expand history panel" panel="right" />
{(file) => ( { setLocalOpen((prev) => ({ ...prev, [file.path]: open })); }} isReviewed={reviewed().has(file.path)} onToggleReview={() => toggleReview(file.path)} className="untangled-pr-diff-file" headerClassName="untangled-pr-diff-file-header" chevronClassName="untangled-pr-diff-chevron size-4 shrink-0" pathClassName="text-base text-gray-100" showEllipsis /> )}
); };