Skip to content

Commit eaa9a9e

Browse files
author
andypalmi
committed
fix: improve snapshot diff viewer highlights, config nodes, and diff panel
- Use persistentHighlight and clearHighlight() from flow-renderer so highlights persist until the user navigates and stale glows are cleared - Use allChanges option so all changed nodes get highlight() entries - Skip highlighting for config nodes (no canvas presence); show "Global Config" / "Flow Config" badge in navigation bar - Detect config nodes by absence of x/y coordinates - Ignore w/h in computeDiff (computed by Node-RED, not user data) - Add "Simple view" toggle to hide position (x, y) changes - Add word wrap and Prettify/Raw toggles in the diff panel - Resolve group (g) property to display name; capitalize labels - Add g to compact props set Closes #7062
1 parent 531d622 commit eaa9a9e

2 files changed

Lines changed: 169 additions & 37 deletions

File tree

frontend/src/components/dialogs/AssetCompareDialog.vue

Lines changed: 88 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@
1515
option-title-key="description"
1616
class="flex-grow"
1717
/>
18+
<button
19+
v-if="hasCompared"
20+
v-ff-tooltip:left="'Simple view hides changes to node positions'"
21+
class="text-xs px-2 py-1 rounded border font-medium shrink-0"
22+
:class="hidePositionChanges
23+
? 'bg-blue-50 border-blue-300 text-blue-700'
24+
: 'border-gray-300 text-gray-600 hover:bg-gray-50'"
25+
@click="hidePositionChanges = !hidePositionChanges"
26+
>
27+
Simple view
28+
</button>
1829
</div>
1930

