@syntax-syllogism/flow-delta 0.3.0 → 0.5.0
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.
- package/README.md +18 -3
- package/dist/cli.js +299 -27
- package/dist/github-report.js +342 -0
- package/dist/gitlab-report.js +122 -40
- package/examples/cloudflare-worker/src/index.ts +63 -0
- package/examples/cloudflare-worker/wrangler.toml +8 -0
- package/examples/github-actions.yml +56 -0
- package/examples/gitlab-ci.yml +23 -0
- package/package.json +8 -4
- package/scripts/r2-publish.mjs +132 -0
package/README.md
CHANGED
|
@@ -5,10 +5,12 @@ A semantic, visual diff for Salesforce Flows: parse two versions of a
|
|
|
5
5
|
interactive HTML artifact (plus `diff.json`) that shows added / deleted /
|
|
6
6
|
modified / unchanged nodes and edges with per-property deltas.
|
|
7
7
|
|
|
8
|
-
Inspired by Google's [Flow Lens](https://github.com/google/flow-lens), with
|
|
9
|
-
|
|
8
|
+
Inspired by Google's [Flow Lens](https://github.com/google/flow-lens), with a
|
|
9
|
+
focus on CI review workflows for GitLab merge requests and GitHub pull requests.
|
|
10
|
+
We also opted for our own HTML output over plantuml, graphviz, or mermaid.
|
|
10
11
|
|
|
11
|
-
[Sample
|
|
12
|
+
- [Sample GitLab project with artifacts](https://gitlab.com/j.p.richter/flow-delta-example/-/merge_requests/)
|
|
13
|
+
- [Sample GitHub project with artifacts](https://github.com/Syntax-Syllogism/flow-delta-example/pulls)
|
|
12
14
|
|
|
13
15
|
## Usage
|
|
14
16
|
|
|
@@ -19,6 +21,19 @@ for full file-mode and git-mode options.
|
|
|
19
21
|
npx tsx src/cli.ts --old before.flow-meta.xml --new after.flow-meta.xml --out ./flow-delta-out --json
|
|
20
22
|
```
|
|
21
23
|
|
|
24
|
+
CI reporters read the generated `flow-delta-out/*.diff.json` files and post a
|
|
25
|
+
sticky review comment:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
flow-delta-gitlab --in flow-delta-out
|
|
29
|
+
flow-delta-github --in flow-delta-out
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The GitHub reporter can also consume `--artifact-urls <manifest.json>` for
|
|
33
|
+
user-owned live-render links, such as R2 presigned URLs or Cloudflare
|
|
34
|
+
Worker-backed URLs. See [docs/ci.md](docs/ci.md) for GitLab/GitHub workflow
|
|
35
|
+
recipes, private-repo artifact viewing, and smoke harnesses.
|
|
36
|
+
|
|
22
37
|
## Development
|
|
23
38
|
|
|
24
39
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -628,6 +628,38 @@ function isPlainObject(value) {
|
|
|
628
628
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
+
// src/model/flow-header.ts
|
|
632
|
+
import { Parser as Parser2 } from "xml2js";
|
|
633
|
+
var FLOW_HEADER_KEYS = [
|
|
634
|
+
"status",
|
|
635
|
+
"processType",
|
|
636
|
+
"runInMode",
|
|
637
|
+
"apiVersion",
|
|
638
|
+
"triggerOrder",
|
|
639
|
+
"description",
|
|
640
|
+
"interviewLabel",
|
|
641
|
+
"isTemplate"
|
|
642
|
+
];
|
|
643
|
+
async function extractFlowHeader(xml) {
|
|
644
|
+
try {
|
|
645
|
+
const parsed = await new Parser2({ explicitArray: false }).parseStringPromise(xml);
|
|
646
|
+
const flow = parsed.Flow;
|
|
647
|
+
if (!flow) {
|
|
648
|
+
return {};
|
|
649
|
+
}
|
|
650
|
+
const header = {};
|
|
651
|
+
for (const key of FLOW_HEADER_KEYS) {
|
|
652
|
+
const value = flow[key];
|
|
653
|
+
if (value !== void 0 && (value === null || typeof value !== "object")) {
|
|
654
|
+
header[key] = String(value);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return header;
|
|
658
|
+
} catch {
|
|
659
|
+
return {};
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
631
663
|
// src/diff/deep-diff.ts
|
|
632
664
|
function deepDiff(before, after, path = "") {
|
|
633
665
|
if (Object.is(before, after)) {
|
|
@@ -685,21 +717,32 @@ function diffModel(oldModel, newModel) {
|
|
|
685
717
|
const edgeIds = [.../* @__PURE__ */ new Set([...oldEdges.keys(), ...newEdges.keys()])].sort();
|
|
686
718
|
const nodes = nodeIds.map((id) => classifyNode(oldNodes.get(id), newNodes.get(id)));
|
|
687
719
|
const edges = edgeIds.map((id) => classifyEdge(oldEdges.get(id), newEdges.get(id)));
|
|
720
|
+
const flowChanges = diffFlowHeaders(oldModel, newModel);
|
|
688
721
|
const summary = {
|
|
689
722
|
addedNodes: nodes.filter((node) => node.status === "added").length,
|
|
690
723
|
removedNodes: nodes.filter((node) => node.status === "deleted").length,
|
|
691
724
|
modifiedNodes: nodes.filter((node) => node.status === "modified").length,
|
|
692
725
|
unchangedNodes: nodes.filter((node) => node.status === "unchanged").length,
|
|
693
726
|
addedEdges: edges.filter((edge) => edge.status === "added").length,
|
|
694
|
-
removedEdges: edges.filter((edge) => edge.status === "deleted").length
|
|
727
|
+
removedEdges: edges.filter((edge) => edge.status === "deleted").length,
|
|
728
|
+
changedFlowAttributes: flowChanges.length
|
|
695
729
|
};
|
|
696
730
|
return {
|
|
697
731
|
flowName: selectFlowName(oldModel, newModel),
|
|
698
732
|
summary,
|
|
733
|
+
...flowChanges.length > 0 ? { flowChanges } : {},
|
|
699
734
|
nodes,
|
|
700
735
|
edges
|
|
701
736
|
};
|
|
702
737
|
}
|
|
738
|
+
function diffFlowHeaders(oldModel, newModel) {
|
|
739
|
+
if (!oldModel.header || !newModel.header) {
|
|
740
|
+
return [];
|
|
741
|
+
}
|
|
742
|
+
const changes = deepDiff(oldModel.header, newModel.header);
|
|
743
|
+
const order = new Map(FLOW_HEADER_KEYS.map((key, index) => [key, index]));
|
|
744
|
+
return changes.sort((left, right) => (order.get(left.path) ?? 999) - (order.get(right.path) ?? 999));
|
|
745
|
+
}
|
|
703
746
|
function selectFlowName(oldModel, newModel) {
|
|
704
747
|
if (newModel.nodes.length > 0 || newModel.edges.length > 0) {
|
|
705
748
|
return newModel.flowName;
|
|
@@ -1516,6 +1559,7 @@ function snapshotPanelClientScript() {
|
|
|
1516
1559
|
}
|
|
1517
1560
|
|
|
1518
1561
|
// src/render/render-html.ts
|
|
1562
|
+
var THEME_STORAGE_KEY = "flow-delta-theme";
|
|
1519
1563
|
function renderHtml(layout) {
|
|
1520
1564
|
const data = {
|
|
1521
1565
|
diff: layout.diff,
|
|
@@ -1532,25 +1576,72 @@ function renderHtml(layout) {
|
|
|
1532
1576
|
const edgeStat = s.addedEdges + s.removedEdges > 0 ? `<span class="sep">\xB7</span><span class="stat edges">+${s.addedEdges}/\u2212${s.removedEdges} edges</span>` : "";
|
|
1533
1577
|
const metaHtml = `${stat(s.addedNodes, "added", "added")}<span class="sep">\xB7</span>${stat(s.removedNodes, "deleted", "deleted")}<span class="sep">\xB7</span>${stat(s.modifiedNodes, "modified", "modified")}${edgeStat}`;
|
|
1534
1578
|
const viewBox = fitViewBox(layout.width, layout.height);
|
|
1579
|
+
const flowBannerHtml = renderFlowBanner(layout.diff.flowChanges);
|
|
1535
1580
|
return `<!doctype html>
|
|
1536
1581
|
<html lang="en">
|
|
1537
1582
|
<head>
|
|
1538
1583
|
<meta charset="utf-8" />
|
|
1539
1584
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1540
1585
|
<title>${escapeHtml2(layout.diff.flowName)}</title>
|
|
1586
|
+
<script>
|
|
1587
|
+
(() => {
|
|
1588
|
+
const key = ${JSON.stringify(THEME_STORAGE_KEY)};
|
|
1589
|
+
const allowed = new Set(["system", "light", "dark"]);
|
|
1590
|
+
let theme = "system";
|
|
1591
|
+
try {
|
|
1592
|
+
const stored = localStorage.getItem(key);
|
|
1593
|
+
if (allowed.has(stored)) theme = stored;
|
|
1594
|
+
} catch {}
|
|
1595
|
+
document.documentElement.dataset.theme = theme;
|
|
1596
|
+
})();
|
|
1597
|
+
</script>
|
|
1541
1598
|
<style>
|
|
1542
1599
|
:root {
|
|
1543
1600
|
color-scheme: light;
|
|
1544
1601
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
1545
|
-
--bg: #f5f7fb; --surface: #ffffff; --border: #e3e8ef;
|
|
1602
|
+
--bg: #f5f7fb; --surface: #ffffff; --surface-muted: #f8fafc; --surface-subtle: #fbfcfe; --surface-selected: #eff6ff; --border: #e3e8ef; --focus: #93c5fd;
|
|
1546
1603
|
--text: #1f2937; --muted: #64748b; --faint: #94a3b8;
|
|
1604
|
+
--node-text: #111827; --shadow: rgba(15, 23, 42, 0.12); --shadow-strong: rgba(15, 23, 42, 0.2);
|
|
1605
|
+
--grid-dot: #dfe5ee; --canvas-start: #ffffff; --canvas-end: #f7f9fc;
|
|
1547
1606
|
--added: #16a34a; --added-fill: #dcfce7;
|
|
1548
1607
|
--deleted: #dc2626; --deleted-fill: #fee2e2;
|
|
1549
1608
|
--modified: #d97706; --modified-fill: #fef3c7;
|
|
1550
1609
|
--unchanged: #94a3b8; --unchanged-fill: #e9edf3;
|
|
1551
1610
|
--edge: #64748b; --fault: #7c3aed;
|
|
1611
|
+
--inserted-text: #14532d; --deleted-text: #7f1d1d; --deleted-line: rgba(127, 29, 29, 0.5);
|
|
1552
1612
|
--panel-width: 34vw;
|
|
1553
1613
|
}
|
|
1614
|
+
@media (prefers-color-scheme: dark) {
|
|
1615
|
+
:root:not([data-theme="light"]) {
|
|
1616
|
+
color-scheme: dark;
|
|
1617
|
+
--bg: #0f172a; --surface: #111827; --surface-muted: #1f2937; --surface-subtle: #172033; --surface-selected: #1e3a5f; --border: #334155; --focus: #60a5fa;
|
|
1618
|
+
--text: #e5e7eb; --muted: #a5b4c7; --faint: #718096;
|
|
1619
|
+
--node-text: #f8fafc; --shadow: rgba(0, 0, 0, 0.35); --shadow-strong: rgba(0, 0, 0, 0.55);
|
|
1620
|
+
--grid-dot: #334155; --canvas-start: #0f172a; --canvas-end: #111827;
|
|
1621
|
+
--added: #4ade80; --added-fill: #123f2a;
|
|
1622
|
+
--deleted: #f87171; --deleted-fill: #4a1d23;
|
|
1623
|
+
--modified: #fbbf24; --modified-fill: #46330d;
|
|
1624
|
+
--unchanged: #64748b; --unchanged-fill: #263241;
|
|
1625
|
+
--edge: #94a3b8; --fault: #c4b5fd;
|
|
1626
|
+
--inserted-text: #bbf7d0; --deleted-text: #fecaca; --deleted-line: rgba(254, 202, 202, 0.55);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
:root[data-theme="light"] {
|
|
1630
|
+
color-scheme: light;
|
|
1631
|
+
}
|
|
1632
|
+
:root[data-theme="dark"] {
|
|
1633
|
+
color-scheme: dark;
|
|
1634
|
+
--bg: #0f172a; --surface: #111827; --surface-muted: #1f2937; --surface-subtle: #172033; --surface-selected: #1e3a5f; --border: #334155; --focus: #60a5fa;
|
|
1635
|
+
--text: #e5e7eb; --muted: #a5b4c7; --faint: #718096;
|
|
1636
|
+
--node-text: #f8fafc; --shadow: rgba(0, 0, 0, 0.35); --shadow-strong: rgba(0, 0, 0, 0.55);
|
|
1637
|
+
--grid-dot: #334155; --canvas-start: #0f172a; --canvas-end: #111827;
|
|
1638
|
+
--added: #4ade80; --added-fill: #123f2a;
|
|
1639
|
+
--deleted: #f87171; --deleted-fill: #4a1d23;
|
|
1640
|
+
--modified: #fbbf24; --modified-fill: #46330d;
|
|
1641
|
+
--unchanged: #64748b; --unchanged-fill: #263241;
|
|
1642
|
+
--edge: #94a3b8; --fault: #c4b5fd;
|
|
1643
|
+
--inserted-text: #bbf7d0; --deleted-text: #fecaca; --deleted-line: rgba(254, 202, 202, 0.55);
|
|
1644
|
+
}
|
|
1554
1645
|
* { box-sizing: border-box; }
|
|
1555
1646
|
body { margin: 0; height: 100vh; display: grid; grid-template-rows: auto 1fr; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.45; }
|
|
1556
1647
|
header { display: flex; justify-content: space-between; gap: 16px; align-items: center; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--surface); }
|
|
@@ -1566,7 +1657,7 @@ function renderHtml(layout) {
|
|
|
1566
1657
|
.filters { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
|
1567
1658
|
.filters button {
|
|
1568
1659
|
border: 1px solid var(--border);
|
|
1569
|
-
background:
|
|
1660
|
+
background: var(--surface-muted);
|
|
1570
1661
|
color: var(--muted);
|
|
1571
1662
|
padding: 6px 11px;
|
|
1572
1663
|
border-radius: 999px;
|
|
@@ -1577,15 +1668,19 @@ function renderHtml(layout) {
|
|
|
1577
1668
|
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
|
1578
1669
|
}
|
|
1579
1670
|
.filters button.active {
|
|
1580
|
-
background:
|
|
1581
|
-
border-color:
|
|
1671
|
+
background: var(--surface-selected);
|
|
1672
|
+
border-color: var(--focus);
|
|
1582
1673
|
color: var(--text);
|
|
1583
1674
|
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.12);
|
|
1584
1675
|
}
|
|
1585
1676
|
.filters button:focus-visible {
|
|
1586
|
-
outline: 2px solid
|
|
1677
|
+
outline: 2px solid var(--focus);
|
|
1587
1678
|
outline-offset: 2px;
|
|
1588
1679
|
}
|
|
1680
|
+
.theme-toggle { display: inline-flex; padding: 2px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-muted); }
|
|
1681
|
+
.theme-toggle button { border: 0; border-radius: 999px; background: transparent; color: var(--muted); padding: 4px 9px; font: inherit; font-size: 11.5px; font-weight: 650; cursor: pointer; }
|
|
1682
|
+
.theme-toggle button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px var(--shadow); }
|
|
1683
|
+
.theme-toggle button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
1589
1684
|
.legend { display: flex; gap: 14px; font-size: 12px; flex-wrap: wrap; color: var(--muted); }
|
|
1590
1685
|
.legend span::before { content: ""; display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 6px; vertical-align: 0; }
|
|
1591
1686
|
.legend .added::before { background: var(--added); }
|
|
@@ -1598,8 +1693,8 @@ function renderHtml(layout) {
|
|
|
1598
1693
|
svg {
|
|
1599
1694
|
width: 100%; height: 100%; display: block;
|
|
1600
1695
|
background:
|
|
1601
|
-
radial-gradient(circle,
|
|
1602
|
-
linear-gradient(
|
|
1696
|
+
radial-gradient(circle, var(--grid-dot) 1px, transparent 1.4px) -11px -11px / 22px 22px,
|
|
1697
|
+
linear-gradient(var(--canvas-start), var(--canvas-end));
|
|
1603
1698
|
}
|
|
1604
1699
|
.panel { position: relative; min-width: 0; border-left: 1px solid var(--border); background: var(--surface); padding: 20px; overflow-y: auto; overflow-x: hidden; }
|
|
1605
1700
|
body.panel-collapsed .panel { padding: 0; border-left: 0; overflow: hidden; }
|
|
@@ -1607,24 +1702,24 @@ function renderHtml(layout) {
|
|
|
1607
1702
|
/* drag-to-resize handle on the panel's left edge */
|
|
1608
1703
|
.panel-resizer { position: absolute; left: 0; top: 0; bottom: 0; width: 9px; transform: translateX(-50%); cursor: col-resize; z-index: 6; touch-action: none; }
|
|
1609
1704
|
.panel-resizer::before { content: ""; position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: var(--border); transform: translateX(-50%); transition: background-color 0.12s ease, width 0.12s ease; }
|
|
1610
|
-
.panel-resizer:hover::before, .panel-resizer.dragging::before { background:
|
|
1705
|
+
.panel-resizer:hover::before, .panel-resizer.dragging::before { background: var(--focus); width: 3px; }
|
|
1611
1706
|
body.panel-collapsed .panel-resizer { display: none; }
|
|
1612
1707
|
.panel-head { display: flex; align-items: flex-start; gap: 8px; }
|
|
1613
1708
|
.panel-head h2 { flex: 1; min-width: 0; }
|
|
1614
|
-
.panel-toggle, .panel-reopen { border: 1px solid var(--border); background:
|
|
1709
|
+
.panel-toggle, .panel-reopen { border: 1px solid var(--border); background: var(--surface-muted); color: var(--muted); border-radius: 7px; cursor: pointer; font: inherit; line-height: 1; }
|
|
1615
1710
|
.panel-toggle { flex: none; width: 26px; height: 26px; font-size: 16px; display: grid; place-items: center; }
|
|
1616
|
-
.panel-toggle:hover, .panel-reopen:hover { color: var(--text); border-color:
|
|
1617
|
-
.panel-toggle:focus-visible, .panel-reopen:focus-visible { outline: 2px solid
|
|
1711
|
+
.panel-toggle:hover, .panel-reopen:hover { color: var(--text); border-color: var(--focus); background: var(--surface-selected); }
|
|
1712
|
+
.panel-toggle:focus-visible, .panel-reopen:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
1618
1713
|
/* floating tab to reopen the panel once collapsed */
|
|
1619
|
-
.panel-reopen { display: none; position: absolute; top: 14px; right: 14px; z-index: 7; padding: 7px 12px; font-size: 12px; font-weight: 600; box-shadow: 0 1px 3px
|
|
1714
|
+
.panel-reopen { display: none; position: absolute; top: 14px; right: 14px; z-index: 7; padding: 7px 12px; font-size: 12px; font-weight: 600; box-shadow: 0 1px 3px var(--shadow); }
|
|
1620
1715
|
body.panel-collapsed .panel-reopen { display: inline-flex; align-items: center; gap: 6px; }
|
|
1621
1716
|
.panel h2 { margin: 0 0 8px; font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
|
|
1622
|
-
.panel .badge { display: inline-block; padding: 3px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.03em; background:
|
|
1717
|
+
.panel .badge { display: inline-block; padding: 3px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.03em; background: var(--unchanged-fill); color: var(--muted); margin-bottom: 16px; }
|
|
1623
1718
|
.panel.added .badge { background: var(--added-fill); color: var(--added); }
|
|
1624
1719
|
.panel.deleted .badge { background: var(--deleted-fill); color: var(--deleted); }
|
|
1625
1720
|
.panel.modified .badge { background: var(--modified-fill); color: var(--modified); }
|
|
1626
1721
|
.panel .empty { color: var(--muted); font-size: 13px; }
|
|
1627
|
-
.detail-section { margin: 0 0 12px; border: 1px solid var(--border); border-radius: 8px; background:
|
|
1722
|
+
.detail-section { margin: 0 0 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-subtle); overflow: hidden; }
|
|
1628
1723
|
.detail-section.snapshot-section { border-left-width: 3px; }
|
|
1629
1724
|
.detail-section.snapshot-section.added { border-left-color: rgba(22, 163, 74, 0.5); }
|
|
1630
1725
|
.detail-section.snapshot-section.deleted { border-left-color: rgba(220, 38, 38, 0.45); }
|
|
@@ -1640,8 +1735,8 @@ function renderHtml(layout) {
|
|
|
1640
1735
|
.change-kind.removed { color: var(--deleted); background: var(--deleted-fill); }
|
|
1641
1736
|
/* git-diff value grammar */
|
|
1642
1737
|
.val { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; border-radius: 4px; padding: 1px 5px; overflow-wrap: anywhere; }
|
|
1643
|
-
.val.ins { color:
|
|
1644
|
-
.val.del { color:
|
|
1738
|
+
.val.ins { color: var(--inserted-text); background: var(--added-fill); }
|
|
1739
|
+
.val.del { color: var(--deleted-text); background: var(--deleted-fill); text-decoration: line-through; text-decoration-color: var(--deleted-line); }
|
|
1645
1740
|
.val.faint { color: var(--muted); background: transparent; padding-left: 0; }
|
|
1646
1741
|
.val.one-sided { color: var(--text); background: transparent; padding-left: 0; }
|
|
1647
1742
|
.val em { font-style: normal; color: var(--faint); }
|
|
@@ -1678,6 +1773,41 @@ function renderHtml(layout) {
|
|
|
1678
1773
|
.group-head { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; }
|
|
1679
1774
|
.group-label { font-weight: 700; font-size: 12.5px; }
|
|
1680
1775
|
.outcome-group .changes { margin-bottom: 8px; }
|
|
1776
|
+
/* Flow-level changes: a floating tab over the canvas's top-left corner,
|
|
1777
|
+
styled to match .panel-reopen's collapsed-state language (same surface,
|
|
1778
|
+
border, radius, shadow) so there's one disclosure idiom in the artifact
|
|
1779
|
+
instead of two. It reserves no layout row. */
|
|
1780
|
+
.flow-tab {
|
|
1781
|
+
position: absolute; top: 14px; left: 14px; z-index: 6;
|
|
1782
|
+
display: inline-flex; align-items: center; gap: 7px;
|
|
1783
|
+
border: 1px solid var(--border); background: var(--surface-muted); color: var(--muted);
|
|
1784
|
+
border-radius: 7px; padding: 7px 12px; font: inherit; font-size: 12px; font-weight: 600; line-height: 1;
|
|
1785
|
+
box-shadow: 0 1px 3px var(--shadow); cursor: pointer;
|
|
1786
|
+
}
|
|
1787
|
+
.flow-tab:hover { color: var(--text); border-color: var(--focus); background: var(--surface-selected); }
|
|
1788
|
+
.flow-tab:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
1789
|
+
.flow-tab .flow-tab-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--modified); flex: none; }
|
|
1790
|
+
.flow-tab.deactivated .flow-tab-dot { background: var(--deleted); }
|
|
1791
|
+
.flow-tab.activated .flow-tab-dot { background: var(--added); }
|
|
1792
|
+
.flow-tab .flow-tab-chev { font-size: 9px; opacity: 0.75; transition: transform 0.12s ease; }
|
|
1793
|
+
.flow-tab[aria-expanded="true"] .flow-tab-chev { transform: rotate(180deg); }
|
|
1794
|
+
.flow-popover {
|
|
1795
|
+
position: absolute; top: 14px; left: 14px; z-index: 6; width: min(300px, calc(100% - 28px));
|
|
1796
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
|
1797
|
+
box-shadow: 0 16px 40px -12px var(--shadow-strong); overflow: hidden;
|
|
1798
|
+
}
|
|
1799
|
+
.flow-popover[hidden] { display: none; }
|
|
1800
|
+
.flow-popover-head { padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--surface-muted); font-size: 12px; font-weight: 650; }
|
|
1801
|
+
.flow-popover-body { padding: 12px; max-height: 60vh; overflow: auto; }
|
|
1802
|
+
.flow-banner-callout { margin-bottom: 8px; font-weight: 750; }
|
|
1803
|
+
.flow-banner-callout.deactivated { color: var(--deleted); }
|
|
1804
|
+
.flow-banner-callout.activated { color: var(--added); }
|
|
1805
|
+
.flow-banner-callout.neutral { color: var(--modified); }
|
|
1806
|
+
.flow-banner-list { display: grid; gap: 7px; }
|
|
1807
|
+
.flow-change-row { display: grid; grid-template-columns: minmax(90px, 120px) minmax(0, 1fr); align-items: start; gap: 10px; }
|
|
1808
|
+
.flow-change-row + .flow-change-row { margin-top: 7px; }
|
|
1809
|
+
.flow-change-label { color: var(--muted); font-size: 12px; font-weight: 700; }
|
|
1810
|
+
.flow-change-value { min-width: 0; max-height: 180px; overflow: auto; }
|
|
1681
1811
|
.edge { fill: none; }
|
|
1682
1812
|
.edge.unchanged { stroke-width: 1.4; opacity: 0.55; }
|
|
1683
1813
|
.edge.normal.unchanged { stroke: var(--edge); marker-end: url(#arrow-normal); }
|
|
@@ -1688,26 +1818,26 @@ function renderHtml(layout) {
|
|
|
1688
1818
|
#arrow-fault path { fill: var(--fault); }
|
|
1689
1819
|
#arrow-added path { fill: var(--added); }
|
|
1690
1820
|
#arrow-deleted path { fill: var(--deleted); }
|
|
1691
|
-
.node rect { rx: 11; ry: 11; stroke-width: 1.75; filter: drop-shadow(0 1px 2px
|
|
1821
|
+
.node rect { rx: 11; ry: 11; stroke-width: 1.75; filter: drop-shadow(0 1px 2px var(--shadow)); }
|
|
1692
1822
|
.node { cursor: pointer; }
|
|
1693
|
-
.node text { font-size: 12px; fill:
|
|
1823
|
+
.node text { font-size: 12px; fill: var(--node-text); pointer-events: none; }
|
|
1694
1824
|
.node.added rect { fill: var(--added-fill); stroke: var(--added); }
|
|
1695
1825
|
.node.deleted rect { fill: var(--deleted-fill); stroke: var(--deleted); }
|
|
1696
1826
|
.node.modified rect { fill: var(--modified-fill); stroke: var(--modified); }
|
|
1697
1827
|
.node.unchanged rect { fill: var(--unchanged-fill); stroke: var(--unchanged); }
|
|
1698
|
-
.node.selected rect { stroke-width: 3; filter: drop-shadow(0 2px 6px
|
|
1828
|
+
.node.selected rect { stroke-width: 3; filter: drop-shadow(0 2px 6px var(--shadow-strong)); }
|
|
1699
1829
|
.node-label { white-space: pre; }
|
|
1700
1830
|
/* Type-based styling \u2014 SHAPE & stroke pattern only. Status (added/deleted/
|
|
1701
1831
|
modified/unchanged) remains the sole authority on fill/stroke COLOR, so
|
|
1702
1832
|
the legend stays reliable. Start/end stand out via a pill shape, heavier
|
|
1703
1833
|
stroke, and a neutral emphasis halo \u2014 never via a status-like color. */
|
|
1704
|
-
.node.type-start rect, .node.type-end rect { rx: 24; ry: 24; stroke-width: 2.5; filter: drop-shadow(0 0 0 3px
|
|
1834
|
+
.node.type-start rect, .node.type-end rect { rx: 24; ry: 24; stroke-width: 2.5; filter: drop-shadow(0 0 0 3px var(--shadow)) drop-shadow(0 1px 3px var(--shadow-strong)); }
|
|
1705
1835
|
.node.type-recordLookup rect, .node.type-recordCreate rect, .node.type-recordUpdate rect, .node.type-recordDelete rect { rx: 4; ry: 4; }
|
|
1706
1836
|
.node.type-screen rect { rx: 6; ry: 6; }
|
|
1707
1837
|
.node.type-subflow rect { stroke-width: 2.5; }
|
|
1708
1838
|
.node.type-loop rect { stroke-dasharray: 2 3; }
|
|
1709
1839
|
.node.type-wait rect { stroke-dasharray: 4 4; }
|
|
1710
|
-
.node.selected.type-start rect, .node.selected.type-end rect { stroke-width: 3.5; filter: drop-shadow(0 0 0 5px
|
|
1840
|
+
.node.selected.type-start rect, .node.selected.type-end rect { stroke-width: 3.5; filter: drop-shadow(0 0 0 5px var(--shadow-strong)) drop-shadow(0 2px 6px var(--shadow-strong)); }
|
|
1711
1841
|
@media (max-width: 1000px) {
|
|
1712
1842
|
.panel { padding: 16px; }
|
|
1713
1843
|
}
|
|
@@ -1720,6 +1850,11 @@ function renderHtml(layout) {
|
|
|
1720
1850
|
<div class="meta">${metaHtml}</div>
|
|
1721
1851
|
</div>
|
|
1722
1852
|
<div class="header-actions">
|
|
1853
|
+
<div class="theme-toggle" role="group" aria-label="Color theme">
|
|
1854
|
+
<button type="button" data-theme-choice="system" aria-pressed="true">System</button>
|
|
1855
|
+
<button type="button" data-theme-choice="light" aria-pressed="false">Light</button>
|
|
1856
|
+
<button type="button" data-theme-choice="dark" aria-pressed="false">Dark</button>
|
|
1857
|
+
</div>
|
|
1723
1858
|
<div class="filters" role="group" aria-label="Diff view filters">
|
|
1724
1859
|
<button type="button" class="active" data-view-mode="all">All</button>
|
|
1725
1860
|
<button type="button" data-view-mode="after">After</button>
|
|
@@ -1736,6 +1871,7 @@ function renderHtml(layout) {
|
|
|
1736
1871
|
</header>
|
|
1737
1872
|
<main>
|
|
1738
1873
|
<div class="canvas">
|
|
1874
|
+
${flowBannerHtml}
|
|
1739
1875
|
<svg id="flow-svg" viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Flow diff">
|
|
1740
1876
|
<defs>
|
|
1741
1877
|
<marker id="arrow-normal" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
|
|
@@ -1777,12 +1913,46 @@ function renderHtml(layout) {
|
|
|
1777
1913
|
const panelBadge = document.getElementById("panel-badge");
|
|
1778
1914
|
const panelBody = document.getElementById("panel-body");
|
|
1779
1915
|
const filterButtons = [...document.querySelectorAll(".filters button")];
|
|
1916
|
+
const themeButtons = [...document.querySelectorAll(".theme-toggle button")];
|
|
1780
1917
|
const nodesById = new Map(DATA.nodes.map((node) => [node.id, node]));
|
|
1781
1918
|
const nodeElements = new Map([...document.querySelectorAll(".node")].map((node) => [node.dataset.nodeId, node]));
|
|
1782
1919
|
const edgeElements = new Map([...document.querySelectorAll(".edge")].map((edge) => [edge.dataset.edgeId, edge]));
|
|
1920
|
+
const flowTab = document.getElementById("flow-tab");
|
|
1921
|
+
const flowPopover = document.getElementById("flow-popover");
|
|
1922
|
+
function closeFlowPopover() {
|
|
1923
|
+
if (!flowTab || !flowPopover || flowPopover.hidden) return;
|
|
1924
|
+
flowTab.setAttribute("aria-expanded", "false");
|
|
1925
|
+
flowPopover.hidden = true;
|
|
1926
|
+
}
|
|
1783
1927
|
let viewBox = svg.viewBox.baseVal;
|
|
1784
1928
|
let dragStart = null;
|
|
1785
1929
|
let selectedNodeId = null;
|
|
1930
|
+
const THEME_STORAGE_KEY = ${JSON.stringify(THEME_STORAGE_KEY)};
|
|
1931
|
+
const VALID_THEMES = new Set(["system", "light", "dark"]);
|
|
1932
|
+
|
|
1933
|
+
function readStoredTheme() {
|
|
1934
|
+
try {
|
|
1935
|
+
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
|
1936
|
+
return VALID_THEMES.has(stored) ? stored : "system";
|
|
1937
|
+
} catch {
|
|
1938
|
+
return "system";
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
function persistTheme(theme) {
|
|
1943
|
+
try {
|
|
1944
|
+
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
1945
|
+
} catch {}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function applyTheme(theme) {
|
|
1949
|
+
const next = VALID_THEMES.has(theme) ? theme : "system";
|
|
1950
|
+
document.documentElement.dataset.theme = next;
|
|
1951
|
+
themeButtons.forEach((button) => {
|
|
1952
|
+
const active = button.dataset.themeChoice === next;
|
|
1953
|
+
button.setAttribute("aria-pressed", active ? "true" : "false");
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1786
1956
|
|
|
1787
1957
|
function isNodeVisible(node, mode) {
|
|
1788
1958
|
if (mode === "after") return node.status !== "deleted";
|
|
@@ -1997,6 +2167,7 @@ function renderHtml(layout) {
|
|
|
1997
2167
|
document.querySelectorAll(".node").forEach((node) => {
|
|
1998
2168
|
node.addEventListener("click", (event) => {
|
|
1999
2169
|
event.stopPropagation();
|
|
2170
|
+
closeFlowPopover();
|
|
2000
2171
|
selectNode(node.dataset.nodeId);
|
|
2001
2172
|
});
|
|
2002
2173
|
});
|
|
@@ -2035,6 +2206,14 @@ function renderHtml(layout) {
|
|
|
2035
2206
|
});
|
|
2036
2207
|
});
|
|
2037
2208
|
|
|
2209
|
+
themeButtons.forEach((button) => {
|
|
2210
|
+
button.addEventListener("click", () => {
|
|
2211
|
+
const theme = button.dataset.themeChoice;
|
|
2212
|
+
applyTheme(theme);
|
|
2213
|
+
persistTheme(theme);
|
|
2214
|
+
});
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2038
2217
|
// Panel: collapse to focus on the canvas, and drag the left edge to resize.
|
|
2039
2218
|
const panelToggle = document.getElementById("panel-toggle");
|
|
2040
2219
|
const panelReopen = document.getElementById("panel-reopen");
|
|
@@ -2068,11 +2247,91 @@ function renderHtml(layout) {
|
|
|
2068
2247
|
panelResizer.addEventListener("pointercancel", endResize);
|
|
2069
2248
|
}
|
|
2070
2249
|
|
|
2250
|
+
// Flow-level changes tab: floats over the canvas, opens a popover on click.
|
|
2251
|
+
if (flowTab && flowPopover) {
|
|
2252
|
+
flowTab.addEventListener("click", (event) => {
|
|
2253
|
+
event.stopPropagation();
|
|
2254
|
+
const open = flowPopover.hidden;
|
|
2255
|
+
flowTab.setAttribute("aria-expanded", open ? "true" : "false");
|
|
2256
|
+
flowPopover.hidden = !open;
|
|
2257
|
+
});
|
|
2258
|
+
document.addEventListener("click", (event) => {
|
|
2259
|
+
if (!flowPopover.hidden && !flowPopover.contains(event.target)) {
|
|
2260
|
+
closeFlowPopover();
|
|
2261
|
+
}
|
|
2262
|
+
});
|
|
2263
|
+
document.addEventListener("keydown", (event) => {
|
|
2264
|
+
if (event.key === "Escape" && !flowPopover.hidden) {
|
|
2265
|
+
closeFlowPopover();
|
|
2266
|
+
flowTab.focus();
|
|
2267
|
+
}
|
|
2268
|
+
});
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
applyTheme(readStoredTheme());
|
|
2071
2272
|
applyView("all");
|
|
2072
2273
|
</script>
|
|
2073
2274
|
</body>
|
|
2074
2275
|
</html>`;
|
|
2075
2276
|
}
|
|
2277
|
+
function renderFlowBanner(changes) {
|
|
2278
|
+
if (!changes || changes.length === 0) {
|
|
2279
|
+
return "";
|
|
2280
|
+
}
|
|
2281
|
+
const statusChange = changes.find((change) => change.path === "status");
|
|
2282
|
+
const kind = statusChange ? getStatusKind(statusChange.before, statusChange.after) : "neutral";
|
|
2283
|
+
const callout = statusChange ? renderStatusCallout(statusChange.before, statusChange.after, kind) : "";
|
|
2284
|
+
const count = changes.length;
|
|
2285
|
+
const tabLabel = statusChange && kind !== "neutral" ? `${kind === "deactivated" ? "Deactivated" : "Activated"}${count > 1 ? ` \xB7 ${count} changes` : ""}` : `${count} flow-level ${count === 1 ? "change" : "changes"}`;
|
|
2286
|
+
return `<button type="button" id="flow-tab" class="flow-tab ${kind}" aria-expanded="false" aria-controls="flow-popover">
|
|
2287
|
+
<span class="flow-tab-dot"></span>${escapeHtml2(tabLabel)}<span class="flow-tab-chev">\u25BE</span>
|
|
2288
|
+
</button>
|
|
2289
|
+
<div id="flow-popover" class="flow-popover" role="region" aria-label="Flow-level changes" hidden>
|
|
2290
|
+
<div class="flow-popover-head">Flow-level changes</div>
|
|
2291
|
+
<div class="flow-popover-body">
|
|
2292
|
+
${callout}
|
|
2293
|
+
<div class="flow-banner-list">
|
|
2294
|
+
${changes.map((change) => `<div class="flow-change-row">
|
|
2295
|
+
<div class="flow-change-label">${escapeHtml2(humanizePath(change.path))}</div>
|
|
2296
|
+
<div class="flow-change-value">${renderValueDelta(change.before, change.after)}</div>
|
|
2297
|
+
</div>`).join("")}
|
|
2298
|
+
</div>
|
|
2299
|
+
</div>
|
|
2300
|
+
</div>`;
|
|
2301
|
+
}
|
|
2302
|
+
function getStatusKind(before, after) {
|
|
2303
|
+
const beforeText = String(before);
|
|
2304
|
+
const afterText = String(after);
|
|
2305
|
+
if (beforeText === "Active" && afterText !== "Active") return "deactivated";
|
|
2306
|
+
if (beforeText !== "Active" && afterText === "Active") return "activated";
|
|
2307
|
+
return "neutral";
|
|
2308
|
+
}
|
|
2309
|
+
function renderStatusCallout(before, after, kind) {
|
|
2310
|
+
const beforeText = String(before);
|
|
2311
|
+
const afterText = String(after);
|
|
2312
|
+
if (kind === "deactivated") {
|
|
2313
|
+
return `<div class="flow-banner-callout deactivated">Deactivated (${escapeHtml2(beforeText)} -> ${escapeHtml2(afterText)})</div>`;
|
|
2314
|
+
}
|
|
2315
|
+
if (kind === "activated") {
|
|
2316
|
+
return `<div class="flow-banner-callout activated">Activated (${escapeHtml2(beforeText)} -> ${escapeHtml2(afterText)})</div>`;
|
|
2317
|
+
}
|
|
2318
|
+
return `<div class="flow-banner-callout neutral">Status: ${escapeHtml2(beforeText)} -> ${escapeHtml2(afterText)}</div>`;
|
|
2319
|
+
}
|
|
2320
|
+
function renderValueDelta(before, after) {
|
|
2321
|
+
if (before === void 0) {
|
|
2322
|
+
return `<span class="val ins">${escapeHtml2(formatValue(after))}</span>`;
|
|
2323
|
+
}
|
|
2324
|
+
if (after === void 0) {
|
|
2325
|
+
return `<span class="val del">${escapeHtml2(formatValue(before))}</span>`;
|
|
2326
|
+
}
|
|
2327
|
+
return `<span class="val del">${escapeHtml2(formatValue(before))}</span><span class="arrow">-></span><span class="val ins">${escapeHtml2(formatValue(after))}</span>`;
|
|
2328
|
+
}
|
|
2329
|
+
function formatValue(value) {
|
|
2330
|
+
if (value === void 0) {
|
|
2331
|
+
return "(missing)";
|
|
2332
|
+
}
|
|
2333
|
+
return String(value);
|
|
2334
|
+
}
|
|
2076
2335
|
function fitViewBox(contentWidth, contentHeight) {
|
|
2077
2336
|
const minWidth = 720;
|
|
2078
2337
|
const minHeight = 540;
|
|
@@ -2236,8 +2495,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
2236
2495
|
}
|
|
2237
2496
|
}
|
|
2238
2497
|
async function runFileMode(oldPath, newPath, outDir, writeJson) {
|
|
2239
|
-
const
|
|
2240
|
-
const
|
|
2498
|
+
const oldXml = readFlowFromFile(oldPath);
|
|
2499
|
+
const newXml = readFlowFromFile(newPath);
|
|
2500
|
+
const oldModel = await buildModelWithHeader(oldXml);
|
|
2501
|
+
const newModel = await buildModelWithHeader(newXml);
|
|
2241
2502
|
await writeArtifacts(oldModel, newModel, outDir, writeJson);
|
|
2242
2503
|
}
|
|
2243
2504
|
async function runGitMode(repo, from, to, pattern, outDir, writeJson, changedOnly) {
|
|
@@ -2247,8 +2508,8 @@ async function runGitMode(repo, from, to, pattern, outDir, writeJson, changedOnl
|
|
|
2247
2508
|
try {
|
|
2248
2509
|
const oldXml = readFlowFromGit(repo, from, filePath);
|
|
2249
2510
|
const newXml = readFlowFromGit(repo, to, filePath);
|
|
2250
|
-
const oldModel = oldXml ?
|
|
2251
|
-
const newModel = newXml ?
|
|
2511
|
+
const oldModel = oldXml ? await buildModelWithHeader(oldXml) : buildEmptyModel();
|
|
2512
|
+
const newModel = newXml ? await buildModelWithHeader(newXml) : buildEmptyModel();
|
|
2252
2513
|
await writeArtifacts(oldModel, newModel, outDir, writeJson, filePath);
|
|
2253
2514
|
} catch (error) {
|
|
2254
2515
|
hadFailure = true;
|
|
@@ -2263,6 +2524,11 @@ async function parseXml(xml) {
|
|
|
2263
2524
|
const parser = new FlowParser(xml);
|
|
2264
2525
|
return parser.generateFlowDefinition();
|
|
2265
2526
|
}
|
|
2527
|
+
async function buildModelWithHeader(xml) {
|
|
2528
|
+
const model = buildModel(await parseXml(xml));
|
|
2529
|
+
model.header = await extractFlowHeader(xml);
|
|
2530
|
+
return model;
|
|
2531
|
+
}
|
|
2266
2532
|
function buildEmptyModel() {
|
|
2267
2533
|
return {
|
|
2268
2534
|
flowName: "(unknown)",
|
|
@@ -2281,9 +2547,15 @@ async function writeArtifacts(oldModel, newModel, outDir, writeJson, sourcePath)
|
|
|
2281
2547
|
writeFileSync(join(outDir, `${fileStem}.diff.json`), JSON.stringify(diff, null, 2), "utf8");
|
|
2282
2548
|
}
|
|
2283
2549
|
console.log(
|
|
2284
|
-
`${diff.flowName}: nodes ${diff.summary.addedNodes} added, ${diff.summary.removedNodes} deleted, ${diff.summary.modifiedNodes} modified; edges ${diff.summary.addedEdges} added, ${diff.summary.removedEdges} deleted`
|
|
2550
|
+
`${diff.flowName}: nodes ${diff.summary.addedNodes} added, ${diff.summary.removedNodes} deleted, ${diff.summary.modifiedNodes} modified; edges ${diff.summary.addedEdges} added, ${diff.summary.removedEdges} deleted${formatFlowAttributeSummary(diff.flowChanges)}`
|
|
2285
2551
|
);
|
|
2286
2552
|
}
|
|
2553
|
+
function formatFlowAttributeSummary(flowChanges) {
|
|
2554
|
+
if (!flowChanges || flowChanges.length === 0) {
|
|
2555
|
+
return "";
|
|
2556
|
+
}
|
|
2557
|
+
return `; flow attributes: ${flowChanges.length} changed (${flowChanges.map((change) => change.path).join(", ")})`;
|
|
2558
|
+
}
|
|
2287
2559
|
function discoverGitFiles(repo, from, to, pattern, changedOnly) {
|
|
2288
2560
|
const matcher = createPathMatcher(pattern);
|
|
2289
2561
|
if (!changedOnly) {
|