csr tangled client in solid-js
Something went wrong. Try again.
29 kB · 899 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900import 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, '"') .replace(/'/g, ''');
const customExtensionLanguages: Record<string, string> = { cjs: 'javascript', mjs: 'javascript', cts: 'typescript', mts: 'typescript', conf: 'ini', env: 'ini',};
const filenameLanguages: Record<string, string> = { 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) => '</span>'.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 `<span class="untangled-code-diff-op">${escapeHtml(marker)}</span>${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 ( <div class="untangled-code-scroll" style={{ 'max-height': props.maxHeight }}> <div id={props.id} class={clsx('untangled-code-view', props.wrap && 'untangled-code-view-wrap')} > <For each={lines()}> {(line, index) => { const lineNum = index() + 1; return ( <div class={clsx('untangled-code-line', lineClass(line), !props.diff && isLineHighlighted(lineNum) && 'untangled-code-line--hl')}> <a id={`L${lineNum}`} href={`#L${lineNum}`} class="untangled-code-line-number" onClick={props.diff ? undefined : (e) => handleLineNumberClick(lineNum, e)} > {lineNum} </a> <span class="untangled-code-line-content" innerHTML={highlightedLines()[index()] || ' '} /> </div> ); }} </For> </div> </div> );};
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) => ( <> <DiffStatPill additions={props.additions} deletions={props.deletions} /> <span class={props.class ?? 'text-xs text-gray-600 dark:text-gray-300'}>{changedFilesLabel(props.fileCount)}</span> </>);
const DiffCollapseButton: Component<{ expanded: boolean; onToggle: () => void }> = (props) => ( <button type="button" class="untangled-diff-tool-button" onClick={props.onToggle}> <Show when={props.expanded} fallback={<UnfoldVertical class="size-4" />}> <FoldVertical class="size-4" /> </Show> <span class="hidden md:inline">{props.expanded ? 'collapse all' : 'expand all'}</span> </button>);
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<string>;}> = (props) => ( <div class="untangled-pr-file-tree-list"> <For each={props.nodes}> {(node) => <DiffFileTreeNodeView node={node} reviewedFiles={props.reviewedFiles} />} </For> </div>);
const DiffFileTreeNodeView: Component<{ node: DiffFileTreeNode; reviewedFiles?: () => Set<string>;}> = (props) => ( <div class="untangled-pr-file-tree-node"> <Show when={props.node.file} fallback={ <> <div class="untangled-pr-file-tree-entry" title={props.node.path}> <Folder class="size-4 shrink-0" /> <span class="truncate">{props.node.name}</span> </div> <Show when={props.node.children?.length}> <div class="untangled-pr-file-tree-children"> <DiffFileTreeNodes nodes={props.node.children!} reviewedFiles={props.reviewedFiles} /> </div> </Show> </> } > {(file) => { const isReviewed = () => props.reviewedFiles?.().has(file().path) ?? false; return ( <a href={`#${diffFileElementId(file().path)}`} class="untangled-pr-file-tree-entry untangled-pr-file-tree-link" title={file().path} onClick={(event) => scrollToDiffFile(file().path, event)} > <FileText class="size-4 shrink-0" /> <span class="truncate">{props.node.name}</span> <Show when={isReviewed()}> <span class="review-indicator text-green-600 dark:text-green-400 flex-shrink-0 ml-1"> ✓ </span> </Show> </a> ); }} </Show> </div>);
const DiffStatPill: Component<{ additions: number; deletions: number }> = (props) => ( <div class="untangled-diff-stat-pill"> <Show when={props.additions > 0}> <span class="untangled-diff-stat-add">+{props.additions}</span> </Show> <Show when={props.deletions > 0}> <span class="untangled-diff-stat-del">-{props.deletions}</span> </Show> </div>);
const SidebarToggleButton: Component<{ visible: boolean; onToggle: () => void; class?: string; ariaLabelCollapse: string; ariaLabelExpand: string; panel?: 'left' | 'right';}> = (props) => ( <button type="button" class={props.class} aria-label={props.visible ? props.ariaLabelCollapse : props.ariaLabelExpand} aria-pressed={props.visible} onClick={props.onToggle} > <Show when={props.visible} fallback={ props.panel === 'right' ? <PanelRightOpen class="size-4" /> : <PanelLeftOpen class="size-4" /> }> {props.panel === 'right' ? <PanelRightClose class="size-4" /> : <PanelLeftClose class="size-4" />} </Show> </button>);
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) => ( <details id={diffFileElementId(props.file.path)} class={clsx(props.className, props.isReviewed && 'opacity-60')} open={props.open} onToggle={props.onToggle ? (e) => props.onToggle?.(e.currentTarget.open) : undefined} > <summary class={props.headerClassName}> <div class="flex min-w-0 items-center gap-2"> <ChevronRight class={props.chevronClassName} /> <DiffStatPill additions={props.file.additions} deletions={props.file.deletions} /> <span class={clsx('truncate', props.pathClassName)}>{props.file.path}</span> </div> <Show when={props.onToggleReview}> <label class="flex shrink-0 items-center gap-1 text-xs text-gray-400 dark:text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer" title={props.isReviewed ? 'Mark as unreviewed' : 'Mark as reviewed'} onClick={(e) => { e.stopPropagation(); }} > <input type="checkbox" class="sr-only" checked={props.isReviewed} onChange={props.onToggleReview} /> <Show when={props.isReviewed} fallback={<Circle class="size-4" />} > <CircleCheck class="size-4 text-green-600 dark:text-green-400" /> </Show> <span class="hidden md:inline">reviewed</span> </label> </Show> </summary> <Show when={props.showEllipsis}> <div class="untangled-pr-diff-ellipsis">...</div> </Show> <CodeView text={props.file.text} wrap={props.wrap} maxHeight={props.maxHeight} diff path={props.file.path} /> </details>);
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 ( <div class={clsx('untangled-diff-view', props.variant === 'commit' && 'untangled-diff-view-commit')}> <div class="untangled-diff-panel"> <div class="untangled-diff-toolbar"> <div class="flex min-w-0 items-center gap-2"> <Show when={showFileTree()}> <SidebarToggleButton visible={filesVisible()} onToggle={() => setFilesVisible(!filesVisible())} class="untangled-diff-sidebar-toggle" ariaLabelCollapse="collapse changed files sidebar" ariaLabelExpand="expand changed files sidebar" /> </Show> <DiffToolbarStats additions={stats().additions} deletions={stats().deletions} fileCount={files().length} /> </div> <div class="flex items-center gap-2"> <DiffCollapseButton expanded={expanded()} onToggle={() => setExpanded(!expanded())} /> <div class="untangled-diff-mode-group"> <button type="button" class="untangled-diff-mode-button untangled-diff-mode-button-active"> <FileText class="w-4 h-4" /> unified </button> <button type="button" class="untangled-diff-mode-button" disabled title="Split diff is not available yet"> <Columns2 class="w-4 h-4" /> split </button> </div> </div> </div> <div class={clsx('untangled-diff-panel-body', showFileTree() && filesVisible() && 'untangled-diff-panel-body-with-tree')}> <Show when={showFileTree() && filesVisible()}> <aside class="untangled-pr-file-tree" aria-label="changed files"> <DiffFileTreeNodes nodes={tree()} /> </aside> </Show>
<div class={showFileTree() ? 'untangled-pr-diff-files' : 'untangled-diff-files'}> <For each={visibleFiles()}> {(file) => ( <DiffFileEntry file={file} open={expanded()} wrap={props.wrap} maxHeight={props.maxHeight} className="untangled-diff-file" headerClassName="untangled-diff-file-header" chevronClassName="untangled-diff-chevron size-4 shrink-0" pathClassName="font-mono text-sm text-gray-900 dark:text-gray-100" /> )} </For> <Show when={hiddenFiles().length > 0}> <details class="untangled-diff-show-more"> <summary class="untangled-diff-show-more-summary"> <span class="untangled-diff-show-more-closed"> <ChevronRight class="untangled-diff-chevron size-4 shrink-0" /> Show {hiddenFiles().length} more file{hiddenFiles().length === 1 ? '' : 's'} </span> <span class="untangled-diff-show-more-open"> <ChevronRight class="untangled-diff-chevron size-4 shrink-0" /> Hide {hiddenFiles().length} file{hiddenFiles().length === 1 ? '' : 's'} </span> </summary> <div class="untangled-diff-show-more-files"> <For each={hiddenFiles()}> {(file) => ( <DiffFileEntry file={file} open={expanded()} wrap={props.wrap} maxHeight={props.maxHeight} className="untangled-diff-file" headerClassName="untangled-diff-file-header" chevronClassName="untangled-diff-chevron size-4 shrink-0" pathClassName="font-mono text-sm text-gray-900 dark:text-gray-100" /> )} </For> </div> </details> </Show> <Show when={files().length === 0}> <div class="px-4 py-8 text-center text-sm text-gray-400">No differences found.</div> </Show> </div> </div> </div> </div> );};
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<string> => { 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<Set<string>>(new Set());
createEffect(() => { setReviewed(loadReviewed()); });
const saveReviewed = (newSet: Set<string>) => { 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<Record<string, boolean>>({}); const [hasSetInitial, setHasSetInitial] = createSignal(false);
createEffect(() => { if (files().length > 0) { const isExpanded = expanded(); const next: Record<string, boolean> = {}; 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 ( <section class="untangled-pr-diff-breakout"> {/* backdrop overlay - only visible on mobile when open */} <div id="bottomSheetBackdrop" class={clsx( "fixed inset-0 bg-black/50 md:hidden opacity-0 pointer-events-none transition-opacity duration-300 z-40", props.mobileDrawerOpen && "opacity-100 pointer-events-auto" )} onClick={() => props.onMobileDrawerClose?.()} /> <div class={clsx( 'untangled-pr-diff-toolbar', !filesVisible() && 'untangled-pr-diff-toolbar-sidebar-collapsed', !historyVisible() && 'untangled-pr-diff-toolbar-history-collapsed', )} > <div class="untangled-pr-diff-toolbar-main"> <SidebarToggleButton visible={filesVisible()} onToggle={() => setFilesVisible(!filesVisible())} class="untangled-pr-sidebar-toggle" ariaLabelCollapse="collapse changed files sidebar" ariaLabelExpand="expand changed files sidebar" /> <DiffStatPill additions={stats().additions} deletions={stats().deletions} /> <span class={clsx( 'text-xs font-medium transition-colors duration-150 hidden sm:inline-block', reviewed().size === files().length && files().length > 0 ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400' )} > {reviewed().size > 0 ? `${reviewed().size}/${files().length} file${files().length === 1 ? '' : 's'} reviewed` : `${files().length} changed file${files().length === 1 ? '' : 's'}` } </span> <span class="hidden h-5 w-px bg-gray-300 dark:bg-gray-700 sm:inline-block" /> <span class="uppercase tracking-wide hidden sm:inline-block">diff</span> <span class="rounded border border-gray-300 px-2 py-0.5 font-mono text-xs text-gray-700 dark:border-gray-600 dark:text-gray-200"> {props.roundLabel} </span> </div> <div class="untangled-pr-diff-toolbar-actions flex items-center gap-2"> <DiffCollapseButton expanded={expanded()} onToggle={() => setExpanded(!expanded())} /> <div class="untangled-diff-mode-group"> <button type="button" class="untangled-diff-mode-button untangled-diff-mode-button-active"> <FileText class="w-4 h-4" /> <span class="hidden md:inline">unified</span> </button> <button type="button" class="untangled-diff-mode-button" disabled title="Split diff is not available yet"> <Columns2 class="w-4 h-4" /> <span class="hidden md:inline">split</span> </button> </div> </div> <div class="untangled-pr-diff-toolbar-history"> <SidebarToggleButton visible={historyVisible()} onToggle={() => setHistoryVisible(!historyVisible())} class="untangled-pr-sidebar-toggle" ariaLabelCollapse="collapse history panel" ariaLabelExpand="expand history panel" panel="right" /> </div> </div>
<div class={clsx( 'untangled-pr-diff-grid', !filesVisible() && 'untangled-pr-diff-grid-sidebar-collapsed', !historyVisible() && 'untangled-pr-diff-grid-history-collapsed', )} > <Show when={filesVisible()}> <aside class="untangled-pr-file-tree" aria-label="changed files"> <DiffFileTreeNodes nodes={tree()} reviewedFiles={reviewed} /> </aside> </Show>
<div class="untangled-pr-diff-files"> <For each={files()}> {(file) => ( <DiffFileEntry file={file} open={Boolean(localOpen()[file.path])} onToggle={(open) => { 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 /> )} </For> </div>
<Show when={historyVisible()}> <aside class={clsx( "untangled-pr-history", "fixed bottom-0 left-0 right-0 z-50 w-full md:static md:z-auto md:max-h-screen md:sticky md:top-12 overflow-hidden", props.mobileDrawerOpen && "open" )} > {props.history} </aside> </Show> </div> </section> );};