@emeryld/rrroutes-export 1.0.12 → 1.0.14

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.
@@ -529,6 +529,23 @@
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="tabChangelogRoute" class="field-chip" type="button" aria-pressed="false">
540
+ Changelog / By Route
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>
@@ -612,6 +629,68 @@
612
629
  },
613
630
  ]
614
631
 
632
+ const CHANGELOG_ROUTE_FIELDS = [
633
+ {
634
+ id: 'routeKey',
635
+ label: 'route',
636
+ get: (item) => [item.routeKey],
637
+ },
638
+ {
639
+ id: 'eventType',
640
+ label: 'event type',
641
+ get: (item) => item.events.map((event) => event.type),
642
+ },
643
+ {
644
+ id: 'schemaDiff',
645
+ label: 'schema diff',
646
+ get: (item) =>
647
+ item.events.flatMap((event) =>
648
+ Array.isArray(event.schemaDiff)
649
+ ? event.schemaDiff.map((delta) => JSON.stringify(delta))
650
+ : [],
651
+ ),
652
+ },
653
+ {
654
+ id: 'cfgDiff',
655
+ label: 'cfg diff',
656
+ get: (item) =>
657
+ item.events.flatMap((event) =>
658
+ Array.isArray(event.cfgDiff)
659
+ ? event.cfgDiff.map((delta) => JSON.stringify(delta))
660
+ : [],
661
+ ),
662
+ },
663
+ ]
664
+
665
+ const CHANGELOG_SOURCE_FIELDS = [
666
+ {
667
+ id: 'sourceObject',
668
+ label: 'source object',
669
+ get: (item) => [item.sourceId, item.displayName],
670
+ },
671
+ {
672
+ id: 'eventType',
673
+ label: 'event type',
674
+ get: (item) => item.events.map((event) => event.type),
675
+ },
676
+ {
677
+ id: 'impactedRoutes',
678
+ label: 'impacted routes',
679
+ get: (item) =>
680
+ item.events.flatMap((event) => (Array.isArray(event.impactedRoutes) ? event.impactedRoutes : [])),
681
+ },
682
+ {
683
+ id: 'schemaDiff',
684
+ label: 'schema diff',
685
+ get: (item) =>
686
+ item.events.flatMap((event) =>
687
+ Array.isArray(event.schemaDiff)
688
+ ? event.schemaDiff.map((delta) => JSON.stringify(delta))
689
+ : [],
690
+ ),
691
+ },
692
+ ]
693
+
615
694
  const SCHEMA_SECTIONS = ['params', 'query', 'body', 'output']
616
695
  const SCHEMA_FIELD_IDS = new Set(SCHEMA_SECTIONS)
617
696
  const SOURCE_FIELD_IDS = new Set(['sourceDefinition', 'sourceSchemas'])
@@ -645,11 +724,39 @@
645
724
  },
646
725
  ]
647
726
 
727
+ const VIEW_IDS = {
728
+ leaves: 'leaves',
729
+ changelogRoute: 'changelogRoute',
730
+ changelogSource: 'changelogSource',
731
+ }
732
+
733
+ const TAB_CONFIG = {
734
+ [VIEW_IDS.leaves]: {
735
+ fieldDefs: SEARCH_FIELDS,
736
+ defaultSelected: new Set(SEARCH_FIELDS.map((field) => field.id)),
737
+ },
738
+ [VIEW_IDS.changelogRoute]: {
739
+ fieldDefs: CHANGELOG_ROUTE_FIELDS,
740
+ defaultSelected: new Set(CHANGELOG_ROUTE_FIELDS.map((field) => field.id)),
741
+ },
742
+ [VIEW_IDS.changelogSource]: {
743
+ fieldDefs: CHANGELOG_SOURCE_FIELDS,
744
+ defaultSelected: new Set(CHANGELOG_SOURCE_FIELDS.map((field) => field.id)),
745
+ },
746
+ }
747
+
648
748
  const state = {
649
749
  payload: null,
750
+ leavesPayload: null,
751
+ changelogPayload: null,
650
752
  leaves: [],
651
- mode: 'snapshot',
652
- selectedFieldIds: new Set(SEARCH_FIELDS.map((field) => field.id)),
753
+ activeTab: VIEW_IDS.leaves,
754
+ availableTabs: new Set([VIEW_IDS.leaves]),
755
+ selectedFieldIdsByTab: {
756
+ [VIEW_IDS.leaves]: new Set(TAB_CONFIG[VIEW_IDS.leaves].defaultSelected),
757
+ [VIEW_IDS.changelogRoute]: new Set(TAB_CONFIG[VIEW_IDS.changelogRoute].defaultSelected),
758
+ [VIEW_IDS.changelogSource]: new Set(TAB_CONFIG[VIEW_IDS.changelogSource].defaultSelected),
759
+ },
653
760
  }
