feat: scope-level structural diff via panproto 0.30.0 Upgrade panproto to v0.30.0 which adds panproto_check::report_by_scope. This groups flat vertex/edge changes by their nearest named program element (function, class, type), classifying each scope as: - Added: new function/type - Removed: deleted function/type - SignatureChanged: named edges changed (API surface) - BodyModified: only anonymous children changed (implementation) Backend: - StructuralDiff now carries old/new schemas for scope analysis - structural_diff_to_json calls report_by_scope and includes scopeChanges and namedElements in the JSON output - No hand-written scope analysis: panproto does all the work Frontend: - Complete StructuralDiff.svelte rewrite (603 -> 290 lines) - Removed all client-side scope analysis helpers (extractScope, groupVertices, summarizeNodes, etc.) - panproto handles this - Primary view: scope change map showing which functions/types changed, with code hunks grouped by scope - Structure map: all named elements with status indicators - Breaking/compatible changes below scope view - Raw textual diff at bottom (collapsed)
Author: Aaron Steven White
Commit
1d7f38076409080d37cb1faab19f5adcacc3bf50Parent: 3d9a9e2b60
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-cospan6 files changed +278 -430
@@ -2492,9 +2492,10 @@ dependencies = [
24922492 24932493 [[package]] 24942494 name = "panproto-check" 2495-version = "0.28.0" 2496-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2495+version = "0.30.0" 2496+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 24972497 dependencies = [ 2498+ "memchr", 24982499 "panproto-gat", 24992500 "panproto-lens", 25002501 "panproto-mig",
@@ -2507,8 +2508,8 @@ dependencies = [
25072508 25082509 [[package]] 25092510 name = "panproto-core" 2510-version = "0.28.0" 2511-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2511+version = "0.30.0" 2512+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 25122513 dependencies = [ 25132514 "panproto-check", 25142515 "panproto-gat",
@@ -2523,8 +2524,8 @@ dependencies = [
25232524 25242525 [[package]] 25252526 name = "panproto-expr" 2526-version = "0.28.0" 2527-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2527+version = "0.30.0" 2528+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 25282529 dependencies = [ 25292530 "rustc-hash", 25302531 "serde",
@@ -2533,8 +2534,8 @@ dependencies = [
25332534 25342535 [[package]] 25352536 name = "panproto-expr-parser" 2536-version = "0.28.0" 2537-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2537+version = "0.30.0" 2538+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 25382539 dependencies = [ 25392540 "chumsky", 25402541 "logos 0.16.1",
@@ -2543,8 +2544,8 @@ dependencies = [
25432544 25442545 [[package]] 25452546 name = "panproto-gat" 2546-version = "0.28.0" 2547-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2547+version = "0.30.0" 2548+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 25482549 dependencies = [ 25492550 "panproto-expr", 25502551 "rustc-hash",
@@ -2554,8 +2555,8 @@ dependencies = [
25542555 25552556 [[package]] 25562557 name = "panproto-git" 2557-version = "0.28.0" 2558-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2558+version = "0.30.0" 2559+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 25592560 dependencies = [ 25602561 "git2", 25612562 "miette",
@@ -2572,8 +2573,8 @@ dependencies = [
25722573 25732574 [[package]] 25742575 name = "panproto-grammars" 2575-version = "0.28.0" 2576-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2576+version = "0.30.0" 2577+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 25772578 dependencies = [ 25782579 "cc", 25792580 "serde",
@@ -2584,8 +2585,8 @@ dependencies = [
25842585 25852586 [[package]] 25862587 name = "panproto-inst" 2587-version = "0.28.0" 2588-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2588+version = "0.30.0" 2589+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 25892590 dependencies = [ 25902591 "bumpalo", 25912592 "panproto-expr",
@@ -2600,8 +2601,8 @@ dependencies = [
26002601 26012602 [[package]] 26022603 name = "panproto-io" 2603-version = "0.28.0" 2604-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2604+version = "0.30.0" 2605+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 26052606 dependencies = [ 26062607 "bumpalo", 26072608 "memchr",
@@ -2623,8 +2624,8 @@ dependencies = [
26232624 26242625 [[package]] 26252626 name = "panproto-lens" 2626-version = "0.28.0" 2627-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2627+version = "0.30.0" 2628+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 26282629 dependencies = [ 26292630 "panproto-expr", 26302631 "panproto-gat",
@@ -2640,8 +2641,8 @@ dependencies = [
26402641 26412642 [[package]] 26422643 name = "panproto-lens-dsl" 2643-version = "0.28.0" 2644-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2644+version = "0.30.0" 2645+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 26452646 dependencies = [ 26462647 "miette", 26472648 "nickel-lang",
@@ -2659,8 +2660,8 @@ dependencies = [
26592660 26602661 [[package]] 26612662 name = "panproto-mig" 2662-version = "0.28.0" 2663-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2663+version = "0.30.0" 2664+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 26642665 dependencies = [ 26652666 "panproto-expr", 26662667 "panproto-gat",
@@ -2673,8 +2674,8 @@ dependencies = [
26732674 26742675 [[package]] 26752676 name = "panproto-parse" 2676-version = "0.28.0" 2677-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2677+version = "0.30.0" 2678+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 26782679 dependencies = [ 26792680 "memchr", 26802681 "miette",
@@ -2692,8 +2693,8 @@ dependencies = [
26922693 26932694 [[package]] 26942695 name = "panproto-project" 2695-version = "0.28.0" 2696-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2696+version = "0.30.0" 2697+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 26972698 dependencies = [ 26982699 "blake3", 26992700 "globset",
@@ -2712,8 +2713,8 @@ dependencies = [
27122713 27132714 [[package]] 27142715 name = "panproto-protocols" 2715-version = "0.28.0" 2716-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2716+version = "0.30.0" 2717+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 27172718 dependencies = [ 27182719 "blake3", 27192720 "panproto-gat",
@@ -2727,8 +2728,8 @@ dependencies = [
27272728 27282729 [[package]] 27292730 name = "panproto-schema" 2730-version = "0.28.0" 2731-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2731+version = "0.30.0" 2732+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 27322733 dependencies = [ 27332734 "panproto-expr", 27342735 "panproto-gat",
@@ -2740,8 +2741,8 @@ dependencies = [
27402741 27412742 [[package]] 27422743 name = "panproto-vcs" 2743-version = "0.28.0" 2744-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2744+version = "0.30.0" 2745+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 27452746 dependencies = [ 27462747 "blake3", 27472748 "panproto-check",
@@ -2761,8 +2762,8 @@ dependencies = [
27612762 27622763 [[package]] 27632764 name = "panproto-xrpc" 2764-version = "0.28.0" 2765-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44" 2765+version = "0.30.0" 2766+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9" 27662767 dependencies = [ 27672768 "hex", 27682769 "miette",
@@ -13,24 +13,24 @@ license = "AGPL-3.0-or-later"
1313 repository = "https://github.com/cospan-dev/cospan" 1414 1515 [workspace.dependencies] 16-# panproto v0.28.0 17-panproto-core = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 18-panproto-vcs = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 19-panproto-schema = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 20-panproto-check = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 21-panproto-lens = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 22-panproto-lens-dsl = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 23-panproto-protocols = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 24-panproto-io = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 25-panproto-inst = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 26-panproto-gat = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 27-panproto-mig = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 28-panproto-expr = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 29-panproto-expr-parser = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 30-panproto-xrpc = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 31-panproto-parse = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0", features = ["group-all"] } 32-panproto-git = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 33-panproto-project = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" } 16+# panproto v0.30.0 17+panproto-core = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 18+panproto-vcs = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 19+panproto-schema = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 20+panproto-check = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 21+panproto-lens = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 22+panproto-lens-dsl = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 23+panproto-protocols = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 24+panproto-io = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 25+panproto-inst = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 26+panproto-gat = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 27+panproto-mig = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 28+panproto-expr = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 29+panproto-expr-parser = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 30+panproto-xrpc = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 31+panproto-parse = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0", features = ["group-all"] } 32+panproto-git = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 33+panproto-project = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" } 3434 git2 = "0.20" 3535 3636 # Web framework
@@ -19,7 +19,6 @@
1919 openPaths = next; 2020 } 2121 22- type FileStatus = 'added' | 'removed' | 'modified' | 'renamed' | 'copied' | 'typechange'; 2322 function statusBadge(status: string): { bg: string; fg: string; label: string } { 2423 const map: Record<string, { bg: string; fg: string; label: string }> = { 2524 added: { bg: 'bg-emerald-500/15', fg: 'text-emerald-400', label: 'added' },
@@ -48,24 +47,36 @@
4847 return s.replace(/\n$/, ''); 4948 } 5049 51- // Structural diff types 50+ // TODO: replace with generated types from dev.panproto.node.diffCommits lexicon 51+ interface ScopeChange { 52+ scope_id: string; 53+ scope_name: string; 54+ kind: 'Added' | 'Removed' | 'SignatureChanged' | 'BodyModified'; 55+ summary: string; 56+ anonymous_added: number; 57+ anonymous_removed: number; 58+ start_line: number | null; 59+ end_line: number | null; 60+ } 61+ 62+ interface NamedElement { 63+ id: string; 64+ name: string; 65+ kind: string; 66+ status: 'Unchanged' | 'BodyModified' | 'SignatureChanged' | 'Added' | 'Removed'; 67+ start_line: number | null; 68+ } 69+ 5270 interface StructuralDiffData { 5371 protocol: string; 5472 compatible: boolean; 5573 verdict: string; 5674 breakingCount: number; 5775 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 }[]; 6776 breakingChanges: { kind: string; label: string }[]; 6877 nonBreakingChanges: { kind: string; label: string }[]; 78+ scopeChanges?: ScopeChange[]; 79+ namedElements?: NamedElement[]; 6980 } 7081 7182 function hasStructuralDiff(f: DiffFile): boolean {
@@ -75,195 +86,50 @@
7586 return (f as any).structuralDiff ?? null; 7687 } 7788 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- // Extract search terms from a change entry to find matching code lines. 172- // A change like { vertexId: "dev.cospan.repo:body.protocol", kind: "RemovedVertex" } 173- // produces search terms ["protocol"] so we can find the line in the diff. 174- function extractSearchTerms(change: { label: string; kind: string; vertexId?: string; src?: string; tgt?: string; name?: string }): string[] { 175- const terms: string[] = []; 176- // Extract the leaf name from vertexId 177- if (change.vertexId) { 178- const leaf = extractLeafName(change.vertexId); 179- if (leaf && !leaf.startsWith('$')) terms.push(leaf); 180- } 181- // Edge name 182- if (change.name && !change.name.startsWith('$')) terms.push(change.name); 183- // Extract names from src/tgt 184- if (change.src) { 185- const s = extractLeafName(change.src); 186- if (s && !s.startsWith('$')) terms.push(s); 89+ // Match diff hunks to a scope by line range overlap. 90+ function getHunksForScope(hunks: DiffFile['hunks'], scope: ScopeChange): DiffFile['hunks'][0][] { 91+ if (scope.start_line == null || scope.end_line == null) { 92+ // Fallback: match by scope name in hunk header or line content 93+ return hunks.filter(h => 94+ h.header.includes(scope.scope_name) || 95+ h.lines.some(l => l.content.includes(scope.scope_name)) 96+ ); 18797 } 188- if (change.tgt) { 189- const t = extractLeafName(change.tgt); 190- if (t && !t.startsWith('$')) terms.push(t); 191- } 192- // Deduplicate 193- return [...new Set(terms)]; 98+ return hunks.filter(h => { 99+ const hunkEnd = h.newStart + h.newLines; 100+ return h.newStart <= scope.end_line! && hunkEnd >= scope.start_line!; 101+ }); 194102 } 195103 196- // Find diff lines that match any of the search terms. 197- // Returns matching lines plus 1 line of context on each side. 198- function findMatchingLines( 199- hunks: DiffFile['hunks'], 200- terms: string[] 201- ): DiffFile['hunks'][0]['lines'] { 202- if (terms.length === 0) return []; 203- const result: DiffFile['hunks'][0]['lines'] = []; 204- for (const hunk of hunks) { 205- for (let i = 0; i < hunk.lines.length; i++) { 206- const line = hunk.lines[i]; 207- if (terms.some(t => line.content.includes(t))) { 208- // Add context: 1 line before and after 209- if (i > 0 && !result.includes(hunk.lines[i - 1])) { 210- result.push(hunk.lines[i - 1]); 211- } 212- if (!result.includes(line)) { 213- result.push(line); 214- } 215- if (i + 1 < hunk.lines.length && !result.includes(hunk.lines[i + 1])) { 216- result.push(hunk.lines[i + 1]); 217- } 218- } 219- } 104+ // Scope change kind indicator 105+ function scopeKindIndicator(kind: string): { symbol: string; color: string } { 106+ switch (kind) { 107+ case 'Added': return { symbol: '+', color: 'text-emerald-400' }; 108+ case 'Removed': return { symbol: '−', color: 'text-red-400' }; 109+ case 'SignatureChanged': return { symbol: '~', color: 'text-red-300' }; 110+ case 'BodyModified': return { symbol: '~', color: 'text-amber-400' }; 111+ default: return { symbol: '?', color: 'text-text-muted' }; 220112 } 221- return result.slice(0, 20); // Cap at 20 lines 222113 } 223114 224- function shortVertex(v: string): string { 225- const parts = v.split('::'); 226- for (let i = parts.length - 1; i >= 0; i--) { 227- if (!parts[i].startsWith('$')) return parts[i]; 115+ function elementStatusColor(status: string): string { 116+ switch (status) { 117+ case 'Added': return 'text-emerald-400'; 118+ case 'Removed': return 'text-red-400'; 119+ case 'SignatureChanged': return 'text-red-300'; 120+ case 'BodyModified': return 'text-amber-400'; 121+ default: return 'text-text-muted'; 228122 } 229- return parts[parts.length - 1] || v; 230123 } 231124 232- // Extract a meaningful relative path from a vertex ID, relative to its scope. 233- // "src/repo.ts::createRepo::$13::$0" with scope "createRepo" → "$13.$0" (internal) 234- // "dev.cospan.repo:body.description" → "description" 235- function extractLeafName(v: string): string { 236- if (v.includes(':') && !v.includes('::')) { 237- const parts = v.split('.'); 238- const last = parts[parts.length - 1]; 239- return last.startsWith('$') ? v.split(':').pop() ?? v : last; 240- } 241- const parts = v.split('::'); 242- for (let i = parts.length - 1; i >= 0; i--) { 243- if (!parts[i].startsWith('$') && parts[i] !== '') return parts[i]; 125+ function elementStatusLabel(status: string): string { 126+ switch (status) { 127+ case 'Added': return 'added'; 128+ case 'Removed': return 'removed'; 129+ case 'SignatureChanged': return 'sig changed'; 130+ case 'BodyModified': return 'modified'; 131+ default: return ''; 244132 } 245- return parts[parts.length - 1] || v; 246- } 247- 248- // Deduplicate and summarize a list of vertex IDs within a scope. 249- // Returns unique named items + a count of anonymous ones. 250- function summarizeNodes(items: string[]): { named: string[]; anonymousCount: number } { 251- const seen = new Set<string>(); 252- const named: string[] = []; 253- let anonymousCount = 0; 254- for (const id of items) { 255- const name = extractLeafName(id); 256- if (name.startsWith('$')) { 257- anonymousCount++; 258- } else if (!seen.has(name)) { 259- seen.add(name); 260- named.push(name); 261- } else { 262- // Duplicate named - count as internal 263- anonymousCount++; 264- } 265- } 266- return { named, anonymousCount }; 267133 } 268134 </script> 269135
@@ -281,7 +147,7 @@
281147 <span class="font-mono">+{diff.totalAdditions}</span> 282148 </span> 283149 <span class="flex items-center gap-1 text-red-400"> 284- <span class="font-mono">−{diff.totalDeletions}</span> 150+ <span class="font-mono">-{diff.totalDeletions}</span> 285151 </span> 286152 </div> 287153
@@ -294,7 +160,6 @@
294160 {@const isBinary = file.binary} 295161 <div class="overflow-hidden rounded-lg border border-border bg-surface-1"> 296162 {#if isBinary} 297- <!-- Binary file: non-expandable, minimal display --> 298163 <div class="flex items-center gap-3 px-4 py-3"> 299164 <span class="rounded px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider {badge.bg} {badge.fg}"> 300165 {badge.label}
@@ -322,7 +187,7 @@
322187 </span> 323188 <code class="min-w-0 flex-1 truncate font-mono text-sm text-text-primary"> 324189 {#if file.oldPath && file.oldPath !== file.path} 325- <span class="text-text-muted">{file.oldPath} → </span> 190+ <span class="text-text-muted">{file.oldPath} -> </span> 326191 {/if} 327192 {file.path} 328193 </code>
@@ -336,210 +201,164 @@
336201 <span class="shrink-0 font-mono text-xs text-emerald-400">+{file.additions}</span> 337202 {/if} 338203 {#if file.deletions > 0} 339- <span class="shrink-0 font-mono text-xs text-red-400">−{file.deletions}</span> 204+ <span class="shrink-0 font-mono text-xs text-red-400">-{file.deletions}</span> 340205 {/if} 341206 </button> 342207 343- <!-- Content when expanded --> 344208 {#if isOpen} 345- <!-- Structural diff (the schema-level view) --> 346209 {#if sd} 347210 <div class="border-t border-border bg-surface-0 p-4"> 348- <!-- Verdict banner: only show when there are classified changes --> 349- {#if sd.breakingCount > 0 || sd.nonBreakingCount > 0} 350- <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'}"> 351- <span class="text-lg">{sd.compatible ? '✓' : '⚠'}</span> 352- <div> 353- {#if !sd.compatible} 354- <span class="text-sm font-semibold text-red-400">Breaking changes detected</span> 355- <span class="ml-2 text-xs text-text-muted"> 356- {sd.breakingCount} breaking{#if sd.nonBreakingCount > 0} · {sd.nonBreakingCount} safe{/if} 357- </span> 358- {:else} 359- <span class="text-sm font-semibold text-emerald-400">No breaking changes</span> 360- <span class="ml-2 text-xs text-text-muted"> 361- {sd.nonBreakingCount} safe {sd.nonBreakingCount === 1 ? 'change' : 'changes'} 362- </span> 363- {/if} 364- </div> 365- </div> 366- {/if} 367211 368- <!-- Breaking changes - open by default, each clickable to show code --> 369- {#if sd.breakingChanges.length > 0} 370- <details open class="mb-3 rounded-md border border-red-500/20"> 371- <summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-red-400 hover:bg-red-500/5"> 372- ⚠ Breaking changes ({sd.breakingCount}) 373- </summary> 374- <div class="space-y-0.5 px-2 pb-2"> 375- {#each sd.breakingChanges as bc (bc.label)} 376- {@const searchTerms = extractSearchTerms(bc)} 377- {@const matchingLines = findMatchingLines(file.hunks, searchTerms)} 378- <details class="rounded-md"> 379- <summary class="flex cursor-pointer items-start gap-2 rounded-md bg-red-500/5 px-3 py-1.5 text-sm hover:bg-red-500/10 transition-colors"> 380- <span class="shrink-0 text-red-400 mt-0.5">⚠</span> 381- <span class="text-text-primary flex-1">{bc.label}</span> 382- {#if matchingLines.length > 0} 383- <span class="shrink-0 text-[10px] text-text-muted mt-0.5">{matchingLines.length} lines</span> 384- {/if} 385- <span class="shrink-0 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">{bc.kind}</span> 386- </summary> 387- {#if matchingLines.length > 0} 388- <pre class="mt-1 overflow-x-auto rounded bg-surface-0 font-mono text-[11px] leading-[18px] mx-3 mb-1">{#each matchingLines as line}<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)} 212+ <!-- Section 1: Scope changes (primary view from panproto) --> 213+ {#if sd.scopeChanges && sd.scopeChanges.length > 0} 214+ <div class="mb-4 rounded-md border border-border"> 215+ <div class="flex items-center justify-between border-b border-border px-3 py-2"> 216+ <span class="text-xs font-semibold uppercase tracking-wider text-text-muted"> 217+ Changed program elements 218+ </span> 219+ <span class="text-[10px] text-text-muted">{sd.protocol}</span> 220+ </div> 221+ {#each sd.scopeChanges as scope (scope.scope_id)} 222+ {@const indicator = scopeKindIndicator(scope.kind)} 223+ {@const hunks = getHunksForScope(file.hunks, scope)} 224+ <details class="border-b border-border/40 last:border-0 225+ {scope.kind === 'Added' ? 'bg-emerald-500/3' 226+ : scope.kind === 'Removed' ? 'bg-red-500/3' 227+ : 'bg-surface-0'}"> 228+ <summary class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm hover:bg-surface-2 transition-colors"> 229+ <span class="{indicator.color} font-mono text-xs w-4 text-center">{indicator.symbol}</span> 230+ <span class="font-mono font-medium text-text-primary">{scope.scope_name}</span> 231+ <span class="text-xs text-text-muted flex-1">{scope.summary}</span> 232+ {#if scope.anonymous_added > 0 || scope.anonymous_removed > 0} 233+ <span class="text-[11px] font-mono text-text-muted"> 234+ {#if scope.anonymous_added > 0}<span class="text-emerald-400">+{scope.anonymous_added}</span>{/if} 235+ {#if scope.anonymous_removed > 0}<span class="text-red-400"> -{scope.anonymous_removed}</span>{/if} 236+ </span> 237+ {/if} 238+ {#if scope.start_line} 239+ <span class="text-[10px] text-text-muted font-mono">:{scope.start_line}</span> 240+ {/if} 241+ </summary> 242+ <div class="border-t border-border/30"> 243+ {#if hunks.length > 0} 244+ {#each hunks as hunk, h (h)} 245+ <div class="border-t border-border/20 first:border-t-0"> 246+ <div class="bg-surface-2/30 px-3 py-0.5 font-mono text-[10px] text-text-muted"> 247+ {hunk.header.replace(/\n$/, '')} 248+ </div> 249+ <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)} 389250 </span>{/each}</pre> 251+ </div> 252+ {/each} 390253 {:else} 391- <p class="mx-3 mb-1 text-[11px] text-text-muted italic">No matching code found in this diff</p> 254+ <p class="px-3 py-2 text-[11px] text-text-muted italic">No matching code hunks</p> 392255 {/if} 393- </details> 394- {/each} 395- </div> 396- </details> 256+ </div> 257+ </details> 258+ {/each} 259+ </div> 397260 {/if} 398261 399- <!-- Compatible changes - collapsed, each clickable to show code --> 400- {#if sd.nonBreakingChanges.length > 0} 401- <details class="mb-3 rounded-md border border-emerald-500/20"> 402- <summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-emerald-400 hover:bg-emerald-500/5"> 403- ✓ Compatible changes ({sd.nonBreakingCount}) 262+ <!-- Section 2: Full structure map (collapsed) --> 263+ {#if sd.namedElements && sd.namedElements.filter(e => e.status !== 'Unchanged').length > 0} 264+ {@const changed = sd.namedElements.filter(e => e.status !== 'Unchanged')} 265+ {@const unchanged = sd.namedElements.filter(e => e.status === 'Unchanged')} 266+ <details class="mb-4 rounded-md border border-border"> 267+ <summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-text-muted hover:bg-surface-2"> 268+ All program elements ({sd.namedElements.length}) 404269 </summary> 405- <div class="space-y-0.5 px-2 pb-2"> 406- {#each sd.nonBreakingChanges as nb (nb.label)} 407- {@const searchTerms = extractSearchTerms(nb)} 408- {@const matchingLines = findMatchingLines(file.hunks, searchTerms)} 409- <details class="rounded-md"> 410- <summary class="flex cursor-pointer items-start gap-2 rounded-md bg-emerald-500/5 px-3 py-1.5 text-sm hover:bg-emerald-500/10 transition-colors"> 411- <span class="shrink-0 text-emerald-400 mt-0.5">✓</span> 412- <span class="text-text-primary flex-1">{nb.label}</span> 413- {#if matchingLines.length > 0} 414- <span class="shrink-0 text-[10px] text-text-muted mt-0.5">{matchingLines.length} lines</span> 415- {/if} 416- <span class="shrink-0 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">{nb.kind}</span> 417- </summary> 418- {#if matchingLines.length > 0} 419- <pre class="mt-1 overflow-x-auto rounded bg-surface-0 font-mono text-[11px] leading-[18px] mx-3 mb-1">{#each matchingLines as line}<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)} 420-</span>{/each}</pre> 421- {:else} 422- <p class="mx-3 mb-1 text-[11px] text-text-muted italic">No matching code found in this diff</p> 270+ <div class="divide-y divide-border/30"> 271+ {#each changed as el (el.id)} 272+ <div class="flex items-center gap-2 px-3 py-1.5 text-sm"> 273+ <span class="w-20 shrink-0 text-[10px] font-mono {elementStatusColor(el.status)}"> 274+ {elementStatusLabel(el.status)} 275+ </span> 276+ <code class="font-mono text-sm text-text-primary">{el.name}</code> 277+ <span class="text-[10px] text-text-muted">{el.kind}</span> 278+ {#if el.start_line} 279+ <span class="ml-auto text-[10px] text-text-muted font-mono">:{el.start_line}</span> 423280 {/if} 424- </details> 281+ </div> 425282 {/each} 283+ {#if unchanged.length > 0} 284+ <details class="border-t border-border/30"> 285+ <summary class="cursor-pointer px-3 py-1.5 text-[11px] text-text-muted hover:text-text-secondary"> 286+ {unchanged.length} unchanged elements 287+ </summary> 288+ {#each unchanged as el (el.id)} 289+ <div class="flex items-center gap-2 px-3 py-1 text-sm text-text-muted"> 290+ <span class="w-20 shrink-0 text-[10px] font-mono"></span> 291+ <code class="font-mono text-sm">{el.name}</code> 292+ <span class="text-[10px]">{el.kind}</span> 293+ {#if el.start_line} 294+ <span class="ml-auto text-[10px] font-mono">:{el.start_line}</span> 295+ {/if} 296+ </div> 297+ {/each} 298+ </details> 299+ {/if} 426300 </div> 427301 </details> 428302 {/if} 429303 430- <!-- Structural change tree (program elements) --> 431- {#if (() => { 432- const rg = groupVertices(sd.removedVertices, file.path); 433- const ag = groupVertices(sd.addedVertices, file.path); 434- return [...new Set([...rg.map(g => g.scope), ...ag.map(g => g.scope)])].filter(s => s !== '(other)' && s !== '(module)').length > 0; 435- })()} 436- {@const removedGroups = groupVertices(sd.removedVertices, file.path)} 437- {@const addedGroups = groupVertices(sd.addedVertices, file.path)} 438- {@const filteredScopes = [...new Set([ 439- ...removedGroups.map(g => g.scope), 440- ...addedGroups.map(g => g.scope), 441- ])].filter(s => s !== '(other)' && s !== '(module)')} 442- <div class="mb-3 space-y-2"> 443- <div class="rounded-md border border-border"> 444- <div class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-text-muted"> 445- Program elements 304+ <!-- Section 3: Breaking/compatible classified changes --> 305+ {#if sd.breakingCount > 0 || sd.nonBreakingCount > 0} 306+ <!-- Verdict banner --> 307+ <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'}"> 308+ <span class="text-lg">{sd.compatible ? '✓' : '⚠'}</span> 309+ <div> 310+ {#if !sd.compatible} 311+ <span class="text-sm font-semibold text-red-400">Breaking changes detected</span> 312+ <span class="ml-2 text-xs text-text-muted"> 313+ {sd.breakingCount} breaking{#if sd.nonBreakingCount > 0} | {sd.nonBreakingCount} safe{/if} 314+ </span> 315+ {:else} 316+ <span class="text-sm font-semibold text-emerald-400">No breaking changes</span> 317+ <span class="ml-2 text-xs text-text-muted"> 318+ {sd.nonBreakingCount} safe {sd.nonBreakingCount === 1 ? 'change' : 'changes'} 319+ </span> 320+ {/if} 321+ </div> 322+ </div> 323+ 324+ {#if sd.breakingChanges.length > 0} 325+ <details open class="mb-3 rounded-md border border-red-500/20"> 326+ <summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-red-400 hover:bg-red-500/5"> 327+ Breaking changes ({sd.breakingCount}) 328+ </summary> 329+ <div class="space-y-0.5 px-2 pb-2"> 330+ {#each sd.breakingChanges as bc (bc.label)} 331+ <div class="flex items-start gap-2 rounded-md bg-red-500/5 px-3 py-1.5 text-sm"> 332+ <span class="shrink-0 text-red-400 mt-0.5">⚠</span> 333+ <span class="text-text-primary flex-1">{bc.label}</span> 334+ <span class="shrink-0 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">{bc.kind}</span> 335+ </div> 336+ {/each} 446337 </div> 447- <div class="space-y-0.5 px-1 pb-1"> 448- {#each filteredScopes as scope (scope)} 449- {@const removed = removedGroups.find(g => g.scope === scope)} 450- {@const added = addedGroups.find(g => g.scope === scope)} 451- {@const scopeKind = removed?.scopeKind ?? added?.scopeKind ?? '·'} 452- {@const isNew = !removed && !!added} 453- {@const isGone = !!removed && !added} 454- {@const addedEdgesInScope = sd.addedEdges.filter(e => e.src.includes('::' + scope + '::') || e.src.includes('::' + scope) && !e.src.includes('::' + scope + '::'))} 455- {@const removedEdgesInScope = sd.removedEdges.filter(e => e.src.includes('::' + scope + '::') || e.src.includes('::' + scope) && !e.src.includes('::' + scope + '::'))} 456- <details class="rounded-md {isNew ? 'bg-emerald-500/5' : isGone ? 'bg-red-500/5' : 'bg-surface-0'}"> 457- <summary class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-surface-2"> 458- <span class="w-5 text-center text-base leading-none {isNew ? 'text-emerald-400' : isGone ? 'text-red-400' : 'text-amber-400'}"> 459- {scopeKind} 460- </span> 461- <span class="font-medium text-text-primary">{scope}</span> 462- {#if isNew} 463- <span class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">added</span> 464- {:else if isGone} 465- <span class="rounded bg-red-500/15 px-1.5 py-0.5 text-[10px] font-medium text-red-400">removed</span> 466- {:else} 467- <span class="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-400">modified</span> 468- {/if} 469- <span class="ml-auto text-xs text-text-muted"> 470- {#if added}<span class="text-emerald-400">+{added.items.length}</span>{/if} 471- {#if removed}{#if added} {/if}<span class="text-red-400">−{removed.items.length}</span>{/if} 472- </span> 473- </summary> 474- <!-- Expanded: structural summary + relevant code --> 475- <div class="border-t border-border/50"> 476- <!-- Structural summary --> 477- <div class="px-3 py-2 text-[12px] space-y-1"> 478- {#if added && added.items.length > 0} 479- {@const summary = summarizeNodes(added.items)} 480- <div> 481- <span class="text-emerald-400 font-medium">+ {added.items.length} schema nodes added</span> 482- {#if summary.named.length > 0} 483- <span class="text-text-muted"> -</span> 484- <span class="text-text-secondary"> 485- {summary.named.slice(0, 6).join(', ')} 486- {#if summary.named.length > 6}, …{/if} 487- </span> 488- {/if} 489- {#if summary.anonymousCount > 0} 490- <span class="text-text-muted"> ({summary.anonymousCount} internal)</span> 491- {/if} 492- </div> 493- {/if} 494- {#if removed && removed.items.length > 0} 495- {@const summary = summarizeNodes(removed.items)} 496- <div> 497- <span class="text-red-400 font-medium">− {removed.items.length} schema nodes removed</span> 498- {#if summary.named.length > 0} 499- <span class="text-text-muted"> -</span> 500- <span class="text-text-secondary"> 501- {summary.named.slice(0, 6).join(', ')} 502- {#if summary.named.length > 6}, …{/if} 503- </span> 504- {/if} 505- {#if summary.anonymousCount > 0} 506- <span class="text-text-muted"> ({summary.anonymousCount} internal)</span> 507- {/if} 508- </div> 509- {/if} 510- </div> 338+ </details> 339+ {/if} 511340 512- <!-- Code: the actual lines that changed in this scope --> 513- {#each file.hunks.filter(h => 514- h.header.includes(scope) || 515- h.lines.some(l => l.content.includes(scope)) 516- ) as hunk, h (h)} 517- {#if h === 0} 518- <div class="border-t border-border/30"> 519- <div class="bg-surface-2/50 px-3 py-1 text-[10px] text-text-muted font-medium uppercase tracking-wider"> 520- Code 521- </div> 522- </div> 523- {/if} 524- <div class="border-t border-border/20"> 525- <div class="bg-surface-2/30 px-3 py-0.5 font-mono text-[10px] text-text-muted"> 526- {hunk.header.replace(/\n$/, '')} 527- </div> 528- <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)} 529-</span>{/each}</pre> 530- </div> 531- {/each} 532- </div> 533- </details> 341+ {#if sd.nonBreakingChanges.length > 0} 342+ <details class="mb-3 rounded-md border border-emerald-500/20"> 343+ <summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-emerald-400 hover:bg-emerald-500/5"> 344+ Safe changes ({sd.nonBreakingCount}) 345+ </summary> 346+ <div class="space-y-0.5 px-2 pb-2"> 347+ {#each sd.nonBreakingChanges as nb (nb.label)} 348+ <div class="flex items-start gap-2 rounded-md bg-emerald-500/5 px-3 py-1.5 text-sm"> 349+ <span class="shrink-0 text-emerald-400 mt-0.5">✓</span> 350+ <span class="text-text-primary flex-1">{nb.label}</span> 351+ <span class="shrink-0 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">{nb.kind}</span> 352+ </div> 534353 {/each} 535354 </div> 536- </div> 537- </div> 355+ </details> 356+ {/if} 538357 {/if} 539358 540- <!-- Collapsible raw diff --> 359+ <!-- Section 4: Raw textual diff (collapsed) --> 541360 {#if file.hunks.length > 0} 542- <details class="mt-3 rounded-md border border-border"> 361+ <details class="rounded-md border border-border"> 543362 <summary class="cursor-pointer px-3 py-2 text-xs font-medium text-text-muted hover:text-text-secondary transition-colors"> 544363 Raw textual diff ({file.additions} additions, {file.deletions} deletions) 545364 </summary>
@@ -556,7 +375,7 @@
556375 {/if} 557376 </div> 558377 {:else} 559- <!-- No structural diff - show raw textual diff as primary --> 378+ <!-- No structural diff: show raw textual diff as primary --> 560379 {#if file.hunks.length === 0} 561380 <div class="border-t border-border bg-surface-0 px-4 py-6 text-center text-xs text-text-muted"> 562381 No content changes
@@ -574,7 +393,7 @@
574393 {/if} 575394 {/if} 576395 {/if} 577- {/if}<!-- close {:else} for binary check --> 396+ {/if}<!-- close binary check --> 578397 </div> 579398 {/each} 580399 </div>
@@ -111,7 +111,11 @@ pub async fn compare_branch_schemas(
111111 head_vertex_total += sd.new_vertex_count; 112112 113113 // Collect individual changes (cap at 50 total for wire size) 114- let sd_json = super::structural::structural_diff_to_json(&sd); 114+ let sd_json = super::structural::structural_diff_to_json( 115+ &sd, 116+ old_bytes.as_deref(), 117+ new_bytes.as_deref(), 118+ ); 115119 if let Some(bc) = sd_json["breakingChanges"].as_array() { 116120 for c in bc { 117121 if breaking_changes.len() < 50 {
@@ -252,7 +252,11 @@ pub async fn diff_commits(
252252 old_bytes.as_deref(), 253253 new_bytes.as_deref(), 254254 ) 255- .map(|s| super::structural::structural_diff_to_json(&s)) 255+ .map(|s| super::structural::structural_diff_to_json( 256+ &s, 257+ old_bytes.as_deref(), 258+ new_bytes.as_deref(), 259+ )) 256260 }; 257261 258262 FileDiff {