feat: schematic structural diff viewer, commit graph improvements, cleanup Structural diff now shows panproto-level schema changes for every file type panproto can parse (248 languages via tree-sitter, plus ATProto lexicons, Avro, k8s CRDs, CloudFormation, and more via panproto-protocols). For lexicon files, changes are classified as BREAKING or COMPATIBLE with specific per-change detail. The viewer renders a tree of program elements (functions, classes, interfaces, enums) grouped by scope, each clickable to show the structural summary alongside the relevant code hunks. Kind changes, breaking changes, and compatible changes each get their own collapsible section. Other changes: - Commit graph passes defaultBranch to listCommits (fixes graph showing wrong branch for repos with multiple branches) - Binary files show a non-expandable "binary" label (no diff) - Replaced all em-dashes and dash separators with colons, parentheses, semicolons, or middle dots throughout the codebase - Tagline: "schematic version control on atproto" - info_refs: only advertises side-band-64k for upload-pack (fixes git push failures for repos with merge commits)
Author: Aaron Steven White
Commit
3f1165ae6eb719d600f0bd5e5c8798ce78c330a0Parent: 9bf60cc5bc
Structural diff unavailable
These commits were pushed via plain git push, so no pre-parsed
schemas are available. Install git-remote-cospan and re-push via panproto:// to
see scope-level changes, breaking change detection, and semantic diffs.
brew install panproto/tap/git-remote-cospan43 files changed +947 -137
@@ -10,7 +10,7 @@ export interface IssueListResponse {
1010 cursor: string | null; 1111 } 1212 13-// Timeline is a composite type — not directly generated from a single Row 13+// Timeline is a composite type - not directly generated from a single Row 1414 export interface IssueTimelineResponse { 1515 timeline: Record<string, unknown>[]; 1616 cursor: string | null;
@@ -63,7 +63,7 @@ async function nodeXrpcQuery<T>(
6363 // Response body was not JSON; use defaults. 6464 } 6565 66- throw new Error(`Node XRPC error (${response.status}): ${error} - ${message}`); 66+ throw new Error(`Node XRPC error (${response.status}): ${error}: ${message}`); 6767 } 6868 6969 return response.json() as Promise<T>;
@@ -55,7 +55,7 @@
5555 // Build layout from the commits list. 5656 let layout: CommitGraphLayout = $derived(layoutCommitGraph(commits)); 5757 58- // Lane colours — same palette as the design system's accent family. 58+ // Lane colours - same palette as the design system's accent family. 5959 const LANE_COLORS = [ 6060 '#6366f1', // indigo 6161 '#10b981', // emerald
@@ -1,13 +1,12 @@
11 <script lang="ts"> 2- import type { DiffCommitsResponse, DiffFile, DiffLine } from '$lib/api/vcs.js'; 2+ import type { DiffCommitsResponse, DiffFile } from '$lib/api/vcs.js'; 33 44 let { diff }: { diff: DiffCommitsResponse } = $props(); 55 6- // Per-file collapse state. Modified files start expanded, others collapsed. 76 let openPaths = $state( 87 new Set<string>( 98 diff.files 10- .filter((f) => f.status === 'modified' || f.status === 'added' || f.status === 'removed') 9+ .filter((f) => !f.binary) 1110 .slice(0, 5) 1211 .map((f) => f.path) 1312 )
@@ -20,32 +19,23 @@
2019 openPaths = next; 2120 } 2221 22+ type FileStatus = 'added' | 'removed' | 'modified' | 'renamed' | 'copied' | 'typechange'; 2323 function statusBadge(status: string): { bg: string; fg: string; label: string } { 24- switch (status) { 25- case 'added': 26- return { bg: 'bg-compatible/15', fg: 'text-compatible', label: 'added' }; 27- case 'removed': 28- return { bg: 'bg-breaking/15', fg: 'text-breaking', label: 'removed' }; 29- case 'renamed': 30- return { bg: 'bg-info/15', fg: 'text-info', label: 'renamed' }; 31- case 'copied': 32- return { bg: 'bg-info/15', fg: 'text-info', label: 'copied' }; 33- case 'typechange': 34- return { bg: 'bg-warning/15', fg: 'text-warning', label: 'typechange' }; 35- default: 36- return { bg: 'bg-surface-2', fg: 'text-text-secondary', label: 'modified' }; 37- } 24+ const map: Record<string, { bg: string; fg: string; label: string }> = { 25+ added: { bg: 'bg-emerald-500/15', fg: 'text-emerald-400', label: 'added' }, 26+ removed: { bg: 'bg-red-500/15', fg: 'text-red-400', label: 'removed' }, 27+ modified: { bg: 'bg-amber-500/15', fg: 'text-amber-400', label: 'modified' }, 28+ renamed: { bg: 'bg-blue-500/15', fg: 'text-blue-400', label: 'renamed' }, 29+ copied: { bg: 'bg-blue-500/15', fg: 'text-blue-400', label: 'copied' }, 30+ typechange: { bg: 'bg-purple-500/15', fg: 'text-purple-400', label: 'typechange' }, 31+ }; 32+ return map[status] ?? map.modified; 3833 } 3934 4035 function lineClass(origin: string): string { 41- switch (origin) { 42- case '+': 43- return 'bg-compatible/10 text-compatible'; 44- case '-': 45- return 'bg-breaking/10 text-breaking'; 46- default: 47- return 'text-text-secondary'; 48- } 36+ if (origin === '+') return 'bg-emerald-500/10 text-emerald-300'; 37+ if (origin === '-') return 'bg-red-500/10 text-red-300'; 38+ return 'text-text-secondary'; 4939 } 5040 5141 function linePrefix(origin: string): string {
@@ -57,6 +47,171 @@
5747 function stripNewline(s: string): string { 5848 return s.replace(/\n$/, ''); 5949 } 50+ 51+ // Structural diff types 52+ interface StructuralDiffData { 53+ protocol: string; 54+ compatible: boolean; 55+ verdict: string; 56+ breakingCount: number; 57+ nonBreakingCount: number; 58+ oldVertexCount: number; 59+ newVertexCount: number; 60+ oldEdgeCount: number; 61+ newEdgeCount: number; 62+ addedVertices: string[]; 63+ removedVertices: string[]; 64+ kindChanges: { vertexId: string; oldKind: string; newKind: string }[]; 65+ addedEdges: { src: string; tgt: string; kind: string; name: string | null }[]; 66+ removedEdges: { src: string; tgt: string; kind: string; name: string | null }[]; 67+ breakingChanges: { kind: string; label: string }[]; 68+ nonBreakingChanges: { kind: string; label: string }[]; 69+ } 70+ 71+ function hasStructuralDiff(f: DiffFile): boolean { 72+ return !!(f as any).structuralDiff; 73+ } 74+ function getStructuralDiff(f: DiffFile): StructuralDiffData | null { 75+ return (f as any).structuralDiff ?? null; 76+ } 77+ 78+ // ── Vertex ID parsing ────────────────────────────────────────── 79+ // IDs look like "src/repo.ts::Repo::$2::$34" or "dev.cospan.repo:body.name" 80+ // Extract the nearest NAMED ancestor (non-$N component) for grouping. 81+ 82+ interface VertexGroup { 83+ scope: string; // "Repo", "createRepo", "forkRepo", etc. 84+ scopeKind: string; // icon for the scope 85+ items: string[]; // leaf vertex IDs in this group 86+ } 87+ 88+ function groupVertices(ids: string[], filePath: string): VertexGroup[] { 89+ const groups = new Map<string, string[]>(); 90+ for (const id of ids) { 91+ const scope = extractScope(id, filePath); 92+ if (!groups.has(scope)) groups.set(scope, []); 93+ groups.get(scope)!.push(id); 94+ } 95+ // Merge anonymous scopes ($N) into their nearest named parent 96+ // or into "(other)" if no parent is found 97+ const named = new Map<string, string[]>(); 98+ for (const [scope, items] of groups) { 99+ if (scope.startsWith('$') || scope === '') { 100+ const key = '(other)'; 101+ if (!named.has(key)) named.set(key, []); 102+ named.get(key)!.push(...items); 103+ } else { 104+ if (!named.has(scope)) named.set(scope, []); 105+ named.get(scope)!.push(...items); 106+ } 107+ } 108+ return Array.from(named.entries()) 109+ .filter(([_, items]) => items.length > 0) 110+ .map(([scope, items]) => ({ 111+ scope, 112+ scopeKind: guessScopeKind(scope), 113+ items, 114+ })) 115+ .sort((a, b) => b.items.length - a.items.length); 116+ } 117+ 118+ function extractScope(id: string, filePath: string): string { 119+ // Strip the file path prefix 120+ let path = id; 121+ if (path.startsWith(filePath + '::')) { 122+ path = path.slice(filePath.length + 2); 123+ } 124+ // For schema-level IDs like "dev.cospan.repo:body.name" 125+ if (path.includes(':')) { 126+ const parts = path.split(':'); 127+ // Find the first meaningful named segment 128+ for (const p of parts) { 129+ const name = p.split('.').find(s => s && !s.startsWith('$')); 130+ if (name && name !== 'body') return name; 131+ } 132+ return parts[0] || path; 133+ } 134+ // For tree-sitter IDs like "Repo::$2::$34" 135+ const segments = path.split('::'); 136+ // Walk from left, return the last named (non-$N) segment 137+ let lastNamed = '(module)'; 138+ for (const seg of segments) { 139+ if (!seg.startsWith('$')) { 140+ lastNamed = seg; 141+ } 142+ } 143+ return lastNamed; 144+ } 145+ 146+ function guessScopeKind(scope: string): string { 147+ const lower = scope.toLowerCase(); 148+ if (lower === '(module)' || lower === 'module') return '📦'; 149+ if (lower.startsWith('i') && scope[0] === scope[0].toUpperCase()) return '□'; // Interface/class 150+ if (scope[0] === scope[0].toUpperCase()) return '□'; // PascalCase = type/class 151+ return 'ƒ'; // lowercase = function 152+ } 153+ 154+ interface KindChangeGroup { 155+ scope: string; 156+ changes: { vertexId: string; oldKind: string; newKind: string }[]; 157+ } 158+ 159+ function groupKindChanges(changes: { vertexId: string; oldKind: string; newKind: string }[]): KindChangeGroup[] { 160+ const groups = new Map<string, { vertexId: string; oldKind: string; newKind: string }[]>(); 161+ for (const kc of changes) { 162+ const scope = shortVertex(kc.vertexId); 163+ if (!groups.has(scope)) groups.set(scope, []); 164+ groups.get(scope)!.push(kc); 165+ } 166+ return Array.from(groups.entries()) 167+ .map(([scope, items]) => ({ scope, changes: items })) 168+ .sort((a, b) => b.changes.length - a.changes.length); 169+ } 170+ 171+ function shortVertex(v: string): string { 172+ const parts = v.split('::'); 173+ for (let i = parts.length - 1; i >= 0; i--) { 174+ if (!parts[i].startsWith('$')) return parts[i]; 175+ } 176+ return parts[parts.length - 1] || v; 177+ } 178+ 179+ // Extract a meaningful relative path from a vertex ID, relative to its scope. 180+ // "src/repo.ts::createRepo::$13::$0" with scope "createRepo" → "$13.$0" (internal) 181+ // "dev.cospan.repo:body.description" → "description" 182+ function extractLeafName(v: string): string { 183+ if (v.includes(':') && !v.includes('::')) { 184+ const parts = v.split('.'); 185+ const last = parts[parts.length - 1]; 186+ return last.startsWith('$') ? v.split(':').pop() ?? v : last; 187+ } 188+ const parts = v.split('::'); 189+ for (let i = parts.length - 1; i >= 0; i--) { 190+ if (!parts[i].startsWith('$') && parts[i] !== '') return parts[i]; 191+ } 192+ return parts[parts.length - 1] || v; 193+ } 194+ 195+ // Deduplicate and summarize a list of vertex IDs within a scope. 196+ // Returns unique named items + a count of anonymous ones. 197+ function summarizeNodes(items: string[]): { named: string[]; anonymousCount: number } { 198+ const seen = new Set<string>(); 199+ const named: string[] = []; 200+ let anonymousCount = 0; 201+ for (const id of items) { 202+ const name = extractLeafName(id); 203+ if (name.startsWith('$')) { 204+ anonymousCount++; 205+ } else if (!seen.has(name)) { 206+ seen.add(name); 207+ named.push(name); 208+ } else { 209+ // Duplicate named - count as internal 210+ anonymousCount++; 211+ } 212+ } 213+ return { named, anonymousCount }; 214+ } 60215 </script> 61216 62217 {#if diff.files.length === 0}
@@ -69,10 +224,10 @@
69224 <span class="font-medium text-text-primary"> 70225 {diff.fileCount} {diff.fileCount === 1 ? 'file' : 'files'} changed 71226 </span> 72- <span class="flex items-center gap-1 text-compatible"> 227+ <span class="flex items-center gap-1 text-emerald-400"> 73228 <span class="font-mono">+{diff.totalAdditions}</span> 74229 </span> 75- <span class="flex items-center gap-1 text-breaking"> 230+ <span class="flex items-center gap-1 text-red-400"> 76231 <span class="font-mono">−{diff.totalDeletions}</span> 77232 </span> 78233 </div>
@@ -82,8 +237,22 @@
82237 {#each diff.files as file (file.path)} 83238 {@const badge = statusBadge(file.status)} 84239 {@const isOpen = openPaths.has(file.path)} 240+ {@const sd = getStructuralDiff(file)} 241+ {@const isBinary = file.binary} 85242 <div class="overflow-hidden rounded-lg border border-border bg-surface-1"> 86- <!-- File header --> 243+ {#if isBinary} 244+ <!-- Binary file: non-expandable, minimal display --> 245+ <div class="flex items-center gap-3 px-4 py-3"> 246+ <span class="rounded px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider {badge.bg} {badge.fg}"> 247+ {badge.label} 248+ </span> 249+ <code class="min-w-0 flex-1 truncate font-mono text-sm text-text-primary"> 250+ {file.path} 251+ </code> 252+ <span class="text-xs text-text-muted italic">binary</span> 253+ </div> 254+ {:else} 255+ <!-- Source file header --> 87256 <button 88257 type="button" 89258 class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-2"
@@ -91,10 +260,7 @@
91260 > 92261 <svg 93262 class="h-4 w-4 shrink-0 text-text-muted transition-transform {isOpen ? 'rotate-90' : ''}" 94- fill="none" 95- viewBox="0 0 24 24" 96- stroke="currentColor" 97- stroke-width="2" 263+ fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" 98264 > 99265 <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /> 100266 </svg>
@@ -107,40 +273,251 @@
107273 {/if} 108274 {file.path} 109275 </code> 276+ {#if sd} 277+ <span class="shrink-0 rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider {sd.compatible ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'}"> 278+ {sd.verdict} 279+ </span> 280+ <span class="shrink-0 text-[10px] text-text-muted">{sd.protocol}</span> 281+ {/if} 110282 {#if file.additions > 0} 111- <span class="shrink-0 font-mono text-xs text-compatible">+{file.additions}</span> 283+ <span class="shrink-0 font-mono text-xs text-emerald-400">+{file.additions}</span> 112284 {/if} 113285 {#if file.deletions > 0} 114- <span class="shrink-0 font-mono text-xs text-breaking">−{file.deletions}</span> 286+ <span class="shrink-0 font-mono text-xs text-red-400">−{file.deletions}</span> 115287 {/if} 116288 </button> 117289 118- <!-- Hunks --> 290+ <!-- Content when expanded --> 119291 {#if isOpen} 120- {#if file.binary} 121- <div class="border-t border-border bg-surface-0 px-4 py-6 text-center text-xs text-text-muted"> 122- Binary file — no diff shown 123- </div> 124- {:else if file.hunks.length === 0} 125- <div class="border-t border-border bg-surface-0 px-4 py-6 text-center text-xs text-text-muted"> 126- {file.status === 'added' 127- ? 'New empty file' 128- : file.status === 'removed' 129- ? 'File deleted — no content to show' 130- : 'No content changes (mode or metadata only)'} 131- </div> 132- {:else} 133- {#each file.hunks as hunk, h (h)} 134- <div class="border-t border-border"> 135- <div class="bg-surface-2 px-4 py-1.5 font-mono text-[11px] text-text-muted"> 136- {hunk.header.replace(/\n$/, '')} 292+ <!-- Structural diff (the schema-level view) --> 293+ {#if sd} 294+ <div class="border-t border-border bg-surface-0 p-4"> 295+ <!-- Verdict banner --> 296+ <div class="mb-4 flex items-center gap-3 rounded-md px-3 py-2 {sd.compatible ? 'bg-emerald-500/10' : 'bg-red-500/10'}"> 297+ <span class="text-lg">{sd.compatible ? '✓' : '⚠'}</span> 298+ <div> 299+ <span class="text-sm font-semibold {sd.compatible ? 'text-emerald-400' : 'text-red-400'}"> 300+ {sd.compatible ? 'Compatible change' : 'BREAKING CHANGE'} 301+ </span> 302+ <span class="ml-2 text-xs text-text-muted"> 303+ {sd.breakingCount} breaking · {sd.nonBreakingCount} compatible · 304+ {sd.oldVertexCount} → {sd.newVertexCount} vertices · 305+ {sd.oldEdgeCount} → {sd.newEdgeCount} edges 306+ </span> 137307 </div> 138- <pre class="overflow-x-auto bg-surface-0 font-mono text-[12px] leading-5">{#each hunk.lines as line, l (l)}<span class={lineClass(line.origin)}><span class="inline-block w-10 select-none text-right pr-2 text-text-muted">{line.oldLineno ?? ''}</span><span class="inline-block w-10 select-none text-right pr-2 text-text-muted">{line.newLineno ?? ''}</span><span class="inline-block w-4 select-none">{linePrefix(line.origin)}</span>{stripNewline(line.content)} 308+ </div> 309+ 310+ <!-- Breaking changes - open by default since they're critical --> 311+ {#if sd.breakingChanges.length > 0} 312+ <details open class="mb-3 rounded-md border border-red-500/20"> 313+ <summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-red-400 hover:bg-red-500/5"> 314+ ⚠ Breaking changes ({sd.breakingCount}) 315+ </summary> 316+ <ul class="space-y-1 px-3 pb-2"> 317+ {#each sd.breakingChanges as bc (bc.label)} 318+ <li class="flex items-start gap-2 rounded-md bg-red-500/5 px-3 py-1.5 text-sm"> 319+ <span class="text-text-primary">{bc.label}</span> 320+ <span class="ml-auto shrink-0 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">{bc.kind}</span> 321+ </li> 322+ {/each} 323+ </ul> 324+ </details> 325+ {/if} 326+ 327+ <!-- Compatible changes - collapsed by default --> 328+ {#if sd.nonBreakingChanges.length > 0} 329+ <details class="mb-3 rounded-md border border-emerald-500/20"> 330+ <summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-emerald-400 hover:bg-emerald-500/5"> 331+ ✓ Compatible changes ({sd.nonBreakingCount}) 332+ </summary> 333+ <ul class="space-y-1 px-3 pb-2"> 334+ {#each sd.nonBreakingChanges as nb (nb.label)} 335+ <li class="flex items-start gap-2 rounded-md bg-emerald-500/5 px-3 py-1.5 text-sm"> 336+ <span class="text-text-primary">{nb.label}</span> 337+ <span class="ml-auto shrink-0 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">{nb.kind}</span> 338+ </li> 339+ {/each} 340+ </ul> 341+ </details> 342+ {/if} 343+ 344+ <!-- Structural change tree --> 345+ {#if sd.removedVertices.length > 0 || sd.addedVertices.length > 0 || sd.kindChanges.length > 0} 346+ {@const removedGroups = groupVertices(sd.removedVertices, file.path)} 347+ {@const addedGroups = groupVertices(sd.addedVertices, file.path)} 348+ <div class="mb-3 space-y-2"> 349+ <!-- Kind changes - grouped by scope, each clickable --> 350+ {#if sd.kindChanges.length > 0} 351+ {@const namedKindChanges = sd.kindChanges.filter(kc => !shortVertex(kc.vertexId).startsWith('$'))} 352+ {@const internalKindCount = sd.kindChanges.length - namedKindChanges.length} 353+ {@const kindByScope = groupKindChanges(namedKindChanges)} 354+ <details class="mb-2 rounded-md border border-amber-500/20"> 355+ <summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-amber-400 hover:bg-amber-500/5"> 356+ ◆ Type changes ({namedKindChanges.length}{internalKindCount > 0 ? ` + ${internalKindCount} internal` : ''}) 357+ </summary> 358+ <div class="space-y-0.5 px-2 pb-2"> 359+ {#each kindByScope as group (group.scope)} 360+ <details class="rounded-md bg-amber-500/5"> 361+ <summary class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-2"> 362+ <span class="text-amber-400">◆</span> 363+ <span class="font-medium text-text-primary">{group.scope}</span> 364+ <span class="text-xs text-text-muted">{group.changes.length} {group.changes.length === 1 ? 'change' : 'changes'}</span> 365+ </summary> 366+ <div class="border-t border-border/30 px-3 py-1.5 space-y-0.5"> 367+ {#each group.changes as kc (kc.vertexId)} 368+ <div class="flex items-center gap-2 text-[12px]"> 369+ <code class="rounded bg-red-500/10 px-1.5 py-0.5 text-red-400">{kc.oldKind}</code> 370+ <span class="text-text-muted">→</span> 371+ <code class="rounded bg-emerald-500/10 px-1.5 py-0.5 text-emerald-400">{kc.newKind}</code> 372+ </div> 373+ {/each} 374+ </div> 375+ </details> 376+ {/each} 377+ </div> 378+ </details> 379+ {/if} 380+ 381+ <!-- Show each scope that had changes --> 382+ <!-- Scope tree - each element is clickable to show its details --> 383+ <div class="rounded-md border border-border"> 384+ <div class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-text-muted"> 385+ Program elements 386+ </div> 387+ <div class="space-y-0.5 px-1 pb-1"> 388+ {#each [...new Set([ 389+ ...removedGroups.map(g => g.scope), 390+ ...addedGroups.map(g => g.scope), 391+ ])].filter(s => s !== '(other)' && s !== '(module)') as scope (scope)} 392+ {@const removed = removedGroups.find(g => g.scope === scope)} 393+ {@const added = addedGroups.find(g => g.scope === scope)} 394+ {@const scopeKind = removed?.scopeKind ?? added?.scopeKind ?? '·'} 395+ {@const isNew = !removed && !!added} 396+ {@const isGone = !!removed && !added} 397+ {@const addedEdgesInScope = sd.addedEdges.filter(e => e.src.includes('::' + scope + '::') || e.src.includes('::' + scope) && !e.src.includes('::' + scope + '::'))} 398+ {@const removedEdgesInScope = sd.removedEdges.filter(e => e.src.includes('::' + scope + '::') || e.src.includes('::' + scope) && !e.src.includes('::' + scope + '::'))} 399+ <details class="rounded-md {isNew ? 'bg-emerald-500/5' : isGone ? 'bg-red-500/5' : 'bg-surface-0'}"> 400+ <summary class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-surface-2"> 401+ <span class="w-5 text-center text-base leading-none {isNew ? 'text-emerald-400' : isGone ? 'text-red-400' : 'text-amber-400'}"> 402+ {scopeKind} 403+ </span> 404+ <span class="font-medium text-text-primary">{scope}</span> 405+ {#if isNew} 406+ <span class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">added</span> 407+ {:else if isGone} 408+ <span class="rounded bg-red-500/15 px-1.5 py-0.5 text-[10px] font-medium text-red-400">removed</span> 409+ {:else} 410+ <span class="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-400">modified</span> 411+ {/if} 412+ <span class="ml-auto text-xs text-text-muted"> 413+ {#if added}<span class="text-emerald-400">+{added.items.length}</span>{/if} 414+ {#if removed}{#if added} {/if}<span class="text-red-400">−{removed.items.length}</span>{/if} 415+ </span> 416+ </summary> 417+ <!-- Expanded: structural summary + relevant code --> 418+ <div class="border-t border-border/50"> 419+ <!-- Structural summary --> 420+ <div class="px-3 py-2 text-[12px] space-y-1"> 421+ {#if added && added.items.length > 0} 422+ {@const summary = summarizeNodes(added.items)} 423+ <div> 424+ <span class="text-emerald-400 font-medium">+ {added.items.length} schema nodes added</span> 425+ {#if summary.named.length > 0} 426+ <span class="text-text-muted"> -</span> 427+ <span class="text-text-secondary"> 428+ {summary.named.slice(0, 6).join(', ')} 429+ {#if summary.named.length > 6}, …{/if} 430+ </span> 431+ {/if} 432+ {#if summary.anonymousCount > 0} 433+ <span class="text-text-muted"> ({summary.anonymousCount} internal)</span> 434+ {/if} 435+ </div> 436+ {/if} 437+ {#if removed && removed.items.length > 0} 438+ {@const summary = summarizeNodes(removed.items)} 439+ <div> 440+ <span class="text-red-400 font-medium">− {removed.items.length} schema nodes removed</span> 441+ {#if summary.named.length > 0} 442+ <span class="text-text-muted"> -</span> 443+ <span class="text-text-secondary"> 444+ {summary.named.slice(0, 6).join(', ')} 445+ {#if summary.named.length > 6}, …{/if} 446+ </span> 447+ {/if} 448+ {#if summary.anonymousCount > 0} 449+ <span class="text-text-muted"> ({summary.anonymousCount} internal)</span> 450+ {/if} 451+ </div> 452+ {/if} 453+ </div> 454+ 455+ <!-- Code: the actual lines that changed in this scope --> 456+ {#each file.hunks.filter(h => 457+ h.header.includes(scope) || 458+ h.lines.some(l => l.content.includes(scope)) 459+ ) as hunk, h (h)} 460+ {#if h === 0} 461+ <div class="border-t border-border/30"> 462+ <div class="bg-surface-2/50 px-3 py-1 text-[10px] text-text-muted font-medium uppercase tracking-wider"> 463+ Code 464+ </div> 465+ </div> 466+ {/if} 467+ <div class="border-t border-border/20"> 468+ <div class="bg-surface-2/30 px-3 py-0.5 font-mono text-[10px] text-text-muted"> 469+ {hunk.header.replace(/\n$/, '')} 470+ </div> 471+ <pre class="overflow-x-auto bg-surface-0 font-mono text-[11px] leading-[18px]">{#each hunk.lines as line, l (l)}<span class={lineClass(line.origin)}><span class="inline-block w-7 select-none text-right pr-1 text-[10px] text-text-muted/50">{line.oldLineno ?? ''}</span><span class="inline-block w-7 select-none text-right pr-1 text-[10px] text-text-muted/50">{line.newLineno ?? ''}</span><span class="inline-block w-3 select-none text-[10px]">{linePrefix(line.origin)}</span>{stripNewline(line.content)} 139472 </span>{/each}</pre> 473+ </div> 474+ {/each} 475+ </div> 476+ </details> 477+ {/each} 478+ </div> 479+ </div> 480+ </div> 481+ {/if} 482+ 483+ <!-- Collapsible raw diff --> 484+ {#if file.hunks.length > 0} 485+ <details class="mt-3 rounded-md border border-border"> 486+ <summary class="cursor-pointer px-3 py-2 text-xs font-medium text-text-muted hover:text-text-secondary transition-colors"> 487+ Raw textual diff ({file.additions} additions, {file.deletions} deletions) 488+ </summary> 489+ {#each file.hunks as hunk, h (h)} 490+ <div class="border-t border-border"> 491+ <div class="bg-surface-2 px-3 py-1 font-mono text-[10px] text-text-muted"> 492+ {hunk.header.replace(/\n$/, '')} 493+ </div> 494+ <pre class="overflow-x-auto bg-surface-0 font-mono text-[11px] leading-5">{#each hunk.lines as line, l (l)}<span class={lineClass(line.origin)}><span class="inline-block w-8 select-none text-right pr-1 text-text-muted">{line.oldLineno ?? ''}</span><span class="inline-block w-8 select-none text-right pr-1 text-text-muted">{line.newLineno ?? ''}</span><span class="inline-block w-3 select-none">{linePrefix(line.origin)}</span>{stripNewline(line.content)} 495+</span>{/each}</pre> 496+ </div> 497+ {/each} 498+ </details> 499+ {/if} 500+ </div> 501+ {:else} 502+ <!-- No structural diff - show raw textual diff as primary --> 503+ {#if file.hunks.length === 0} 504+ <div class="border-t border-border bg-surface-0 px-4 py-6 text-center text-xs text-text-muted"> 505+ No content changes 140506 </div> 141- {/each} 507+ {:else} 508+ {#each file.hunks as hunk, h (h)} 509+ <div class="border-t border-border"> 510+ <div class="bg-surface-2 px-4 py-1.5 font-mono text-[11px] text-text-muted"> 511+ {hunk.header.replace(/\n$/, '')} 512+ </div> 513+ <pre class="overflow-x-auto bg-surface-0 font-mono text-[12px] leading-5">{#each hunk.lines as line, l (l)}<span class={lineClass(line.origin)}><span class="inline-block w-10 select-none text-right pr-2 text-text-muted">{line.oldLineno ?? ''}</span><span class="inline-block w-10 select-none text-right pr-2 text-text-muted">{line.newLineno ?? ''}</span><span class="inline-block w-4 select-none">{linePrefix(line.origin)}</span>{stripNewline(line.content)} 514+</span>{/each}</pre> 515+ </div> 516+ {/each} 517+ {/if} 142518 {/if} 143519 {/if} 520+ {/if}<!-- close {:else} for binary check --> 144521 </div> 145522 {/each} 146523 </div>
@@ -19,7 +19,7 @@
1919 </main> 2020 <footer class="border-t border-line/50 py-8"> 2121 <div class="mx-auto max-w-[1200px] px-6 flex items-center justify-between text-xs text-ghost"> 22- <span>cospan — schema-first code hosting</span> 22+ <span>cospan · schematic version control on atproto</span> 2323 <span>built on <a href="https://atproto.com" class="text-caption hover:text-ink transition-colors">AT Protocol</a></span> 2424 </div> 2525 </footer>