654
761
 
655
762
  const fileInput = document.getElementById('fileInput')
@@ -669,6 +776,9 @@
669
776
  const statusEl = document.getElementById('status')
670
777
  const resultsEl = document.getElementById('results')
671
778
  const controlsEl = document.querySelector('.controls')
779
+ const tabLeavesBtn = document.getElementById('tabLeaves')
780
+ const tabChangelogRouteBtn = document.getElementById('tabChangelogRoute')
781
+ const tabChangelogSourceBtn = document.getElementById('tabChangelogSource')
672
782
 
673
783
  const URL_PARAM_KEY = 'filters'
674
784
  let isHydratingFromUrl = false
@@ -1316,107 +1426,153 @@
1316
1426
  )
1317
1427
  }
1318
1428
 
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),
1429
+ function isBundlePayload(value) {
1430
+ return (
1431
+ value &&
1432
+ value.kind === 'finalized-leaves-bundle' &&
1433
+ value.leavesPayload &&
1434
+ value.changelogPayload
1336
1435
  )
1436
+ }
1337
1437
 
1338
- statusEl.textContent = `Loaded changelog payload with ${payload.commits.length} commits.`
1339
- resultsEl.innerHTML = ''
1438
+ function currentFieldDefs() {
1439
+ return TAB_CONFIG[state.activeTab]?.fieldDefs || []
1440
+ }
1340
1441
 
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 renderEventDetails = (event) => {
1354
- const card = el('div', 'leaf-content')
1355
- const commit = commitBySha.get(event.commitSha)
1356
- card.appendChild(
1357
- el(
1358
- 'div',
1359
- 'meta',
1360
- `${event.commitSha.slice(0, 12)}${commit?.subject ? ` - ${commit.subject}` : ''}`,
1361
- ),
1362
- )
1363
- card.appendChild(el('div', 'mono', event.type))
1364
- if (Array.isArray(event.schemaDiff) && event.schemaDiff.length > 0) {
1365
- card.appendChild(el('div', 'meta', `schema changes: ${event.schemaDiff.length}`))
1366
- }
1367
- if (Array.isArray(event.cfgDiff) && event.cfgDiff.length > 0) {
1368
- card.appendChild(el('div', 'meta', `cfg changes: ${event.cfgDiff.length}`))
1369
- }
1370
- if (Array.isArray(event.impactedRoutes) && event.impactedRoutes.length > 0) {
1371
- card.appendChild(
1372
- el('div', 'meta', `impacted routes: ${event.impactedRoutes.length}`),
1373
- )
1374
- }
1375
- return card
1376
- }
1442
+ function formatCommitDateTime(value) {
1443
+ if (!value) return null
1444
+ const parsed = new Date(value)
1445
+ if (Number.isNaN(parsed.getTime())) return String(value)
1446
+ return parsed.toLocaleString()
1447
+ }
1377
1448
 
