Skip to content

Commit a9428bd

Browse files
authored
Merge pull request #7081 from FlowFuse/fix/snapshot-diff-viewer
Fix snapshot diff viewer highlights and diff panel
2 parents d4c6264 + 7bc345b commit a9428bd

2 files changed

Lines changed: 172 additions & 37 deletions

File tree

frontend/src/components/dialogs/AssetCompareDialog.vue

Lines changed: 89 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,23 @@ 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 (IGNORED_PROPS.has(prop)) continue
211+
if (this.hidePositionChanges && POSITION_PROPS.has(prop)) continue
187212
group.propChanges.push({ prop, value1: isAdded ? undefined : val, value2: isAdded ? val : undefined })
188213
}
189214
} else if (change.diffType === 'changed') {
190215
group.propChanges.push({ prop: change.prop, value1: change.value1, value2: change.value2 })
191216
}
192217
}
218+
// When hiding position changes, drop nodes that only had position diffs
219+
if (this.hidePositionChanges) {
220+
for (const [key, group] of groups) {
221+
if (group.diffType === 'changed' && group.propChanges.length === 0) {
222+
groups.delete(key)
223+
}
224+
}
225+
}
193226
return [...groups.values()]
194227
},
195228
currentGroup () {
@@ -198,6 +231,12 @@ export default {
198231
currentGroupChanges () {
199232
return this.transformChanges(this.currentGroup?.propChanges || [])
200233
},
234+
currentGroupCategoryLabel () {
235+
const cat = this.currentGroup?.category
236+
if (cat === 'global-config') return 'Global Config'
237+
if (cat === 'flow-config') return 'Flow Config'
238+
return null
239+
},
201240
currentGroupTabMove () {
202241
// Returns { from, to } if a changed node moved between tabs, null otherwise.
203242
// Not shown for added/deleted nodes — the tab is part of their identity,
@@ -214,6 +253,13 @@ export default {
214253
watch: {
215254
compareSnapshot (val) {
216255
if (val) this.renderComparison()
256+
},
257+
hidePositionChanges () {
258+
// Clamp index — the list may have shrunk
259+
if (this.currentGroupIndex >= this.groupedChanges.length) {
260+
this.currentGroupIndex = Math.max(0, this.groupedChanges.length - 1)
261+
}
262+
this.highlightCurrent()
217263
}
218264
},
219265
mounted () {
@@ -257,9 +303,12 @@ export default {
257303
// Explicit scope prevents the renderer from using Tailwind utility
258304
// classes (e.g. flex-1) as CSS selectors, which would leak
259305
// svg sizing rules to the rest of the page.
260-
scope: 'ff-flow-compare-view'
306+
scope: 'ff-flow-compare-view',
307+
persistentHighlight: true,
308+
allChanges: true
261309
})
262310
this.rendererChanges = result?.changes || []
311+
this.clearRendererHighlight = result?.clearHighlight || (() => {})
263312
this.changes = this.computeDiff(compareFlow, this.flow)
264313
this.currentGroupIndex = 0
265314
this.hasCompared = true
@@ -281,16 +330,13 @@ export default {
281330
highlightCurrent () {
282331
const group = this.currentGroup
283332
if (!group) return
333+
this.clearRendererHighlight()
334+
if (CONFIG_CATEGORIES.has(group.category)) return
284335
// Always pass layerNo explicitly from our own diffType so the renderer
285336
// 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%).
288337
const layerNo = group.diffType === 'added' ? 1 : group.diffType === 'deleted' ? 0 : -1
289338
// 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.
339+
// the renderer updates layer opacities immediately.
294340
if (layerNo !== -1) {
295341
const slider = this.$refs.compareViewer?.querySelector('.flow-compare-slider')
296342
const target = layerNo === 1 ? 90 : 10
@@ -300,18 +346,14 @@ export default {
300346
}
301347
}
302348
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.
307349
const proxy = this.rendererChanges.find(rc => rc.tab === group.nodeId && rc.highlight)
308350
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)
351+
} else {
352+
for (const rc of this.rendererChanges) {
353+
if (rc.item === group.nodeId && rc.highlight) {
354+
rc.highlight(layerNo)
355+
}
356+
}
315357
}
316358
},
317359
diffTypeBadgeClass (diffType) {
@@ -355,12 +397,16 @@ export default {
355397
if (c.prop === 'x') { xChange = c; continue }
356398
if (c.prop === 'y') { yChange = c; continue }
357399
if (c.prop === 'z') {
358-
result.push({ prop: 'z', label: 'tab', value1: this.resolveTabName(c.value1), value2: this.resolveTabName(c.value2) })
400+
result.push({ prop: 'z', label: 'Tab', value1: this.resolveTabName(c.value1), value2: this.resolveTabName(c.value2) })
401+
continue
402+
}
403+
if (c.prop === 'g') {
404+
result.push({ prop: 'g', label: 'Group', value1: this.resolveNodeName(c.value1), value2: this.resolveNodeName(c.value2) })
359405
continue
360406
}
361407
if (c.prop === 'disabled') {
362408
// 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) })
409+
result.push({ prop: 'disabled', label: 'Status', value1: this.resolveDisabled(c.value1), value2: this.resolveDisabled(c.value2) })
364410
continue
365411
}
366412
if (c.prop === 'wires') {
@@ -399,10 +445,26 @@ export default {
399445
const tab = this.nodeMap[tabId]
400446
return tab ? (tab.label || tabId) : tabId
401447
},
448+
resolveNodeName (nodeId) {
449+
if (!nodeId) return nodeId
450+
const node = this.nodeMap[nodeId]
451+
return node ? (node.name || node.label || nodeId) : nodeId
452+
},
402453
resolveDisabled (val) {
403454
if (val === undefined || val === null) return undefined
404455
return val ? 'disabled' : 'enabled'
405456
},
457+
nodeCategory (node) {
458+
if (!node || !node.type) return 'node'
459+
if (node.type === 'tab') return 'tab'
460+
if (node.type === 'group') return 'group'
461+
if (node.type === 'subflow') return 'subflow'
462+
// Config nodes have no canvas position — works for all node packages
463+
if (node.x === undefined && node.y === undefined) {
464+
return node.z ? 'flow-config' : 'global-config'
465+
}
466+
return 'node'
467+
},
406468
computeDiff (flow1, flow2) {
407469
const map1 = {}
408470
const map2 = {}
@@ -422,7 +484,7 @@ export default {
422484
const n1 = map1[id]
423485
const n2 = map2[id]
424486
for (const prop of new Set([...Object.keys(n1), ...Object.keys(n2)])) {
425-
if (prop === 'id') continue
487+
if (IGNORED_PROPS.has(prop)) continue
426488
const v1 = n1[prop]
427489
const v2 = n2[prop]
428490
if (JSON.stringify(v1) !== JSON.stringify(v2)) {

frontend/src/components/dialogs/SnapshotDiffChangePanel.vue

Lines changed: 83 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,45 @@ 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 trimmed = v.trim()
181+
const first = trimmed[0]
182+
const last = trimmed[trimmed.length - 1]
183+
return (first === '{' && last === '}') || (first === '[' && last === ']')
184+
}
185+
return false
186+
},
187+
tryPrettify (v) {
188+
if (this.looksLikeJson(v)) {
189+
try { return JSON.stringify(JSON.parse(v), null, 2) } catch (_) { /* not valid JSON */ }
190+
}
191+
return null
192+
},
193+
prettify () {
194+
const p1 = this.tryPrettify(this.value1)
195+
const p2 = this.tryPrettify(this.value2)
196+
if (p1 !== null || p2 !== null) {
197+
this.prettified = true
198+
this.lines = this.buildLines(
199+
p1 !== null ? p1 : this.value1,
200+
p2 !== null ? p2 : this.value2
201+
)
202+
this.collapsed = false
203+
}
204+
},
205+
unprettify () {
206+
this.prettified = false
207+
this.lines = this.buildLines(this.value1, this.value2)
208+
},
209+
buildLines (v1Raw, v2Raw) {
210+
const v1 = this.stringify(v1Raw)
211+
const v2 = this.stringify(v2Raw)
147212
148213
if (!v1.includes('\n') && !v2.includes('\n')) {
149214
const result = []
@@ -246,4 +311,12 @@ export default {
246311
padding: 0 0.4rem;
247312
user-select: none;
248313
}
314+
.diff-scroll-container {
315+
overflow-x: auto;
316+
padding-bottom: 0.5rem;
317+
}
318+
.diff-scroll-container:not(.diff-wrap) > div {
319+
width: max-content;
320+
min-width: 100%;
321+
}
249322
</style>

0 commit comments

Comments
 (0)