csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

refactor into components more, cleanup code, use full highlight.js language set

dawn (May 30, 2026, 7:49 PM +0300) 01cf619b f53af1d1

+1299 -1133
+5 -4
AGENTS.md
··· 32 32 - `src/pages/repo/pulls.tsx`: pull list, new pull, pull detail, and patch/diff rendering calls. 33 33 - `src/pages/repo/pulls-helpers.ts`: pull-specific pure helpers such as state filter parsing. 34 34 - `src/components/common.tsx`: generic UI primitives such as `Avatar`, `LoadingState`, `StateBadge`, and form/button/card style helpers. 35 - - `src/components/repo.tsx`: repo presentation components such as tabs, file rows, README/comment cards, `CodeView`, `DiffView`, branch pills, and repo skeletons. 35 + - `src/components/repo.tsx`: repo presentation components such as tabs, file rows, README/comment cards, branch pills, and repo skeletons. 36 + - `src/components/code-view.tsx`: code and diff presentation components, highlighting helper functions, `CodeView`, `DiffView`, and `PullDiffView`. 36 37 - `src/lib/api.ts`: compatibility facade that re-exports domain APIs. Do not add implementation here. 37 38 - `src/lib/api/constants.ts`: public service URLs and OAuth scope exports. 38 39 - `src/lib/api/appview.ts`: appview JSON transport, fallback, and appview response types. ··· 108 109 109 110 - Start from the matching upstream template in `../tangled-upstream/appview/pages/templates/...`, then check nearby Go/router files when behavior or icon names are data-driven. 110 111 - For repo chrome, `templates/layouts/repobase.html` is the main reference. Confirm header grouping, action buttons, tab icons, label spacing, font sizes, and vertical padding against that file before patching `src/pages/repo/shared.tsx`. 111 - - For code/blob views, compare `templates/repo/index.html` and `templates/repo/blob.html` before changing `src/pages/repo/code.tsx`, `src/components/repo.tsx`, or file-view CSS. 112 + - For code/blob views, compare `templates/repo/index.html` and `templates/repo/blob.html` before changing `src/pages/repo/code.tsx`, `src/components/code-view.tsx`, or file-view CSS. 112 113 - Treat screenshots as prompts to inspect upstream source, not as the only source of truth. 113 114 - Small differences such as `gap-*`, `py-*`, icon choice, and short-rev styling matter. 114 115 - Remove or hide upstream controls that are not implemented locally instead of shipping fake UI. ··· 132 133 - Preserve the `.untangled-shell` wrapper in `RootShell`; widening pages changes the app layout. 133 134 - Do not add a footer unless explicitly requested. 134 135 - File/tree ordering should be folders first, then files alphabetically. This is implemented in `sortedTreeEntries` in `src/lib/repo-utils.ts`. 135 - - File/blob rendering uses `CodeView` in `src/components/repo.tsx`. 136 + - File/blob rendering uses `CodeView` in `src/components/code-view.tsx`. 136 137 - PR diffs use `DiffView`, which parses unified patches into collapsible per-file panels and reuses `CodeView`. 137 138 - Repo overview and blob pages should resolve `HEAD` to the default branch for user-facing links when possible. 138 139 - Keep unimplemented upstream features visually inert rather than adding fake behavior. ··· 153 154 - Keep repo-route behavior in `src/pages/repo/*.tsx`. 154 155 - Keep repo-route pure helpers in nearby `src/pages/repo/*-helpers.ts` files. 155 156 - Keep generic display components in `src/components/common.tsx`. 156 - - Keep repo-specific display components in `src/components/repo.tsx`. 157 + - Keep repo-specific display components in `src/components/repo.tsx` and `src/components/code-view.tsx`. 157 158 - Keep shared pure repo helpers in `src/lib/repo-utils.ts`. 158 159 - Do not add large page logic back into `src/App.tsx`. 159 160 - Do not remove existing behavior while restructuring.
+850
src/components/code-view.tsx
··· 1 + import clsx from 'clsx'; 2 + import hljs from 'highlight.js'; 3 + import { For, Show, createSignal, createMemo, createEffect, onCleanup, onMount, type Component, type JSX } from 'solid-js'; 4 + import { 5 + ChevronRight, 6 + Circle, 7 + CircleCheck, 8 + Columns2, 9 + FileText, 10 + FoldVertical, 11 + Folder, 12 + PanelLeftClose, 13 + PanelLeftOpen, 14 + PanelRightClose, 15 + PanelRightOpen, 16 + UnfoldVertical, 17 + } from 'lucide-solid'; 18 + 19 + const splitCodeLines = (value: string): string[] => { 20 + const normalized = value.endsWith('\n') ? value.slice(0, -1) : value; 21 + return normalized.length === 0 ? [''] : normalized.split('\n'); 22 + }; 23 + 24 + const escapeHtml = (value: string) => 25 + value 26 + .replace(/&/g, '&amp;') 27 + .replace(/</g, '&lt;') 28 + .replace(/>/g, '&gt;') 29 + .replace(/"/g, '&quot;') 30 + .replace(/'/g, '&#39;'); 31 + 32 + const customExtensionLanguages: Record<string, string> = { 33 + cjs: 'javascript', 34 + mjs: 'javascript', 35 + cts: 'typescript', 36 + mts: 'typescript', 37 + conf: 'ini', 38 + env: 'ini', 39 + }; 40 + 41 + const filenameLanguages: Record<string, string> = { 42 + dockerfile: 'bash', 43 + gemfile: 'ruby', 44 + makefile: 'makefile', 45 + rakefile: 'ruby', 46 + }; 47 + 48 + const languageFromPath = (path?: string) => { 49 + if (!path) return undefined; 50 + 51 + const filename = path.split('/').pop()?.toLowerCase() ?? ''; 52 + if (filenameLanguages[filename]) return filenameLanguages[filename]; 53 + 54 + const extension = filename.includes('.') ? filename.split('.').pop() : filename; 55 + if (!extension) return undefined; 56 + 57 + // Check if highlight.js registers this extension directly or as a built-in alias 58 + if (hljs.getLanguage(extension)) { 59 + return extension; 60 + } 61 + 62 + return customExtensionLanguages[extension] ?? extension; 63 + }; 64 + 65 + const resolveHighlightLanguage = (language?: string, path?: string) => { 66 + const candidate = (language ?? languageFromPath(path))?.toLowerCase(); 67 + if (!candidate || candidate === 'plaintext' || candidate === 'text') return undefined; 68 + return hljs.getLanguage(candidate) ? candidate : undefined; 69 + }; 70 + 71 + const closeOpenSpans = (count: number) => '</span>'.repeat(count); 72 + 73 + const splitHighlightedHtmlLines = (html: string): string[] => { 74 + const lineParts = html.split('\n'); 75 + const activeTags: string[] = []; 76 + 77 + return lineParts.map((linePart) => { 78 + const prefix = activeTags.join(''); 79 + 80 + for (const tag of linePart.match(/<\/?span\b[^>]*>/g) ?? []) { 81 + if (tag.startsWith('</')) { 82 + activeTags.pop(); 83 + } else { 84 + activeTags.push(tag); 85 + } 86 + } 87 + 88 + return `${prefix}${linePart}${closeOpenSpans(activeTags.length)}`; 89 + }); 90 + }; 91 + 92 + const highlightCodeLines = (text: string, language?: string): string[] => { 93 + const normalized = text.endsWith('\n') ? text.slice(0, -1) : text; 94 + const fallback = () => splitCodeLines(normalized).map(escapeHtml); 95 + 96 + if (!language) return fallback(); 97 + 98 + try { 99 + const highlighted = hljs.highlight(normalized, { language, ignoreIllegals: true }).value; 100 + return splitHighlightedHtmlLines(highlighted); 101 + } catch { 102 + return fallback(); 103 + } 104 + }; 105 + 106 + const diffMetadataLine = (line: string) => 107 + line.startsWith('diff --git ') || 108 + line.startsWith('index ') || 109 + line.startsWith('---') || 110 + line.startsWith('+++') || 111 + line.startsWith('@@') || 112 + line.startsWith('Binary files ') || 113 + line.startsWith('new file mode ') || 114 + line.startsWith('deleted file mode ') || 115 + line.startsWith('old mode ') || 116 + line.startsWith('new mode ') || 117 + line.startsWith('similarity index ') || 118 + line.startsWith('dissimilarity index ') || 119 + line.startsWith('rename from ') || 120 + line.startsWith('rename to '); 121 + 122 + const highlightDiffLine = (line: string, language?: string) => { 123 + if (diffMetadataLine(line)) return escapeHtml(line); 124 + 125 + const marker = line[0]; 126 + const isChangedLine = marker === '+' || marker === '-' || marker === ' '; 127 + if (!isChangedLine) return escapeHtml(line); 128 + 129 + const content = line.slice(1); 130 + const highlighted = highlightCodeLines(content, language)[0] ?? escapeHtml(content); 131 + return `<span class="untangled-code-diff-op">${escapeHtml(marker)}</span>${highlighted}`; 132 + }; 133 + 134 + /** Parse a URL hash like #L5 or #L3-L8 into {start, end} (1-based, inclusive). */ 135 + const parseLineHash = (hash: string): { start: number; end: number } | null => { 136 + const m = /^#L(\d+)(?:-L?(\d+))?$/.exec(hash); 137 + if (!m) return null; 138 + const start = parseInt(m[1], 10); 139 + const end = m[2] ? parseInt(m[2], 10) : start; 140 + if (isNaN(start) || isNaN(end)) return null; 141 + return { start: Math.min(start, end), end: Math.max(start, end) }; 142 + }; 143 + 144 + export const CodeView: Component<{ 145 + text: string; 146 + id?: string; 147 + wrap?: boolean; 148 + maxHeight?: string; 149 + diff?: boolean; 150 + path?: string; 151 + language?: string; 152 + }> = (props) => { 153 + const lines = createMemo(() => splitCodeLines(props.text)); 154 + const highlightedLines = createMemo(() => { 155 + const language = resolveHighlightLanguage(props.language, props.path); 156 + if (props.diff) return lines().map((line) => highlightDiffLine(line, language)); 157 + return highlightCodeLines(props.text, language); 158 + }); 159 + const lineClass = (line: string) => 160 + props.diff 161 + ? clsx( 162 + line.startsWith('+') && !line.startsWith('+++') && 'untangled-code-line-add', 163 + line.startsWith('-') && !line.startsWith('---') && 'untangled-code-line-del', 164 + line.startsWith('@@') && 'untangled-code-line-hunk', 165 + ) 166 + : undefined; 167 + 168 + // Line selection state (only used in non-diff mode) 169 + const [selection, setSelection] = createSignal<{ start: number; end: number } | null>(null); 170 + // The line number of the last plain click (anchor for shift-click ranges) 171 + let anchorLine: number | null = null; 172 + 173 + const applyHashSelection = (scroll = false) => { 174 + const parsed = parseLineHash(window.location.hash); 175 + setSelection(parsed); 176 + if (scroll && parsed) { 177 + requestAnimationFrame(() => { 178 + const target = document.getElementById(`L${parsed.start}`); 179 + target?.scrollIntoView({ behavior: 'instant', block: 'center' }); 180 + }); 181 + } 182 + }; 183 + 184 + const handleLineNumberClick = (lineNum: number, event: MouseEvent) => { 185 + event.preventDefault(); 186 + let hash: string; 187 + if (event.shiftKey && anchorLine !== null) { 188 + const lo = Math.min(anchorLine, lineNum); 189 + const hi = Math.max(anchorLine, lineNum); 190 + hash = `#L${lo}-L${hi}`; 191 + } else { 192 + anchorLine = lineNum; 193 + hash = `#L${lineNum}`; 194 + } 195 + history.pushState(null, '', hash); 196 + setSelection(parseLineHash(hash)); 197 + }; 198 + 199 + onMount(() => { 200 + if (props.diff) return; 201 + 202 + applyHashSelection(true); 203 + 204 + const onHashChange = () => applyHashSelection(false); 205 + window.addEventListener('hashchange', onHashChange); 206 + window.addEventListener('popstate', onHashChange); 207 + onCleanup(() => { 208 + window.removeEventListener('hashchange', onHashChange); 209 + window.removeEventListener('popstate', onHashChange); 210 + }); 211 + }); 212 + 213 + const isLineHighlighted = (lineNum: number) => { 214 + const s = selection(); 215 + if (!s) return false; 216 + return lineNum >= s.start && lineNum <= s.end; 217 + }; 218 + 219 + return ( 220 + <div class="untangled-code-scroll" style={{ 'max-height': props.maxHeight }}> 221 + <div 222 + id={props.id} 223 + class={clsx('untangled-code-view', props.wrap && 'untangled-code-view-wrap')} 224 + > 225 + <For each={lines()}> 226 + {(line, index) => { 227 + const lineNum = index() + 1; 228 + return ( 229 + <div class={clsx('untangled-code-line', lineClass(line), !props.diff && isLineHighlighted(lineNum) && 'untangled-code-line--hl')}> 230 + <a 231 + id={`L${lineNum}`} 232 + href={`#L${lineNum}`} 233 + class="untangled-code-line-number" 234 + onClick={props.diff ? undefined : (e) => handleLineNumberClick(lineNum, e)} 235 + > 236 + {lineNum} 237 + </a> 238 + <span class="untangled-code-line-content" innerHTML={highlightedLines()[index()] || ' '} /> 239 + </div> 240 + ); 241 + }} 242 + </For> 243 + </div> 244 + </div> 245 + ); 246 + }; 247 + 248 + interface DiffFile { 249 + path: string; 250 + text: string; 251 + additions: number; 252 + deletions: number; 253 + } 254 + 255 + const parseDiffFiles = (patch: string): DiffFile[] => { 256 + const files: DiffFile[] = []; 257 + let current: { path: string; lines: string[]; additions: number; deletions: number } | null = null; 258 + 259 + const pushCurrent = () => { 260 + if (!current) return; 261 + const hasRenderableDiff = current.lines.some( 262 + (line) => 263 + line.startsWith('@@') || 264 + line.startsWith('Binary files ') || 265 + line.startsWith('new file mode ') || 266 + line.startsWith('deleted file mode ') || 267 + line.startsWith('old mode ') || 268 + line.startsWith('similarity index ') || 269 + line.startsWith('rename from '), 270 + ); 271 + if (!hasRenderableDiff && current.additions === 0 && current.deletions === 0) { 272 + current = null; 273 + return; 274 + } 275 + files.push({ 276 + path: current.path, 277 + text: current.lines.join('\n'), 278 + additions: current.additions, 279 + deletions: current.deletions, 280 + }); 281 + }; 282 + 283 + for (const line of splitCodeLines(patch)) { 284 + if (line.startsWith('diff --git ')) { 285 + pushCurrent(); 286 + const match = /^diff --git a\/(.*?) b\/(.*)$/.exec(line); 287 + current = { 288 + path: match?.[2] ?? line.replace('diff --git ', ''), 289 + lines: [line], 290 + additions: 0, 291 + deletions: 0, 292 + }; 293 + continue; 294 + } 295 + 296 + if (!current) { 297 + current = { path: 'patch', lines: [], additions: 0, deletions: 0 }; 298 + } 299 + 300 + if (line.startsWith('+') && !line.startsWith('+++')) current.additions += 1; 301 + if (line.startsWith('-') && !line.startsWith('---')) current.deletions += 1; 302 + current.lines.push(line); 303 + } 304 + 305 + pushCurrent(); 306 + return files; 307 + }; 308 + 309 + const changedFilesLabel = (count: number) => `${count} changed file${count === 1 ? '' : 's'}`; 310 + 311 + const DiffToolbarStats: Component<{ additions: number; deletions: number; fileCount: number; class?: string }> = (props) => ( 312 + <> 313 + <DiffStatPill additions={props.additions} deletions={props.deletions} /> 314 + <span class={props.class ?? 'text-xs text-gray-600 dark:text-gray-300'}>{changedFilesLabel(props.fileCount)}</span> 315 + </> 316 + ); 317 + 318 + const DiffCollapseButton: Component<{ expanded: boolean; onToggle: () => void }> = (props) => ( 319 + <button type="button" class="untangled-diff-tool-button" onClick={props.onToggle}> 320 + <Show when={props.expanded} fallback={<UnfoldVertical class="size-4" />}> 321 + <FoldVertical class="size-4" /> 322 + </Show> 323 + <span>{props.expanded ? 'collapse all' : 'expand all'}</span> 324 + </button> 325 + ); 326 + 327 + const totalDiffStats = (files: DiffFile[]) => 328 + files.reduce( 329 + (acc, file) => ({ 330 + additions: acc.additions + file.additions, 331 + deletions: acc.deletions + file.deletions, 332 + }), 333 + { additions: 0, deletions: 0 }, 334 + ); 335 + 336 + type DiffFileTreeNode = { 337 + name: string; 338 + path: string; 339 + file?: DiffFile; 340 + children?: DiffFileTreeNode[]; 341 + }; 342 + 343 + const sortDiffFileTree = (nodes: DiffFileTreeNode[]): DiffFileTreeNode[] => 344 + nodes 345 + .sort((a, b) => a.name.localeCompare(b.name)) 346 + .map((node) => ({ 347 + ...node, 348 + children: node.children ? sortDiffFileTree(node.children) : undefined, 349 + })); 350 + 351 + const diffFileTree = (files: DiffFile[]) => { 352 + const roots: DiffFileTreeNode[] = []; 353 + 354 + for (const file of files) { 355 + const parts = file.path.split('/').filter(Boolean); 356 + const pathParts = parts.length > 0 ? parts : [file.path || 'patch']; 357 + let siblings = roots; 358 + let path = ''; 359 + 360 + for (const [index, part] of pathParts.entries()) { 361 + path = path ? `${path}/${part}` : part; 362 + const isFile = index === pathParts.length - 1; 363 + 364 + if (isFile) { 365 + siblings.push({ name: part, path: file.path, file }); 366 + continue; 367 + } 368 + 369 + let node = siblings.find((child) => !child.file && child.name === part); 370 + if (!node) { 371 + node = { name: part, path, children: [] }; 372 + siblings.push(node); 373 + } 374 + 375 + siblings = node.children!; 376 + } 377 + } 378 + 379 + return sortDiffFileTree(roots); 380 + }; 381 + 382 + const diffFileElementId = (path: string) => 383 + `diff-${Array.from(path, (char) => char.codePointAt(0)!.toString(16).padStart(4, '0')).join('-')}`; 384 + 385 + const scrollToDiffFile = (path: string, event: MouseEvent) => { 386 + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; 387 + 388 + const target = document.getElementById(diffFileElementId(path)); 389 + if (!target) return; 390 + 391 + event.preventDefault(); 392 + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); 393 + 394 + const hash = `#${target.id}`; 395 + if (window.location.hash !== hash) { 396 + window.history.pushState(null, '', `${window.location.pathname}${window.location.search}${hash}`); 397 + } 398 + }; 399 + 400 + const DiffFileTreeNodes: Component<{ 401 + nodes: DiffFileTreeNode[]; 402 + reviewedFiles?: () => Set<string>; 403 + }> = (props) => ( 404 + <div class="untangled-pr-file-tree-list"> 405 + <For each={props.nodes}> 406 + {(node) => <DiffFileTreeNodeView node={node} reviewedFiles={props.reviewedFiles} />} 407 + </For> 408 + </div> 409 + ); 410 + 411 + const DiffFileTreeNodeView: Component<{ 412 + node: DiffFileTreeNode; 413 + reviewedFiles?: () => Set<string>; 414 + }> = (props) => ( 415 + <div class="untangled-pr-file-tree-node"> 416 + <Show 417 + when={props.node.file} 418 + fallback={ 419 + <> 420 + <div class="untangled-pr-file-tree-entry" title={props.node.path}> 421 + <Folder class="size-4 shrink-0" /> 422 + <span class="truncate">{props.node.name}</span> 423 + </div> 424 + <Show when={props.node.children?.length}> 425 + <div class="untangled-pr-file-tree-children"> 426 + <DiffFileTreeNodes nodes={props.node.children!} reviewedFiles={props.reviewedFiles} /> 427 + </div> 428 + </Show> 429 + </> 430 + } 431 + > 432 + {(file) => { 433 + const isReviewed = () => props.reviewedFiles?.().has(file().path) ?? false; 434 + return ( 435 + <a 436 + href={`#${diffFileElementId(file().path)}`} 437 + class="untangled-pr-file-tree-entry untangled-pr-file-tree-link" 438 + title={file().path} 439 + onClick={(event) => scrollToDiffFile(file().path, event)} 440 + > 441 + <FileText class="size-4 shrink-0" /> 442 + <span class="truncate">{props.node.name}</span> 443 + <Show when={isReviewed()}> 444 + <span class="review-indicator text-green-600 dark:text-green-400 flex-shrink-0 ml-1"> 445 + 446 + </span> 447 + </Show> 448 + </a> 449 + ); 450 + }} 451 + </Show> 452 + </div> 453 + ); 454 + 455 + const DiffStatPill: Component<{ additions: number; deletions: number }> = (props) => ( 456 + <div class="untangled-diff-stat-pill"> 457 + <Show when={props.additions > 0}> 458 + <span class="untangled-diff-stat-add">+{props.additions}</span> 459 + </Show> 460 + <Show when={props.deletions > 0}> 461 + <span class="untangled-diff-stat-del">-{props.deletions}</span> 462 + </Show> 463 + </div> 464 + ); 465 + 466 + const SidebarToggleButton: Component<{ 467 + visible: boolean; 468 + onToggle: () => void; 469 + class?: string; 470 + ariaLabelCollapse: string; 471 + ariaLabelExpand: string; 472 + panel?: 'left' | 'right'; 473 + }> = (props) => ( 474 + <button 475 + type="button" 476 + class={props.class} 477 + aria-label={props.visible ? props.ariaLabelCollapse : props.ariaLabelExpand} 478 + aria-pressed={props.visible} 479 + onClick={props.onToggle} 480 + > 481 + <Show when={props.visible} fallback={ 482 + props.panel === 'right' ? <PanelRightOpen class="size-4" /> : <PanelLeftOpen class="size-4" /> 483 + }> 484 + {props.panel === 'right' ? <PanelRightClose class="size-4" /> : <PanelLeftClose class="size-4" />} 485 + </Show> 486 + </button> 487 + ); 488 + 489 + const DiffFileEntry: Component<{ 490 + file: DiffFile; 491 + open: boolean; 492 + onToggle?: (open: boolean) => void; 493 + isReviewed?: boolean; 494 + onToggleReview?: () => void; 495 + wrap?: boolean; 496 + maxHeight?: string; 497 + className?: string; 498 + headerClassName?: string; 499 + chevronClassName?: string; 500 + pathClassName?: string; 501 + showEllipsis?: boolean; 502 + }> = (props) => ( 503 + <details 504 + id={diffFileElementId(props.file.path)} 505 + class={clsx(props.className, props.isReviewed && 'opacity-60')} 506 + open={props.open} 507 + onToggle={props.onToggle ? (e) => props.onToggle?.(e.currentTarget.open) : undefined} 508 + > 509 + <summary class={props.headerClassName}> 510 + <div class="flex min-w-0 items-center gap-2"> 511 + <ChevronRight class={props.chevronClassName} /> 512 + <DiffStatPill additions={props.file.additions} deletions={props.file.deletions} /> 513 + <span class={clsx('truncate', props.pathClassName)}>{props.file.path}</span> 514 + </div> 515 + <Show when={props.onToggleReview}> 516 + <label 517 + 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" 518 + title={props.isReviewed ? 'Mark as unreviewed' : 'Mark as reviewed'} 519 + onClick={(e) => { 520 + e.stopPropagation(); 521 + }} 522 + > 523 + <input 524 + type="checkbox" 525 + class="sr-only" 526 + checked={props.isReviewed} 527 + onChange={props.onToggleReview} 528 + /> 529 + <Show 530 + when={props.isReviewed} 531 + fallback={<Circle class="size-4" />} 532 + > 533 + <CircleCheck class="size-4 text-green-600 dark:text-green-400" /> 534 + </Show> 535 + <span class="hidden md:inline">reviewed</span> 536 + </label> 537 + </Show> 538 + </summary> 539 + <Show when={props.showEllipsis}> 540 + <div class="untangled-pr-diff-ellipsis">...</div> 541 + </Show> 542 + <CodeView text={props.file.text} wrap={props.wrap} maxHeight={props.maxHeight} diff path={props.file.path} /> 543 + </details> 544 + ); 545 + 546 + export const DiffView: Component<{ 547 + patch: string; 548 + maxHeight?: string; 549 + wrap?: boolean; 550 + defaultExpanded?: boolean; 551 + showFileTree?: boolean; 552 + variant?: 'default' | 'commit'; 553 + }> = (props) => { 554 + const files = createMemo(() => parseDiffFiles(props.patch)); 555 + const stats = createMemo(() => totalDiffStats(files())); 556 + const [expanded, setExpanded] = createSignal(props.defaultExpanded ?? false); 557 + const [filesVisible, setFilesVisible] = createSignal(true); 558 + const previewLimit = 5; 559 + const showFileTree = createMemo(() => Boolean(props.showFileTree && files().length > 0)); 560 + const tree = createMemo(() => diffFileTree(files())); 561 + const visibleFiles = createMemo(() => (showFileTree() ? files() : files().slice(0, previewLimit))); 562 + const hiddenFiles = createMemo(() => (showFileTree() ? [] : files().slice(previewLimit))); 563 + 564 + return ( 565 + <div class={clsx('untangled-diff-view', props.variant === 'commit' && 'untangled-diff-view-commit')}> 566 + <div class="untangled-diff-panel"> 567 + <div class="untangled-diff-toolbar"> 568 + <div class="flex min-w-0 items-center gap-2"> 569 + <Show when={showFileTree()}> 570 + <SidebarToggleButton 571 + visible={filesVisible()} 572 + onToggle={() => setFilesVisible(!filesVisible())} 573 + class="untangled-diff-sidebar-toggle" 574 + ariaLabelCollapse="collapse changed files sidebar" 575 + ariaLabelExpand="expand changed files sidebar" 576 + /> 577 + </Show> 578 + <DiffToolbarStats additions={stats().additions} deletions={stats().deletions} fileCount={files().length} /> 579 + </div> 580 + <div class="flex items-center gap-2"> 581 + <DiffCollapseButton expanded={expanded()} onToggle={() => setExpanded(!expanded())} /> 582 + <div class="untangled-diff-mode-group"> 583 + <button type="button" class="untangled-diff-mode-button untangled-diff-mode-button-active"> 584 + <FileText class="w-4 h-4" /> 585 + unified 586 + </button> 587 + <button type="button" class="untangled-diff-mode-button" disabled title="Split diff is not available yet"> 588 + <Columns2 class="w-4 h-4" /> 589 + split 590 + </button> 591 + </div> 592 + </div> 593 + </div> 594 + <div class={clsx('untangled-diff-panel-body', showFileTree() && filesVisible() && 'untangled-diff-panel-body-with-tree')}> 595 + <Show when={showFileTree() && filesVisible()}> 596 + <aside class="untangled-pr-file-tree" aria-label="changed files"> 597 + <DiffFileTreeNodes nodes={tree()} /> 598 + </aside> 599 + </Show> 600 + 601 + <div class={showFileTree() ? 'untangled-pr-diff-files' : 'untangled-diff-files'}> 602 + <For each={visibleFiles()}> 603 + {(file) => ( 604 + <DiffFileEntry 605 + file={file} 606 + open={expanded()} 607 + wrap={props.wrap} 608 + maxHeight={props.maxHeight} 609 + className="untangled-diff-file" 610 + headerClassName="untangled-diff-file-header" 611 + chevronClassName="untangled-diff-chevron size-4 shrink-0" 612 + pathClassName="font-mono text-sm text-gray-900 dark:text-gray-100" 613 + /> 614 + )} 615 + </For> 616 + <Show when={hiddenFiles().length > 0}> 617 + <details class="untangled-diff-show-more"> 618 + <summary class="untangled-diff-show-more-summary"> 619 + <span class="untangled-diff-show-more-closed"> 620 + <ChevronRight class="untangled-diff-chevron size-4 shrink-0" /> 621 + Show {hiddenFiles().length} more file{hiddenFiles().length === 1 ? '' : 's'} 622 + </span> 623 + <span class="untangled-diff-show-more-open"> 624 + <ChevronRight class="untangled-diff-chevron size-4 shrink-0" /> 625 + Hide {hiddenFiles().length} file{hiddenFiles().length === 1 ? '' : 's'} 626 + </span> 627 + </summary> 628 + <div class="untangled-diff-show-more-files"> 629 + <For each={hiddenFiles()}> 630 + {(file) => ( 631 + <DiffFileEntry 632 + file={file} 633 + open={expanded()} 634 + wrap={props.wrap} 635 + maxHeight={props.maxHeight} 636 + className="untangled-diff-file" 637 + headerClassName="untangled-diff-file-header" 638 + chevronClassName="untangled-diff-chevron size-4 shrink-0" 639 + pathClassName="font-mono text-sm text-gray-900 dark:text-gray-100" 640 + /> 641 + )} 642 + </For> 643 + </div> 644 + </details> 645 + </Show> 646 + <Show when={files().length === 0}> 647 + <div class="px-4 py-8 text-center text-sm text-gray-400">No differences found.</div> 648 + </Show> 649 + </div> 650 + </div> 651 + </div> 652 + </div> 653 + ); 654 + }; 655 + 656 + export const PullDiffView: Component<{ 657 + patch: string; 658 + roundLabel: string; 659 + storageKey: string; 660 + history: JSX.Element; 661 + }> = (props) => { 662 + const files = createMemo(() => parseDiffFiles(props.patch)); 663 + const stats = createMemo(() => totalDiffStats(files())); 664 + const tree = createMemo(() => diffFileTree(files())); 665 + const [expanded, setExpanded] = createSignal(true); 666 + const [filesVisible, setFilesVisible] = createSignal(true); 667 + const [historyVisible, setHistoryVisible] = createSignal(true); 668 + 669 + const loadReviewed = (): Set<string> => { 670 + try { 671 + const data = localStorage.getItem(props.storageKey); 672 + if (!data) return new Set(); 673 + const entry = JSON.parse(data); 674 + return new Set(Array.isArray(entry) ? entry : (entry.files || [])); 675 + } catch { 676 + return new Set(); 677 + } 678 + }; 679 + 680 + const [reviewed, setReviewed] = createSignal<Set<string>>(new Set()); 681 + 682 + createEffect(() => { 683 + setReviewed(loadReviewed()); 684 + }); 685 + 686 + const saveReviewed = (newSet: Set<string>) => { 687 + const currentFilePaths = new Set(files().map((f) => f.path)); 688 + const filesToSave = Array.from(newSet).filter((path) => currentFilePaths.has(path)); 689 + try { 690 + localStorage.setItem( 691 + props.storageKey, 692 + JSON.stringify({ 693 + files: filesToSave, 694 + ts: Date.now(), 695 + }), 696 + ); 697 + } catch (e) { 698 + console.error('Failed to save reviewed files:', e); 699 + } 700 + }; 701 + 702 + const pruneStale = () => { 703 + const now = Date.now(); 704 + const REVIEWED_PREFIX = 'reviewed:'; 705 + const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; 706 + for (let i = 0; i < localStorage.length; i++) { 707 + const key = localStorage.key(i); 708 + if (key && key.startsWith(REVIEWED_PREFIX) && key !== props.storageKey) { 709 + try { 710 + const entry = JSON.parse(localStorage.getItem(key) || '{}'); 711 + if (!entry.ts || now - entry.ts > MAX_AGE_MS) { 712 + localStorage.removeItem(key); 713 + } 714 + } catch { 715 + localStorage.removeItem(key); 716 + } 717 + } 718 + } 719 + }; 720 + 721 + createEffect(() => { 722 + if (Math.random() < 0.1) { 723 + pruneStale(); 724 + } 725 + }); 726 + 727 + const [localOpen, setLocalOpen] = createSignal<Record<string, boolean>>({}); 728 + const [hasSetInitial, setHasSetInitial] = createSignal(false); 729 + 730 + createEffect(() => { 731 + if (files().length > 0) { 732 + const isExpanded = expanded(); 733 + const next: Record<string, boolean> = {}; 734 + for (const f of files()) { 735 + if (!hasSetInitial() && reviewed().has(f.path)) { 736 + next[f.path] = false; 737 + } else { 738 + next[f.path] = isExpanded; 739 + } 740 + } 741 + setLocalOpen(next); 742 + setHasSetInitial(true); 743 + } 744 + }); 745 + 746 + const toggleReview = (path: string) => { 747 + const nextReviewed = new Set(reviewed()); 748 + if (nextReviewed.has(path)) { 749 + nextReviewed.delete(path); 750 + } else { 751 + nextReviewed.add(path); 752 + setLocalOpen((prev) => ({ ...prev, [path]: false })); 753 + } 754 + setReviewed(nextReviewed); 755 + saveReviewed(nextReviewed); 756 + }; 757 + 758 + return ( 759 + <section class="untangled-pr-diff-breakout"> 760 + <div 761 + class={clsx( 762 + 'untangled-pr-diff-toolbar', 763 + !filesVisible() && 'untangled-pr-diff-toolbar-sidebar-collapsed', 764 + !historyVisible() && 'untangled-pr-diff-toolbar-history-collapsed', 765 + )} 766 + > 767 + <div class="untangled-pr-diff-toolbar-main"> 768 + <SidebarToggleButton 769 + visible={filesVisible()} 770 + onToggle={() => setFilesVisible(!filesVisible())} 771 + class="untangled-pr-sidebar-toggle" 772 + ariaLabelCollapse="collapse changed files sidebar" 773 + ariaLabelExpand="expand changed files sidebar" 774 + /> 775 + <DiffStatPill additions={stats().additions} deletions={stats().deletions} /> 776 + <span 777 + class={clsx( 778 + 'text-xs font-medium transition-colors duration-150', 779 + reviewed().size === files().length && files().length > 0 780 + ? 'text-green-600 dark:text-green-400' 781 + : 'text-gray-600 dark:text-gray-400' 782 + )} 783 + > 784 + {reviewed().size > 0 785 + ? `${reviewed().size}/${files().length} file${files().length === 1 ? '' : 's'} reviewed` 786 + : `${files().length} changed file${files().length === 1 ? '' : 's'}` 787 + } 788 + </span> 789 + <span class="hidden h-5 w-px bg-gray-300 dark:bg-gray-700 sm:inline-block" /> 790 + <span class="uppercase tracking-wide">diff</span> 791 + <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"> 792 + {props.roundLabel} 793 + </span> 794 + </div> 795 + <div class="untangled-pr-diff-toolbar-actions"> 796 + <DiffCollapseButton expanded={expanded()} onToggle={() => setExpanded(!expanded())} /> 797 + </div> 798 + <div class="untangled-pr-diff-toolbar-history"> 799 + <SidebarToggleButton 800 + visible={historyVisible()} 801 + onToggle={() => setHistoryVisible(!historyVisible())} 802 + class="untangled-pr-sidebar-toggle" 803 + ariaLabelCollapse="collapse history panel" 804 + ariaLabelExpand="expand history panel" 805 + panel="right" 806 + /> 807 + </div> 808 + </div> 809 + 810 + <div 811 + class={clsx( 812 + 'untangled-pr-diff-grid', 813 + !filesVisible() && 'untangled-pr-diff-grid-sidebar-collapsed', 814 + !historyVisible() && 'untangled-pr-diff-grid-history-collapsed', 815 + )} 816 + > 817 + <Show when={filesVisible()}> 818 + <aside class="untangled-pr-file-tree" aria-label="changed files"> 819 + <DiffFileTreeNodes nodes={tree()} reviewedFiles={reviewed} /> 820 + </aside> 821 + </Show> 822 + 823 + <div class="untangled-pr-diff-files"> 824 + <For each={files()}> 825 + {(file) => ( 826 + <DiffFileEntry 827 + file={file} 828 + open={Boolean(localOpen()[file.path])} 829 + onToggle={(open) => { 830 + setLocalOpen((prev) => ({ ...prev, [file.path]: open })); 831 + }} 832 + isReviewed={reviewed().has(file.path)} 833 + onToggleReview={() => toggleReview(file.path)} 834 + className="untangled-pr-diff-file" 835 + headerClassName="untangled-pr-diff-file-header" 836 + chevronClassName="untangled-pr-diff-chevron size-4 shrink-0" 837 + pathClassName="text-base text-gray-100" 838 + showEllipsis 839 + /> 840 + )} 841 + </For> 842 + </div> 843 + 844 + <Show when={historyVisible()}> 845 + <aside class="untangled-pr-history">{props.history}</aside> 846 + </Show> 847 + </div> 848 + </section> 849 + ); 850 + };
+251 -825
src/components/repo.tsx
··· 3 3 import hljs from 'highlight.js/lib/common'; 4 4 import { 5 5 Check, 6 - ChevronRight, 7 - Circle, 8 - CircleCheck, 9 - Columns2, 10 6 Copy, 11 7 Download, 12 8 ExternalLink, 13 9 FileText, 14 - FoldVertical, 15 - Folder, 16 - PanelLeftClose, 17 - PanelLeftOpen, 18 - PanelRightClose, 19 - PanelRightOpen, 10 + File, 20 11 Square, 21 12 SquareCheckBig, 22 - UnfoldVertical, 23 13 BookMarked, 24 14 GitFork, 25 15 Star, ··· 28 18 } from 'lucide-solid'; 29 19 import { marked } from 'marked'; 30 20 import { A, useNavigate } from '@solidjs/router'; 31 - import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; 21 + import { For, Show, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; 32 22 import type { RepoContext, TreeEntry } from '../lib/api/repos'; 33 23 import type { Did } from '@atcute/lexicons/syntax'; 34 24 import { createQuery } from '@tanstack/solid-query'; ··· 36 26 import { getRepoStarCount } from '../lib/api/stars'; 37 27 import { getRepoIssueCount } from '../lib/api/issues'; 38 28 import { getRepoPullCount } from '../lib/api/pulls'; 39 - import { formatRelativeTime, languageColor, encodePath } from '../lib/repo-utils'; 29 + import { formatRelativeTime, languageColor, encodePath, formatLanguagePercent, blobHref, treeHref } from '../lib/repo-utils'; 40 30 import { getTangledAppviewService } from '../lib/settings'; 41 - import { Avatar, SkeletonBlock, buttonStyles, cardStyles } from './common'; 31 + import { Avatar, SkeletonBlock, StateBadge, PlaceholderAvatar, buttonStyles, cardStyles } from './common'; 42 32 43 33 marked.setOptions({ 44 34 gfm: true, ··· 1020 1010 return <div class={classes()} innerHTML={html()} onClick={onMarkdownClick} />; 1021 1011 }; 1022 1012 1023 - const splitCodeLines = (value: string): string[] => { 1024 - const normalized = value.endsWith('\n') ? value.slice(0, -1) : value; 1025 - return normalized.length === 0 ? [''] : normalized.split('\n'); 1026 - }; 1027 1013 1028 - const escapeHtml = (value: string) => 1029 - value 1030 - .replace(/&/g, '&amp;') 1031 - .replace(/</g, '&lt;') 1032 - .replace(/>/g, '&gt;') 1033 - .replace(/"/g, '&quot;') 1034 - .replace(/'/g, '&#39;'); 1035 - 1036 - const languageAliases: Record<string, string> = { 1037 - cjs: 'javascript', 1038 - conf: 'ini', 1039 - cts: 'typescript', 1040 - cxx: 'cpp', 1041 - dockerfile: 'bash', 1042 - env: 'ini', 1043 - h: 'c', 1044 - hpp: 'cpp', 1045 - htm: 'xml', 1046 - html: 'xml', 1047 - js: 'javascript', 1048 - jsonc: 'json', 1049 - jsx: 'javascript', 1050 - ksh: 'bash', 1051 - m: 'objectivec', 1052 - markdown: 'markdown', 1053 - md: 'markdown', 1054 - mjs: 'javascript', 1055 - mm: 'objectivec', 1056 - mts: 'typescript', 1057 - patch: 'diff', 1058 - pl: 'perl', 1059 - pm: 'perl', 1060 - pyw: 'python', 1061 - rake: 'ruby', 1062 - rs: 'rust', 1063 - sh: 'bash', 1064 - svg: 'xml', 1065 - toml: 'ini', 1066 - ts: 'typescript', 1067 - tsx: 'typescript', 1068 - txt: 'plaintext', 1069 - yml: 'yaml', 1070 - zsh: 'bash', 1071 - }; 1072 - 1073 - const filenameLanguages: Record<string, string> = { 1074 - dockerfile: 'bash', 1075 - gemfile: 'ruby', 1076 - makefile: 'makefile', 1077 - rakefile: 'ruby', 1078 - }; 1079 - 1080 - const languageFromPath = (path?: string) => { 1081 - if (!path) return undefined; 1082 - 1083 - const filename = path.split('/').pop()?.toLowerCase() ?? ''; 1084 - if (filenameLanguages[filename]) return filenameLanguages[filename]; 1085 - 1086 - const extension = filename.includes('.') ? filename.split('.').pop() : filename; 1087 - if (!extension) return undefined; 1088 - 1089 - return languageAliases[extension] ?? extension; 1090 - }; 1091 - 1092 - const resolveHighlightLanguage = (language?: string, path?: string) => { 1093 - const candidate = (language ?? languageFromPath(path))?.toLowerCase(); 1094 - if (!candidate || candidate === 'plaintext' || candidate === 'text') return undefined; 1095 - return hljs.getLanguage(candidate) ? candidate : undefined; 1096 - }; 1097 - 1098 - const closeOpenSpans = (count: number) => '</span>'.repeat(count); 1099 - 1100 - const splitHighlightedHtmlLines = (html: string): string[] => { 1101 - const lineParts = html.split('\n'); 1102 - const activeTags: string[] = []; 1103 - 1104 - return lineParts.map((linePart) => { 1105 - const prefix = activeTags.join(''); 1106 - 1107 - for (const tag of linePart.match(/<\/?span\b[^>]*>/g) ?? []) { 1108 - if (tag.startsWith('</')) { 1109 - activeTags.pop(); 1110 - } else { 1111 - activeTags.push(tag); 1112 - } 1113 - } 1114 - 1115 - return `${prefix}${linePart}${closeOpenSpans(activeTags.length)}`; 1116 - }); 1117 - }; 1118 - 1119 - const highlightCodeLines = (text: string, language?: string): string[] => { 1120 - const normalized = text.endsWith('\n') ? text.slice(0, -1) : text; 1121 - const fallback = () => splitCodeLines(normalized).map(escapeHtml); 1122 - 1123 - if (!language) return fallback(); 1124 - 1125 - try { 1126 - const highlighted = hljs.highlight(normalized, { language, ignoreIllegals: true }).value; 1127 - return splitHighlightedHtmlLines(highlighted); 1128 - } catch { 1129 - return fallback(); 1130 - } 1131 - }; 1132 - 1133 - const diffMetadataLine = (line: string) => 1134 - line.startsWith('diff --git ') || 1135 - line.startsWith('index ') || 1136 - line.startsWith('---') || 1137 - line.startsWith('+++') || 1138 - line.startsWith('@@') || 1139 - line.startsWith('Binary files ') || 1140 - line.startsWith('new file mode ') || 1141 - line.startsWith('deleted file mode ') || 1142 - line.startsWith('old mode ') || 1143 - line.startsWith('new mode ') || 1144 - line.startsWith('similarity index ') || 1145 - line.startsWith('dissimilarity index ') || 1146 - line.startsWith('rename from ') || 1147 - line.startsWith('rename to '); 1148 - 1149 - const highlightDiffLine = (line: string, language?: string) => { 1150 - if (diffMetadataLine(line)) return escapeHtml(line); 1151 - 1152 - const marker = line[0]; 1153 - const isChangedLine = marker === '+' || marker === '-' || marker === ' '; 1154 - if (!isChangedLine) return escapeHtml(line); 1155 - 1156 - const content = line.slice(1); 1157 - const highlighted = highlightCodeLines(content, language)[0] ?? escapeHtml(content); 1158 - return `<span class="untangled-code-diff-op">${escapeHtml(marker)}</span>${highlighted}`; 1159 - }; 1160 - 1161 - /** Parse a URL hash like #L5 or #L3-L8 into {start, end} (1-based, inclusive). */ 1162 - const parseLineHash = (hash: string): { start: number; end: number } | null => { 1163 - const m = /^#L(\d+)(?:-L?(\d+))?$/.exec(hash); 1164 - if (!m) return null; 1165 - const start = parseInt(m[1], 10); 1166 - const end = m[2] ? parseInt(m[2], 10) : start; 1167 - if (isNaN(start) || isNaN(end)) return null; 1168 - return { start: Math.min(start, end), end: Math.max(start, end) }; 1169 - }; 1170 - 1171 - export const CodeView: Component<{ 1172 - text: string; 1173 - id?: string; 1174 - wrap?: boolean; 1175 - maxHeight?: string; 1176 - diff?: boolean; 1177 - path?: string; 1178 - language?: string; 1179 - }> = (props) => { 1180 - const lines = createMemo(() => splitCodeLines(props.text)); 1181 - const highlightedLines = createMemo(() => { 1182 - const language = resolveHighlightLanguage(props.language, props.path); 1183 - if (props.diff) return lines().map((line) => highlightDiffLine(line, language)); 1184 - return highlightCodeLines(props.text, language); 1185 - }); 1186 - const lineClass = (line: string) => 1187 - props.diff 1188 - ? clsx( 1189 - line.startsWith('+') && !line.startsWith('+++') && 'untangled-code-line-add', 1190 - line.startsWith('-') && !line.startsWith('---') && 'untangled-code-line-del', 1191 - line.startsWith('@@') && 'untangled-code-line-hunk', 1192 - ) 1193 - : undefined; 1194 - 1195 - // Line selection state (only used in non-diff mode) 1196 - const [selection, setSelection] = createSignal<{ start: number; end: number } | null>(null); 1197 - // The line number of the last plain click (anchor for shift-click ranges) 1198 - let anchorLine: number | null = null; 1199 - 1200 - const applyHashSelection = (scroll = false) => { 1201 - const parsed = parseLineHash(window.location.hash); 1202 - setSelection(parsed); 1203 - if (scroll && parsed) { 1204 - requestAnimationFrame(() => { 1205 - const target = document.getElementById(`L${parsed.start}`); 1206 - target?.scrollIntoView({ behavior: 'instant', block: 'center' }); 1207 - }); 1208 - } 1209 - }; 1210 - 1211 - const handleLineNumberClick = (lineNum: number, event: MouseEvent) => { 1212 - event.preventDefault(); 1213 - let hash: string; 1214 - if (event.shiftKey && anchorLine !== null) { 1215 - const lo = Math.min(anchorLine, lineNum); 1216 - const hi = Math.max(anchorLine, lineNum); 1217 - hash = `#L${lo}-L${hi}`; 1218 - } else { 1219 - anchorLine = lineNum; 1220 - hash = `#L${lineNum}`; 1221 - } 1222 - history.pushState(null, '', hash); 1223 - setSelection(parseLineHash(hash)); 1224 - }; 1225 - 1226 - onMount(() => { 1227 - if (props.diff) return; 1228 - 1229 - applyHashSelection(true); 1230 - 1231 - const onHashChange = () => applyHashSelection(false); 1232 - window.addEventListener('hashchange', onHashChange); 1233 - window.addEventListener('popstate', onHashChange); 1234 - onCleanup(() => { 1235 - window.removeEventListener('hashchange', onHashChange); 1236 - window.removeEventListener('popstate', onHashChange); 1237 - }); 1238 - }); 1239 - 1240 - const isLineHighlighted = (lineNum: number) => { 1241 - const s = selection(); 1242 - if (!s) return false; 1243 - return lineNum >= s.start && lineNum <= s.end; 1244 - }; 1245 - 1246 - return ( 1247 - <div class="untangled-code-scroll" style={{ 'max-height': props.maxHeight }}> 1248 - <div 1249 - id={props.id} 1250 - class={clsx('untangled-code-view', props.wrap && 'untangled-code-view-wrap')} 1251 - > 1252 - <For each={lines()}> 1253 - {(line, index) => { 1254 - const lineNum = index() + 1; 1255 - return ( 1256 - <div class={clsx('untangled-code-line', lineClass(line), !props.diff && isLineHighlighted(lineNum) && 'untangled-code-line--hl')}> 1257 - <a 1258 - id={`L${lineNum}`} 1259 - href={`#L${lineNum}`} 1260 - class="untangled-code-line-number" 1261 - onClick={props.diff ? undefined : (e) => handleLineNumberClick(lineNum, e)} 1262 - > 1263 - {lineNum} 1264 - </a> 1265 - <span class="untangled-code-line-content" innerHTML={highlightedLines()[index()] || ' '} /> 1266 - </div> 1267 - ); 1268 - }} 1269 - </For> 1270 - </div> 1271 - </div> 1272 - ); 1273 - }; 1274 - 1275 - interface DiffFile { 1276 - path: string; 1277 - text: string; 1278 - additions: number; 1279 - deletions: number; 1280 - } 1281 - 1282 - const parseDiffFiles = (patch: string): DiffFile[] => { 1283 - const files: DiffFile[] = []; 1284 - let current: { path: string; lines: string[]; additions: number; deletions: number } | null = null; 1285 - 1286 - const pushCurrent = () => { 1287 - if (!current) return; 1288 - const hasRenderableDiff = current.lines.some( 1289 - (line) => 1290 - line.startsWith('@@') || 1291 - line.startsWith('Binary files ') || 1292 - line.startsWith('new file mode ') || 1293 - line.startsWith('deleted file mode ') || 1294 - line.startsWith('old mode ') || 1295 - line.startsWith('similarity index ') || 1296 - line.startsWith('rename from '), 1297 - ); 1298 - if (!hasRenderableDiff && current.additions === 0 && current.deletions === 0) { 1299 - current = null; 1300 - return; 1301 - } 1302 - files.push({ 1303 - path: current.path, 1304 - text: current.lines.join('\n'), 1305 - additions: current.additions, 1306 - deletions: current.deletions, 1307 - }); 1308 - }; 1309 - 1310 - for (const line of splitCodeLines(patch)) { 1311 - if (line.startsWith('diff --git ')) { 1312 - pushCurrent(); 1313 - const match = /^diff --git a\/(.*?) b\/(.*)$/.exec(line); 1314 - current = { 1315 - path: match?.[2] ?? line.replace('diff --git ', ''), 1316 - lines: [line], 1317 - additions: 0, 1318 - deletions: 0, 1319 - }; 1320 - continue; 1321 - } 1322 - 1323 - if (!current) { 1324 - current = { path: 'patch', lines: [], additions: 0, deletions: 0 }; 1325 - } 1326 - 1327 - if (line.startsWith('+') && !line.startsWith('+++')) current.additions += 1; 1328 - if (line.startsWith('-') && !line.startsWith('---')) current.deletions += 1; 1329 - current.lines.push(line); 1330 - } 1331 - 1332 - pushCurrent(); 1333 - return files; 1334 - }; 1335 - 1336 - const changedFilesLabel = (count: number) => `${count} changed file${count === 1 ? '' : 's'}`; 1337 - 1338 - const DiffToolbarStats: Component<{ additions: number; deletions: number; fileCount: number; class?: string }> = (props) => ( 1339 - <> 1340 - <DiffStatPill additions={props.additions} deletions={props.deletions} /> 1341 - <span class={props.class ?? 'text-xs text-gray-600 dark:text-gray-300'}>{changedFilesLabel(props.fileCount)}</span> 1342 - </> 1343 - ); 1344 - 1345 - const DiffCollapseButton: Component<{ expanded: boolean; onToggle: () => void }> = (props) => ( 1346 - <button type="button" class="untangled-diff-tool-button" onClick={props.onToggle}> 1347 - <Show when={props.expanded} fallback={<UnfoldVertical class="size-4" />}> 1348 - <FoldVertical class="size-4" /> 1349 - </Show> 1350 - <span>{props.expanded ? 'collapse all' : 'expand all'}</span> 1351 - </button> 1352 - ); 1353 - 1354 - export const DiffView: Component<{ 1355 - patch: string; 1356 - maxHeight?: string; 1357 - wrap?: boolean; 1358 - defaultExpanded?: boolean; 1359 - showFileTree?: boolean; 1360 - variant?: 'default' | 'commit'; 1361 - }> = (props) => { 1362 - const files = createMemo(() => parseDiffFiles(props.patch)); 1363 - const stats = createMemo(() => totalDiffStats(files())); 1364 - const [expanded, setExpanded] = createSignal(props.defaultExpanded ?? false); 1365 - const [filesVisible, setFilesVisible] = createSignal(true); 1366 - const previewLimit = 5; 1367 - const showFileTree = createMemo(() => Boolean(props.showFileTree && files().length > 0)); 1368 - const tree = createMemo(() => diffFileTree(files())); 1369 - const visibleFiles = createMemo(() => (showFileTree() ? files() : files().slice(0, previewLimit))); 1370 - const hiddenFiles = createMemo(() => (showFileTree() ? [] : files().slice(previewLimit))); 1371 - 1372 - return ( 1373 - <div class={clsx('untangled-diff-view', props.variant === 'commit' && 'untangled-diff-view-commit')}> 1374 - <div class="untangled-diff-panel"> 1375 - <div class="untangled-diff-toolbar"> 1376 - <div class="flex min-w-0 items-center gap-2"> 1377 - <Show when={showFileTree()}> 1378 - <button 1379 - type="button" 1380 - class="untangled-diff-sidebar-toggle" 1381 - aria-label={filesVisible() ? 'collapse changed files sidebar' : 'expand changed files sidebar'} 1382 - aria-pressed={filesVisible()} 1383 - onClick={() => setFilesVisible(!filesVisible())} 1384 - > 1385 - <Show when={filesVisible()} fallback={<PanelLeftOpen class="size-4" />}> 1386 - <PanelLeftClose class="size-4" /> 1387 - </Show> 1388 - </button> 1389 - </Show> 1390 - <DiffToolbarStats additions={stats().additions} deletions={stats().deletions} fileCount={files().length} /> 1391 - </div> 1392 - <div class="flex items-center gap-2"> 1393 - <DiffCollapseButton expanded={expanded()} onToggle={() => setExpanded(!expanded())} /> 1394 - <div class="untangled-diff-mode-group"> 1395 - <button type="button" class="untangled-diff-mode-button untangled-diff-mode-button-active"> 1396 - <FileText class="w-4 h-4" /> 1397 - unified 1398 - </button> 1399 - <button type="button" class="untangled-diff-mode-button" disabled title="Split diff is not available yet"> 1400 - <Columns2 class="w-4 h-4" /> 1401 - split 1402 - </button> 1403 - </div> 1404 - </div> 1405 - </div> 1406 - <div class={clsx('untangled-diff-panel-body', showFileTree() && filesVisible() && 'untangled-diff-panel-body-with-tree')}> 1407 - <Show when={showFileTree() && filesVisible()}> 1408 - <aside class="untangled-pr-file-tree" aria-label="changed files"> 1409 - <DiffFileTreeNodes nodes={tree()} /> 1410 - </aside> 1411 - </Show> 1412 - 1413 - <div class={showFileTree() ? 'untangled-pr-diff-files' : 'untangled-diff-files'}> 1414 - <For each={visibleFiles()}> 1415 - {(file) => ( 1416 - <details id={diffFileElementId(file.path)} class="untangled-diff-file" open={expanded()}> 1417 - <summary class="untangled-diff-file-header"> 1418 - <div class="flex min-w-0 items-center gap-2"> 1419 - <ChevronRight class="untangled-diff-chevron size-4 shrink-0" /> 1420 - <DiffStatPill additions={file.additions} deletions={file.deletions} /> 1421 - <span class="truncate font-mono text-sm text-gray-900 dark:text-gray-100">{file.path}</span> 1422 - </div> 1423 - </summary> 1424 - <CodeView text={file.text} wrap={props.wrap} maxHeight={props.maxHeight} diff path={file.path} /> 1425 - </details> 1426 - )} 1427 - </For> 1428 - <Show when={hiddenFiles().length > 0}> 1429 - <details class="untangled-diff-show-more"> 1430 - <summary class="untangled-diff-show-more-summary"> 1431 - <span class="untangled-diff-show-more-closed"> 1432 - <ChevronRight class="untangled-diff-chevron size-4 shrink-0" /> 1433 - Show {hiddenFiles().length} more file{hiddenFiles().length === 1 ? '' : 's'} 1434 - </span> 1435 - <span class="untangled-diff-show-more-open"> 1436 - <ChevronRight class="untangled-diff-chevron size-4 shrink-0" /> 1437 - Hide {hiddenFiles().length} file{hiddenFiles().length === 1 ? '' : 's'} 1438 - </span> 1439 - </summary> 1440 - <div class="untangled-diff-show-more-files"> 1441 - <For each={hiddenFiles()}> 1442 - {(file) => ( 1443 - <details id={diffFileElementId(file.path)} class="untangled-diff-file" open={expanded()}> 1444 - <summary class="untangled-diff-file-header"> 1445 - <div class="flex min-w-0 items-center gap-2"> 1446 - <ChevronRight class="untangled-diff-chevron size-4 shrink-0" /> 1447 - <DiffStatPill additions={file.additions} deletions={file.deletions} /> 1448 - <span class="truncate font-mono text-sm text-gray-900 dark:text-gray-100">{file.path}</span> 1449 - </div> 1450 - </summary> 1451 - <CodeView text={file.text} wrap={props.wrap} maxHeight={props.maxHeight} diff path={file.path} /> 1452 - </details> 1453 - )} 1454 - </For> 1455 - </div> 1456 - </details> 1457 - </Show> 1458 - <Show when={files().length === 0}> 1459 - <div class="px-4 py-8 text-center text-sm text-gray-400">No differences found.</div> 1460 - </Show> 1461 - </div> 1462 - </div> 1463 - </div> 1464 - </div> 1465 - ); 1466 - }; 1467 - 1468 - const totalDiffStats = (files: DiffFile[]) => 1469 - files.reduce( 1470 - (acc, file) => ({ 1471 - additions: acc.additions + file.additions, 1472 - deletions: acc.deletions + file.deletions, 1473 - }), 1474 - { additions: 0, deletions: 0 }, 1475 - ); 1476 - 1477 - type DiffFileTreeNode = { 1478 - name: string; 1479 - path: string; 1480 - file?: DiffFile; 1481 - children?: DiffFileTreeNode[]; 1482 - }; 1483 - 1484 - const sortDiffFileTree = (nodes: DiffFileTreeNode[]): DiffFileTreeNode[] => 1485 - nodes 1486 - .sort((a, b) => a.name.localeCompare(b.name)) 1487 - .map((node) => ({ 1488 - ...node, 1489 - children: node.children ? sortDiffFileTree(node.children) : undefined, 1490 - })); 1491 - 1492 - const diffFileTree = (files: DiffFile[]) => { 1493 - const roots: DiffFileTreeNode[] = []; 1494 - 1495 - for (const file of files) { 1496 - const parts = file.path.split('/').filter(Boolean); 1497 - const pathParts = parts.length > 0 ? parts : [file.path || 'patch']; 1498 - let siblings = roots; 1499 - let path = ''; 1500 - 1501 - for (const [index, part] of pathParts.entries()) { 1502 - path = path ? `${path}/${part}` : part; 1503 - const isFile = index === pathParts.length - 1; 1504 - 1505 - if (isFile) { 1506 - siblings.push({ name: part, path: file.path, file }); 1507 - continue; 1508 - } 1509 - 1510 - let node = siblings.find((child) => !child.file && child.name === part); 1511 - if (!node) { 1512 - node = { name: part, path, children: [] }; 1513 - siblings.push(node); 1514 - } 1515 - 1516 - siblings = node.children!; 1517 - } 1518 - } 1519 - 1520 - return sortDiffFileTree(roots); 1521 - }; 1522 - 1523 - const diffFileElementId = (path: string) => 1524 - `diff-${Array.from(path, (char) => char.codePointAt(0)!.toString(16).padStart(4, '0')).join('-')}`; 1525 - 1526 - const scrollToDiffFile = (path: string, event: MouseEvent) => { 1527 - if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; 1528 - 1529 - const target = document.getElementById(diffFileElementId(path)); 1530 - if (!target) return; 1531 - 1532 - event.preventDefault(); 1533 - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); 1534 - 1535 - const hash = `#${target.id}`; 1536 - if (window.location.hash !== hash) { 1537 - window.history.pushState(null, '', `${window.location.pathname}${window.location.search}${hash}`); 1538 - } 1539 - }; 1540 - 1541 - const DiffFileTreeNodes: Component<{ 1542 - nodes: DiffFileTreeNode[]; 1543 - reviewedFiles?: () => Set<string>; 1544 - }> = (props) => ( 1545 - <div class="untangled-pr-file-tree-list"> 1546 - <For each={props.nodes}> 1547 - {(node) => <DiffFileTreeNodeView node={node} reviewedFiles={props.reviewedFiles} />} 1548 - </For> 1549 - </div> 1550 - ); 1551 - 1552 - const DiffFileTreeNodeView: Component<{ 1553 - node: DiffFileTreeNode; 1554 - reviewedFiles?: () => Set<string>; 1555 - }> = (props) => ( 1556 - <div class="untangled-pr-file-tree-node"> 1557 - <Show 1558 - when={props.node.file} 1559 - fallback={ 1560 - <> 1561 - <div class="untangled-pr-file-tree-entry" title={props.node.path}> 1562 - <Folder class="size-4 shrink-0" /> 1563 - <span class="truncate">{props.node.name}</span> 1564 - </div> 1565 - <Show when={props.node.children?.length}> 1566 - <div class="untangled-pr-file-tree-children"> 1567 - <DiffFileTreeNodes nodes={props.node.children!} reviewedFiles={props.reviewedFiles} /> 1568 - </div> 1569 - </Show> 1570 - </> 1571 - } 1572 - > 1573 - {(file) => { 1574 - const isReviewed = () => props.reviewedFiles?.().has(file().path) ?? false; 1575 - return ( 1576 - <a 1577 - href={`#${diffFileElementId(file().path)}`} 1578 - class="untangled-pr-file-tree-entry untangled-pr-file-tree-link" 1579 - title={file().path} 1580 - onClick={(event) => scrollToDiffFile(file().path, event)} 1581 - > 1582 - <FileText class="size-4 shrink-0" /> 1583 - <span class="truncate">{props.node.name}</span> 1584 - <Show when={isReviewed()}> 1585 - <span class="review-indicator text-green-600 dark:text-green-400 flex-shrink-0 ml-1"> 1586 - 1587 - </span> 1588 - </Show> 1589 - </a> 1590 - ); 1591 - }} 1592 - </Show> 1593 - </div> 1594 - ); 1595 - 1596 - const DiffStatPill: Component<{ additions: number; deletions: number }> = (props) => ( 1597 - <div class="untangled-diff-stat-pill"> 1598 - <Show when={props.additions > 0}> 1599 - <span class="untangled-diff-stat-add">+{props.additions}</span> 1600 - </Show> 1601 - <Show when={props.deletions > 0}> 1602 - <span class="untangled-diff-stat-del">-{props.deletions}</span> 1603 - </Show> 1604 - </div> 1605 - ); 1606 - 1607 - export const PullDiffView: Component<{ 1608 - patch: string; 1609 - roundLabel: string; 1610 - storageKey: string; 1611 - history: JSX.Element; 1612 - }> = (props) => { 1613 - const files = createMemo(() => parseDiffFiles(props.patch)); 1614 - const stats = createMemo(() => totalDiffStats(files())); 1615 - const tree = createMemo(() => diffFileTree(files())); 1616 - const [expanded, setExpanded] = createSignal(true); 1617 - const [filesVisible, setFilesVisible] = createSignal(true); 1618 - const [historyVisible, setHistoryVisible] = createSignal(true); 1619 - 1620 - const loadReviewed = (): Set<string> => { 1621 - try { 1622 - const data = localStorage.getItem(props.storageKey); 1623 - if (!data) return new Set(); 1624 - const entry = JSON.parse(data); 1625 - return new Set(Array.isArray(entry) ? entry : (entry.files || [])); 1626 - } catch { 1627 - return new Set(); 1628 - } 1629 - }; 1630 - 1631 - const [reviewed, setReviewed] = createSignal<Set<string>>(new Set()); 1632 - 1633 - createEffect(() => { 1634 - setReviewed(loadReviewed()); 1635 - }); 1636 - 1637 - const saveReviewed = (newSet: Set<string>) => { 1638 - const currentFilePaths = new Set(files().map((f) => f.path)); 1639 - const filesToSave = Array.from(newSet).filter((path) => currentFilePaths.has(path)); 1640 - try { 1641 - localStorage.setItem( 1642 - props.storageKey, 1643 - JSON.stringify({ 1644 - files: filesToSave, 1645 - ts: Date.now(), 1646 - }), 1647 - ); 1648 - } catch (e) { 1649 - console.error('Failed to save reviewed files:', e); 1650 - } 1651 - }; 1652 - 1653 - const pruneStale = () => { 1654 - const now = Date.now(); 1655 - const REVIEWED_PREFIX = 'reviewed:'; 1656 - const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; 1657 - for (let i = 0; i < localStorage.length; i++) { 1658 - const key = localStorage.key(i); 1659 - if (key && key.startsWith(REVIEWED_PREFIX) && key !== props.storageKey) { 1660 - try { 1661 - const entry = JSON.parse(localStorage.getItem(key) || '{}'); 1662 - if (!entry.ts || now - entry.ts > MAX_AGE_MS) { 1663 - localStorage.removeItem(key); 1664 - } 1665 - } catch { 1666 - localStorage.removeItem(key); 1667 - } 1668 - } 1669 - } 1670 - }; 1671 - 1672 - createEffect(() => { 1673 - if (Math.random() < 0.1) { 1674 - pruneStale(); 1675 - } 1676 - }); 1677 - 1678 - const [localOpen, setLocalOpen] = createSignal<Record<string, boolean>>({}); 1679 - const [hasSetInitial, setHasSetInitial] = createSignal(false); 1680 - 1681 - createEffect(() => { 1682 - if (files().length > 0) { 1683 - const isExpanded = expanded(); 1684 - const next: Record<string, boolean> = {}; 1685 - for (const f of files()) { 1686 - if (!hasSetInitial() && reviewed().has(f.path)) { 1687 - next[f.path] = false; 1688 - } else { 1689 - next[f.path] = isExpanded; 1690 - } 1691 - } 1692 - setLocalOpen(next); 1693 - setHasSetInitial(true); 1694 - } 1695 - }); 1696 - 1697 - const toggleReview = (path: string) => { 1698 - const nextReviewed = new Set(reviewed()); 1699 - if (nextReviewed.has(path)) { 1700 - nextReviewed.delete(path); 1701 - } else { 1702 - nextReviewed.add(path); 1703 - setLocalOpen((prev) => ({ ...prev, [path]: false })); 1704 - } 1705 - setReviewed(nextReviewed); 1706 - saveReviewed(nextReviewed); 1707 - }; 1708 - 1709 - return ( 1710 - <section class="untangled-pr-diff-breakout"> 1711 - <div 1712 - class={clsx( 1713 - 'untangled-pr-diff-toolbar', 1714 - !filesVisible() && 'untangled-pr-diff-toolbar-sidebar-collapsed', 1715 - !historyVisible() && 'untangled-pr-diff-toolbar-history-collapsed', 1716 - )} 1717 - > 1718 - <div class="untangled-pr-diff-toolbar-main"> 1719 - <button 1720 - type="button" 1721 - class="untangled-pr-sidebar-toggle" 1722 - aria-label={filesVisible() ? 'collapse changed files sidebar' : 'expand changed files sidebar'} 1723 - aria-pressed={filesVisible()} 1724 - onClick={() => setFilesVisible(!filesVisible())} 1725 - > 1726 - <Show when={filesVisible()} fallback={<PanelLeftOpen class="size-4" />}> 1727 - <PanelLeftClose class="size-4" /> 1728 - </Show> 1729 - </button> 1730 - <DiffStatPill additions={stats().additions} deletions={stats().deletions} /> 1731 - <span 1732 - class={clsx( 1733 - 'text-xs font-medium transition-colors duration-150', 1734 - reviewed().size === files().length && files().length > 0 1735 - ? 'text-green-600 dark:text-green-400' 1736 - : 'text-gray-600 dark:text-gray-400' 1737 - )} 1738 - > 1739 - {reviewed().size > 0 1740 - ? `${reviewed().size}/${files().length} file${files().length === 1 ? '' : 's'} reviewed` 1741 - : `${files().length} changed file${files().length === 1 ? '' : 's'}` 1742 - } 1743 - </span> 1744 - <span class="hidden h-5 w-px bg-gray-300 dark:bg-gray-700 sm:inline-block" /> 1745 - <span class="uppercase tracking-wide">diff</span> 1746 - <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"> 1747 - {props.roundLabel} 1748 - </span> 1749 - </div> 1750 - <div class="untangled-pr-diff-toolbar-actions"> 1751 - <DiffCollapseButton expanded={expanded()} onToggle={() => setExpanded(!expanded())} /> 1752 - </div> 1753 - <div class="untangled-pr-diff-toolbar-history"> 1754 - <button 1755 - type="button" 1756 - class="untangled-pr-sidebar-toggle" 1757 - aria-label={historyVisible() ? 'collapse history panel' : 'expand history panel'} 1758 - aria-pressed={historyVisible()} 1759 - onClick={() => setHistoryVisible(!historyVisible())} 1760 - > 1761 - <Show when={historyVisible()} fallback={<PanelRightOpen class="size-4" />}> 1762 - <PanelRightClose class="size-4" /> 1763 - </Show> 1764 - </button> 1765 - </div> 1766 - </div> 1767 - 1768 - <div 1769 - class={clsx( 1770 - 'untangled-pr-diff-grid', 1771 - !filesVisible() && 'untangled-pr-diff-grid-sidebar-collapsed', 1772 - !historyVisible() && 'untangled-pr-diff-grid-history-collapsed', 1773 - )} 1774 - > 1775 - <Show when={filesVisible()}> 1776 - <aside class="untangled-pr-file-tree" aria-label="changed files"> 1777 - <DiffFileTreeNodes nodes={tree()} reviewedFiles={reviewed} /> 1778 - </aside> 1779 - </Show> 1780 - 1781 - <div class="untangled-pr-diff-files"> 1782 - <For each={files()}> 1783 - {(file) => ( 1784 - <details 1785 - id={diffFileElementId(file.path)} 1786 - class={clsx('untangled-pr-diff-file', reviewed().has(file.path) && 'opacity-60')} 1787 - open={Boolean(localOpen()[file.path])} 1788 - onToggle={(e) => { 1789 - const target = e.currentTarget; 1790 - setLocalOpen((prev) => ({ ...prev, [file.path]: target.open })); 1791 - }} 1792 - > 1793 - <summary class="untangled-pr-diff-file-header"> 1794 - <div class="flex min-w-0 items-center gap-2"> 1795 - <ChevronRight class="untangled-pr-diff-chevron size-4 shrink-0" /> 1796 - <DiffStatPill additions={file.additions} deletions={file.deletions} /> 1797 - <span class="truncate text-base text-gray-100">{file.path}</span> 1798 - </div> 1799 - <label 1800 - 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" 1801 - title={reviewed().has(file.path) ? 'Mark as unreviewed' : 'Mark as reviewed'} 1802 - onClick={(e) => { 1803 - e.stopPropagation(); 1804 - }} 1805 - > 1806 - <input 1807 - type="checkbox" 1808 - class="sr-only" 1809 - checked={reviewed().has(file.path)} 1810 - onChange={() => toggleReview(file.path)} 1811 - /> 1812 - <Show 1813 - when={reviewed().has(file.path)} 1814 - fallback={<Circle class="size-4" />} 1815 - > 1816 - <CircleCheck class="size-4 text-green-600 dark:text-green-400" /> 1817 - </Show> 1818 - <span class="hidden md:inline">reviewed</span> 1819 - </label> 1820 - </summary> 1821 - <div class="untangled-pr-diff-ellipsis">...</div> 1822 - <CodeView text={file.text} diff path={file.path} /> 1823 - </details> 1824 - )} 1825 - </For> 1826 - </div> 1827 - 1828 - <Show when={historyVisible()}> 1829 - <aside class="untangled-pr-history">{props.history}</aside> 1830 - </Show> 1831 - </div> 1832 - </section> 1833 - ); 1834 - }; 1835 1014 1836 1015 export const AsideCard: Component<{ title: string; children: JSX.Element }> = (props) => ( 1837 1016 <div class={cardStyles('p-4')}> ··· 2120 1299 </div> 2121 1300 ); 2122 1301 }; 1302 + 1303 + export const IssueOrPullRow: Component<{ 1304 + title: string; 1305 + number: number | string; 1306 + state: 'open' | 'closed' | 'merged'; 1307 + kind: 'issue' | 'pull'; 1308 + author: { did: string; handle: string }; 1309 + createdAt: string; 1310 + href: string; 1311 + meta?: JSX.Element; 1312 + children?: JSX.Element; 1313 + }> = (props) => { 1314 + return ( 1315 + <A 1316 + href={props.href} 1317 + class="untangled-list-row" 1318 + > 1319 + <div class="min-w-0"> 1320 + <div class="text-base text-gray-900 dark:text-gray-100"> 1321 + {props.title}{' '} 1322 + <span class="text-gray-500 dark:text-gray-400 font-normal">#{props.number}</span> 1323 + </div> 1324 + <div class="mt-3 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 1325 + <StateBadge state={props.state} kind={props.kind} /> 1326 + <Avatar did={props.author.did} size="size-5" /> 1327 + <span>{props.author.handle}</span> 1328 + <span>·</span> 1329 + <span>{formatRelativeTime(props.createdAt)}</span> 1330 + {props.meta} 1331 + </div> 1332 + {props.children} 1333 + </div> 1334 + </A> 1335 + ); 1336 + }; 1337 + 1338 + export const ThreadCommentView: Component<{ 1339 + id?: string; 1340 + author: { did: string; handle: string }; 1341 + createdAt: string; 1342 + markdown: string; 1343 + isAuthor?: boolean; 1344 + isReply?: boolean; 1345 + class?: string; 1346 + }> = (props) => ( 1347 + <div 1348 + id={props.id} 1349 + class={clsx( 1350 + 'flex gap-2 w-full mx-auto text-sm text-gray-900 dark:text-gray-100', 1351 + props.isReply && 'pr-4', 1352 + props.class || 'py-4' 1353 + )} 1354 + > 1355 + <div class="shrink-0 h-fit relative z-10"> 1356 + <Avatar did={props.author.did} size="size-8 mr-1" /> 1357 + </div> 1358 + <div class="min-w-0 flex-1"> 1359 + <div class="flex flex-wrap items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 mb-0.5"> 1360 + <A 1361 + class="text-gray-700 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white font-medium hover:underline" 1362 + href={`/${props.author.did}`} 1363 + > 1364 + {props.author.handle} 1365 + </A> 1366 + <Show when={props.isAuthor}> 1367 + <span class="text-xs text-gray-400 dark:text-gray-500">(author)</span> 1368 + </Show> 1369 + <span class="select-none">·</span> 1370 + <Show when={props.id} fallback={<span>{formatRelativeTime(props.createdAt)}</span>}> 1371 + <a href={`#${props.id}`} class="hover:underline"> 1372 + {formatRelativeTime(props.createdAt)} 1373 + </a> 1374 + </Show> 1375 + </div> 1376 + <div class="min-w-0 break-words text-gray-800 dark:text-gray-200"> 1377 + <MarkdownBlock markdown={props.markdown} /> 1378 + </div> 1379 + </div> 1380 + </div> 1381 + ); 1382 + 1383 + const commitSummary = (message: string) => message.trim().split('\n')[0] || 'no commit message'; 1384 + 1385 + export const PathBreadcrumbs: Component<{ 1386 + repo: Parameters<typeof treeHref>[0]; 1387 + refName: string; 1388 + path: string; 1389 + withDivider?: boolean; 1390 + }> = (props) => { 1391 + const segments = createMemo(() => props.path.split('/').filter(Boolean)); 1392 + 1393 + return ( 1394 + <div class={clsx('text-base', props.withDivider && 'pb-2 mb-3 border-b border-gray-200 dark:border-gray-700')}> 1395 + <div class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 1396 + <A href={treeHref(props.repo, props.refName)} class="text-gray-500 dark:text-gray-400 hover:underline"> 1397 + {props.repo.slug} 1398 + </A> 1399 + <For each={segments()}> 1400 + {(segment, index) => ( 1401 + <> 1402 + <span class="px-1">/</span> 1403 + <Show 1404 + when={index() < segments().length - 1} 1405 + fallback={<span class="text-gray-900 dark:text-gray-100">{segment}</span>} 1406 + > 1407 + <A 1408 + href={treeHref(props.repo, props.refName, segments().slice(0, index() + 1).join('/'))} 1409 + class="text-gray-500 dark:text-gray-400 hover:underline" 1410 + > 1411 + {segment} 1412 + </A> 1413 + </Show> 1414 + </> 1415 + )} 1416 + </For> 1417 + </div> 1418 + </div> 1419 + ); 1420 + }; 1421 + 1422 + export const TreeLatestCommitPanel: Component<{ 1423 + repo: Parameters<typeof treeHref>[0]; 1424 + commit: { 1425 + hash: string; 1426 + message: string; 1427 + when: string; 1428 + author?: { 1429 + email: string; 1430 + name: string; 1431 + }; 1432 + }; 1433 + }> = (props) => ( 1434 + <div class="pb-2 mb-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-wrap text-sm gap-2"> 1435 + <div class="flex min-w-0 flex-wrap items-center gap-1"> 1436 + <Show 1437 + when={props.commit.author?.email?.startsWith('did:') ? props.commit.author.email : undefined} 1438 + fallback={<PlaceholderAvatar size="size-6" iconSize="size-4" />} 1439 + > 1440 + {(did) => <Avatar did={did()} size="size-6" />} 1441 + </Show> 1442 + <span class="font-medium text-gray-700 dark:text-gray-200">{props.commit.author?.name || 'unknown author'}</span> 1443 + <span class="select-none text-gray-400 dark:text-gray-500">·</span> 1444 + <A 1445 + href={`/${props.repo.owner.handle}/${props.repo.slug}/commit/${props.commit.hash}`} 1446 + class="min-w-0 truncate no-underline hover:underline text-gray-900 dark:text-white" 1447 + > 1448 + {commitSummary(props.commit.message)} 1449 + </A> 1450 + <span class="select-none text-gray-400 dark:text-gray-500">·</span> 1451 + <span class="text-gray-500 dark:text-gray-400">{formatRelativeTime(props.commit.when)}</span> 1452 + </div> 1453 + <A 1454 + href={`/${props.repo.owner.handle}/${props.repo.slug}/commit/${props.commit.hash}`} 1455 + class="inline-flex w-fit items-center rounded bg-gray-100 px-2 py-1 font-mono text-xs text-gray-700 no-underline hover:underline dark:bg-gray-900 dark:text-gray-300" 1456 + > 1457 + {props.commit.hash.slice(0, 8)} 1458 + </A> 1459 + </div> 1460 + ); 1461 + 1462 + export const RepoLanguageBar: Component<{ 1463 + languages: Array<{ name: string; percentage: number; color?: string }>; 1464 + loading?: boolean; 1465 + }> = (props) => ( 1466 + <Show when={props.languages.length > 0} fallback={<Show when={props.loading}><RepoLanguageBarSkeleton /></Show>}> 1467 + <details class="group -my-4 -m-6 mb-4"> 1468 + <summary class="untangled-language-bar"> 1469 + <For each={props.languages}> 1470 + {(language) => { 1471 + const name = language.name || 'Other'; 1472 + const color = language.color || languageColor(name); 1473 + return ( 1474 + <div 1475 + class="untangled-language-segment" 1476 + title={`${name} ${formatLanguagePercent(language.percentage)}`} 1477 + style={{ 1478 + width: `${language.percentage}%`, 1479 + 'background-color': color, 1480 + }} 1481 + /> 1482 + ); 1483 + }} 1484 + </For> 1485 + </summary> 1486 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap"> 1487 + <For each={props.languages}> 1488 + {(language) => { 1489 + const name = language.name || 'Other'; 1490 + const color = language.color || languageColor(name); 1491 + return ( 1492 + <div class="flex flex-grow items-center justify-center gap-2 text-xs"> 1493 + <div 1494 + class="size-2 rounded-full" 1495 + style={{ 1496 + background: `radial-gradient(circle at 35% 35%, color-mix(in srgb, ${color} 70%, white), ${color} 30%, color-mix(in srgb, ${color} 85%, black))`, 1497 + }} 1498 + /> 1499 + <div> 1500 + {name}{' '} 1501 + <span class="text-gray-500 dark:text-gray-400"> 1502 + {formatLanguagePercent(language.percentage)} 1503 + </span> 1504 + </div> 1505 + </div> 1506 + ); 1507 + }} 1508 + </For> 1509 + </div> 1510 + </details> 1511 + </Show> 1512 + ); 1513 + 1514 + export const LoadingFilePath: Component<{ 1515 + repo: Parameters<typeof treeHref>[0]; 1516 + refName: string; 1517 + path: string; 1518 + }> = (props) => { 1519 + const segments = createMemo(() => props.path.split('/').filter(Boolean)); 1520 + const filename = createMemo(() => segments().at(-1) ?? props.path); 1521 + 1522 + return ( 1523 + <div class="untangled-blob-card"> 1524 + <div class="untangled-blob-header pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 1525 + <div class="flex flex-col md:flex-row md:justify-between gap-2"> 1526 + <PathBreadcrumbs repo={props.repo} refName={props.refName} path={props.path} /> 1527 + <div class="untangled-blob-file-info text-gray-500 md:text-sm flex flex-wrap md:gap-0 items-center"> 1528 + <span> 1529 + at <A href={treeHref(props.repo, props.refName)}>{props.refName}</A> 1530 + </span> 1531 + </div> 1532 + </div> 1533 + </div> 1534 + <div class="space-y-3"> 1535 + <OverviewFileRow 1536 + name={filename()} 1537 + href={blobHref(props.repo, props.refName, props.path)} 1538 + icon={<File class="size-4 flex-shrink-0" />} 1539 + class="untangled-file-row-loading" 1540 + /> 1541 + </div> 1542 + </div> 1543 + ); 1544 + }; 1545 + 1546 + export { CodeView, DiffView, PullDiffView } from './code-view'; 1547 + 1548 + 2123 1549 2124 1550
+128
src/index.css
··· 1934 1934 1935 1935 .untangled-safe-header { 1936 1936 padding-top: env(safe-area-inset-top); 1937 + z-index: 20; 1937 1938 } 1938 1939 1939 1940 .untangled-unread-badge { ··· 2001 2002 .untangled-toggle-button-meta-active, 2002 2003 .untangled-toggle-button-idle:hover .untangled-toggle-button-meta { 2003 2004 color: rgb(156 163 175); 2005 + } 2006 + } 2007 + 2008 + .untangled-toggle-group { 2009 + display: flex; 2010 + align-items: stretch; 2011 + border-radius: 0.25rem; 2012 + border: 1px solid rgb(209 213 219); 2013 + overflow: hidden; 2014 + } 2015 + @media (prefers-color-scheme: dark) { 2016 + .untangled-toggle-group { 2017 + border-color: rgb(55 65 81); 2018 + } 2019 + } 2020 + 2021 + .untangled-list-row { 2022 + display: block; 2023 + border-radius: 0.25rem; 2024 + background-color: rgb(255 255 255); 2025 + padding: 1rem 1.5rem; 2026 + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 2027 + text-decoration-line: none; 2028 + } 2029 + .untangled-list-row:hover { 2030 + text-decoration-line: underline; 2031 + } 2032 + @media (prefers-color-scheme: dark) { 2033 + .untangled-list-row { 2034 + background-color: rgb(31 41 55); 2035 + border: 1px solid rgb(55 65 81); 2036 + } 2037 + } 2038 + 2039 + .untangled-thread-group { 2040 + overflow: hidden; 2041 + border-radius: 0.25rem; 2042 + border: 1px solid rgb(229 231 235); 2043 + background-color: rgb(249 250 251); 2044 + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 2045 + } 2046 + @media (prefers-color-scheme: dark) { 2047 + .untangled-thread-group { 2048 + border-color: rgb(55 65 81); 2049 + background-color: rgb(31 41 55 / 0.5); 2050 + } 2051 + } 2052 + 2053 + .untangled-thread-comment { 2054 + display: flex; 2055 + gap: 0.5rem; 2056 + border-radius: 0.25rem; 2057 + background-color: rgb(255 255 255); 2058 + padding: 1rem 1.5rem; 2059 + } 2060 + @media (prefers-color-scheme: dark) { 2061 + .untangled-thread-comment { 2062 + background-color: rgb(31 41 55); 2063 + } 2064 + } 2065 + 2066 + .untangled-thread-replies-list { 2067 + position: relative; 2068 + margin-left: 2.5rem; 2069 + border-left: 1px solid rgb(229 231 235); 2070 + } 2071 + @media (prefers-color-scheme: dark) { 2072 + .untangled-thread-replies-list { 2073 + border-left-color: rgb(55 65 81); 2074 + } 2075 + } 2076 + 2077 + .untangled-thread-reply { 2078 + margin-left: -1rem; 2079 + display: flex; 2080 + gap: 0.5rem; 2081 + padding: 1rem 1rem 1rem 0; 2082 + } 2083 + 2084 + .untangled-thread-reply-trigger { 2085 + display: flex; 2086 + width: 100%; 2087 + align-items: center; 2088 + gap: 0.5rem; 2089 + border-top: 1px solid rgb(209 213 219); 2090 + background: transparent; 2091 + padding: 0.5rem 1.5rem; 2092 + text-align: left; 2093 + font-size: 0.875rem; 2094 + color: rgb(107 114 128); 2095 + } 2096 + @media (prefers-color-scheme: dark) { 2097 + .untangled-thread-reply-trigger { 2098 + border-top-color: rgb(55 65 81); 2099 + color: rgb(156 163 175); 2100 + } 2101 + } 2102 + 2103 + .untangled-thread-reply-form { 2104 + display: flex; 2105 + width: 100%; 2106 + flex-direction: column; 2107 + gap: 0.5rem; 2108 + border-top: 1px solid rgb(229 231 235); 2109 + padding: 0.5rem; 2110 + } 2111 + @media (prefers-color-scheme: dark) { 2112 + .untangled-thread-reply-form { 2113 + border-top-color: rgb(55 65 81); 2114 + } 2115 + } 2116 + 2117 + .untangled-comment-composer { 2118 + position: relative; 2119 + display: flex; 2120 + min-width: 0; 2121 + width: 100%; 2122 + flex-direction: column; 2123 + gap: 0.75rem; 2124 + border-radius: 0.25rem; 2125 + background-color: rgb(255 255 255); 2126 + padding: 1rem; 2127 + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 2128 + } 2129 + @media (prefers-color-scheme: dark) { 2130 + .untangled-comment-composer { 2131 + background-color: rgb(31 41 55); 2004 2132 } 2005 2133 } 2006 2134
+1 -1
src/layout.tsx
··· 395 395 396 396 export const RootShell: Component<{ children?: JSX.Element }> = (props) => ( 397 397 <div class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white"> 398 - <header class="untangled-safe-header w-full shadow-sm dark:text-white bg-white dark:bg-gray-800" style="z-index: 20;"> 398 + <header class="untangled-safe-header w-full shadow-sm dark:text-white bg-white dark:bg-gray-800"> 399 399 <Topbar /> 400 400 </header> 401 401
+2 -180
src/pages/repo/code.tsx
··· 5 5 import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, type Accessor, type Component } from 'solid-js'; 6 6 import { buildBlobDataUrl, decodeBlobBytes, decodeBlobText, getRepoBlob, getRepoBranches, getRepoDefaultBranch, getRepoLanguages, getRepoLog, getRepoTags, getRepoTree, type CommitHeadline } from '../../lib/api/repos'; 7 7 import { Avatar, ErrorState, PlaceholderAvatar, buttonStyles, cardStyles } from '../../components/common'; 8 - import { CloneDropdown, CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoBlobSkeleton, RepoLanguageBarSkeleton, RepoTreeSkeleton } from '../../components/repo'; 8 + import { CloneDropdown, CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoBlobSkeleton, RepoTreeSkeleton, PathBreadcrumbs, TreeLatestCommitPanel, RepoLanguageBar, LoadingFilePath } from '../../components/repo'; 9 9 import { RepoFrame, useRepoQuery } from './shared'; 10 - import { blobHref, commitHref, commitsHref, countLines, decodeRoutePath, formatBytes, formatLanguagePercent, formatRelativeTime, getErrorMessage, getParentPath, imageLike, isDirectory, joinPath, languageColor, markdownLike, safeDecode, sortedTreeEntries, svgLike, treeHref, updateRecentRepoLanguage, videoLike } from '../../lib/repo-utils'; 10 + import { blobHref, commitHref, commitsHref, countLines, decodeRoutePath, formatBytes, formatRelativeTime, getErrorMessage, getParentPath, imageLike, isDirectory, joinPath, markdownLike, safeDecode, sortedTreeEntries, svgLike, treeHref, updateRecentRepoLanguage, videoLike } from '../../lib/repo-utils'; 11 11 import { LOADING_DELAY_MS, hasNamedRef, normalizeLanguages, resolveDefaultBranchName, resolveRouteRefAndPath, shouldAnimateNavigation, useDelayedLoading } from './code-helpers'; 12 12 13 - const PathBreadcrumbs: Component<{ 14 - repo: Parameters<typeof treeHref>[0]; 15 - refName: string; 16 - path: string; 17 - withDivider?: boolean; 18 - }> = (props) => { 19 - const segments = createMemo(() => props.path.split('/').filter(Boolean)); 20 - 21 - return ( 22 - <div class={clsx('text-base', props.withDivider && 'pb-2 mb-3 border-b border-gray-200 dark:border-gray-700')}> 23 - <div class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 24 - <A href={treeHref(props.repo, props.refName)} class="text-gray-500 dark:text-gray-400 hover:underline"> 25 - {props.repo.slug} 26 - </A> 27 - <For each={segments()}> 28 - {(segment, index) => ( 29 - <> 30 - <span class="px-1">/</span> 31 - <Show 32 - when={index() < segments().length - 1} 33 - fallback={<span class="text-gray-900 dark:text-gray-100">{segment}</span>} 34 - > 35 - <A 36 - href={treeHref(props.repo, props.refName, segments().slice(0, index() + 1).join('/'))} 37 - class="text-gray-500 dark:text-gray-400 hover:underline" 38 - > 39 - {segment} 40 - </A> 41 - </Show> 42 - </> 43 - )} 44 - </For> 45 - </div> 46 - </div> 47 - ); 48 - }; 49 - 50 - const commitSummary = (message: string) => message.trim().split('\n')[0] || 'no commit message'; 51 - 52 13 const readmeIsAvailable = ( 53 14 readme: { filename: string; contents: string } | undefined, 54 15 entries: Array<{ name: string; mode: string }>, ··· 58 19 59 20 const filename = readme.filename.trim().toLowerCase(); 60 21 return filename.length > 0 && entries.some((entry) => !entry.mode.startsWith('004') && entry.name.toLowerCase() === filename); 61 - }; 62 - 63 - const TreeLatestCommitPanel: Component<{ 64 - repo: Parameters<typeof treeHref>[0]; 65 - commit: { 66 - hash: string; 67 - message: string; 68 - when: string; 69 - author?: { 70 - email: string; 71 - name: string; 72 - }; 73 - }; 74 - }> = (props) => ( 75 - <div class="pb-2 mb-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-wrap text-sm gap-2"> 76 - <div class="flex min-w-0 flex-wrap items-center gap-1"> 77 - <Show 78 - when={props.commit.author?.email?.startsWith('did:') ? props.commit.author.email : undefined} 79 - fallback={<PlaceholderAvatar size="size-6" iconSize="size-4" />} 80 - > 81 - {(did) => <Avatar did={did()} size="size-6" />} 82 - </Show> 83 - <span class="font-medium text-gray-700 dark:text-gray-200">{props.commit.author?.name || 'unknown author'}</span> 84 - <span class="select-none text-gray-400 dark:text-gray-500">·</span> 85 - <A 86 - href={`/${props.repo.owner.handle}/${props.repo.slug}/commit/${props.commit.hash}`} 87 - class="min-w-0 truncate no-underline hover:underline text-gray-900 dark:text-white" 88 - > 89 - {commitSummary(props.commit.message)} 90 - </A> 91 - <span class="select-none text-gray-400 dark:text-gray-500">·</span> 92 - <span class="text-gray-500 dark:text-gray-400">{formatRelativeTime(props.commit.when)}</span> 93 - </div> 94 - <A 95 - href={`/${props.repo.owner.handle}/${props.repo.slug}/commit/${props.commit.hash}`} 96 - class="inline-flex w-fit items-center rounded bg-gray-100 px-2 py-1 font-mono text-xs text-gray-700 no-underline hover:underline dark:bg-gray-900 dark:text-gray-300" 97 - > 98 - {props.commit.hash.slice(0, 8)} 99 - </A> 100 - </div> 101 - ); 102 - 103 - // const FallbackLanguageBar: Component = () => ( 104 - // <details class="group -my-4 -m-6 mb-4"> 105 - // <summary class="untangled-language-bar"> 106 - // <div 107 - // class="untangled-language-segment" 108 - // title="Other 100%" 109 - // style={{ 110 - // width: "100%", 111 - // 'background-color': languageColor("Other"), 112 - // }} 113 - // /> 114 - // </summary> 115 - // </details> 116 - // ); 117 - 118 - const RepoLanguageBar: Component<{ 119 - languages: Array<{ name: string; percentage: number; color?: string }>; 120 - loading?: boolean; 121 - }> = (props) => ( 122 - <Show when={props.languages.length > 0} fallback={<Show when={props.loading}><RepoLanguageBarSkeleton /></Show>}> 123 - <details class="group -my-4 -m-6 mb-4"> 124 - <summary class="untangled-language-bar"> 125 - <For each={props.languages}> 126 - {(language) => { 127 - const name = language.name || 'Other'; 128 - const color = language.color || languageColor(name); 129 - return ( 130 - <div 131 - class="untangled-language-segment" 132 - title={`${name} ${formatLanguagePercent(language.percentage)}`} 133 - style={{ 134 - width: `${language.percentage}%`, 135 - 'background-color': color, 136 - }} 137 - /> 138 - ); 139 - }} 140 - </For> 141 - </summary> 142 - <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap"> 143 - <For each={props.languages}> 144 - {(language) => { 145 - const name = language.name || 'Other'; 146 - const color = language.color || languageColor(name); 147 - return ( 148 - <div class="flex flex-grow items-center justify-center gap-2 text-xs"> 149 - <div 150 - class="size-2 rounded-full" 151 - style={{ 152 - background: `radial-gradient(circle at 35% 35%, color-mix(in srgb, ${color} 70%, white), ${color} 30%, color-mix(in srgb, ${color} 85%, black))`, 153 - }} 154 - /> 155 - <div> 156 - {name}{' '} 157 - <span class="text-gray-500 dark:text-gray-400"> 158 - {formatLanguagePercent(language.percentage)} 159 - </span> 160 - </div> 161 - </div> 162 - ); 163 - }} 164 - </For> 165 - </div> 166 - </details> 167 - </Show> 168 - ); 169 - 170 - const LoadingFilePath: Component<{ 171 - repo: Parameters<typeof treeHref>[0]; 172 - refName: string; 173 - path: string; 174 - }> = (props) => { 175 - const segments = createMemo(() => props.path.split('/').filter(Boolean)); 176 - const filename = createMemo(() => segments().at(-1) ?? props.path); 177 - 178 - return ( 179 - <div class="untangled-blob-card"> 180 - <div class="untangled-blob-header pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 181 - <div class="flex flex-col md:flex-row md:justify-between gap-2"> 182 - <PathBreadcrumbs repo={props.repo} refName={props.refName} path={props.path} /> 183 - <div class="untangled-blob-file-info text-gray-500 md:text-sm flex flex-wrap md:gap-0 items-center"> 184 - <span> 185 - at <A href={treeHref(props.repo, props.refName)}>{props.refName}</A> 186 - </span> 187 - </div> 188 - </div> 189 - </div> 190 - <div class="space-y-3"> 191 - <OverviewFileRow 192 - name={filename()} 193 - href={blobHref(props.repo, props.refName, props.path)} 194 - icon={<File class="size-4 flex-shrink-0" />} 195 - class="untangled-file-row-loading" 196 - /> 197 - </div> 198 - </div> 199 - ); 200 22 }; 201 23 202 24 const RepoCodePageLegacy: Component = () => {
+37 -63
src/pages/repo/issues.tsx
··· 10 10 import type { RepoContext } from '../../lib/api/repos'; 11 11 import { useAuth } from '../../lib/auth'; 12 12 import { Avatar, ErrorState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, inputStyles, textareaStyles } from '../../components/common'; 13 - import { AtUriPanel, MarkdownBlock, RepoFormSkeleton, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 13 + import { AtUriPanel, MarkdownBlock, RepoFormSkeleton, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton, IssueOrPullRow, ThreadCommentView } from '../../components/repo'; 14 14 import { RepoFrame, issueQueryKey, issuesQueryKey, useRepoQuery } from './shared'; 15 15 import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, issueHref, parseIntegerSearchParam, uniqueCommenters, saveRecentIssueOrPull } from '../../lib/repo-utils'; 16 16 import { buildIssueCommentThreads, buildIssueFilterQuery, parseIssueFilter } from './issues-helpers'; ··· 86 86 <RepoFrame active="issues" loadingFallback={<RepoListPageSkeleton kind="issues" showCreate={Boolean(auth.agent())} />}> 87 87 <div class="rounded bg-white p-4 dark:bg-gray-800"> 88 88 <div class="untangled-filter-grid"> 89 - <div class="flex items-stretch rounded border border-gray-300 dark:border-gray-700"> 89 + <div class="untangled-toggle-group"> 90 90 <ToggleButton 91 91 active={stateFilter() === 'open'} 92 92 onClick={() => setStateFilter('open')} ··· 159 159 <div class="contents"> 160 160 <For each={paginated()}> 161 161 {(issue) => ( 162 - <A 162 + <IssueOrPullRow 163 + title={issue.value.title} 164 + number={issue.number || issue.rkey} 165 + state={issue.state} 166 + kind="issue" 167 + author={{ did: issue.author.did, handle: issue.author.handle }} 168 + createdAt={issue.value.createdAt} 163 169 href={issueHref(repoQuery.data!, issue.uri)} 164 - class="block rounded shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700 no-underline hover:underline" 165 - > 166 - <div class="min-w-0"> 167 - <div class="text-base text-gray-900 dark:text-gray-100"> 168 - {issue.value.title}{' '} 169 - <span class="text-gray-500 dark:text-gray-400">#{issue.number || issue.rkey}</span> 170 - </div> 171 - <div class="mt-3 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 172 - <StateBadge state={issue.state} kind="issue" /> 173 - <Avatar did={issue.author.did} size="size-5" /> 174 - <span>{issue.author.handle}</span> 175 - <span>·</span> 176 - <span>{formatRelativeTime(issue.value.createdAt)}</span> 177 - </div> 178 - </div> 179 - </A> 180 - )} 181 - </For> 170 + /> 171 + )} 172 + </For> 182 173 </div> 183 174 </div> 184 175 <Show when={repoQuery.data}> ··· 530 521 </div> 531 522 </section> 532 523 533 - <div class="flex min-w-0 flex-col gap-4"> 534 - <div class="mt-4 flex flex-col gap-4"> 524 + <div class="flex min-w-0 flex-col gap-4"> <div class="mt-4 flex flex-col gap-4"> 535 525 <For each={commentThreads()}> 536 526 {(thread) => ( 537 - <div class="overflow-hidden rounded border border-gray-200 bg-gray-50 shadow-sm dark:border-gray-700 dark:bg-gray-800/50"> 538 - <div id={`comment-${thread.item.rkey}`} class="flex gap-2 rounded bg-white px-6 py-4 dark:bg-gray-800"> 539 - <div class="shrink-0"> 540 - <Avatar did={thread.item.author.did} size="size-8 mr-1" /> 541 - </div> 542 - <div class="min-w-0 flex-1"> 543 - <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 544 - <a href={`/${thread.item.author.did}`} class="text-gray-700 dark:text-gray-200">{thread.item.author.handle}</a> 545 - <Show when={thread.item.author.did === detail().issue.author.did}> 546 - <span>(author)</span> 547 - </Show> 548 - <span class="select-none">·</span> 549 - <a href={`#comment-${thread.item.rkey}`} class="hover:underline">{formatRelativeTime(thread.item.value.createdAt)}</a> 550 - </div> 551 - <MarkdownBlock markdown={thread.item.value.body} /> 552 - </div> 553 - </div> 527 + <div class="untangled-thread-group"> 528 + <ThreadCommentView 529 + id={`comment-${thread.item.rkey}`} 530 + author={{ did: thread.item.author.did, handle: thread.item.author.handle }} 531 + createdAt={thread.item.value.createdAt} 532 + markdown={thread.item.value.body} 533 + isAuthor={thread.item.author.did === detail().issue.author.did} 534 + class="untangled-thread-comment" 535 + /> 554 536 555 537 <Show when={thread.replies.length > 0}> 556 - <div class="relative ml-10 border-l border-gray-200 dark:border-gray-700"> 538 + <div class="untangled-thread-replies-list"> 557 539 <For each={thread.replies}> 558 540 {(reply) => ( 559 - <div id={`comment-${reply.rkey}`} class="-ml-4 flex gap-2 py-4 pr-4"> 560 - <div class="shrink-0 relative z-10"> 561 - <Avatar did={reply.author.did} size="size-8 mr-1" /> 562 - </div> 563 - <div class="min-w-0 flex-1"> 564 - <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 565 - <a href={`/${reply.author.did}`} class="text-gray-700 dark:text-gray-200">{reply.author.handle}</a> 566 - <Show when={reply.author.did === detail().issue.author.did}> 567 - <span>(author)</span> 568 - </Show> 569 - <span class="select-none">·</span> 570 - <a href={`#comment-${reply.rkey}`} class="hover:underline">{formatRelativeTime(reply.value.createdAt)}</a> 571 - </div> 572 - <MarkdownBlock markdown={reply.value.body} /> 573 - </div> 574 - </div> 541 + <ThreadCommentView 542 + id={`comment-${reply.rkey}`} 543 + author={{ did: reply.author.did, handle: reply.author.handle }} 544 + createdAt={reply.value.createdAt} 545 + markdown={reply.value.body} 546 + isAuthor={reply.author.did === detail().issue.author.did} 547 + class="untangled-thread-reply" 548 + /> 575 549 )} 576 550 </For> 577 551 </div> ··· 583 557 fallback={ 584 558 <button 585 559 type="button" 586 - class="flex w-full items-center gap-2 border-t border-gray-300 px-6 py-2 text-left text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400" 560 + class="untangled-thread-reply-trigger" 587 561 onClick={() => { 588 562 setReplyingTo(thread.item); 589 563 setReplyBody(''); ··· 598 572 > 599 573 <form 600 574 onSubmit={(event) => void submitReply(event, thread.item)} 601 - class="flex w-full flex-col gap-2 border-t border-gray-200 p-2 dark:border-gray-700" 575 + class="untangled-thread-reply-form" 602 576 > 603 577 <Show when={currentDid()}> 604 578 {(did) => ( 605 579 <div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 606 580 <Avatar did={did()} size="size-6" /> 607 - <span>{currentHandle()}</span> 581 + <span>{currentHandle()}</span> 608 582 </div> 609 583 )} 610 584 </Show> ··· 635 609 </For> 636 610 637 611 <Show when={auth.agent()}> 638 - <form onSubmit={addComment} class="flex min-w-0 flex-col gap-2"> 639 - <div class="relative flex min-w-0 w-full flex-col gap-3 rounded bg-white px-4 py-4 shadow-sm dark:bg-gray-800"> 612 + <form onSubmit={addComment} class="flex min-w-0 flex-col gap-2"> 613 + <div class="untangled-comment-composer"> 640 614 <Show when={currentDid()}> 641 615 {(did) => ( 642 616 <div class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200"> ··· 657 631 </Show> 658 632 </div> 659 633 <div class="flex flex-wrap items-center gap-2"> 660 - <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')} disabled={working() || !comment().trim()}> 634 + <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')} disabled={working() || !comment().trim()}> 661 635 <Show when={workingAction() === 'comment'} fallback={<MessageSquarePlus class="size-4" />}> 662 636 <LoaderCircle class="size-4 animate-spin" /> 663 637 </Show>
+25 -60
src/pages/repo/pulls.tsx
··· 28 28 import { compareBranches, getRepoBranches, type RepoContext, compareFork, listRepoRecords } from '../../lib/api/repos'; 29 29 import { useAuth } from '../../lib/auth'; 30 30 import { Avatar, ErrorState, LoadingState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common'; 31 - import { AtUriPanel, BranchPill, DiffView, MarkdownBlock, PullComposeSkeleton, PullDiffSkeleton, PullDiffView, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 31 + import { AtUriPanel, BranchPill, DiffView, MarkdownBlock, PullComposeSkeleton, PullDiffSkeleton, PullDiffView, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton, IssueOrPullRow, ThreadCommentView } from '../../components/repo'; 32 32 import { RepoFrame, pullQueryKey, pullsQueryKey, useRepoQuery } from './shared'; 33 33 import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, parseIntegerSearchParam, pullHref, uniqueCommenters, saveRecentIssueOrPull } from '../../lib/repo-utils'; 34 34 import { buildPullFilterQuery, parsePastedPatchPrefill, parsePullFilter, type PullSourceMode, getRoundIndexForComment, buildPRCommentThreads } from './pulls-helpers'; ··· 104 104 <RepoFrame active="pulls" loadingFallback={<RepoListPageSkeleton kind="pulls" showCreate={Boolean(auth.agent())} />}> 105 105 <div class="rounded bg-white p-4 dark:bg-gray-800"> 106 106 <div class="untangled-filter-grid"> 107 - <div class="flex items-stretch rounded border border-gray-300 dark:border-gray-700"> 107 + <div class="untangled-toggle-group"> 108 108 <ToggleButton 109 109 active={stateFilter() === 'open'} 110 110 onClick={() => setStateFilter('open')} ··· 183 183 <div class="contents"> 184 184 <For each={paginated()}> 185 185 {(pull) => ( 186 - <A 186 + <IssueOrPullRow 187 + title={pull.value.title} 188 + number={pull.number || pull.rkey} 189 + state={pull.state} 190 + kind="pull" 191 + author={{ did: pull.author.did, handle: pull.author.handle }} 192 + createdAt={pull.value.createdAt} 187 193 href={pullHref(repoQuery.data!, pull.uri)} 188 - class="block rounded shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700 no-underline hover:underline" 194 + meta={ 195 + <> 196 + <span>·</span> 197 + <span>{pull.value.rounds.length} round{pull.value.rounds.length === 1 ? '' : 's'}</span> 198 + </> 199 + } 189 200 > 190 - <div class="min-w-0"> 191 - <div class="text-base text-gray-900 dark:text-gray-100"> 192 - {pull.value.title}{' '} 193 - <span class="text-gray-500 dark:text-gray-400">#{pull.number || pull.rkey}</span> 194 - </div> 195 - <div class="mt-3 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 196 - <StateBadge state={pull.state} kind="pull" /> 197 - <Avatar did={pull.author.did} size="size-5" /> 198 - <span>{pull.author.handle}</span> 199 - <span>·</span> 200 - <span>{formatRelativeTime(pull.value.createdAt)}</span> 201 - <span>·</span> 202 - <span>{pull.value.rounds.length} round{pull.value.rounds.length === 1 ? '' : 's'}</span> 203 - </div> 204 201 <div class="mt-3 flex flex-wrap items-center gap-2 text-sm text-gray-600 dark:text-gray-300"> 205 202 <span>targeting</span> 206 203 <BranchPill name={pull.value.target.branch} /> 207 204 <span>from</span> 208 205 <BranchPill name={pull.value.source?.branch ?? pull.value.target.branch} /> 209 206 </div> 210 - </div> 211 - </A> 212 - )} 213 - </For> 207 + </IssueOrPullRow> 208 + )} 209 + </For> 214 210 </div> 215 211 </div> 216 212 <Show when={repoQuery.data}> ··· 1008 1004 return data; 1009 1005 }; 1010 1006 1011 - const PRCommentView: Component<{ 1012 - author: string; 1013 - did: string; 1014 - createdAt: string; 1015 - markdown: string; 1016 - isReply?: boolean; 1017 - id?: string; 1018 - }> = (props) => ( 1019 - <div 1020 - id={props.id} 1021 - class={clsx( 1022 - 'flex gap-2 w-full mx-auto text-sm text-gray-900 dark:text-gray-100 py-4', 1023 - props.isReply && 'pr-4' 1024 - )} 1025 - > 1026 - <div class="shrink-0 h-fit relative z-10"> 1027 - <Avatar did={props.did} size="size-8 mr-1" /> 1028 - </div> 1029 - <div class="min-w-0 flex-1"> 1030 - <div class="flex flex-wrap items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 mb-0.5"> 1031 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 font-medium hover:underline" href={`/${props.did}`}>{props.author}</a> 1032 - <span class="select-none">·</span> 1033 - <Show when={props.id} fallback={<span>{formatRelativeTime(props.createdAt)}</span>}> 1034 - <a href={`#${props.id}`} class="hover:underline">{formatRelativeTime(props.createdAt)}</a> 1035 - </Show> 1036 - </div> 1037 - <div class="min-w-0 break-words text-gray-800 dark:text-gray-200"> 1038 - <MarkdownBlock markdown={props.markdown} /> 1039 - </div> 1040 - </div> 1041 - </div> 1042 - ); 1007 + 1043 1008 1044 1009 export const PullPage: Component = () => { 1045 1010 const auth = useAuth(); ··· 1541 1506 <For each={roundThreads()}> 1542 1507 {(thread) => ( 1543 1508 <div class="flex flex-col -ml-4"> 1544 - <PRCommentView 1509 + <ThreadCommentView 1545 1510 id={`comment-${thread.item.rkey}`} 1546 - author={thread.item.author.handle} 1547 - did={thread.item.author.did} 1511 + author={{ handle: thread.item.author.handle, did: thread.item.author.did }} 1548 1512 createdAt={thread.item.value.createdAt} 1549 1513 markdown={thread.item.value.body} 1514 + isAuthor={thread.item.author.did === detail().pull.author.did} 1550 1515 /> 1551 1516 <Show when={thread.replies.length > 0}> 1552 1517 <div class="relative ml-10 border-l border-gray-200 dark:border-gray-700"> 1553 1518 <For each={thread.replies}> 1554 1519 {(reply) => ( 1555 1520 <div class="-ml-4"> 1556 - <PRCommentView 1521 + <ThreadCommentView 1557 1522 id={`comment-${reply.rkey}`} 1558 - author={reply.author.handle} 1559 - did={reply.author.did} 1523 + author={{ handle: reply.author.handle, did: reply.author.did }} 1560 1524 createdAt={reply.value.createdAt} 1561 1525 markdown={reply.value.body} 1526 + isAuthor={reply.author.did === detail().pull.author.did} 1562 1527 isReply={true} 1563 1528 /> 1564 1529 </div>