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 -->
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,15 @@ import Alerts from '../../services/alerts.js'
103115
104116import 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+ // Node categories that have no visual presence on the SVG canvas
125+ const CONFIG_CATEGORIES = new Set ([' global-config' , ' flow-config' ])
126+
107127const SIDEBAR_MIN_WIDTH = 200
108128const SIDEBAR_MAX_WIDTH = 800
109129
@@ -150,7 +170,8 @@ export default {
150170 sidebarWidth: 380 ,
151171 resizing: false ,
152172 resizeStartX: 0 ,
153- resizeStartWidth: 0
173+ resizeStartWidth: 0 ,
174+ hidePositionChanges: false
154175 }
155176 },
156177 computed: {
@@ -163,6 +184,7 @@ export default {
163184 groupedChanges () {
164185 const groups = new Map ()
165186 for (const change of this .changes ) {
187+ if (this .hidePositionChanges && change .prop && POSITION_PROPS .has (change .prop )) continue
166188 const key = change .item
167189 if (! groups .has (key)) {
168190 const node = this .nodeMap [key] || {}
@@ -173,6 +195,7 @@ export default {
173195 name: node .name || node .label || node .type || key,
174196 type: node .type || ' ' ,
175197 diffType: change .diffType ,
198+ category: this .nodeCategory (node),
176199 propChanges: []
177200 })
178201 }
@@ -181,15 +204,22 @@ export default {
181204 const node = this .nodeMap [key] || {}
182205 const isAdded = change .diffType === ' added'
183206 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)
186207 if (prop === ' id' || prop === ' type' || prop === ' z' || prop === ' name' || prop === ' label' ) continue
208+ if (this .hidePositionChanges && POSITION_PROPS .has (prop)) continue
187209 group .propChanges .push ({ prop, value1: isAdded ? undefined : val, value2: isAdded ? val : undefined })
188210 }
189211 } else if (change .diffType === ' changed' ) {
190212 group .propChanges .push ({ prop: change .prop , value1: change .value1 , value2: change .value2 })
191213 }
192214 }
215+ // When hiding position changes, drop nodes that only had position diffs
216+ if (this .hidePositionChanges ) {
217+ for (const [key , group ] of groups) {
218+ if (group .diffType === ' changed' && group .propChanges .length === 0 ) {
219+ groups .delete (key)
220+ }
221+ }
222+ }
193223 return [... groups .values ()]
194224 },
195225 currentGroup () {
@@ -198,6 +228,12 @@ export default {
198228 currentGroupChanges () {
199229 return this .transformChanges (this .currentGroup ? .propChanges || [])
200230 },
231+ currentGroupCategoryLabel () {
232+ const cat = this .currentGroup ? .category
233+ if (cat === ' global-config' ) return ' Global Config'
234+ if (cat === ' flow-config' ) return ' Flow Config'
235+ return null
236+ },
201237 currentGroupTabMove () {
202238 // Returns { from, to } if a changed node moved between tabs, null otherwise.
203239 // Not shown for added/deleted nodes — the tab is part of their identity,
@@ -214,6 +250,13 @@ export default {
214250 watch: {
215251 compareSnapshot (val ) {
216252 if (val) this .renderComparison ()
253+ },
254+ hidePositionChanges () {
255+ // Clamp index — the list may have shrunk
256+ if (this .currentGroupIndex >= this .groupedChanges .length ) {
257+ this .currentGroupIndex = Math .max (0 , this .groupedChanges .length - 1 )
258+ }
259+ this .highlightCurrent ()
217260 }
218261 },
219262 mounted () {
@@ -257,9 +300,12 @@ export default {
257300 // Explicit scope prevents the renderer from using Tailwind utility
258301 // classes (e.g. flex-1) as CSS selectors, which would leak
259302 // svg sizing rules to the rest of the page.
260- scope: ' ff-flow-compare-view'
303+ scope: ' ff-flow-compare-view' ,
304+ persistentHighlight: true ,
305+ allChanges: true
261306 })
262307 this .rendererChanges = result? .changes || []
308+ this .clearRendererHighlight = result? .clearHighlight || (() => {})
263309 this .changes = this .computeDiff (compareFlow, this .flow )
264310 this .currentGroupIndex = 0
265311 this .hasCompared = true
@@ -281,16 +327,13 @@ export default {
281327 highlightCurrent () {
282328 const group = this .currentGroup
283329 if (! group) return
330+ this .clearRendererHighlight ()
331+ if (CONFIG_CATEGORIES .has (group .category )) return
284332 // Always pass layerNo explicitly from our own diffType so the renderer
285333 // 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%).
288334 const layerNo = group .diffType === ' added' ? 1 : group .diffType === ' deleted' ? 0 : - 1
289335 // 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.
336+ // the renderer updates layer opacities immediately.
294337 if (layerNo !== - 1 ) {
295338 const slider = this .$refs .compareViewer ? .querySelector (' .flow-compare-slider' )
296339 const target = layerNo === 1 ? 90 : 10
@@ -300,18 +343,14 @@ export default {
300343 }
301344 }
302345 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.
307346 const proxy = this .rendererChanges .find (rc => rc .tab === group .nodeId && rc .highlight )
308347 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)
348+ } else {
349+ for ( const rc of this . rendererChanges ) {
350+ if ( rc . item === group . nodeId && rc . highlight ) {
351+ rc . highlight (layerNo )
352+ }
353+ }
315354 }
316355 },
317356 diffTypeBadgeClass (diffType ) {
@@ -355,12 +394,16 @@ export default {
355394 if (c .prop === ' x' ) { xChange = c; continue }
356395 if (c .prop === ' y' ) { yChange = c; continue }
357396 if (c .prop === ' z' ) {
358- result .push ({ prop: ' z' , label: ' tab' , value1: this .resolveTabName (c .value1 ), value2: this .resolveTabName (c .value2 ) })
397+ result .push ({ prop: ' z' , label: ' Tab' , value1: this .resolveTabName (c .value1 ), value2: this .resolveTabName (c .value2 ) })
398+ continue
399+ }
400+ if (c .prop === ' g' ) {
401+ result .push ({ prop: ' g' , label: ' Group' , value1: this .resolveNodeName (c .value1 ), value2: this .resolveNodeName (c .value2 ) })
359402 continue
360403 }
361404 if (c .prop === ' disabled' ) {
362405 // 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 ) })
406+ result .push ({ prop: ' disabled' , label: ' Status ' , value1: this .resolveDisabled (c .value1 ), value2: this .resolveDisabled (c .value2 ) })
364407 continue
365408 }
366409 if (c .prop === ' wires' ) {
@@ -399,10 +442,26 @@ export default {
399442 const tab = this .nodeMap [tabId]
400443 return tab ? (tab .label || tabId) : tabId
401444 },
445+ resolveNodeName (nodeId ) {
446+ if (! nodeId) return nodeId
447+ const node = this .nodeMap [nodeId]
448+ return node ? (node .name || node .label || nodeId) : nodeId
449+ },
402450 resolveDisabled (val ) {
403451 if (val === undefined || val === null ) return undefined
404452 return val ? ' disabled' : ' enabled'
405453 },
454+ nodeCategory (node ) {
455+ if (! node || ! node .type ) return ' node'
456+ if (node .type === ' tab' ) return ' tab'
457+ if (node .type === ' group' ) return ' group'
458+ if (node .type === ' subflow' ) return ' subflow'
459+ // Config nodes have no canvas position — works for all node packages
460+ if (node .x === undefined && node .y === undefined ) {
461+ return node .z ? ' flow-config' : ' global-config'
462+ }
463+ return ' node'
464+ },
406465 computeDiff (flow1 , flow2 ) {
407466 const map1 = {}
408467 const map2 = {}
@@ -422,7 +481,7 @@ export default {
422481 const n1 = map1[id]
423482 const n2 = map2[id]
424483 for (const prop of new Set ([... Object .keys (n1), ... Object .keys (n2)])) {
425- if (prop === ' id ' ) continue
484+ if (IGNORED_PROPS . has ( prop) ) continue
426485 const v1 = n1[prop]
427486 const v2 = n2[prop]
428487 if (JSON .stringify (v1) !== JSON .stringify (v2)) {
0 commit comments