1378
- const renderByRoute = () => {
1379
- view.innerHTML = ''
1380
- if (routeEntries.length === 0) {
1381
- view.appendChild(el('div', 'empty', 'No route changes found in this range.'))
1382
- return
1383
- }
1449
+ function formatRouteSchemaEntry(entry) {
1450
+ if (!entry || typeof entry !== 'object') return 'n/a'
1451
+ const parts = [
1452
+ `type=${entry.type}`,
1453
+ `nullable=${Boolean(entry.nullable)}`,
1454
+ `optional=${Boolean(entry.optional)}`,
1455
+ ]
1456
+ if ('literal' in entry) {
1457
+ parts.push(`literal=${JSON.stringify(entry.literal)}`)
1458
+ }
1459
+ return parts.join(', ')
1460
+ }
1384
1461
 
1385
- routeEntries.forEach(([routeKey, events]) => {
1386
- const details = el('details', 'leaf')
1387
- details.appendChild(el('summary', '', `${routeKey} (${events.length})`))
1388
- events.forEach((event) => details.appendChild(renderEventDetails(event)))
1389
- view.appendChild(details)
1462
+ function formatSchemaDiffLine(delta) {
1463
+ if (!delta || typeof delta !== 'object') return null
1464
+ if ('section' in delta) {
1465
+ const location = `${delta.section}.${delta.path}`
1466
+ if (delta.op === 'added') return `added ${location}: ${JSON.stringify(delta.after || [])}`
1467
+ if (delta.op === 'removed') return `removed ${location}: ${JSON.stringify(delta.before || [])}`
1468
+ return `changed ${location}: ${JSON.stringify(delta.before || [])} -> ${JSON.stringify(delta.after || [])}`
1469
+ }
1470
+ if ('path' in delta) {
1471
+ if (delta.op === 'added') return `added ${delta.path}: ${formatRouteSchemaEntry(delta.after)}`
1472
+ if (delta.op === 'removed') return `removed ${delta.path}: ${formatRouteSchemaEntry(delta.before)}`
1473
+ return `changed ${delta.path}: ${formatRouteSchemaEntry(delta.before)} -> ${formatRouteSchemaEntry(delta.after)}`
1474
+ }
1475
+ return null
1476
+ }
1477
+
1478
+ function formatCfgDiffLine(diff) {
1479
+ if (!diff || typeof diff !== 'object') return null
1480
+ return `${diff.field}: ${JSON.stringify(diff.before)} -> ${JSON.stringify(diff.after)}`
1481
+ }
1482
+
1483
+ function renderEventCard(event, commitBySha) {
1484
+ const card = el('div', 'leaf-content')
1485
+ const commit = commitBySha.get(event.commitSha)
1486
+ const commitDateTime = formatCommitDateTime(commit?.authorDate)
1487
+ const commitMeta = [
1488
+ event.commitSha.slice(0, 12),
1489
+ commitDateTime,
1490
+ commit?.authorName || null,
1491
+ commit?.subject || null,
1492
+ ]
1493
+ .filter(Boolean)
1494
+ .join(' - ')
1495
+ card.appendChild(el('div', 'meta', commitMeta))
1496
+ card.appendChild(el('div', 'mono', event.type))
1497
+ if (Array.isArray(event.schemaDiff) && event.schemaDiff.length > 0) {
1498
+ card.appendChild(el('div', 'meta', `schema changes: ${event.schemaDiff.length}`))
1499
+ const schemaChanges = el('div')
1500
+ event.schemaDiff.forEach((delta) => {
1501
+ const line = formatSchemaDiffLine(delta)
1502
+ if (!line) return
1503
+ schemaChanges.appendChild(el('div', 'meta', line))
1390
1504
  })
1505
+ card.appendChild(schemaChanges)
1391
1506
  }
1392
-
1393
- const renderBySource = () => {
1394
- view.innerHTML = ''
1395
- if (sourceEntries.length === 0) {
1396
- view.appendChild(
1397
- el('div', 'empty', 'No source-object changes found in this range.'),
1398
- )
1399
- return
1400
- }
1401
-
1402
- sourceEntries.forEach(([sourceId, events]) => {
1403
- const details = el('details', 'leaf')
1404
- const display = events[0]?.displayName || sourceId
1405
- details.appendChild(el('summary', '', `${display} (${events.length})`))
1406
- events.forEach((event) => details.appendChild(renderEventDetails(event)))
1407
- view.appendChild(details)
1507
+ if (Array.isArray(event.cfgDiff) && event.cfgDiff.length > 0) {
1508
+ card.appendChild(el('div', 'meta', `cfg changes: ${event.cfgDiff.length}`))
1509
+ const cfgChanges = el('div')
1510
+ event.cfgDiff.forEach((diff) => {
1511
+ const line = formatCfgDiffLine(diff)
1512
+ if (!line) return
1513
+ cfgChanges.appendChild(el('div', 'meta', line))
1408
1514
  })
1515
+ card.appendChild(cfgChanges)
1516
+ }
1517
+ if (Array.isArray(event.impactedRoutes) && event.impactedRoutes.length > 0) {
1518
+ card.appendChild(el('div', 'meta', `impacted routes: ${event.impactedRoutes.length}`))
1409
1519
  }
1520
+ return card
1521
+ }
1410
1522
 
1411
- byRouteBtn.addEventListener('click', renderByRoute)
1412
- bySourceBtn.addEventListener('click', renderBySource)
1413
- renderByRoute()
1523
+ function changelogItemsByTab(tabId) {
1524
+ const payload = state.changelogPayload
1525
+ if (!payload) return []
1526
+ if (tabId === VIEW_IDS.changelogRoute) {
1527
+ return Object.entries(payload.byRoute || {})
1528
+ .sort(([a], [b]) => a.localeCompare(b))
1529
+ .map(([routeKey, events]) => ({ routeKey, events }))
1530
+ }
1531
+ if (tabId === VIEW_IDS.changelogSource) {
1532
+ return Object.entries(payload.bySourceObject || {})
1533
+ .sort(([a], [b]) => a.localeCompare(b))
1534
+ .map(([sourceId, events]) => ({
1535
+ sourceId,
1536
+ events,
1537
+ displayName: events[0]?.displayName || sourceId,
1538
+ }))
1539
+ }
1540
+ return []
1414
1541
  }
1415
1542
 
1416
- function renderResults() {
1417
- if (state.mode === 'changelog') {
1543
+ function matchesGenericItem(item, fieldDefs, engine, selectedIds) {
1544
+ if (!engine.active) return true
1545
+ return fieldDefs.some((field) => {
1546
+ if (selectedIds.length > 0 && !selectedIds.includes(field.id)) return false
1547
+ const tokens = toTokens(field.get(item))
1548
+ return tokens.some((token) => engine.test(token))
1549
+ })
1550
+ }
1551
+
1552
+ function renderCollection(items, renderItem, emptyMessage) {
1553
+ if (items.length === 0) {
1554
+ resultsEl.appendChild(el('div', 'empty', emptyMessage))
1418
1555
  return
1419
1556
  }
1557
+ items.forEach((item) => {
1558
+ resultsEl.appendChild(renderItem(item))
1559
+ })
1560
+ }
1561
+
1562
+ function renderTabButtons() {
1563
+ setPressed(tabLeavesBtn, state.activeTab === VIEW_IDS.leaves)
1564
+ setPressed(tabChangelogRouteBtn, state.activeTab === VIEW_IDS.changelogRoute)
1565
+ setPressed(tabChangelogSourceBtn, state.activeTab === VIEW_IDS.changelogSource)
1566
+ tabLeavesBtn.style.display = state.availableTabs.has(VIEW_IDS.leaves) ? '' : 'none'
1567
+ tabChangelogRouteBtn.style.display = state.availableTabs.has(VIEW_IDS.changelogRoute) ? '' : 'none'
1568
+ tabChangelogSourceBtn.style.display = state.availableTabs.has(VIEW_IDS.changelogSource) ? '' : 'none'
1569
+ const isLeavesTab = state.activeTab === VIEW_IDS.leaves
1570
+ coreFieldsBtn.style.display = isLeavesTab ? '' : 'none'
1571
+ schemasOnlyFieldsBtn.style.display = isLeavesTab ? '' : 'none'
1572
+ sourceOnlyFieldsBtn.style.display = isLeavesTab ? '' : 'none'
1573
+ }
1574
+
1575
+ function renderResults() {
1420
1576
  const engine = createSearchEngine(searchInput.value.trim(), {
1421
1577
  caseSensitive: isPressed(caseSensitiveToggle),
1422
1578
  regex: isPressed(regexSearchToggle),
@@ -1434,51 +1590,84 @@
1434
1590
  }
1435
1591
 
1436
1592
  const selectedIds = selectedFieldIds()
1437
- const filtered = state.leaves.filter((leaf) => matchesLeaf(leaf, engine, selectedIds))
1438
-
1439
- statusEl.textContent = `${filtered.length} / ${state.leaves.length} routes matched.`
1440
1593
  resultsEl.innerHTML = ''
1594
+ renderTabButtons()
1441
1595
  renderActiveFilterChips({ selectedIds, hasRegexError: false })
1442
-
1443
- if (filtered.length === 0) {
1444
- resultsEl.appendChild(el('div', 'empty', 'No matches.'))
1445
- syncFilterStateToUrl()
1446
- return
1596
+ if (state.activeTab === VIEW_IDS.leaves) {
1597
+ const filtered = state.leaves.filter((leaf) => matchesLeaf(leaf, engine, selectedIds))
1598
+ statusEl.textContent = `${filtered.length} / ${state.leaves.length} routes matched.`
1599
+ renderCollection(filtered, (leaf) => renderLeaf(leaf, engine, selectedIds), 'No matches.')
1600
+ } else {
1601
+ const fieldDefs = currentFieldDefs()
1602
+ const items = changelogItemsByTab(state.activeTab)
1603
+ const filtered = items.filter((item) => matchesGenericItem(item, fieldDefs, engine, selectedIds))
1604
+ statusEl.textContent =
1605
+ state.activeTab === VIEW_IDS.changelogRoute
1606
+ ? `${filtered.length} / ${items.length} route changelog groups matched.`
1607
+ : `${filtered.length} / ${items.length} source changelog groups matched.`
1608
+ const commitBySha = new Map(
1609
+ (state.changelogPayload?.commits || []).map((commit) => [commit.sha, commit]),
1610
+ )
1611
+ renderCollection(
1612
+ filtered,
1613
+ (item) => {
1614
+ const details = el('details', 'leaf')
1615
+ const title =
1616
+ state.activeTab === VIEW_IDS.changelogRoute
1617
+ ? `${item.routeKey} (${item.events.length})`
1618
+ : `${item.displayName} (${item.events.length})`
1619
+ details.appendChild(el('summary', '', title))
1620
+ item.events.forEach((event) => details.appendChild(renderEventCard(event, commitBySha)))
1621
+ return details
1622
+ },
1623
+ 'No matches.',
1624
+ )
1447
1625
  }
1448
-
1449
- filtered.forEach((leaf) => {
1450
- resultsEl.appendChild(renderLeaf(leaf, engine, selectedIds))
1451
- })
1452
1626
  syncFilterStateToUrl()
1453
1627
  }
1454
1628
 
1455
1629
  function selectedFieldIds() {
1456
- return SEARCH_FIELDS.filter((field) => state.selectedFieldIds.has(field.id)).map(
1630
+ const selected = state.selectedFieldIdsByTab[state.activeTab] || new Set()
1631
+ return currentFieldDefs().filter((field) => selected.has(field.id)).map(
1457
1632
  (field) => field.id,
1458
1633
  )
1459
1634
  }
1460
1635
 
1461
1636
  function renderFieldChips() {
1637
+ const fieldDefs = currentFieldDefs()
1638
+ const selected = state.selectedFieldIdsByTab[state.activeTab] || new Set()
1462
1639
  fieldChipGroups.innerHTML = ''
1463
- FIELD_GROUPS.forEach((group) => {
1640
+ const groups =
1641
+ state.activeTab === VIEW_IDS.leaves
1642
+ ? FIELD_GROUPS
1643
+ : [
1644
+ {
1645
+ id: 'tabFields',
1646
+ label: state.activeTab === VIEW_IDS.changelogRoute ? 'Route fields' : 'Source fields',
1647
+ fieldIds: fieldDefs.map((field) => field.id),
1648
+ },
1649
+ ]
1650
+
1651
+ groups.forEach((group) => {
1464
1652
  const groupWrap = el('div', 'scope-group')
1465
1653
  groupWrap.appendChild(el('div', 'scope-group-title', group.label))
1466
1654
  const chipsWrap = el('div', 'scope-group-chips')
1467
1655
 
1468
1656
  group.fieldIds.forEach((fieldId) => {
1469
- const field = SEARCH_FIELDS.find((item) => item.id === fieldId)
1657
+ const field = fieldDefs.find((item) => item.id === fieldId)
1470
1658
  if (!field) return
1471
1659
  const chip = document.createElement('button')
1472
1660
  chip.type = 'button'
1473
1661
  chip.className = 'field-chip'
1474
1662
  chip.textContent = field.label
1475
- setPressed(chip, state.selectedFieldIds.has(field.id))
1663
+ setPressed(chip, selected.has(field.id))
1476
1664
  chip.addEventListener('click', () => {
1477
- if (state.selectedFieldIds.has(field.id)) {
1478
- state.selectedFieldIds.delete(field.id)
1665
+ if (selected.has(field.id)) {
1666
+ selected.delete(field.id)
1479
1667
  } else {
1480
- state.selectedFieldIds.add(field.id)
1668
+ selected.add(field.id)
1481
1669
  }
1670
+ state.selectedFieldIdsByTab[state.activeTab] = selected
1482
1671
  renderFieldChips()
1483
1672
  renderResults()
1484
1673
  })
@@ -1492,8 +1681,8 @@
1492
1681
 
1493
1682
  function setFieldSelection(allowedIds, options = {}) {
1494
1683
  const { rerender = true } = options
1495
- state.selectedFieldIds = new Set(
1496
- SEARCH_FIELDS.map((field) => field.id).filter((id) => allowedIds.has(id)),
1684
+ state.selectedFieldIdsByTab[state.activeTab] = new Set(
1685
+ currentFieldDefs().map((field) => field.id).filter((id) => allowedIds.has(id)),
1497
1686
  )
1498
1687
  renderFieldChips()
1499
1688
  if (rerender) renderResults()
@@ -1505,7 +1694,7 @@
1505
1694
  setPressed(regexSearchToggle, false)
1506
1695
  typeMatchModeInput.value = 'contains'
1507
1696
  setPressed(schemaRowsMatchOnlyToggle, false)
1508
- setFieldSelection(new Set(SEARCH_FIELDS.map((field) => field.id)), { rerender: false })
1697
+ setFieldSelection(new Set(currentFieldDefs().map((field) => field.id)), { rerender: false })
1509
1698
  renderResults()
1510
1699
  }
1511
1700
 
@@ -1561,7 +1750,7 @@
1561
1750
  }
1562
1751
  if (hasRegexError) chipModels.push({ text: 'regex error' })
1563
1752
 
1564
- const allIds = SEARCH_FIELDS.map((field) => field.id)
1753
+ const allIds = currentFieldDefs().map((field) => field.id)
1565
1754
  if (selectedIds.length !== allIds.length) {
1566
1755
  chipModels.push({
1567
1756
  text: `fields: ${selectedIds.join(', ') || 'none'}`,
@@ -1588,12 +1777,15 @@
1588
1777
 
1589
1778
  function collectFilterState() {
1590
1779
  return {
1780
+ activeTab: state.activeTab,
1591
1781
  search: searchInput.value,
1592
1782
  caseSensitive: isPressed(caseSensitiveToggle),
1593
1783
  regex: isPressed(regexSearchToggle),
1594
1784
  typeMatchMode: typeMatchModeInput.value === 'exact' ? 'exact' : 'contains',
1595
1785
  schemaRowsMatchOnly: isPressed(schemaRowsMatchOnlyToggle),
1596
- selectedFields: selectedFieldIds(),
1786
+ selectedFieldsByTab: Object.fromEntries(
1787
+ Object.entries(state.selectedFieldIdsByTab).map(([key, value]) => [key, Array.from(value)]),
1788
+ ),
1597
1789
  }
1598
1790
  }
1599
1791
 
@@ -1618,6 +1810,13 @@
1618
1810
  try {
1619
1811
  const parsed = JSON.parse(decodeURIComponent(raw))
1620
1812
  isHydratingFromUrl = true
1813
+ if (
1814
+ parsed.activeTab === VIEW_IDS.leaves ||
1815
+ parsed.activeTab === VIEW_IDS.changelogRoute ||
1816
+ parsed.activeTab === VIEW_IDS.changelogSource
1817
+ ) {
1818
+ state.activeTab = parsed.activeTab
1819
+ }
1621
1820
  if (typeof parsed.search === 'string') searchInput.value = parsed.search
1622
1821
  setPressed(caseSensitiveToggle, Boolean(parsed.caseSensitive))
1623
1822
  setPressed(regexSearchToggle, Boolean(parsed.regex))
@@ -1626,8 +1825,12 @@
1626
1825
  }
1627
1826
  setPressed(schemaRowsMatchOnlyToggle, Boolean(parsed.schemaRowsMatchOnly))
1628
1827
 
1629
- if (Array.isArray(parsed.selectedFields)) {
1630
- setFieldSelection(new Set(parsed.selectedFields), { rerender: false })
1828
+ if (parsed.selectedFieldsByTab && typeof parsed.selectedFieldsByTab === 'object') {
1829
+ Object.entries(parsed.selectedFieldsByTab).forEach(([tabId, ids]) => {
1830
+ if (!Array.isArray(ids)) return
1831
+ if (!state.selectedFieldIdsByTab[tabId]) return
1832
+ state.selectedFieldIdsByTab[tabId] = new Set(ids)
1833
+ })
1631
1834
  }
1632
1835
  } catch (error) {
1633
1836
  // Ignore malformed hash state.
@@ -1639,40 +1842,84 @@
1639
1842
  async function handleFile(file) {
1640
1843
  const text = await file.text()
1641
1844
  const parsed = JSON.parse(text)
1642
- if (isChangelogPayload(parsed)) {
1643
- renderChangelogPayload(parsed)
1644
- return
1645
- }
1646
- if (!parsed || !Array.isArray(parsed.leaves)) {
1845
+ if (
1846
+ !isBundlePayload(parsed) &&
1847
+ !isChangelogPayload(parsed) &&
1848
+ (!parsed || !Array.isArray(parsed.leaves))
1849
+ ) {
1647
1850
  throw new Error(
1648
- 'Invalid export file: expected top-level "leaves" array or changelog payload.',
1851
+ 'Invalid export file: expected leaves payload, changelog payload, or unified bundle.',
1649
1852
  )
1650
1853
  }
1854
+ applyLoadedPayload(parsed)
1855
+ renderResults()
1856
+ }
1651
1857
 
1652
- state.mode = 'snapshot'
1653
- if (controlsEl) {
1654
- controlsEl.style.display = ''
1655
- }
1656
- state.payload = parsed
1657
- state.leaves = parsed.leaves
1858
+ function setActiveTab(nextTab) {
1859
+ if (!state.availableTabs.has(nextTab)) return
1860
+ state.activeTab = nextTab
1861
+ renderFieldChips()
1658
1862
  renderResults()
1659
1863
  }
1660
1864
 
1865
+ function applyLoadedPayload(payload) {
1866
+ state.payload = payload
1867
+ state.availableTabs = new Set()
1868
+ state.leavesPayload = null
1869
+ state.changelogPayload = null
1870
+ state.leaves = []
1871
+
1872
+ if (isBundlePayload(payload)) {
1873
+ state.leavesPayload = payload.leavesPayload
1874
+ state.changelogPayload = payload.changelogPayload
1875
+ } else if (isChangelogPayload(payload)) {
1876
+ state.changelogPayload = payload
1877
+ } else if (payload && Array.isArray(payload.leaves)) {
1878
+ state.leavesPayload = payload
1879
+ }
1880
+
1881
+ if (state.leavesPayload) {
1882
+ state.availableTabs.add(VIEW_IDS.leaves)
1883
+ state.leaves = state.leavesPayload.leaves || []
1884
+ }
1885
+ if (state.changelogPayload) {
1886
+ state.availableTabs.add(VIEW_IDS.changelogRoute)
1887
+ state.availableTabs.add(VIEW_IDS.changelogSource)
1888
+ }
1889
+
1890
+ if (!state.availableTabs.has(state.activeTab)) {
1891
+ state.activeTab = state.availableTabs.has(VIEW_IDS.leaves)
1892
+ ? VIEW_IDS.leaves
1893
+ : state.availableTabs.has(VIEW_IDS.changelogRoute)
1894
+ ? VIEW_IDS.changelogRoute
1895
+ : VIEW_IDS.changelogSource
1896
+ }
1897
+
1898
+ if (state.leavesPayload && state.changelogPayload) {
1899
+ statusEl.textContent =
1900
+ `Loaded unified bundle with ${state.leaves.length} routes and ${
1901
+ state.changelogPayload.commits?.length || 0
1902
+ } commits.`
1903
+ } else if (state.leavesPayload) {
1904
+ statusEl.textContent = `Loaded payload with ${state.leaves.length} routes.`
1905
+ } else if (state.changelogPayload) {
1906
+ statusEl.textContent =
1907
+ `Loaded changelog payload with ${state.changelogPayload.commits?.length || 0} commits.`
1908
+ } else {
1909
+ statusEl.textContent = 'No payload loaded.'
1910
+ }
1911
+ }
1912
+
1661
1913
  function initializeFromBakedPayload() {
1662
1914
  const baked = window.__FINALIZED_LEAVES_PAYLOAD
1663
- if (isChangelogPayload(baked)) {
1664
- renderChangelogPayload(baked)
1915
+ if (
1916
+ !isBundlePayload(baked) &&
1917
+ !isChangelogPayload(baked) &&
1918
+ (!baked || !Array.isArray(baked.leaves))
1919
+ ) {
1665
1920
  return
1666
1921
  }
1667
- if (!baked || !Array.isArray(baked.leaves)) return
1668
-
1669
- state.mode = 'snapshot'
1670
- if (controlsEl) {
1671
- controlsEl.style.display = ''
1672
- }
1673
- state.payload = baked
1674
- state.leaves = baked.leaves
1675
- statusEl.textContent = `Loaded baked payload with ${state.leaves.length} routes.`
1922
+ applyLoadedPayload(baked)
1676
1923
  renderResults()
1677
1924
  }
1678
1925
 
@@ -1703,8 +1950,12 @@
1703
1950
  renderResults()
1704
1951
  })
1705
1952
 
1953
+ tabLeavesBtn.addEventListener('click', () => setActiveTab(VIEW_IDS.leaves))
1954
+ tabChangelogRouteBtn.addEventListener('click', () => setActiveTab(VIEW_IDS.changelogRoute))
1955
+ tabChangelogSourceBtn.addEventListener('click', () => setActiveTab(VIEW_IDS.changelogSource))
1956
+
1706
1957
  selectAllFieldsBtn.addEventListener('click', () =>
1707
- setFieldSelection(new Set(SEARCH_FIELDS.map((field) => field.id))),
1958
+ setFieldSelection(new Set(currentFieldDefs().map((field) => field.id))),
1708
1959
  )
1709
1960
  clearAllFieldsBtn.addEventListener('click', () => setFieldSelection(new Set()))
1710
1961
  coreFieldsBtn.addEventListener('click', () => setFieldSelection(CORE_FIELD_IDS))