@emeryld/rrroutes-export 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Finalized Leaves Viewer</title>
6
+ <title>RRRoutes Export Viewer</title>
7
7
  <style>
8
8
  :root {
9
9
  --bg: #212121;
@@ -472,7 +472,7 @@
472
472
  </head>
473
473
  <body>
474
474
  <div class="wrap">
475
- <h1>Finalized Leaves Viewer</h1>
475
+ <h1>RRRoutes Export Viewer</h1>
476
476
  <div class="card controls">
477
477
  <div class="primary-row">
478
478
  <label class="control-block">
@@ -529,12 +529,29 @@
529
529
  </div>
530
530
  </details>
531
531
 
532
+ <div class="scope-row">
533
+ <div class="scope-group">
534
+ <div class="scope-group-title">Views</div>
535
+ <div class="scope-group-chips">
536
+ <button id="tabLeaves" class="field-chip" type="button" aria-pressed="true">
537
+ Leaves
538
+ </button>
539
+ <button id="tabChangelog" class="field-chip" type="button" aria-pressed="false">
540
+ Changelog
541
+ </button>
542
+ <button id="tabChangelogSource" class="field-chip" type="button" aria-pressed="false">
543
+ Changelog / By Source
544
+ </button>
545
+ </div>
546
+ </div>
547
+ </div>
548
+
532
549
  <div class="active-filters-row">
533
550
  <div class="active-filters-title">Active filters</div>
534
551
  <div id="activeFilterChips" class="chips filters"></div>
535
552
  </div>
536
553
 
537
- <div id="status" class="meta">Load a JSON export to begin.</div>
554
+ <div id="status" class="meta">Load a leaves export, changelog export, or unified bundle JSON to begin.</div>
538
555
  </div>
539
556
 
540
557
  <div id="results"></div>
@@ -612,6 +629,84 @@
612
629
  },
613
630
  ]
614
631
 
632
+ const CHANGELOG_TIMELINE_FIELDS = [
633
+ {
634
+ id: 'eventKind',
635
+ label: 'kind',
636
+ get: (item) => [item.eventKind],
637
+ },
638
+ {
639
+ id: 'eventType',
640
+ label: 'event',
641
+ get: (item) => [item.event.type],
642
+ },
643
+ {
644
+ id: 'routeKey',
645
+ label: 'route',
646
+ get: (item) => [item.routeKey],
647
+ },
648
+ {
649
+ id: 'sourceObject',
650
+ label: 'source object',
651
+ get: (item) => [item.sourceObjectId, item.displayName],
652
+ },
653
+ {
654
+ id: 'commit',
655
+ label: 'commit',
656
+ get: (item) => [
657
+ item.event.commitSha,
658
+ item.commit?.authorDate,
659
+ item.commit?.authorName,
660
+ item.commit?.subject,
661
+ ],
662
+ },
663
+ {
664
+ id: 'schemaDiff',
665
+ label: 'schema diff',
666
+ get: (item) =>
667
+ Array.isArray(item.event.schemaDiff)
668
+ ? item.event.schemaDiff.map((delta) => JSON.stringify(delta))
669
+ : [],
670
+ },
671
+ {
672
+ id: 'cfgDiff',
673
+ label: 'cfg diff',
674
+ get: (item) =>
675
+ Array.isArray(item.event.cfgDiff)
676
+ ? item.event.cfgDiff.map((delta) => JSON.stringify(delta))
677
+ : [],
678
+ },
679
+ ]
680
+
681
+ const CHANGELOG_SOURCE_FIELDS = [
682
+ {
683
+ id: 'sourceObject',
684
+ label: 'source object',
685
+ get: (item) => [item.sourceId, item.displayName],
686
+ },
687
+ {
688
+ id: 'eventType',
689
+ label: 'event type',
690
+ get: (item) => item.events.map((event) => event.type),
691
+ },
692
+ {
693
+ id: 'impactedRoutes',
694
+ label: 'impacted routes',
695
+ get: (item) =>
696
+ item.events.flatMap((event) => (Array.isArray(event.impactedRoutes) ? event.impactedRoutes : [])),
697
+ },
698
+ {
699
+ id: 'schemaDiff',
700
+ label: 'schema diff',
701
+ get: (item) =>
702
+ item.events.flatMap((event) =>
703
+ Array.isArray(event.schemaDiff)
704
+ ? event.schemaDiff.map((delta) => JSON.stringify(delta))
705
+ : [],
706
+ ),
707
+ },
708
+ ]
709
+
615
710
  const SCHEMA_SECTIONS = ['params', 'query', 'body', 'output']
616
711
  const SCHEMA_FIELD_IDS = new Set(SCHEMA_SECTIONS)
617
712
  const SOURCE_FIELD_IDS = new Set(['sourceDefinition', 'sourceSchemas'])
@@ -645,11 +740,39 @@
645
740
  },
646
741
  ]
647
742
 