2031
<!-- Loading state -->
@@ -25,9 +36,10 @@
2536
<!-- Navigation bar — shown after comparison -->
2637
<div v-if="hasCompared && !loading" class="flex items-center gap-2 px-3 py-1.5 border-b border-gray-200 bg-white shrink-0">
2738
<div v-if="currentGroup" class="flex-1 flex items-center gap-2 min-w-0">
28-
<span class="text-xs font-semibold px-1.5 py-0.5 rounded shrink-0" :class="diffTypeBadgeClass(currentGroup.diffType)">{{ currentGroup.diffType }}</span>
39+
<span class="text-xs font-semibold px-1.5 py-0.5 rounded capitalize shrink-0" :class="diffTypeBadgeClass(currentGroup.diffType)">{{ currentGroup.diffType }}</span>
2940
<span class="font-semibold text-sm text-gray-800 truncate">{{ currentGroup.name }}</span>
30-
<span class="text-xs text-gray-400 shrink-0">{{ currentGroup.type }}</span>
41+
<span class="text-xs font-semibold text-gray-700 bg-gray-200 px-1.5 py-0.5 rounded shrink-0">{{ currentGroup.type }}</span>
42+
<span v-if="currentGroupCategoryLabel" class="text-xs font-semibold text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded shrink-0">{{ currentGroupCategoryLabel }}</span>
3143
<span v-if="currentGroupTabMove" class="text-xs text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded shrink-0">
3244
{{ currentGroupTabMove.from }} → {{ currentGroupTabMove.to }}
3345
</span>
@@ -103,7 +115,17 @@ import Alerts from '../../services/alerts.js'
103115
104116
import SnapshotDiffChangePanel from './SnapshotDiffChangePanel.vue'
105117
106-
const COMPACT_PROPS = new Set(['position', 'z', 'name', 'label', 'type', 'wires', 'disabled'])
118+
// Props shown in compact (single-line) mode in the diff sidebar
119+
const COMPACT_PROPS = new Set(['position', 'z', 'g', 'name', 'label', 'type', 'wires', 'disabled'])
120+
// Props to skip entirely in computeDiff — computed by Node-RED at render time, not user data
121+
const IGNORED_PROPS = new Set(['id', 'w', 'h'])
122+
// Props that represent node position — can be toggled off by the user
123+
const POSITION_PROPS = new Set(['x', 'y'])
124+
// Props shown in the nav header — skip when expanding all props for added/deleted nodes
125+
const HEADER_PROPS = new Set(['id', 'type', 'z', 'name', 'label'])
126+
// Node categories that have no visual presence on the SVG canvas
127+
const CONFIG_CATEGORIES = new Set(['global-config', 'flow-config'])
128+
107129
const SIDEBAR_MIN_WIDTH = 200
108130
const SIDEBAR_MAX_WIDTH = 800
109131
@@ -150,7 +172,8 @@ export default {
150172
sidebarWidth: 380,
151173
resizing: false,
152174
resizeStartX: 0,
153-
resizeStartWidth: 0
175+
resizeStartWidth: 0,
176+
hidePositionChanges: false
154177
}
155178
},
156179
computed: {
@@ -163,6 +186,7 @@ export default {
163186
groupedChanges () {
164187
const groups = new Map()
165188
for (const change of this.changes) {
189+
if (this.hidePositionChanges && change.prop && POSITION_PROPS.has(change.prop)) continue
166190
const key = change.item
167191
if (!groups.has(key)) {
168192
const node = this.nodeMap[key] || {}
@@ -173,6 +197,7 @@ export default {
173197
name: node.name || node.label || node.type || key,
174198
type: node.type || '',
175199
diffType: change.diffType,
200+
category: this.nodeCategory(node),
176201
propChanges: []
177202
})
178203
}
@@ -181,15 +206,22 @@ export default {
181206
const node = this.nodeMap[key] || {}
182207
const isAdded = change.diffType === 'added'
183208
for (const [prop, val] of Object.entries(node)) {
184-
// Skip id (internal) and props already represented in the nav header:
185-
// type (shown as badge), name/label (shown as node display name), z (tab)
186-
if (prop === 'id' || prop === 'type' || prop === 'z' || prop === 'name' || prop === 'label') continue
209+
if (HEADER_PROPS.has(prop)) continue
210+
if (this.hidePositionChanges && POSITION_PROPS.has(prop)) continue
187211
group.propChanges.push({ prop, value1: isAdded ? undefined : val, value2: isAdded ? val : undefined })
188212
}
189213
} else if (change.diffType === 'changed') {
190214
group.propChanges.push({ prop: change.prop, value1: change.value1, value2: change.value2 })
191215
}
192216
}
217+
// When hiding position changes, drop nodes that only had position diffs
218+
if (this.hidePositionChanges) {
219+
for (const [key, group] of groups) {
220+
if (group.diffType === 'changed' && group.propChanges.length === 0) {
221+
groups.delete(key)
222+
}
223+
}
224+
}
193225
return [...groups.values()]
194226
},
195227
currentGroup () {
@@ -198,6 +230,12 @@ export default {
198230
currentGroupChanges () {
199231
return this.transformChanges(this.currentGroup?.propChanges || [])
200232
},
233+
currentGroupCategoryLabel () {
234+
const cat = this.currentGroup?.category
235+
if (cat === 'global-config') return 'Global Config'
236+
if (cat === 'flow-config') return 'Flow Config'
237+
return null
238+
},
201239
currentGroupTabMove () {
202240
// Returns { from, to } if a changed node moved between tabs, null otherwise.
203241
// Not shown for added/deleted nodes — the tab is part of their identity,
@@ -214,6 +252,13 @@ export default {
214252
watch: {
215253
compareSnapshot (val) {
216254
if (val) this.renderComparison()
255+
},
256+
hidePositionChanges () {
257+
// Clamp index — the list may have shrunk
258+
if (this.currentGroupIndex >= this.groupedChanges.length) {
259+
this.currentGroupIndex = Math.max(0, this.groupedChanges.length - 1)
260+
}
261+
this.highlightCurrent()
217262
}
218263
},
219264
mounted () {
@@ -257,9 +302,12 @@ export default {
257302
// Explicit scope prevents the renderer from using Tailwind utility
258303
// classes (e.g. flex-1) as CSS selectors, which would leak
259304
// svg sizing rules to the rest of the page.
260-
scope: 'ff-flow-compare-view'
305+
scope: 'ff-flow-compare-view',
306+
persistentHighlight: true,
307+
allChanges: true
261308
})
262309
this.rendererChanges = result?.changes || []
310+
this.clearRendererHighlight = result?.clearHighlight || (() => {})
263311
this.changes = this.computeDiff(compareFlow, this.flow)
264312
this.currentGroupIndex = 0
265313
this.hasCompared = true
@@ -281,16 +329,13 @@ export default {
281329
highlightCurrent () {
282330
const group = this.currentGroup
283331
if (!group) return
332+
this.clearRendererHighlight()
333+
if (CONFIG_CATEGORIES.has(group.category)) return
284334
// Always pass layerNo explicitly from our own diffType so the renderer
285335
// shows the correct layer regardless of its internal change classification.
286-
// The renderer's fallback (no layerNo) infers from rc.diffType, which can
287-
// be unreliable for tab entries ('tab' diffType → defaults to layer 0 → 10%).
288336
const layerNo = group.diffType === 'added' ? 1 : group.diffType === 'deleted' ? 0 : -1
289337
// Jump the slider directly to the target and dispatch one input event so
290-
// the renderer updates layer opacities immediately. This bypasses the
291-
// renderer's slow JS stepping loop (1 unit / 10 ms → up to 900 ms).
292-
// The visual smoothness comes from a CSS transition on the SVG layers
293-
// defined in the <style> block below.
338+
// the renderer updates layer opacities immediately.
294339
if (layerNo !== -1) {
295340
const slider = this.$refs.compareViewer?.querySelector('.flow-compare-slider')
296341
const target = layerNo === 1 ? 90 : 10
@@ -300,18 +345,14 @@ export default {
300345
}
301346
}
302347
if (group.type === 'tab') {
303-
// The renderer's highlight() for a tab entry looks up the item as an
304-
// SVG node, which fails (tabs are DOM elements, not SVG nodes). Instead,
305-
// find any node that lives on this tab and highlight that — the renderer
306-
// will click the tab and navigate there as a side effect.
307348
const proxy = this.rendererChanges.find(rc => rc.tab === group.nodeId && rc.highlight)
308349
if (proxy) proxy.highlight(layerNo)
309-
return
310-
}
311-
// Highlight all renderer changes for this node — handles nodes that
312-
// appear in multiple tabs (e.g. moved from one tab to another)
313-
for (const rc of this.rendererChanges) {
314-
if (rc.item === group.nodeId && rc.highlight) rc.highlight(layerNo)
350+
} else {
351+
for (const rc of this.rendererChanges) {
352+
if (rc.item === group.nodeId && rc.highlight) {
353+
rc.highlight(layerNo)
354+
}
355+
}
315356
}
316357
},
317358
diffTypeBadgeClass (diffType) {
@@ -355,12 +396,16 @@ export default {
355396
if (c.prop === 'x') { xChange = c; continue }
356397
if (c.prop === 'y') { yChange = c; continue }
357398
if (c.prop === 'z') {
358-
result.push({ prop: 'z', label: 'tab', value1: this.resolveTabName(c.value1), value2: this.resolveTabName(c.value2) })
399+
result.push({ prop: 'z', label: 'Tab', value1: this.resolveTabName(c.value1), value2: this.resolveTabName(c.value2) })
400+
continue
401+
}
402+
if (c.prop === 'g') {
403+
result.push({ prop: 'g', label: 'Group', value1: this.resolveNodeName(c.value1), value2: this.resolveNodeName(c.value2) })
359404
continue
360405
}
361406
if (c.prop === 'disabled') {
362407
// Show as "enabled" / "disabled" rather than raw true/false
363-
result.push({ prop: 'disabled', label: 'status', value1: this.resolveDisabled(c.value1), value2: this.resolveDisabled(c.value2) })
408+
result.push({ prop: 'disabled', label: 'Status', value1: this.resolveDisabled(c.value1), value2: this.resolveDisabled(c.value2) })
364409
continue
365410
}
366411
if (c.prop === 'wires') {
@@ -399,10 +444,26 @@ export default {
399444
const tab = this.nodeMap[tabId]
400445
return tab ? (tab.label || tabId) : tabId
401446
},
447+
resolveNodeName (nodeId) {
448+
if (!nodeId) return nodeId
449+
const node = this.nodeMap[nodeId]
450+
return node ? (node.name || node.label || nodeId) : nodeId
451+
},
402452
resolveDisabled (val) {
403453
if (val === undefined || val === null) return undefined
404454
return val ? 'disabled' : 'enabled'
405455
},
456+
nodeCategory (node) {
457+
if (!node || !node.type) return 'node'
458+
if (node.type === 'tab') return 'tab'
459+
if (node.type === 'group') return 'group'
460+
if (node.type === 'subflow') return 'subflow'
461+
// Config nodes have no canvas position — works for all node packages
462+
if (node.x === undefined && node.y === undefined) {
463+
return node.z ? 'flow-config' : 'global-config'
464+
}
465+
return 'node'
466+
},
406467
computeDiff (flow1, flow2) {
407468
const map1 = {}
408469
const map2 = {}
@@ -422,7 +483,7 @@ export default {
422483
const n1 = map1[id]
423484
const n2 = map2[id]
424485
for (const prop of new Set([...Object.keys(n1), ...Object.keys(n2)])) {
425-
if (prop === 'id') continue
486+
if (IGNORED_PROPS.has(prop)) continue
426487
const v1 = n1[prop]
427488
const v2 = n2[prop]
428489
if (JSON.stringify(v1) !== JSON.stringify(v2)) {

frontend/src/components/dialogs/SnapshotDiffChangePanel.vue

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,36 @@
3535
>
3636
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
3737
</svg>
38-
<span>property</span>
38+
<span>Property</span>
3939
<span class="font-semibold text-gray-700">{{ label ?? prop }}</span>
40-
<span class="ml-auto text-gray-400">{{ changeSummary }}</span>
40+
<span class="ml-auto flex items-center gap-2">
41+
<template v-if="!collapsed">
42+
<!-- Wrap toggle (shown when any line exceeds 50 chars) -->
43+
<button
44+
v-if="hasLongLines"
45+
class="text-gray-400 hover:text-gray-600 px-1 py-0.5 rounded hover:bg-gray-200"
46+
title="Toggle word wrap"
47+
@click.stop="wrapped = !wrapped"
48+
>Wrap</button>
49+
<!-- Prettify button (shown when value looks like JSON) -->
50+
<button
51+
v-if="canPrettify && !prettified"
52+
class="text-gray-400 hover:text-gray-600 px-1 py-0.5 rounded hover:bg-gray-200"
53+
title="Pretty-print JSON and re-diff"
54+
@click.stop="prettify"
55+
>Prettify</button>
56+
<button
57+
v-if="prettified"
58+
class="text-blue-500 hover:text-blue-700 px-1 py-0.5 rounded hover:bg-blue-50"
59+
title="Show raw values"
60+
@click.stop="unprettify"
61+
>Raw</button>
62+
</template>
63+
<span class="text-gray-400">{{ changeSummary }}</span>
64+
</span>
4165
</div>
42-
<div v-show="!collapsed" class="overflow-x-auto font-mono">
43-
<div class="min-w-max">
66+
<div v-show="!collapsed" class="font-mono">
67+
<div class="diff-scroll-container" :class="{ 'diff-wrap': wrapped }">
4468
<template v-for="(line, i) in lines" :key="i">
4569
<!-- Collapsed unchanged section -->
4670
<div
@@ -61,7 +85,7 @@
6185
>
6286
<span class="line-num border-r select-none shrink-0" :class="lineNumClass(line)">{{ line.oldNum || '' }}</span>
6387
<span class="line-num border-r select-none shrink-0" :class="lineNumClass(line)">{{ line.newNum || '' }}</span>
64-
<span class="px-2 whitespace-pre">{{ linePrefix(line) }}{{ line.text }}</span>
88+
<span class="px-2" :class="wrapped ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'">{{ linePrefix(line) }}{{ line.text }}</span>
6589
</div>
6690
</template>
6791
</div>
@@ -74,6 +98,7 @@
7498
import { diffLines } from 'diff'
7599
76100
const CONTEXT = 3
101+
const LONG_LINE_THRESHOLD = 50
77102
78103
export default {
79104
name: 'SnapshotDiffChangePanel',
@@ -85,7 +110,7 @@ export default {
85110
compact: { type: Boolean, default: false }
86111
},
87112
data () {
88-
return { lines: [], collapsed: true }
113+
return { lines: [], collapsed: true, wrapped: false, prettified: false }
89114
},
90115
computed: {
91116
changeSummary () {
@@ -96,6 +121,12 @@ export default {
96121
if (removed) return `-${removed}`
97122
return ''
98123
},
124+
hasLongLines () {
125+
return this.lines.some(l => l.text && l.text.length > LONG_LINE_THRESHOLD)
126+
},
127+
canPrettify () {
128+
return this.looksLikeJson(this.value1) || this.looksLikeJson(this.value2)
129+
},
99130
compactSegments () {
100131
const segments = []
101132
@@ -139,11 +170,43 @@ export default {
139170
},
140171
methods: {
141172
rebuildLines () {
142-
if (!this.compact) this.lines = this.buildLines()
173+
if (!this.compact) {
174+
this.prettified = false
175+
this.lines = this.buildLines(this.value1, this.value2)
176+
}
143177
},
144-
buildLines () {
145-
const v1 = this.stringify(this.value1)
146-
const v2 = this.stringify(this.value2)
178+
looksLikeJson (v) {
179+
if (typeof v === 'string' && v.length > 2) {
180+
const c = v[0]
181+
return c === '{' || c === '['
182+
}
183+
return false
184+
},
185+
tryPrettify (v) {
186+
if (typeof v === 'string' && v.length > 2 && (v[0] === '{' || v[0] === '[')) {
187+
try { return JSON.stringify(JSON.parse(v), null, 2) } catch (_) { /* not valid JSON */ }
188+
}
189+
return null
190+
},
191+
prettify () {
192+
const p1 = this.tryPrettify(this.value1)
193+
const p2 = this.tryPrettify(this.value2)
194+
if (p1 !== null || p2 !== null) {
195+
this.prettified = true
196+
this.lines = this.buildLines(
197+
p1 !== null ? p1 : this.value1,
198+
p2 !== null ? p2 : this.value2
199+
)
200+
this.collapsed = false
201+
}
202+
},
203+
unprettify () {
204+
this.prettified = false
205+
this.lines = this.buildLines(this.value1, this.value2)
206+
},
207+
buildLines (v1Raw, v2Raw) {
208+
const v1 = this.stringify(v1Raw)
209+
const v2 = this.stringify(v2Raw)
147210
148211
if (!v1.includes('\n') && !v2.includes('\n')) {
149212
const result = []
@@ -246,4 +309,12 @@ export default {
246309
padding: 0 0.4rem;
247310
user-select: none;
248311
}
312+
.diff-scroll-container {
313+
overflow-x: auto;
314+
padding-bottom: 0.5rem;
315+
}
316+
.diff-scroll-container:not(.diff-wrap) > div {
317+
width: max-content;
318+
min-width: 100%;
319+
}
249320
</style>

0 commit comments

Comments
 (0)