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, '"')
.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>{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;
}> = (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 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">
<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',
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">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">
<DiffCollapseButton expanded={expanded()} onToggle={() => setExpanded(!expanded())} />
</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="untangled-pr-history">{props.history}</aside>
</Show>
</div>
</section>
);
};