743
+ const VIEW_IDS = {
744
+ leaves: 'leaves',
745
+ changelog: 'changelog',
746
+ changelogSource: 'changelogSource',
747
+ }
748
+
749
+ const TAB_CONFIG = {
750
+ [VIEW_IDS.leaves]: {
751
+ fieldDefs: SEARCH_FIELDS,
752
+ defaultSelected: new Set(SEARCH_FIELDS.map((field) => field.id)),
753
+ },
754
+ [VIEW_IDS.changelog]: {
755
+ fieldDefs: CHANGELOG_TIMELINE_FIELDS,
756
+ defaultSelected: new Set(CHANGELOG_TIMELINE_FIELDS.map((field) => field.id)),
757
+ },
758
+ [VIEW_IDS.changelogSource]: {
759
+ fieldDefs: CHANGELOG_SOURCE_FIELDS,
760
+ defaultSelected: new Set(CHANGELOG_SOURCE_FIELDS.map((field) => field.id)),
761
+ },
762
+ }
763
+
648
764
  const state = {
649
765
  payload: null,
766
+ leavesPayload: null,
767
+ changelogPayload: null,
650
768
  leaves: [],
651
- mode: 'snapshot',
652
- selectedFieldIds: new Set(SEARCH_FIELDS.map((field) => field.id)),
769
+ activeTab: VIEW_IDS.leaves,
770
+ availableTabs: new Set([VIEW_IDS.leaves]),
771
+ selectedFieldIdsByTab: {
772
+ [VIEW_IDS.leaves]: new Set(TAB_CONFIG[VIEW_IDS.leaves].defaultSelected),
773
+ [VIEW_IDS.changelog]: new Set(TAB_CONFIG[VIEW_IDS.changelog].defaultSelected),
774
+ [VIEW_IDS.changelogSource]: new Set(TAB_CONFIG[VIEW_IDS.changelogSource].defaultSelected),
775
+ },
653
776
  }
654
777
 
655
778
  const fileInput = document.getElementById('fileInput')
@@ -669,6 +792,9 @@
669
792
  const statusEl = document.getElementById('status')
670
793
  const resultsEl = document.getElementById('results')
671
794
  const controlsEl = document.querySelector('.controls')
795
+ const tabLeavesBtn = document.getElementById('tabLeaves')
796
+ const tabChangelogBtn = document.getElementById('tabChangelog')
797
+ const tabChangelogSourceBtn = document.getElementById('tabChangelogSource')
672
798
 
673
799
  const URL_PARAM_KEY = 'filters'
674
800
  let isHydratingFromUrl = false
@@ -1304,6 +1430,29 @@
1304
1430
  content.appendChild(separatedSchemas)
1305
1431
  }
1306
1432
 
1433
+ const routeHistory = state.changelogPayload?.byRoute?.[leaf.key]
1434
+ if (Array.isArray(routeHistory) && routeHistory.length > 0) {
1435
+ const commitBySha = new Map(
1436
+ (state.changelogPayload?.commits || []).map((commit) => [commit.sha, commit]),
1437
+ )
1438
+ const routeChangelogSection = el('div', 'section')
1439
+ const routeChangelogDetails = el('details')
1440
+ const routeChangelogSummary = el(
1441
+ 'summary',
1442
+ '',
1443
+ `Route Changelog (${routeHistory.length} event${routeHistory.length === 1 ? '' : 's'})`,
1444
+ )
1445
+ routeChangelogDetails.appendChild(routeChangelogSummary)
1446
+
1447
+ const routeChangelogBody = el('div', 'leaf-content')
1448
+ routeHistory.forEach((event) => {
1449
+ routeChangelogBody.appendChild(renderEventCard(event, commitBySha))
1450
+ })
1451
+ routeChangelogDetails.appendChild(routeChangelogBody)
1452
+ routeChangelogSection.appendChild(routeChangelogDetails)
1453
+ content.appendChild(routeChangelogSection)
1454
+ }
1455
+
1307
1456
  details.appendChild(content)
1308
1457
  return details
1309
1458
  }
@@ -1316,188 +1465,193 @@
1316
1465
  )
1317
1466
  }
1318
1467
 
