Skip to content

Commit 1058820

Browse files
Spriteclaude
andcommitted
feat: add concurrent edit warnings in node editor
Show a live yellow banner when another user is editing the same node, and prompt for confirmation when saving a node that was modified remotely via Automerge while the editor was open. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f51f532 commit 1058820

3 files changed

Lines changed: 165 additions & 76 deletions

File tree

packages/node_modules/@node-red/editor-client/src/js/multiplayer.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,12 @@ RED.multiplayer = (function () {
482482
}
483483

484484
return {
485+
getSessionUser: function(sessionId) {
486+
return sessions[sessionId]?.user;
487+
},
488+
getActiveSessionId: function() {
489+
return activeSessionId;
490+
},
485491
init: function () {
486492

487493

packages/node_modules/@node-red/editor-client/src/js/ui/editor.js

Lines changed: 149 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,9 @@ RED.editor = (function() {
10241024
var removeInfoEditorOnClose = false;
10251025
var skipInfoRefreshOnClose = false;
10261026
var activeEditPanes = [];
1027+
var nodeModifiedExternally = false;
1028+
var onNodeMultiplayerChange = null;
1029+
var onRemoteChanges = null;
10271030

10281031
editStack.push(node);
10291032
RED.view.state(RED.state.EDITING);
@@ -1114,95 +1117,112 @@ RED.editor = (function() {
11141117
text: RED._("common.label.done"),
11151118
class: "primary",
11161119
click: function() {
1117-
var editState = {
1118-
changes: {},
1119-
changed: false,
1120-
outputMap: null
1121-
}
1122-
var wasDirty = RED.nodes.dirty();
1123-
1124-
handleEditSave(editing_node,editState)
1125-
1126-
activeEditPanes.forEach(function(pane) {
1127-
if (pane.apply) {
1128-
pane.apply.call(pane, editState);
1120+
function doSave() {
1121+
var editState = {
1122+
changes: {},
1123+
changed: false,
1124+
outputMap: null
11291125
}
1130-
})
1126+
var wasDirty = RED.nodes.dirty();
11311127

1132-
var removedLinks = updateNodeProperties(editing_node, editState.outputMap);
1128+
handleEditSave(editing_node,editState)
11331129

1134-
if ($("#node-input-node-disabled").prop('checked')) {
1135-
if (node.d !== true) {
1136-
editState.changes.d = node.d;
1137-
editState.changed = true;
1138-
node.d = true;
1139-
}
1140-
} else {
1141-
if (node.d === true) {
1142-
editState.changes.d = node.d;
1143-
editState.changed = true;
1144-
delete node.d;
1145-
}
1146-
}
1130+
activeEditPanes.forEach(function(pane) {
1131+
if (pane.apply) {
1132+
pane.apply.call(pane, editState);
1133+
}
1134+
})
11471135

1148-
node.resize = true;
1136+
var removedLinks = updateNodeProperties(editing_node, editState.outputMap);
11491137

1150-
if (editState.changed) {
1151-
var wasChanged = editing_node.changed;
1152-
editing_node.changed = true;
1153-
RED.nodes.dirty(true);
1138+
if ($("#node-input-node-disabled").prop('checked')) {
1139+
if (node.d !== true) {
1140+
editState.changes.d = node.d;
1141+
editState.changed = true;
1142+
node.d = true;
1143+
}
1144+
} else {
1145+
if (node.d === true) {
1146+
editState.changes.d = node.d;
1147+
editState.changed = true;
1148+
delete node.d;
1149+
}
1150+
}
11541151

1155-
var activeSubflow = RED.nodes.subflow(RED.workspaces.active());
1156-
var subflowInstances = null;
1157-
if (activeSubflow) {
1158-
subflowInstances = [];
1159-
RED.nodes.eachNode(function(n) {
1160-
if (n.type == "subflow:"+RED.workspaces.active()) {
1161-
subflowInstances.push({
1162-
id:n.id,
1163-
changed:n.changed
1164-
});
1165-
n.changed = true;
1166-
n.dirty = true;
1167-
updateNodeProperties(n);
1152+
node.resize = true;
1153+
1154+
if (editState.changed) {
1155+
var wasChanged = editing_node.changed;
1156+
editing_node.changed = true;
1157+
RED.nodes.dirty(true);
1158+
1159+
var activeSubflow = RED.nodes.subflow(RED.workspaces.active());
1160+
var subflowInstances = null;
1161+
if (activeSubflow) {
1162+
subflowInstances = [];
1163+
RED.nodes.eachNode(function(n) {
1164+
if (n.type == "subflow:"+RED.workspaces.active()) {
1165+
subflowInstances.push({
1166+
id:n.id,
1167+
changed:n.changed
1168+
});
1169+
n.changed = true;
1170+
n.dirty = true;
1171+
updateNodeProperties(n);
1172+
}
1173+
});
1174+
}
1175+
let historyEvent = {
1176+
t:'edit',
1177+
node:editing_node,
1178+
changes:editState.changes,
1179+
links:removedLinks,
1180+
dirty:wasDirty,
1181+
changed:wasChanged
1182+
};
1183+
if (editState.outputMap) {
1184+
historyEvent.outputMap = editState.outputMap;
1185+
}
1186+
if (subflowInstances && subflowInstances.length) {
1187+
historyEvent.subflow = {
1188+
instances:subflowInstances
11681189
}
1169-
});
1170-
}
1171-
let historyEvent = {
1172-
t:'edit',
1173-
node:editing_node,
1174-
changes:editState.changes,
1175-
links:removedLinks,
1176-
dirty:wasDirty,
1177-
changed:wasChanged
1178-
};
1179-
if (editState.outputMap) {
1180-
historyEvent.outputMap = editState.outputMap;
1181-
}
1182-
if (subflowInstances && subflowInstances.length) {
1183-
historyEvent.subflow = {
1184-
instances:subflowInstances
11851190
}
1186-
}
11871191

1188-
if (editState.history) {
1189-
historyEvent = {
1190-
t: 'multi',
1191-
events: [ historyEvent, ...editState.history ],
1192-
dirty: wasDirty
1192+
if (editState.history) {
1193+
historyEvent = {
1194+
t: 'multi',
1195+
events: [ historyEvent, ...editState.history ],
1196+
dirty: wasDirty
1197+
}
11931198
}
1194-
}
11951199

1196-
RED.history.push(historyEvent);
1197-
if (RED.automerge && RED.automerge.isEnabled()) {
1198-
RED.automerge.updateNode(editing_node.id, RED.nodes.nodeToExportable(editing_node));
1200+
RED.history.push(historyEvent);
1201+
if (RED.automerge && RED.automerge.isEnabled()) {
1202+
RED.automerge.updateNode(editing_node.id, RED.nodes.nodeToExportable(editing_node));
1203+
}
11991204
}
1205+
editing_node.dirty = true;
1206+
validateNode(editing_node);
1207+
RED.events.emit("editor:save",editing_node);
1208+
RED.events.emit("nodes:change",editing_node);
1209+
RED.tray.close();
12001210
}
1201-
editing_node.dirty = true;
1202-
validateNode(editing_node);
1203-
RED.events.emit("editor:save",editing_node);
1204-
RED.events.emit("nodes:change",editing_node);
1205-
RED.tray.close();
1211+
if (nodeModifiedExternally) {
1212+
var notification = RED.notify(
1213+
'<p><strong>This node was modified by another user while you were editing.</strong></p>' +
1214+
'<p>Saving will overwrite their changes.</p>',
1215+
{
1216+
modal: true, fixed: true, type: "warning",
1217+
buttons: [
1218+
{ text: RED._("common.label.cancel"), click: function() { notification.close(); } },
1219+
{ text: "Save anyway", class: "primary", click: function() { notification.close(); doSave(); } }
1220+
]
1221+
}
1222+
);
1223+
return;
1224+
}
1225+
doSave();
12061226
}
12071227
}
12081228
],
@@ -1226,6 +1246,52 @@ RED.editor = (function() {
12261246
var trayBody = tray.find('.red-ui-tray-body');
12271247
trayBody.parent().css('overflow','hidden');
12281248

1249+
var multiplayerBanner = $('<div class="red-ui-editor-multiplayer-banner" style="display:none">' +
1250+
'<i class="fa fa-users"></i> <span></span></div>').prependTo(trayBody.parent());
1251+
1252+
nodeModifiedExternally = false;
1253+
1254+
function updateMultiplayerBanner() {
1255+
var users = editing_node._multiplayer?.users || [];
1256+
var mySessionId = RED.multiplayer.getActiveSessionId();
1257+
var otherUsers = [];
1258+
for (var i = 0; i < users.length; i++) {
1259+
if (users[i] !== mySessionId) {
1260+
var u = RED.multiplayer.getSessionUser(users[i]);
1261+
if (u) { otherUsers.push(u.username || 'anonymous'); }
1262+
}
1263+
}
1264+
if (otherUsers.length > 0 || nodeModifiedExternally) {
1265+
var msg = '';
1266+
if (otherUsers.length === 1) {
1267+
msg = otherUsers[0] + ' is also editing this node';
1268+
} else if (otherUsers.length > 1) {
1269+
msg = otherUsers[0] + ' and ' + (otherUsers.length - 1) + ' other' + (otherUsers.length - 1 > 1 ? 's are' : ' is') + ' also editing this node';
1270+
}
1271+
if (nodeModifiedExternally) {
1272+
if (msg) { msg += ' — '; }
1273+
msg += 'this node has been modified externally';
1274+
}
1275+
multiplayerBanner.find('span').text(msg);
1276+
multiplayerBanner.show();
1277+
} else {
1278+
multiplayerBanner.hide();
1279+
}
1280+
}
1281+
1282+
onNodeMultiplayerChange = function(n) {
1283+
if (n.id === editing_node.id) { updateMultiplayerBanner(); }
1284+
};
1285+
RED.events.on('nodes:change', onNodeMultiplayerChange);
1286+
1287+
onRemoteChanges = function(event) {
1288+
if (event.updated && event.updated.some(function(n) { return n.id === editing_node.id; })) {
1289+
nodeModifiedExternally = true;
1290+
updateMultiplayerBanner();
1291+
}
1292+
};
1293+
RED.events.on('automerge:remote-changes', onRemoteChanges);
1294+
12291295
var trayFooterLeft = $('<div class="red-ui-tray-footer-left"></div>').appendTo(trayFooter)
12301296

12311297
var helpButton = $('<button type="button" class="red-ui-button"><i class="fa fa-book"></button>').on("click", function(evt) {
@@ -1263,10 +1329,17 @@ RED.editor = (function() {
12631329
trayBody.i18n();
12641330
trayFooter.i18n();
12651331
buildingEditDialog = false;
1332+
updateMultiplayerBanner();
12661333
done();
12671334
});
12681335
},
12691336
close: function() {
1337+
if (onNodeMultiplayerChange) {
1338+
RED.events.off('nodes:change', onNodeMultiplayerChange);
1339+
}
1340+
if (onRemoteChanges) {
1341+
RED.events.off('automerge:remote-changes', onRemoteChanges);
1342+
}
12701343
if (RED.view.state() != RED.state.IMPORT_DRAGGING) {
12711344
RED.view.state(RED.state.DEFAULT);
12721345
}

packages/node_modules/@node-red/editor-client/src/sass/multiplayer.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,14 @@ $multiplayer-user-icon-shadow: 0px 0px 4px var(--red-ui-shadow);
145145
font-size: 11px;
146146
user-select: none;
147147
}
148+
}
149+
150+
.red-ui-editor-multiplayer-banner {
151+
background: #fff3cd;
152+
border-bottom: 1px solid #ffc107;
153+
color: #856404;
154+
padding: 6px 12px;
155+
font-size: 12px;
156+
line-height: 20px;
157+
i.fa { margin-right: 6px; }
148158
}

0 commit comments

Comments
 (0)