1319
- function renderChangelogPayload(payload) {
1320
- state.mode = 'changelog'
1321
- state.payload = payload
1322
- state.leaves = []
1323
-
1324
- if (controlsEl) {
1325
- controlsEl.style.display = 'none'
1326
- }
1327
-
1328
- const commitBySha = new Map(
1329
- (payload.commits || []).map((commit) => [commit.sha, commit]),
1330
- )
1331
- const routeEntries = Object.entries(payload.byRoute || {}).sort(
1332
- ([a], [b]) => a.localeCompare(b),
1333
- )
1334
- const sourceEntries = Object.entries(payload.bySourceObject || {}).sort(
1335
- ([a], [b]) => a.localeCompare(b),
1468
+ function isBundlePayload(value) {
1469
+ return (
1470
+ value &&
1471
+ value.kind === 'finalized-leaves-bundle' &&
1472
+ value.leavesPayload &&
1473
+ value.changelogPayload
1336
1474
  )
1475
+ }
1337
1476
 
1338
- statusEl.textContent = `Loaded changelog payload with ${payload.commits.length} commits.`
1339
- resultsEl.innerHTML = ''
1477
+ function currentFieldDefs() {
1478
+ return TAB_CONFIG[state.activeTab]?.fieldDefs || []
1479
+ }
1340
1480
 
1341
- const tabs = el('div', 'chips')
1342
- const byRouteBtn = el('button', 'chip chip-btn', 'By Route')
1343
- const bySourceBtn = el('button', 'chip chip-btn', 'By Source Object')
1344
- byRouteBtn.type = 'button'
1345
- bySourceBtn.type = 'button'
1346
- tabs.appendChild(byRouteBtn)
1347
- tabs.appendChild(bySourceBtn)
1348
- resultsEl.appendChild(tabs)
1349
-
1350
- const view = el('div')
1351
- resultsEl.appendChild(view)
1352
-
1353
- const formatCommitDateTime = (value) => {
1354
- if (!value) return null
1355
- const parsed = new Date(value)
1356
- if (Number.isNaN(parsed.getTime())) return String(value)
1357
- return parsed.toLocaleString()
1358
- }
1481
+ function formatCommitDateTime(value) {
1482
+ if (!value) return null
1483
+ const parsed = new Date(value)
1484
+ if (Number.isNaN(parsed.getTime())) return String(value)
1485
+ return parsed.toLocaleString()
1486
+ }
1359
1487
 
1360
- const formatRouteSchemaEntry = (entry) => {
1361
- if (!entry || typeof entry !== 'object') return 'n/a'
1362
- const parts = [
1363
- `type=${entry.type}`,
1364
- `nullable=${Boolean(entry.nullable)}`,
1365
- `optional=${Boolean(entry.optional)}`,
1366
- ]
1367
- if ('literal' in entry) {
1368
- parts.push(`literal=${JSON.stringify(entry.literal)}`)
1369
- }
1370
- return parts.join(', ')
1488
+ function formatRouteSchemaEntry(entry) {
1489
+ if (!entry || typeof entry !== 'object') return 'n/a'
1490
+ const parts = [
1491
+ `type=${entry.type}`,
1492
+ `nullable=${Boolean(entry.nullable)}`,
1493
+ `optional=${Boolean(entry.optional)}`,
1494
+ ]
1495
+ if ('literal' in entry) {
1496
+ parts.push(`literal=${JSON.stringify(entry.literal)}`)
1371
1497
  }
1498
+ return parts.join(', ')
1499
+ }
1372
1500
 
1373
- const formatSchemaDiffLine = (delta) => {
1374
- if (!delta || typeof delta !== 'object') return null
1375
-
1376
- // Source-object schema diff shape: { section, path, op, before?, after? }
1377
- if ('section' in delta) {
1378
- const location = `${delta.section}.${delta.path}`
1379
- if (delta.op === 'added') {
1380
- return `added ${location}: ${JSON.stringify(delta.after || [])}`
1381
- }
1382
- if (delta.op === 'removed') {
1383
- return `removed ${location}: ${JSON.stringify(delta.before || [])}`
1384
- }
1385
- return `changed ${location}: ${JSON.stringify(delta.before || [])} -> ${JSON.stringify(
1386
- delta.after || [],
1387
- )}`
1388
- }
1389
-
1390
- // Route schema diff shape: { path, op, before?, after? }
1391
- if ('path' in delta) {
1392
- if (delta.op === 'added') {
1393
- return `added ${delta.path}: ${formatRouteSchemaEntry(delta.after)}`
1394
- }
1395
- if (delta.op === 'removed') {
1396
- return `removed ${delta.path}: ${formatRouteSchemaEntry(delta.before)}`
1397
- }
1398
- return `changed ${delta.path}: ${formatRouteSchemaEntry(
1399
- delta.before,
1400
- )} -> ${formatRouteSchemaEntry(delta.after)}`
1401
- }
1402
-
1403
- return null
1501
+ function formatSchemaDiffLine(delta) {
1502
+ if (!delta || typeof delta !== 'object') return null
1503
+ if ('section' in delta) {
1504
+ const location = `${delta.section}.${delta.path}`
1505
+ if (delta.op === 'added') return `added ${location}: ${JSON.stringify(delta.after || [])}`
1506
+ if (delta.op === 'removed') return `removed ${location}: ${JSON.stringify(delta.before || [])}`
1507
+ return `changed ${location}: ${JSON.stringify(delta.before || [])} -> ${JSON.stringify(delta.after || [])}`
1404
1508
  }
1405
-
1406
- const formatCfgDiffLine = (diff) => {
1407
- if (!diff || typeof diff !== 'object') return null
1408
- return `${diff.field}: ${JSON.stringify(diff.before)} -> ${JSON.stringify(diff.after)}`
1509
+ if ('path' in delta) {
1510
+ if (delta.op === 'added') return `added ${delta.path}: ${formatRouteSchemaEntry(delta.after)}`
1511
+ if (delta.op === 'removed') return `removed ${delta.path}: ${formatRouteSchemaEntry(delta.before)}`
1512
+ return `changed ${delta.path}: ${formatRouteSchemaEntry(delta.before)} -> ${formatRouteSchemaEntry(delta.after)}`
1409
1513
  }
1410
-
1411
- const renderEventDetails = (event) => {
1412
- const card = el('div', 'leaf-content')
1413
- const commit = commitBySha.get(event.commitSha)
1414
- const commitDateTime = formatCommitDateTime(commit?.authorDate)
1415
- const commitMeta = [
1416
- event.commitSha.slice(0, 12),
1417
- commitDateTime,
1418
- commit?.authorName || null,
1419
- commit?.subject || null,
1420
- ]
1421
- .filter(Boolean)
1422
- .join(' - ')
1423
- card.appendChild(
1424
- el(
1425
- 'div',
1426
- 'meta',
1427
- commitMeta,
1428
- ),
1429
- )
1430
- card.appendChild(el('div', 'mono', event.type))
1431
- if (Array.isArray(event.schemaDiff) && event.schemaDiff.length > 0) {
1432
- card.appendChild(el('div', 'meta', `schema changes: ${event.schemaDiff.length}`))
1433
- const schemaChanges = el('div')
1434
- event.schemaDiff.forEach((delta) => {
1435
- const line = formatSchemaDiffLine(delta)
1436
- if (!line) return
1437
- schemaChanges.appendChild(el('div', 'meta', line))
1514
+ return null
1515
+ }
1516
+
1517
+ function formatCfgDiffLine(diff) {
1518
+ if (!diff || typeof diff !== 'object') return null
1519
+ return `${diff.field}: ${JSON.stringify(diff.before)} -> ${JSON.stringify(diff.after)}`
1520
+ }
1521
+
1522
+ function renderEventCard(event, commitBySha) {
1523
+ const card = el('div', 'leaf-content')
1524
+ const commit = commitBySha.get(event.commitSha)
1525
+ const commitDateTime = formatCommitDateTime(commit?.authorDate)
1526
+ const commitMeta = [
1527
+ event.commitSha.slice(0, 12),
1528
+ commitDateTime,
1529
+ commit?.authorName || null,
1530
+ commit?.subject || null,
1531
+ ]
1532
+ .filter(Boolean)
1533
+ .join(' - ')
1534
+ card.appendChild(el('div', 'meta', commitMeta))
1535
+ card.appendChild(el('div', 'mono', event.type))
1536
+ if (Array.isArray(event.schemaDiff) && event.schemaDiff.length > 0) {
1537
+ card.appendChild(el('div', 'meta', `schema changes: ${event.schemaDiff.length}`))
1538
+ const schemaChanges = el('div')
1539
+ event.schemaDiff.forEach((delta) => {
1540
+ const line = formatSchemaDiffLine(delta)
1541
+ if (!line) return
1542
+ schemaChanges.appendChild(el('div', 'meta', line))
1543
+ })
1544
+ card.appendChild(schemaChanges)
1545
+ }
1546
+ if (Array.isArray(event.cfgDiff) && event.cfgDiff.length > 0) {
1547
+ card.appendChild(el('div', 'meta', `cfg changes: ${event.cfgDiff.length}`))
1548
+ const cfgChanges = el('div')
1549
+ event.cfgDiff.forEach((diff) => {
1550
+ const line = formatCfgDiffLine(diff)
1551
+ if (!line) return
1552
+ cfgChanges.appendChild(el('div', 'meta', line))
1553
+ })
1554
+ card.appendChild(cfgChanges)
1555
+ }
1556
+ if (Array.isArray(event.impactedRoutes) && event.impactedRoutes.length > 0) {
1557
+ card.appendChild(el('div', 'meta', `impacted routes: ${event.impactedRoutes.length}`))
1558
+ }
1559
+ return card
1560
+ }
1561
+
1562
+ function buildChronologicalEvents(payload) {
1563
+ if (!payload) return []
1564
+ const commitBySha = new Map((payload.commits || []).map((commit) => [commit.sha, commit]))
1565
+ const commitOrder = new Map((payload.commits || []).map((commit, index) => [commit.sha, index]))
1566
+ const rows = []
1567
+ let sequence = 0
1568
+
1569
+ Object.entries(payload.byRoute || {}).forEach(([routeKey, events]) => {
1570
+ ;(events || []).forEach((event) => {
1571
+ rows.push({
1572
+ eventKind: 'route',
1573
+ routeKey,
1574
+ event,
1575
+ commit: commitBySha.get(event.commitSha),
1576
+ commitIndex: commitOrder.get(event.commitSha) ?? Number.MAX_SAFE_INTEGER,
1577
+ eventIndex: sequence++,
1438
1578
  })
1439
- card.appendChild(schemaChanges)
1440
- }
1441
- if (Array.isArray(event.cfgDiff) && event.cfgDiff.length > 0) {
1442
- card.appendChild(el('div', 'meta', `cfg changes: ${event.cfgDiff.length}`))
1443
- const cfgChanges = el('div')
1444
- event.cfgDiff.forEach((diff) => {
1445
- const line = formatCfgDiffLine(diff)
1446
- if (!line) return
1447
- cfgChanges.appendChild(el('div', 'meta', line))
1579
+ })
1580
+ })
1581
+
1582
+ Object.entries(payload.bySourceObject || {}).forEach(([sourceObjectId, events]) => {
1583
+ ;(events || []).forEach((event) => {
1584
+ rows.push({
1585
+ eventKind: 'source',
1586
+ sourceObjectId,
1587
+ displayName: event.displayName || sourceObjectId,
1588
+ event,
1589
+ commit: commitBySha.get(event.commitSha),
1590
+ commitIndex: commitOrder.get(event.commitSha) ?? Number.MAX_SAFE_INTEGER,
1591
+ eventIndex: sequence++,
1448
1592
  })
1449
- card.appendChild(cfgChanges)
1450
- }
1451
- if (Array.isArray(event.impactedRoutes) && event.impactedRoutes.length > 0) {
1452
- card.appendChild(
1453
- el('div', 'meta', `impacted routes: ${event.impactedRoutes.length}`),
1454
- )
1455
- }
1456
- return card
1457
- }
1593
+ })
1594
+ })
1458
1595
 
1459
- const renderByRoute = () => {
1460
- view.innerHTML = ''
1461
- if (routeEntries.length === 0) {
1462
- view.appendChild(el('div', 'empty', 'No route changes found in this range.'))
1463
- return
1464
- }
1596
+ rows.sort((a, b) => {
1597
+ if (a.commitIndex !== b.commitIndex) return a.commitIndex - b.commitIndex
1598
+ return a.eventIndex - b.eventIndex
1599
+ })
1465
1600
 
1466
- routeEntries.forEach(([routeKey, events]) => {
1467
- const details = el('details', 'leaf')
1468
- details.appendChild(el('summary', '', `${routeKey} (${events.length})`))
1469
- events.forEach((event) => details.appendChild(renderEventDetails(event)))
1470
- view.appendChild(details)
1471
- })
1601
+ return rows
1602
+ }
1603
+
1604
+ function changelogItemsByTab(tabId) {
1605
+ const payload = state.changelogPayload
1606
+ if (!payload) return []
1607
+ if (tabId === VIEW_IDS.changelog) {
1608
+ return buildChronologicalEvents(payload)
1609
+ }
1610
+ if (tabId === VIEW_IDS.changelogSource) {
1611
+ return Object.entries(payload.bySourceObject || {})
1612
+ .sort(([a], [b]) => a.localeCompare(b))
1613
+ .map(([sourceId, events]) => ({
1614
+ sourceId,
1615
+ events,
1616
+ displayName: events[0]?.displayName || sourceId,
1617
+ }))
1472
1618
  }
1619
+ return []
1620
+ }
1473
1621
 
1474
- const renderBySource = () => {
1475
- view.innerHTML = ''
1476
- if (sourceEntries.length === 0) {
1477
- view.appendChild(
1478
- el('div', 'empty', 'No source-object changes found in this range.'),
1479
- )
1480
- return
1481
- }
1622
+ function matchesGenericItem(item, fieldDefs, engine, selectedIds) {
1623
+ if (!engine.active) return true
1624
+ return fieldDefs.some((field) => {
1625
+ if (selectedIds.length > 0 && !selectedIds.includes(field.id)) return false
1626
+ const tokens = toTokens(field.get(item))
1627
+ return tokens.some((token) => engine.test(token))
1628
+ })
1629
+ }
1482
1630
 
1483
- sourceEntries.forEach(([sourceId, events]) => {
1484
- const details = el('details', 'leaf')
1485
- const display = events[0]?.displayName || sourceId
1486
- details.appendChild(el('summary', '', `${display} (${events.length})`))
1487
- events.forEach((event) => details.appendChild(renderEventDetails(event)))
1488
- view.appendChild(details)
1489
- })
1631
+ function renderCollection(items, renderItem, emptyMessage) {
1632
+ if (items.length === 0) {
1633
+ resultsEl.appendChild(el('div', 'empty', emptyMessage))
1634
+ return
1490
1635
  }
1636
+ items.forEach((item) => {
1637
+ resultsEl.appendChild(renderItem(item))
1638
+ })
1639
+ }
1491
1640
 
1492
- byRouteBtn.addEventListener('click', renderByRoute)
1493
- bySourceBtn.addEventListener('click', renderBySource)
1494
- renderByRoute()
1641
+ function renderTabButtons() {
1642
+ setPressed(tabLeavesBtn, state.activeTab === VIEW_IDS.leaves)
1643
+ setPressed(tabChangelogBtn, state.activeTab === VIEW_IDS.changelog)
1644
+ setPressed(tabChangelogSourceBtn, state.activeTab === VIEW_IDS.changelogSource)
1645
+ tabLeavesBtn.style.display = state.availableTabs.has(VIEW_IDS.leaves) ? '' : 'none'
1646
+ tabChangelogBtn.style.display = state.availableTabs.has(VIEW_IDS.changelog) ? '' : 'none'
1647
+ tabChangelogSourceBtn.style.display = state.availableTabs.has(VIEW_IDS.changelogSource) ? '' : 'none'
1648
+ const isLeavesTab = state.activeTab === VIEW_IDS.leaves
1649
+ coreFieldsBtn.style.display = isLeavesTab ? '' : 'none'
1650
+ schemasOnlyFieldsBtn.style.display = isLeavesTab ? '' : 'none'
1651
+ sourceOnlyFieldsBtn.style.display = isLeavesTab ? '' : 'none'
1495
1652
  }
1496
1653
 
1497
1654
  function renderResults() {
1498
- if (state.mode === 'changelog') {
1499
- return
1500
- }
1501
1655
  const engine = createSearchEngine(searchInput.value.trim(), {
1502
1656
  caseSensitive: isPressed(caseSensitiveToggle),
1503
1657
  regex: isPressed(regexSearchToggle),
@@ -1515,51 +1669,88 @@
1515
1669
  }
1516
1670
 
1517
1671
  const selectedIds = selectedFieldIds()
1518
- const filtered = state.leaves.filter((leaf) => matchesLeaf(leaf, engine, selectedIds))
1519
-
1520
- statusEl.textContent = `${filtered.length} / ${state.leaves.length} routes matched.`
1521
1672
  resultsEl.innerHTML = ''
1673
+ renderTabButtons()
1522
1674
  renderActiveFilterChips({ selectedIds, hasRegexError: false })
1523
-
1524
- if (filtered.length === 0) {
1525
- resultsEl.appendChild(el('div', 'empty', 'No matches.'))
1526
- syncFilterStateToUrl()
1527
- return
1675
+ if (state.activeTab === VIEW_IDS.leaves) {
1676
+ const filtered = state.leaves.filter((leaf) => matchesLeaf(leaf, engine, selectedIds))
1677
+ statusEl.textContent = `${filtered.length} / ${state.leaves.length} routes matched.`
1678
+ renderCollection(filtered, (leaf) => renderLeaf(leaf, engine, selectedIds), 'No matches.')
1679
+ } else {
1680
+ const fieldDefs = currentFieldDefs()
1681
+ const items = changelogItemsByTab(state.activeTab)
1682
+ const filtered = items.filter((item) => matchesGenericItem(item, fieldDefs, engine, selectedIds))
1683
+ statusEl.textContent =
1684
+ state.activeTab === VIEW_IDS.changelog
1685
+ ? `${filtered.length} / ${items.length} changelog events matched.`
1686
+ : `${filtered.length} / ${items.length} source changelog groups matched.`
1687
+ const commitBySha = new Map(
1688
+ (state.changelogPayload?.commits || []).map((commit) => [commit.sha, commit]),
1689
+ )
1690
+ renderCollection(
1691
+ filtered,
1692
+ (item) => {
1693
+ const details = el('details', 'leaf')
1694
+ const title =
1695
+ state.activeTab === VIEW_IDS.changelog
1696
+ ? `${item.eventKind === 'route' ? `Route: ${item.routeKey}` : `Source: ${item.displayName || item.sourceObjectId}`} (${item.event.type})`
1697
+ : `${item.displayName} (${item.events.length})`
1698
+ details.appendChild(el('summary', '', title))
1699
+ if (state.activeTab === VIEW_IDS.changelog) {
1700
+ details.appendChild(renderEventCard(item.event, commitBySha))
1701
+ } else {
1702
+ item.events.forEach((event) => details.appendChild(renderEventCard(event, commitBySha)))
1703
+ }
1704
+ return details
1705
+ },
1706
+ 'No matches.',
1707
+ )
1528
1708
  }
1529
-
1530
- filtered.forEach((leaf) => {
1531
- resultsEl.appendChild(renderLeaf(leaf, engine, selectedIds))
1532
- })
1533
1709
  syncFilterStateToUrl()
1534
1710
  }
1535
1711
 
1536
1712
  function selectedFieldIds() {
1537
- return SEARCH_FIELDS.filter((field) => state.selectedFieldIds.has(field.id)).map(
1713
+ const selected = state.selectedFieldIdsByTab[state.activeTab] || new Set()
1714
+ return currentFieldDefs().filter((field) => selected.has(field.id)).map(
1538
1715
  (field) => field.id,
1539
1716
  )
1540
1717
  }
1541
1718
 
1542
1719
  function renderFieldChips() {
1720
+ const fieldDefs = currentFieldDefs()
1721
+ const selected = state.selectedFieldIdsByTab[state.activeTab] || new Set()
1543
1722
  fieldChipGroups.innerHTML = ''
1544
- FIELD_GROUPS.forEach((group) => {
1723
+ const groups =
1724
+ state.activeTab === VIEW_IDS.leaves
1725
+ ? FIELD_GROUPS
1726
+ : [
1727
+ {
1728
+ id: 'tabFields',
1729
+ label: state.activeTab === VIEW_IDS.changelog ? 'Changelog fields' : 'Source fields',
1730
+ fieldIds: fieldDefs.map((field) => field.id),
1731
+ },
1732
+ ]
1733
+
1734
+ groups.forEach((group) => {
1545
1735
  const groupWrap = el('div', 'scope-group')
1546
1736
  groupWrap.appendChild(el('div', 'scope-group-title', group.label))
1547
1737
  const chipsWrap = el('div', 'scope-group-chips')
1548
1738
 
1549
1739
  group.fieldIds.forEach((fieldId) => {
1550
- const field = SEARCH_FIELDS.find((item) => item.id === fieldId)
1740
+ const field = fieldDefs.find((item) => item.id === fieldId)
1551
1741
  if (!field) return
1552
1742
  const chip = document.createElement('button')
1553
1743
  chip.type = 'button'
1554
1744
  chip.className = 'field-chip'
1555
1745
  chip.textContent = field.label
1556
- setPressed(chip, state.selectedFieldIds.has(field.id))
1746
+ setPressed(chip, selected.has(field.id))
1557
1747
  chip.addEventListener('click', () => {
1558
- if (state.selectedFieldIds.has(field.id)) {
1559
- state.selectedFieldIds.delete(field.id)
1748
+ if (selected.has(field.id)) {
1749
+ selected.delete(field.id)
1560
1750
  } else {
1561
- state.selectedFieldIds.add(field.id)
1751
+ selected.add(field.id)
1562
1752
  }
1753
+ state.selectedFieldIdsByTab[state.activeTab] = selected
1563
1754
  renderFieldChips()
1564
1755
  renderResults()
1565
1756
  })
@@ -1573,8 +1764,8 @@
1573
1764
 
1574
1765
  function setFieldSelection(allowedIds, options = {}) {
1575
1766
  const { rerender = true } = options
1576
- state.selectedFieldIds = new Set(
1577
- SEARCH_FIELDS.map((field) => field.id).filter((id) => allowedIds.has(id)),
1767
+ state.selectedFieldIdsByTab[state.activeTab] = new Set(
1768
+ currentFieldDefs().map((field) => field.id).filter((id) => allowedIds.has(id)),
1578
1769
  )
1579
1770
  renderFieldChips()
1580
1771
  if (rerender) renderResults()
@@ -1586,7 +1777,7 @@
1586
1777
  setPressed(regexSearchToggle, false)
1587
1778
  typeMatchModeInput.value = 'contains'
1588
1779
  setPressed(schemaRowsMatchOnlyToggle, false)
1589
- setFieldSelection(new Set(SEARCH_FIELDS.map((field) => field.id)), { rerender: false })
1780
+ setFieldSelection(new Set(currentFieldDefs().map((field) => field.id)), { rerender: false })
1590
1781
  renderResults()
1591
1782
  }
1592
1783
 
@@ -1642,7 +1833,7 @@
1642
1833
  }
1643
1834
  if (hasRegexError) chipModels.push({ text: 'regex error' })
1644
1835
 
1645
- const allIds = SEARCH_FIELDS.map((field) => field.id)
1836
+ const allIds = currentFieldDefs().map((field) => field.id)
1646
1837
  if (selectedIds.length !== allIds.length) {
1647
1838
  chipModels.push({
1648
1839
  text: `fields: ${selectedIds.join(', ') || 'none'}`,
@@ -1669,12 +1860,15 @@
1669
1860
 
1670
1861
  function collectFilterState() {
1671
1862
  return {
1863
+ activeTab: state.activeTab,
1672
1864
  search: searchInput.value,
1673
1865
  caseSensitive: isPressed(caseSensitiveToggle),
1674
1866
  regex: isPressed(regexSearchToggle),
1675
1867
  typeMatchMode: typeMatchModeInput.value === 'exact' ? 'exact' : 'contains',
1676
1868
  schemaRowsMatchOnly: isPressed(schemaRowsMatchOnlyToggle),
1677
- selectedFields: selectedFieldIds(),
1869
+ selectedFieldsByTab: Object.fromEntries(
1870
+ Object.entries(state.selectedFieldIdsByTab).map(([key, value]) => [key, Array.from(value)]),
1871
+ ),
1678
1872
  }
1679
1873
  }
1680
1874
 
@@ -1699,6 +1893,16 @@
1699
1893
  try {
1700
1894
  const parsed = JSON.parse(decodeURIComponent(raw))
1701
1895
  isHydratingFromUrl = true
1896
+ if (
1897
+ parsed.activeTab === VIEW_IDS.leaves ||
1898
+ parsed.activeTab === VIEW_IDS.changelog ||
1899
+ parsed.activeTab === VIEW_IDS.changelogSource
1900
+ ) {
1901
+ state.activeTab = parsed.activeTab
1902
+ }
1903
+ if (parsed.activeTab === 'changelogRoute') {
1904
+ state.activeTab = VIEW_IDS.changelog
1905
+ }
1702
1906
  if (typeof parsed.search === 'string') searchInput.value = parsed.search
1703
1907
  setPressed(caseSensitiveToggle, Boolean(parsed.caseSensitive))
1704
1908
  setPressed(regexSearchToggle, Boolean(parsed.regex))
@@ -1707,8 +1911,12 @@
1707
1911
  }
1708
1912
  setPressed(schemaRowsMatchOnlyToggle, Boolean(parsed.schemaRowsMatchOnly))
1709
1913
 
1710
- if (Array.isArray(parsed.selectedFields)) {
1711
- setFieldSelection(new Set(parsed.selectedFields), { rerender: false })
1914
+ if (parsed.selectedFieldsByTab && typeof parsed.selectedFieldsByTab === 'object') {
1915
+ Object.entries(parsed.selectedFieldsByTab).forEach(([tabId, ids]) => {
1916
+ if (!Array.isArray(ids)) return
1917
+ if (!state.selectedFieldIdsByTab[tabId]) return
1918
+ state.selectedFieldIdsByTab[tabId] = new Set(ids)
1919
+ })
1712
1920
  }
1713
1921
  } catch (error) {
1714
1922
  // Ignore malformed hash state.
@@ -1720,40 +1928,84 @@
1720
1928
  async function handleFile(file) {
1721
1929
  const text = await file.text()
1722
1930
  const parsed = JSON.parse(text)
1723
- if (isChangelogPayload(parsed)) {
1724
- renderChangelogPayload(parsed)
1725
- return
1726
- }
1727
- if (!parsed || !Array.isArray(parsed.leaves)) {
1931
+ if (
1932
+ !isBundlePayload(parsed) &&
1933
+ !isChangelogPayload(parsed) &&
1934
+ (!parsed || !Array.isArray(parsed.leaves))
1935
+ ) {
1728
1936
  throw new Error(
1729
- 'Invalid export file: expected top-level "leaves" array or changelog payload.',
1937
+ 'Invalid export file: expected leaves payload, changelog payload, or unified bundle.',
1730
1938
  )
1731
1939
  }
1940
+ applyLoadedPayload(parsed)
1941
+ renderResults()
1942
+ }
1732
1943
 
1733
- state.mode = 'snapshot'
1734
- if (controlsEl) {
1735
- controlsEl.style.display = ''
1736
- }
1737
- state.payload = parsed
1738
- state.leaves = parsed.leaves
1944
+ function setActiveTab(nextTab) {
1945
+ if (!state.availableTabs.has(nextTab)) return
1946
+ state.activeTab = nextTab
1947
+ renderFieldChips()
1739
1948
  renderResults()
1740
1949
  }
1741
1950
 
1951
+ function applyLoadedPayload(payload) {
1952
+ state.payload = payload
1953
+ state.availableTabs = new Set()
1954
+ state.leavesPayload = null
1955
+ state.changelogPayload = null
1956
+ state.leaves = []
1957
+
1958
+ if (isBundlePayload(payload)) {
1959
+ state.leavesPayload = payload.leavesPayload
1960
+ state.changelogPayload = payload.changelogPayload
1961
+ } else if (isChangelogPayload(payload)) {
1962
+ state.changelogPayload = payload
1963
+ } else if (payload && Array.isArray(payload.leaves)) {
1964
+ state.leavesPayload = payload
1965
+ }
1966
+
1967
+ if (state.leavesPayload) {
1968
+ state.availableTabs.add(VIEW_IDS.leaves)
1969
+ state.leaves = state.leavesPayload.leaves || []
1970
+ }
1971
+ if (state.changelogPayload) {
1972
+ state.availableTabs.add(VIEW_IDS.changelog)
1973
+ state.availableTabs.add(VIEW_IDS.changelogSource)
1974
+ }
1975
+
1976
+ if (!state.availableTabs.has(state.activeTab)) {
1977
+ state.activeTab = state.availableTabs.has(VIEW_IDS.leaves)
1978
+ ? VIEW_IDS.leaves
1979
+ : state.availableTabs.has(VIEW_IDS.changelog)
1980
+ ? VIEW_IDS.changelog
1981
+ : VIEW_IDS.changelogSource
1982
+ }
1983
+
1984
+ if (state.leavesPayload && state.changelogPayload) {
1985
+ statusEl.textContent =
1986
+ `Loaded unified bundle with ${state.leaves.length} routes and ${
1987
+ state.changelogPayload.commits?.length || 0
1988
+ } commits.`
1989
+ } else if (state.leavesPayload) {
1990
+ statusEl.textContent = `Loaded payload with ${state.leaves.length} routes.`
1991
+ } else if (state.changelogPayload) {
1992
+ statusEl.textContent =
1993
+ `Loaded changelog payload with ${state.changelogPayload.commits?.length || 0} commits.`
1994
+ } else {
1995
+ statusEl.textContent = 'No payload loaded.'
1996
+ }
1997
+ }
1998
+
1742
1999
  function initializeFromBakedPayload() {
1743
2000
  const baked = window.__FINALIZED_LEAVES_PAYLOAD
1744
- if (isChangelogPayload(baked)) {
1745
- renderChangelogPayload(baked)
2001
+ if (
2002
+ !isBundlePayload(baked) &&
2003
+ !isChangelogPayload(baked) &&
2004
+ (!baked || !Array.isArray(baked.leaves))
2005
+ ) {
1746
2006
  return
1747
2007
  }
1748
- if (!baked || !Array.isArray(baked.leaves)) return
1749
-
1750
- state.mode = 'snapshot'
1751
- if (controlsEl) {
1752
- controlsEl.style.display = ''
1753
- }
1754
- state.payload = baked
1755
- state.leaves = baked.leaves
1756
- statusEl.textContent = `Loaded baked payload with ${state.leaves.length} routes.`
2008
+ applyLoadedPayload(baked)
1757
2009
  renderResults()
1758
2010
  }
1759
2011
 
@@ -1784,8 +2036,12 @@
1784
2036
  renderResults()
1785
2037
  })
1786
2038
 
2039
+ tabLeavesBtn.addEventListener('click', () => setActiveTab(VIEW_IDS.leaves))
2040
+ tabChangelogBtn.addEventListener('click', () => setActiveTab(VIEW_IDS.changelog))
2041
+ tabChangelogSourceBtn.addEventListener('click', () => setActiveTab(VIEW_IDS.changelogSource))
2042
+
1787
2043
  selectAllFieldsBtn.addEventListener('click', () =>
1788
- setFieldSelection(new Set(SEARCH_FIELDS.map((field) => field.id))),
2044
+ setFieldSelection(new Set(currentFieldDefs().map((field) => field.id))),
1789
2045
  )
1790
2046
  clearAllFieldsBtn.addEventListener('click', () => setFieldSelection(new Set()))
1791
2047
  coreFieldsBtn.addEventListener('click', () => setFieldSelection(CORE_FIELD_IDS))